mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-21 20:06:06 +00:00
feat(agent-platform): productize bot event route testing
This commit is contained in:
@@ -58,6 +58,7 @@ The tools wrap the LangBot service layer. Current tools (v1):
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `get_system_info` | Version, edition, instance id |
|
| `get_system_info` | Version, edition, instance id |
|
||||||
| `list_bots` / `get_bot` / `create_bot` / `update_bot` / `delete_bot` | Manage messaging-platform bots (secrets redacted on read) |
|
| `list_bots` / `get_bot` / `create_bot` / `update_bot` / `delete_bot` | Manage messaging-platform bots (secrets redacted on read) |
|
||||||
|
| `list_bot_event_route_statuses` / `test_bot_event_route` | Inspect bot event-route runtime status and dispatch a synthetic test event through saved routes without sending real outbound platform messages |
|
||||||
| `list_agents` / `get_agent` / `create_agent` / `update_agent` / `delete_agent` | Manage the Agent product surface, including Agent orchestrations and Pipelines |
|
| `list_agents` / `get_agent` / `create_agent` / `update_agent` / `delete_agent` | Manage the Agent product surface, including Agent orchestrations and Pipelines |
|
||||||
| `list_pipelines` / `get_pipeline` / `create_pipeline` / `update_pipeline` / `delete_pipeline` | Manage pipelines |
|
| `list_pipelines` / `get_pipeline` / `create_pipeline` / `update_pipeline` / `delete_pipeline` | Manage pipelines |
|
||||||
| `list_llm_models` / `get_llm_model` / `list_embedding_models` / `list_model_providers` | Inspect models & providers |
|
| `list_llm_models` / `get_llm_model` / `list_embedding_models` / `list_model_providers` | Inspect models & providers |
|
||||||
@@ -69,6 +70,12 @@ Mutating tools (`create_*`, `update_*`) take a JSON object matching the same
|
|||||||
shape as the corresponding HTTP API request body. Discover resources with the
|
shape as the corresponding HTTP API request body. Discover resources with the
|
||||||
`list_*` / `get_*` tools before mutating; identifiers are UUIDs.
|
`list_*` / `get_*` tools before mutating; identifiers are UUIDs.
|
||||||
|
|
||||||
|
`test_bot_event_route` uses the bot's saved runtime route table, injects a
|
||||||
|
synthetic event such as `message.received`, and suppresses platform delivery.
|
||||||
|
It still executes the selected processor, so tools and external services may
|
||||||
|
have side effects. Use `payload` for sample event fields, for example
|
||||||
|
`{"message_text": "hello", "chat_type": "private", "chat_id": "u1"}`.
|
||||||
|
|
||||||
## How to use
|
## How to use
|
||||||
|
|
||||||
1. Get an API key (web UI key, or set `api.global_api_key` in config.yaml).
|
1. Get an API key (web UI key, or set `api.global_api_key` in config.yaml).
|
||||||
|
|||||||
@@ -69,6 +69,18 @@ class BotsRouterGroup(group.RouterGroup):
|
|||||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||||
)(_dry_run_event_route)
|
)(_dry_run_event_route)
|
||||||
|
|
||||||
|
@self.route('/<bot_uuid>/event-routes/test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||||
|
async def _(bot_uuid: str) -> str:
|
||||||
|
json_data = await quart.request.json
|
||||||
|
if not isinstance(json_data, dict):
|
||||||
|
return self.http_status(400, -1, 'invalid request body')
|
||||||
|
result = await self.ap.bot_service.dispatch_test_event_route(
|
||||||
|
bot_uuid=bot_uuid,
|
||||||
|
event_type=json_data.get('event_type'),
|
||||||
|
payload=json_data.get('event_data', json_data.get('payload')),
|
||||||
|
)
|
||||||
|
return self.success(data=result)
|
||||||
|
|
||||||
@self.route('/<bot_uuid>/send_message', methods=['POST'], auth_type=group.AuthType.API_KEY)
|
@self.route('/<bot_uuid>/send_message', methods=['POST'], auth_type=group.AuthType.API_KEY)
|
||||||
async def _(bot_uuid: str) -> str:
|
async def _(bot_uuid: str) -> str:
|
||||||
json_data = await quart.request.json
|
json_data = await quart.request.json
|
||||||
|
|||||||
@@ -440,7 +440,7 @@ class BotService:
|
|||||||
'reason': 'Agent target is disabled',
|
'reason': 'Agent target is disabled',
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
if not RuntimeBot._agent_supports_event_type(getattr(agent, 'supported_event_patterns', None), event_type):
|
if not RuntimeBot._agent_supports_event_type(getattr(agent, 'supported_event_patterns', None), event_type):
|
||||||
return self._diagnostic_result(
|
return self._diagnostic_result(
|
||||||
matched=False,
|
matched=False,
|
||||||
@@ -636,7 +636,9 @@ class BotService:
|
|||||||
|
|
||||||
bot = await self.get_bot(bot_data['uuid'])
|
bot = await self.get_bot(bot_data['uuid'])
|
||||||
|
|
||||||
await self.ap.platform_mgr.load_bot(bot)
|
runtime_bot = await self.ap.platform_mgr.load_bot(bot)
|
||||||
|
if runtime_bot.enable:
|
||||||
|
await runtime_bot.run()
|
||||||
|
|
||||||
return bot_data['uuid']
|
return bot_data['uuid']
|
||||||
|
|
||||||
@@ -745,6 +747,58 @@ class BotService:
|
|||||||
'stale_routes': stale_routes,
|
'stale_routes': stale_routes,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async def dispatch_test_event_route(
|
||||||
|
self,
|
||||||
|
bot_uuid: str,
|
||||||
|
event_type: str,
|
||||||
|
payload: dict[str, typing.Any] | None = None,
|
||||||
|
) -> dict[str, typing.Any]:
|
||||||
|
"""Dispatch a synthetic event through the saved Bot runtime route configuration."""
|
||||||
|
event_type = str(event_type or '').strip()
|
||||||
|
if not event_type:
|
||||||
|
return {
|
||||||
|
'dispatched': False,
|
||||||
|
'event_type': '',
|
||||||
|
'failure_code': self.FAILURE_INVALID_EVENT,
|
||||||
|
'reason': 'event_type is required',
|
||||||
|
'suppressed_outputs': [],
|
||||||
|
'route_status': {
|
||||||
|
'routes': [],
|
||||||
|
'unmatched_events': [],
|
||||||
|
'stale_routes': [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if payload is not None and not isinstance(payload, dict):
|
||||||
|
return {
|
||||||
|
'dispatched': False,
|
||||||
|
'event_type': event_type,
|
||||||
|
'failure_code': self.FAILURE_INVALID_EVENT,
|
||||||
|
'reason': 'payload must be an object',
|
||||||
|
'suppressed_outputs': [],
|
||||||
|
'route_status': {
|
||||||
|
'routes': [],
|
||||||
|
'unmatched_events': [],
|
||||||
|
'stale_routes': [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(bot_uuid)
|
||||||
|
if runtime_bot is None:
|
||||||
|
raise Exception('Bot not found')
|
||||||
|
|
||||||
|
dispatch_result = await runtime_bot.dispatch_test_event(event_type, payload or {})
|
||||||
|
route_status = await self.list_event_route_statuses(bot_uuid)
|
||||||
|
return {
|
||||||
|
'dispatched': bool(dispatch_result.get('dispatched')),
|
||||||
|
'event_type': event_type,
|
||||||
|
'status': dispatch_result.get('status'),
|
||||||
|
'binding_id': dispatch_result.get('binding_id'),
|
||||||
|
'failure_code': dispatch_result.get('failure_code'),
|
||||||
|
'reason': dispatch_result.get('reason'),
|
||||||
|
'suppressed_outputs': dispatch_result.get('suppressed_outputs', []),
|
||||||
|
'route_status': route_status,
|
||||||
|
}
|
||||||
|
|
||||||
async def send_message(self, bot_uuid: str, target_type: str, target_id: str, message_chain_data: dict) -> None:
|
async def send_message(self, bot_uuid: str, target_type: str, target_id: str, message_chain_data: dict) -> None:
|
||||||
"""Send message to a specific target via bot
|
"""Send message to a specific target via bot
|
||||||
|
|
||||||
|
|||||||
@@ -113,6 +113,29 @@ class LangBotMCPServer:
|
|||||||
await ap.bot_service.delete_bot(bot_uuid)
|
await ap.bot_service.delete_bot(bot_uuid)
|
||||||
return _dump({'ok': True})
|
return _dump({'ok': True})
|
||||||
|
|
||||||
|
@mcp.tool(description='List recent runtime route status for a bot event route table.')
|
||||||
|
async def list_bot_event_route_statuses(bot_uuid: str) -> str:
|
||||||
|
return _dump(await ap.bot_service.list_event_route_statuses(bot_uuid))
|
||||||
|
|
||||||
|
@mcp.tool(
|
||||||
|
description=(
|
||||||
|
'Dispatch a synthetic event through the saved bot event routes. '
|
||||||
|
'This validates routing without sending real outbound platform messages.'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
async def test_bot_event_route(
|
||||||
|
bot_uuid: str,
|
||||||
|
event_type: str,
|
||||||
|
payload: dict | None = None,
|
||||||
|
) -> str:
|
||||||
|
return _dump(
|
||||||
|
await ap.bot_service.dispatch_test_event_route(
|
||||||
|
bot_uuid=bot_uuid,
|
||||||
|
event_type=event_type,
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# ----- Pipelines ----------------------------------------------- #
|
# ----- Pipelines ----------------------------------------------- #
|
||||||
@mcp.tool(description='List all pipelines.')
|
@mcp.tool(description='List all pipelines.')
|
||||||
async def list_pipelines() -> str:
|
async def list_pipelines() -> str:
|
||||||
|
|||||||
@@ -42,6 +42,224 @@ from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput
|
|||||||
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
|
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
|
||||||
|
|
||||||
|
|
||||||
|
class SyntheticRouteTestAdapter:
|
||||||
|
"""Adapter wrapper that suppresses outbound platform delivery for test events."""
|
||||||
|
|
||||||
|
SIDE_EFFECT_API_NAMES = {
|
||||||
|
'send_message',
|
||||||
|
'reply_message',
|
||||||
|
'reply_message_chunk',
|
||||||
|
'create_message_card',
|
||||||
|
'edit_message',
|
||||||
|
'delete_message',
|
||||||
|
'forward_message',
|
||||||
|
'set_group_name',
|
||||||
|
'mute_member',
|
||||||
|
'unmute_member',
|
||||||
|
'kick_member',
|
||||||
|
'leave_group',
|
||||||
|
'approve_friend_request',
|
||||||
|
'approve_group_invite',
|
||||||
|
'upload_file',
|
||||||
|
'call_platform_api',
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, source: abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||||
|
self.source = source
|
||||||
|
self.bot_account_id = getattr(source, 'bot_account_id', '')
|
||||||
|
self.config = getattr(source, 'config', {})
|
||||||
|
self.logger = getattr(source, 'logger', None)
|
||||||
|
self.suppressed_outputs: list[dict[str, typing.Any]] = []
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _message_to_payload(message: platform_message.MessageChain) -> typing.Any:
|
||||||
|
return message.model_dump() if hasattr(message, 'model_dump') else str(message)
|
||||||
|
|
||||||
|
def _suppress(self, method: str, **payload: typing.Any) -> None:
|
||||||
|
self.suppressed_outputs.append({'method': method, **payload})
|
||||||
|
|
||||||
|
def __getattr__(self, name: str) -> typing.Any:
|
||||||
|
return getattr(self.source, name)
|
||||||
|
|
||||||
|
def get_supported_apis(self) -> list[str]:
|
||||||
|
get_supported_apis = getattr(self.source, 'get_supported_apis', None)
|
||||||
|
if not callable(get_supported_apis):
|
||||||
|
return []
|
||||||
|
return [api_name for api_name in get_supported_apis() if api_name not in self.SIDE_EFFECT_API_NAMES]
|
||||||
|
|
||||||
|
async def send_message(
|
||||||
|
self,
|
||||||
|
target_type: str,
|
||||||
|
target_id: str,
|
||||||
|
message: platform_message.MessageChain,
|
||||||
|
) -> dict[str, typing.Any]:
|
||||||
|
self._suppress(
|
||||||
|
'send_message',
|
||||||
|
target_type=target_type,
|
||||||
|
target_id=target_id,
|
||||||
|
message=self._message_to_payload(message),
|
||||||
|
)
|
||||||
|
return {'suppressed': True}
|
||||||
|
|
||||||
|
async def reply_message(
|
||||||
|
self,
|
||||||
|
message_source: platform_events.MessageEvent,
|
||||||
|
message: platform_message.MessageChain,
|
||||||
|
quote_origin: bool = False,
|
||||||
|
) -> dict[str, typing.Any]:
|
||||||
|
self._suppress(
|
||||||
|
'reply_message',
|
||||||
|
message=self._message_to_payload(message),
|
||||||
|
quote_origin=quote_origin,
|
||||||
|
)
|
||||||
|
return {'suppressed': True}
|
||||||
|
|
||||||
|
async def reply_message_chunk(
|
||||||
|
self,
|
||||||
|
message_source: platform_events.MessageEvent,
|
||||||
|
bot_message: dict,
|
||||||
|
message: platform_message.MessageChain,
|
||||||
|
quote_origin: bool = False,
|
||||||
|
is_final: bool = False,
|
||||||
|
) -> dict[str, typing.Any]:
|
||||||
|
self._suppress(
|
||||||
|
'reply_message_chunk',
|
||||||
|
message=self._message_to_payload(message),
|
||||||
|
quote_origin=quote_origin,
|
||||||
|
is_final=is_final,
|
||||||
|
)
|
||||||
|
return {'suppressed': True}
|
||||||
|
|
||||||
|
async def create_message_card(
|
||||||
|
self,
|
||||||
|
message_id: str | int,
|
||||||
|
event: platform_events.MessageEvent,
|
||||||
|
) -> bool:
|
||||||
|
self._suppress('create_message_card', message_id=str(message_id))
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def is_stream_output_supported(self) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def edit_message(
|
||||||
|
self,
|
||||||
|
chat_type: str,
|
||||||
|
chat_id: typing.Union[int, str],
|
||||||
|
message_id: typing.Union[int, str],
|
||||||
|
new_content: platform_message.MessageChain,
|
||||||
|
) -> None:
|
||||||
|
self._suppress(
|
||||||
|
'edit_message',
|
||||||
|
chat_type=str(chat_type),
|
||||||
|
chat_id=str(chat_id),
|
||||||
|
message_id=str(message_id),
|
||||||
|
new_content=self._message_to_payload(new_content),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def delete_message(
|
||||||
|
self,
|
||||||
|
chat_type: str,
|
||||||
|
chat_id: typing.Union[int, str],
|
||||||
|
message_id: typing.Union[int, str],
|
||||||
|
) -> None:
|
||||||
|
self._suppress(
|
||||||
|
'delete_message',
|
||||||
|
chat_type=str(chat_type),
|
||||||
|
chat_id=str(chat_id),
|
||||||
|
message_id=str(message_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def forward_message(
|
||||||
|
self,
|
||||||
|
from_chat_type: str,
|
||||||
|
from_chat_id: typing.Union[int, str],
|
||||||
|
message_id: typing.Union[int, str],
|
||||||
|
to_chat_type: str,
|
||||||
|
to_chat_id: typing.Union[int, str],
|
||||||
|
) -> platform_events.MessageResult:
|
||||||
|
self._suppress(
|
||||||
|
'forward_message',
|
||||||
|
from_chat_type=str(from_chat_type),
|
||||||
|
from_chat_id=str(from_chat_id),
|
||||||
|
message_id=str(message_id),
|
||||||
|
to_chat_type=str(to_chat_type),
|
||||||
|
to_chat_id=str(to_chat_id),
|
||||||
|
)
|
||||||
|
return platform_events.MessageResult(raw={'suppressed': True})
|
||||||
|
|
||||||
|
async def set_group_name(
|
||||||
|
self,
|
||||||
|
group_id: typing.Union[int, str],
|
||||||
|
name: str,
|
||||||
|
) -> None:
|
||||||
|
self._suppress('set_group_name', group_id=str(group_id), name=name)
|
||||||
|
|
||||||
|
async def mute_member(
|
||||||
|
self,
|
||||||
|
group_id: typing.Union[int, str],
|
||||||
|
user_id: typing.Union[int, str],
|
||||||
|
duration: int = 0,
|
||||||
|
) -> None:
|
||||||
|
self._suppress(
|
||||||
|
'mute_member',
|
||||||
|
group_id=str(group_id),
|
||||||
|
user_id=str(user_id),
|
||||||
|
duration=duration,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def unmute_member(
|
||||||
|
self,
|
||||||
|
group_id: typing.Union[int, str],
|
||||||
|
user_id: typing.Union[int, str],
|
||||||
|
) -> None:
|
||||||
|
self._suppress('unmute_member', group_id=str(group_id), user_id=str(user_id))
|
||||||
|
|
||||||
|
async def kick_member(
|
||||||
|
self,
|
||||||
|
group_id: typing.Union[int, str],
|
||||||
|
user_id: typing.Union[int, str],
|
||||||
|
) -> None:
|
||||||
|
self._suppress('kick_member', group_id=str(group_id), user_id=str(user_id))
|
||||||
|
|
||||||
|
async def leave_group(
|
||||||
|
self,
|
||||||
|
group_id: typing.Union[int, str],
|
||||||
|
) -> None:
|
||||||
|
self._suppress('leave_group', group_id=str(group_id))
|
||||||
|
|
||||||
|
async def approve_friend_request(
|
||||||
|
self,
|
||||||
|
request_id: typing.Union[int, str],
|
||||||
|
approve: bool = True,
|
||||||
|
remark: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._suppress(
|
||||||
|
'approve_friend_request',
|
||||||
|
request_id=str(request_id),
|
||||||
|
approve=approve,
|
||||||
|
remark=remark,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def approve_group_invite(
|
||||||
|
self,
|
||||||
|
request_id: typing.Union[int, str],
|
||||||
|
approve: bool = True,
|
||||||
|
) -> None:
|
||||||
|
self._suppress(
|
||||||
|
'approve_group_invite',
|
||||||
|
request_id=str(request_id),
|
||||||
|
approve=approve,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def upload_file(self, file_data: bytes, filename: str) -> str:
|
||||||
|
self._suppress('upload_file', filename=filename, size=len(file_data))
|
||||||
|
return f'suppressed:{filename}'
|
||||||
|
|
||||||
|
async def call_platform_api(self, action: str, params: dict | None = None) -> dict:
|
||||||
|
self._suppress('call_platform_api', action=action, params=params or {})
|
||||||
|
return {'suppressed': True}
|
||||||
|
|
||||||
|
|
||||||
class RuntimeBot:
|
class RuntimeBot:
|
||||||
"""运行时机器人"""
|
"""运行时机器人"""
|
||||||
|
|
||||||
@@ -53,7 +271,7 @@ class RuntimeBot:
|
|||||||
|
|
||||||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter
|
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter
|
||||||
|
|
||||||
task_wrapper: taskmgr.TaskWrapper
|
task_wrapper: taskmgr.TaskWrapper | None
|
||||||
|
|
||||||
task_context: taskmgr.TaskContext
|
task_context: taskmgr.TaskContext
|
||||||
|
|
||||||
@@ -70,6 +288,7 @@ class RuntimeBot:
|
|||||||
self.bot_entity = bot_entity
|
self.bot_entity = bot_entity
|
||||||
self.enable = bot_entity.enable
|
self.enable = bot_entity.enable
|
||||||
self.adapter = adapter
|
self.adapter = adapter
|
||||||
|
self.task_wrapper = None
|
||||||
self.task_context = taskmgr.TaskContext()
|
self.task_context = taskmgr.TaskContext()
|
||||||
self.logger = logger
|
self.logger = logger
|
||||||
|
|
||||||
@@ -305,6 +524,126 @@ class RuntimeBot:
|
|||||||
"""Return the selected event binding plus per-binding diagnostic steps."""
|
"""Return the selected event binding plus per-binding diagnostic steps."""
|
||||||
return self._evaluate_eba_event_bindings(self._get_event_bindings(), event, event_type)
|
return self._evaluate_eba_event_bindings(self._get_event_bindings(), event, event_type)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_test_platform_event(
|
||||||
|
event_type: str,
|
||||||
|
payload: dict[str, typing.Any] | None = None,
|
||||||
|
) -> platform_events.EBAEvent:
|
||||||
|
"""Build a synthetic platform event for route validation."""
|
||||||
|
payload = payload or {}
|
||||||
|
now = time.time()
|
||||||
|
common = {
|
||||||
|
'type': event_type,
|
||||||
|
'timestamp': payload.get('timestamp') or now,
|
||||||
|
'adapter_name': payload.get('adapter_name') or 'test-event',
|
||||||
|
'source_platform_object': {'synthetic': True, 'payload': payload},
|
||||||
|
}
|
||||||
|
|
||||||
|
user_id = str(payload.get('user_id') or payload.get('sender_id') or 'test-user')
|
||||||
|
user_name = str(payload.get('user_name') or payload.get('sender_name') or 'Test User')
|
||||||
|
group_id = str(payload.get('group_id') or payload.get('chat_id') or 'test-group')
|
||||||
|
group_name = str(payload.get('group_name') or 'Test Group')
|
||||||
|
|
||||||
|
if event_type == 'message.received':
|
||||||
|
chat_type_value = str(payload.get('chat_type') or 'private')
|
||||||
|
chat_type = (
|
||||||
|
platform_entities.ChatType.GROUP
|
||||||
|
if chat_type_value == platform_entities.ChatType.GROUP.value
|
||||||
|
else platform_entities.ChatType.PRIVATE
|
||||||
|
)
|
||||||
|
chat_id = str(
|
||||||
|
payload.get('chat_id') or (group_id if chat_type == platform_entities.ChatType.GROUP else user_id)
|
||||||
|
)
|
||||||
|
message_text = str(payload.get('message_text') or payload.get('text') or '')
|
||||||
|
message_chain_data = payload.get('message_chain')
|
||||||
|
if message_chain_data is None:
|
||||||
|
message_chain = platform_message.MessageChain([platform_message.Plain(text=message_text)])
|
||||||
|
else:
|
||||||
|
message_chain = platform_message.MessageChain.model_validate(message_chain_data)
|
||||||
|
group = (
|
||||||
|
platform_entities.UserGroup(id=chat_id, name=group_name)
|
||||||
|
if chat_type == platform_entities.ChatType.GROUP
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
return platform_events.MessageReceivedEvent(
|
||||||
|
**common,
|
||||||
|
message_id=str(payload.get('message_id') or f'test-message:{uuid.uuid4()}'),
|
||||||
|
message_chain=message_chain,
|
||||||
|
sender=platform_entities.User(id=user_id, nickname=user_name),
|
||||||
|
chat_type=chat_type,
|
||||||
|
chat_id=chat_id,
|
||||||
|
group=group,
|
||||||
|
)
|
||||||
|
|
||||||
|
if event_type == 'group.member_joined':
|
||||||
|
return platform_events.MemberJoinedEvent(
|
||||||
|
**common,
|
||||||
|
group=platform_entities.UserGroup(id=group_id, name=group_name),
|
||||||
|
member=platform_entities.User(id=user_id, nickname=user_name),
|
||||||
|
inviter=platform_entities.User(
|
||||||
|
id=str(payload.get('inviter_id')),
|
||||||
|
nickname=str(payload.get('inviter_name') or ''),
|
||||||
|
)
|
||||||
|
if payload.get('inviter_id')
|
||||||
|
else None,
|
||||||
|
join_type=payload.get('join_type'),
|
||||||
|
)
|
||||||
|
|
||||||
|
if event_type == 'group.member_left':
|
||||||
|
return platform_events.MemberLeftEvent(
|
||||||
|
**common,
|
||||||
|
group=platform_entities.UserGroup(id=group_id, name=group_name),
|
||||||
|
member=platform_entities.User(id=user_id, nickname=user_name),
|
||||||
|
is_kicked=bool(payload.get('is_kicked', False)),
|
||||||
|
operator=platform_entities.User(
|
||||||
|
id=str(payload.get('operator_id')),
|
||||||
|
nickname=str(payload.get('operator_name') or ''),
|
||||||
|
)
|
||||||
|
if payload.get('operator_id')
|
||||||
|
else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
if event_type == 'platform.specific':
|
||||||
|
return platform_events.PlatformSpecificEvent(
|
||||||
|
**common,
|
||||||
|
action=str(payload.get('action') or 'test'),
|
||||||
|
data=payload.get('data') if isinstance(payload.get('data'), dict) else payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
return platform_events.EBAEvent(**common)
|
||||||
|
|
||||||
|
async def dispatch_test_event(
|
||||||
|
self,
|
||||||
|
event_type: str,
|
||||||
|
payload: dict[str, typing.Any] | None = None,
|
||||||
|
) -> dict[str, typing.Any]:
|
||||||
|
"""Dispatch a synthetic event through the real runtime route path."""
|
||||||
|
event_type = str(event_type or '').strip()
|
||||||
|
if not event_type:
|
||||||
|
raise ValueError('event_type is required')
|
||||||
|
|
||||||
|
event = self._build_test_platform_event(event_type, payload)
|
||||||
|
await self._record_event_route_trace(
|
||||||
|
event_type=event_type,
|
||||||
|
status='test_started',
|
||||||
|
reason='Synthetic test event dispatched from control plane',
|
||||||
|
text=f'Test event {event_type} dispatched from control plane',
|
||||||
|
)
|
||||||
|
test_adapter = SyntheticRouteTestAdapter(self.adapter)
|
||||||
|
outcome = await self._dispatch_eba_event_to_agent(
|
||||||
|
event,
|
||||||
|
typing.cast(abstract_platform_adapter.AbstractMessagePlatformAdapter, test_adapter),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
'event_type': event_type,
|
||||||
|
'dispatched': outcome['status'] in {'delivered', 'discarded'},
|
||||||
|
'status': outcome['status'],
|
||||||
|
'binding_id': outcome.get('binding_id'),
|
||||||
|
'failure_code': outcome.get('failure_code'),
|
||||||
|
'reason': outcome.get('reason'),
|
||||||
|
'suppressed_outputs': test_adapter.suppressed_outputs,
|
||||||
|
}
|
||||||
|
|
||||||
async def _record_event_route_trace(
|
async def _record_event_route_trace(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -318,7 +657,7 @@ class RuntimeBot:
|
|||||||
failure_code: str | None = None,
|
failure_code: str | None = None,
|
||||||
reason: str | None = None,
|
reason: str | None = None,
|
||||||
run_id: str | None = None,
|
run_id: str | None = None,
|
||||||
) -> None:
|
) -> dict[str, typing.Any]:
|
||||||
"""Record structured event routing state while preserving the human log."""
|
"""Record structured event routing state while preserving the human log."""
|
||||||
binding = binding or {}
|
binding = binding or {}
|
||||||
metadata = {
|
metadata = {
|
||||||
@@ -335,6 +674,7 @@ class RuntimeBot:
|
|||||||
}
|
}
|
||||||
log_method = getattr(self.logger, level, self.logger.info)
|
log_method = getattr(self.logger, level, self.logger.info)
|
||||||
await log_method(text, metadata=metadata)
|
await log_method(text, metadata=metadata)
|
||||||
|
return metadata
|
||||||
|
|
||||||
def get_pipeline_target_for_event_type(self, event_type: str = 'message.received') -> str | None:
|
def get_pipeline_target_for_event_type(self, event_type: str = 'message.received') -> str | None:
|
||||||
"""Return the first Pipeline target configured for an event type."""
|
"""Return the first Pipeline target configured for an event type."""
|
||||||
@@ -752,6 +1092,7 @@ class RuntimeBot:
|
|||||||
self,
|
self,
|
||||||
envelope: AgentEventEnvelope,
|
envelope: AgentEventEnvelope,
|
||||||
outputs: list[provider_message.Message | provider_message.MessageChunk],
|
outputs: list[provider_message.Message | provider_message.MessageChunk],
|
||||||
|
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter | SyntheticRouteTestAdapter | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if not outputs or not envelope.delivery.reply_target:
|
if not outputs or not envelope.delivery.reply_target:
|
||||||
return
|
return
|
||||||
@@ -773,7 +1114,8 @@ class RuntimeBot:
|
|||||||
if not final_text:
|
if not final_text:
|
||||||
return
|
return
|
||||||
|
|
||||||
await self.adapter.send_message(
|
delivery_adapter = adapter or self.adapter
|
||||||
|
await delivery_adapter.send_message(
|
||||||
str(target_type),
|
str(target_type),
|
||||||
str(target_id),
|
str(target_id),
|
||||||
platform_message.MessageChain([platform_message.Plain(text=final_text)]),
|
platform_message.MessageChain([platform_message.Plain(text=final_text)]),
|
||||||
@@ -799,19 +1141,18 @@ class RuntimeBot:
|
|||||||
self,
|
self,
|
||||||
event: platform_events.EBAEvent,
|
event: platform_events.EBAEvent,
|
||||||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||||||
) -> None:
|
) -> dict[str, typing.Any]:
|
||||||
event_type = getattr(event, 'type', None) or event.__class__.__name__
|
event_type = getattr(event, 'type', None) or event.__class__.__name__
|
||||||
|
|
||||||
event_binding = self._resolve_eba_event_binding(event, event_type)
|
event_binding = self._resolve_eba_event_binding(event, event_type)
|
||||||
if event_binding is None:
|
if event_binding is None:
|
||||||
await self._record_event_route_trace(
|
return await self._record_event_route_trace(
|
||||||
event_type=event_type,
|
event_type=event_type,
|
||||||
status='not_matched',
|
status='not_matched',
|
||||||
failure_code='route_not_found',
|
failure_code='route_not_found',
|
||||||
reason='No event route matched',
|
reason='No event route matched',
|
||||||
text=f'Platform event {event_type} ignored: no event route matched',
|
text=f'Platform event {event_type} ignored: no event route matched',
|
||||||
)
|
)
|
||||||
return
|
|
||||||
|
|
||||||
target_type = event_binding.get('target_type')
|
target_type = event_binding.get('target_type')
|
||||||
await self._record_event_route_trace(
|
await self._record_event_route_trace(
|
||||||
@@ -830,25 +1171,23 @@ class RuntimeBot:
|
|||||||
pipeline_uuid=self.PIPELINE_DISCARD,
|
pipeline_uuid=self.PIPELINE_DISCARD,
|
||||||
routed_by_event_binding=True,
|
routed_by_event_binding=True,
|
||||||
)
|
)
|
||||||
await self._record_event_route_trace(
|
return await self._record_event_route_trace(
|
||||||
event_type=event_type,
|
event_type=event_type,
|
||||||
status='discarded',
|
status='discarded',
|
||||||
binding=event_binding,
|
binding=event_binding,
|
||||||
target_type=target_type,
|
target_type=target_type,
|
||||||
text=f'EBA event {event_type} discarded by event binding',
|
text=f'EBA event {event_type} discarded by event binding',
|
||||||
)
|
)
|
||||||
return
|
return await self._record_event_route_trace(
|
||||||
await self._record_event_route_trace(
|
|
||||||
event_type=event_type,
|
event_type=event_type,
|
||||||
status='discarded',
|
status='discarded',
|
||||||
binding=event_binding,
|
binding=event_binding,
|
||||||
target_type=target_type,
|
target_type=target_type,
|
||||||
text=f'EBA event {event_type} discarded by event binding',
|
text=f'EBA event {event_type} discarded by event binding',
|
||||||
)
|
)
|
||||||
return
|
|
||||||
if target_type == 'pipeline':
|
if target_type == 'pipeline':
|
||||||
if not self._is_message_event_type(event_type):
|
if not self._is_message_event_type(event_type):
|
||||||
await self._record_event_route_trace(
|
return await self._record_event_route_trace(
|
||||||
event_type=event_type,
|
event_type=event_type,
|
||||||
status='failed',
|
status='failed',
|
||||||
level='warning',
|
level='warning',
|
||||||
@@ -859,14 +1198,13 @@ class RuntimeBot:
|
|||||||
reason='Pipeline targets only support message events',
|
reason='Pipeline targets only support message events',
|
||||||
text=f'EBA event {event_type} ignored Pipeline target for non-message event',
|
text=f'EBA event {event_type} ignored Pipeline target for non-message event',
|
||||||
)
|
)
|
||||||
return
|
|
||||||
await self._dispatch_eba_message_to_pipeline(
|
await self._dispatch_eba_message_to_pipeline(
|
||||||
event,
|
event,
|
||||||
adapter,
|
adapter,
|
||||||
pipeline_uuid=event_binding.get('target_uuid'),
|
pipeline_uuid=event_binding.get('target_uuid'),
|
||||||
routed_by_event_binding=True,
|
routed_by_event_binding=True,
|
||||||
)
|
)
|
||||||
await self._record_event_route_trace(
|
return await self._record_event_route_trace(
|
||||||
event_type=event_type,
|
event_type=event_type,
|
||||||
status='delivered',
|
status='delivered',
|
||||||
binding=event_binding,
|
binding=event_binding,
|
||||||
@@ -874,9 +1212,8 @@ class RuntimeBot:
|
|||||||
target_uuid=event_binding.get('target_uuid'),
|
target_uuid=event_binding.get('target_uuid'),
|
||||||
text=f'EBA event {event_type} delivered to Pipeline {event_binding.get("target_uuid") or ""}'.strip(),
|
text=f'EBA event {event_type} delivered to Pipeline {event_binding.get("target_uuid") or ""}'.strip(),
|
||||||
)
|
)
|
||||||
return
|
|
||||||
if target_type != 'agent':
|
if target_type != 'agent':
|
||||||
await self._record_event_route_trace(
|
return await self._record_event_route_trace(
|
||||||
event_type=event_type,
|
event_type=event_type,
|
||||||
status='failed',
|
status='failed',
|
||||||
level='warning',
|
level='warning',
|
||||||
@@ -887,12 +1224,11 @@ class RuntimeBot:
|
|||||||
reason=f'Unsupported event binding target type: {target_type}',
|
reason=f'Unsupported event binding target type: {target_type}',
|
||||||
text=f'EBA event {event_type} ignored unsupported target type {target_type}',
|
text=f'EBA event {event_type} ignored unsupported target type {target_type}',
|
||||||
)
|
)
|
||||||
return
|
|
||||||
|
|
||||||
target_uuid = event_binding.get('target_uuid')
|
target_uuid = event_binding.get('target_uuid')
|
||||||
agent = await self.ap.agent_service.get_agent(target_uuid)
|
agent = await self.ap.agent_service.get_agent(target_uuid)
|
||||||
if not agent or agent.get('kind') != 'agent':
|
if not agent or agent.get('kind') != 'agent':
|
||||||
await self._record_event_route_trace(
|
return await self._record_event_route_trace(
|
||||||
event_type=event_type,
|
event_type=event_type,
|
||||||
status='failed',
|
status='failed',
|
||||||
level='warning',
|
level='warning',
|
||||||
@@ -903,9 +1239,8 @@ class RuntimeBot:
|
|||||||
reason='Agent target not found',
|
reason='Agent target not found',
|
||||||
text=f'EBA event {event_type} target agent not found: {target_uuid}',
|
text=f'EBA event {event_type} target agent not found: {target_uuid}',
|
||||||
)
|
)
|
||||||
return
|
|
||||||
if not agent.get('enabled', True):
|
if not agent.get('enabled', True):
|
||||||
await self._record_event_route_trace(
|
return await self._record_event_route_trace(
|
||||||
event_type=event_type,
|
event_type=event_type,
|
||||||
status='failed',
|
status='failed',
|
||||||
binding=event_binding,
|
binding=event_binding,
|
||||||
@@ -915,9 +1250,8 @@ class RuntimeBot:
|
|||||||
reason='Agent target is disabled',
|
reason='Agent target is disabled',
|
||||||
text=f'EBA event {event_type} target agent disabled: {target_uuid}',
|
text=f'EBA event {event_type} target agent disabled: {target_uuid}',
|
||||||
)
|
)
|
||||||
return
|
|
||||||
if not self._agent_supports_event_type(agent.get('supported_event_patterns'), event_type):
|
if not self._agent_supports_event_type(agent.get('supported_event_patterns'), event_type):
|
||||||
await self._record_event_route_trace(
|
return await self._record_event_route_trace(
|
||||||
event_type=event_type,
|
event_type=event_type,
|
||||||
status='failed',
|
status='failed',
|
||||||
binding=event_binding,
|
binding=event_binding,
|
||||||
@@ -927,11 +1261,10 @@ class RuntimeBot:
|
|||||||
reason='Agent target does not support this event type',
|
reason='Agent target does not support this event type',
|
||||||
text=f'EBA event {event_type} target agent does not support this event: {target_uuid}',
|
text=f'EBA event {event_type} target agent does not support this event: {target_uuid}',
|
||||||
)
|
)
|
||||||
return
|
|
||||||
|
|
||||||
binding = self._agent_product_to_binding(agent, event_binding, event_type, self.bot_entity.uuid)
|
binding = self._agent_product_to_binding(agent, event_binding, event_type, self.bot_entity.uuid)
|
||||||
if binding is None:
|
if binding is None:
|
||||||
await self._record_event_route_trace(
|
return await self._record_event_route_trace(
|
||||||
event_type=event_type,
|
event_type=event_type,
|
||||||
status='failed',
|
status='failed',
|
||||||
level='warning',
|
level='warning',
|
||||||
@@ -942,7 +1275,6 @@ class RuntimeBot:
|
|||||||
reason='Agent target has no runner',
|
reason='Agent target has no runner',
|
||||||
text=f'EBA event {event_type} target agent has no runner: {target_uuid}',
|
text=f'EBA event {event_type} target agent has no runner: {target_uuid}',
|
||||||
)
|
)
|
||||||
return
|
|
||||||
|
|
||||||
envelope = self._eba_event_to_agent_envelope(event, adapter)
|
envelope = self._eba_event_to_agent_envelope(event, adapter)
|
||||||
outputs: list[provider_message.Message | provider_message.MessageChunk] = []
|
outputs: list[provider_message.Message | provider_message.MessageChunk] = []
|
||||||
@@ -950,7 +1282,7 @@ class RuntimeBot:
|
|||||||
async for output in self.ap.agent_run_orchestrator.run(envelope, binding):
|
async for output in self.ap.agent_run_orchestrator.run(envelope, binding):
|
||||||
outputs.append(output)
|
outputs.append(output)
|
||||||
except Exception:
|
except Exception:
|
||||||
await self._record_event_route_trace(
|
return await self._record_event_route_trace(
|
||||||
event_type=event_type,
|
event_type=event_type,
|
||||||
status='failed',
|
status='failed',
|
||||||
level='error',
|
level='error',
|
||||||
@@ -961,12 +1293,11 @@ class RuntimeBot:
|
|||||||
reason='Agent runner failed',
|
reason='Agent runner failed',
|
||||||
text=f'Failed to run Agent for EBA event {event_type}: {traceback.format_exc()}',
|
text=f'Failed to run Agent for EBA event {event_type}: {traceback.format_exc()}',
|
||||||
)
|
)
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await self._deliver_agent_outputs(envelope, outputs)
|
await self._deliver_agent_outputs(envelope, outputs, adapter=adapter)
|
||||||
except Exception:
|
except Exception:
|
||||||
await self._record_event_route_trace(
|
return await self._record_event_route_trace(
|
||||||
event_type=event_type,
|
event_type=event_type,
|
||||||
status='failed',
|
status='failed',
|
||||||
level='error',
|
level='error',
|
||||||
@@ -977,8 +1308,7 @@ class RuntimeBot:
|
|||||||
reason='Agent output delivery failed',
|
reason='Agent output delivery failed',
|
||||||
text=f'Failed to deliver Agent output for EBA event {event_type}: {traceback.format_exc()}',
|
text=f'Failed to deliver Agent output for EBA event {event_type}: {traceback.format_exc()}',
|
||||||
)
|
)
|
||||||
return
|
return await self._record_event_route_trace(
|
||||||
await self._record_event_route_trace(
|
|
||||||
event_type=event_type,
|
event_type=event_type,
|
||||||
status='delivered',
|
status='delivered',
|
||||||
binding=event_binding,
|
binding=event_binding,
|
||||||
@@ -1244,7 +1574,9 @@ class RuntimeBot:
|
|||||||
async def shutdown(self):
|
async def shutdown(self):
|
||||||
await self.adapter.kill()
|
await self.adapter.kill()
|
||||||
|
|
||||||
self.ap.task_mgr.cancel_task(self.task_wrapper.id)
|
if self.task_wrapper is not None:
|
||||||
|
self.ap.task_mgr.cancel_task(self.task_wrapper.id)
|
||||||
|
self.task_wrapper = None
|
||||||
|
|
||||||
|
|
||||||
# 控制QQ消息输入输出的类
|
# 控制QQ消息输入输出的类
|
||||||
|
|||||||
@@ -139,6 +139,18 @@ def fake_bot_app():
|
|||||||
'diagnostic_details': [{'step': 'evaluate_binding', 'binding_id': 'binding-1', 'matched': True}],
|
'diagnostic_details': [{'step': 'evaluate_binding', 'binding_id': 'binding-1', 'matched': True}],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
app.bot_service.dispatch_test_event_route = AsyncMock(
|
||||||
|
return_value={
|
||||||
|
'dispatched': True,
|
||||||
|
'event_type': 'message.received',
|
||||||
|
'suppressed_outputs': [],
|
||||||
|
'route_status': {
|
||||||
|
'routes': [],
|
||||||
|
'unmatched_events': [],
|
||||||
|
'stale_routes': [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
app.bot_service.send_message = AsyncMock()
|
app.bot_service.send_message = AsyncMock()
|
||||||
|
|
||||||
# Platform manager
|
# Platform manager
|
||||||
@@ -309,6 +321,34 @@ class TestBotEventRouteStatusEndpoint:
|
|||||||
fake_bot_app.bot_service.list_event_route_statuses.assert_awaited_with('test-bot-uuid')
|
fake_bot_app.bot_service.list_event_route_statuses.assert_awaited_with('test-bot-uuid')
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures('mock_circular_import_chain')
|
||||||
|
class TestBotEventRouteTestEndpoint:
|
||||||
|
"""Tests for bot event route synthetic dispatch endpoint."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dispatch_test_event_route_success(self, quart_test_client, fake_bot_app):
|
||||||
|
"""POST test route dispatches a synthetic event."""
|
||||||
|
response = await quart_test_client.post(
|
||||||
|
'/api/v1/platform/bots/test-bot-uuid/event-routes/test',
|
||||||
|
headers={'Authorization': 'Bearer test_token'},
|
||||||
|
json={
|
||||||
|
'event_type': 'message.received',
|
||||||
|
'payload': {'message_text': 'hello'},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = await response.get_json()
|
||||||
|
assert data['code'] == 0
|
||||||
|
assert data['data']['dispatched'] is True
|
||||||
|
assert data['data']['event_type'] == 'message.received'
|
||||||
|
fake_bot_app.bot_service.dispatch_test_event_route.assert_awaited_with(
|
||||||
|
bot_uuid='test-bot-uuid',
|
||||||
|
event_type='message.received',
|
||||||
|
payload={'message_text': 'hello'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.usefixtures('mock_circular_import_chain')
|
@pytest.mark.usefixtures('mock_circular_import_chain')
|
||||||
class TestBotSendMessageEndpoint:
|
class TestBotSendMessageEndpoint:
|
||||||
"""Tests for bot send message endpoint."""
|
"""Tests for bot send message endpoint."""
|
||||||
|
|||||||
@@ -37,7 +37,20 @@ def build_ap() -> SimpleNamespace:
|
|||||||
|
|
||||||
ap.apikey_service = SimpleNamespace(verify_api_key=verify_api_key)
|
ap.apikey_service = SimpleNamespace(verify_api_key=verify_api_key)
|
||||||
ap.bot_service = SimpleNamespace(
|
ap.bot_service = SimpleNamespace(
|
||||||
get_bots=AsyncMock(return_value=[{'uuid': 'bot-1', 'name': 'Demo Bot', 'adapter': 'telegram'}])
|
get_bots=AsyncMock(return_value=[{'uuid': 'bot-1', 'name': 'Demo Bot', 'adapter': 'telegram'}]),
|
||||||
|
list_event_route_statuses=AsyncMock(return_value={'routes': [], 'unmatched_events': [], 'stale_routes': []}),
|
||||||
|
dispatch_test_event_route=AsyncMock(
|
||||||
|
return_value={
|
||||||
|
'dispatched': True,
|
||||||
|
'event_type': 'message.received',
|
||||||
|
'suppressed_outputs': [],
|
||||||
|
'route_status': {
|
||||||
|
'routes': [],
|
||||||
|
'unmatched_events': [],
|
||||||
|
'stale_routes': [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
)
|
)
|
||||||
ap.pipeline_service = SimpleNamespace(get_pipelines=AsyncMock(return_value=[{'uuid': 'pl-1', 'name': 'default'}]))
|
ap.pipeline_service = SimpleNamespace(get_pipelines=AsyncMock(return_value=[{'uuid': 'pl-1', 'name': 'default'}]))
|
||||||
ap.llm_model_service = SimpleNamespace(get_llm_models=AsyncMock(return_value=[]))
|
ap.llm_model_service = SimpleNamespace(get_llm_models=AsyncMock(return_value=[]))
|
||||||
@@ -96,7 +109,7 @@ async def main() -> int:
|
|||||||
tools = await session.list_tools()
|
tools = await session.list_tools()
|
||||||
names = [t.name for t in tools.tools]
|
names = [t.name for t in tools.tools]
|
||||||
print(f'PASS: listed {len(names)} tools')
|
print(f'PASS: listed {len(names)} tools')
|
||||||
for required in ('list_bots', 'get_system_info', 'list_skills'):
|
for required in ('list_bots', 'get_system_info', 'list_skills', 'test_bot_event_route'):
|
||||||
if required not in names:
|
if required not in names:
|
||||||
failures.append(f'missing tool {required}')
|
failures.append(f'missing tool {required}')
|
||||||
|
|
||||||
@@ -114,6 +127,20 @@ async def main() -> int:
|
|||||||
else:
|
else:
|
||||||
print('PASS: get_system_info returned version')
|
print('PASS: get_system_info returned version')
|
||||||
|
|
||||||
|
res3 = await session.call_tool(
|
||||||
|
'test_bot_event_route',
|
||||||
|
{
|
||||||
|
'bot_uuid': 'bot-1',
|
||||||
|
'event_type': 'message.received',
|
||||||
|
'payload': {'message_text': 'hello'},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
text3 = res3.content[0].text if res3.content else ''
|
||||||
|
if '"dispatched": true' not in text3:
|
||||||
|
failures.append(f'test_bot_event_route wrong: {text3!r}')
|
||||||
|
else:
|
||||||
|
print('PASS: test_bot_event_route returned dispatch result')
|
||||||
|
|
||||||
shutdown.set()
|
shutdown.set()
|
||||||
with contextlib.suppress(Exception):
|
with contextlib.suppress(Exception):
|
||||||
await asyncio.wait_for(server_task, timeout=5)
|
await asyncio.wait_for(server_task, timeout=5)
|
||||||
|
|||||||
@@ -54,11 +54,7 @@ def _create_mock_result(items: list = None, first_item=None):
|
|||||||
|
|
||||||
def _compiled_update_values(statement):
|
def _compiled_update_values(statement):
|
||||||
"""Return update values without SQLAlchemy WHERE bind params."""
|
"""Return update values without SQLAlchemy WHERE bind params."""
|
||||||
return {
|
return {key: value for key, value in statement.compile().params.items() if not key.startswith('uuid_')}
|
||||||
key: value
|
|
||||||
for key, value in statement.compile().params.items()
|
|
||||||
if not key.startswith('uuid_')
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _create_mock_discover(adapter_webhook_flags: dict[str, bool] = None):
|
def _create_mock_discover(adapter_webhook_flags: dict[str, bool] = None):
|
||||||
@@ -363,7 +359,8 @@ class TestBotServiceCreateBot:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ap.platform_mgr = SimpleNamespace()
|
ap.platform_mgr = SimpleNamespace()
|
||||||
ap.platform_mgr.load_bot = AsyncMock()
|
runtime_bot = SimpleNamespace(enable=True, run=AsyncMock())
|
||||||
|
ap.platform_mgr.load_bot = AsyncMock(return_value=runtime_bot)
|
||||||
|
|
||||||
# Mock pipeline query
|
# Mock pipeline query
|
||||||
pipeline_result = Mock()
|
pipeline_result = Mock()
|
||||||
@@ -394,6 +391,7 @@ class TestBotServiceCreateBot:
|
|||||||
# Verify
|
# Verify
|
||||||
assert bot_uuid is not None
|
assert bot_uuid is not None
|
||||||
assert len(bot_uuid) == 36 # UUID format
|
assert len(bot_uuid) == 36 # UUID format
|
||||||
|
runtime_bot.run.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
class TestBotServiceUpdateBot:
|
class TestBotServiceUpdateBot:
|
||||||
@@ -1125,6 +1123,64 @@ class TestBotServiceEventRouteStatuses:
|
|||||||
assert result['unmatched_events'][0]['failure_code'] == 'route_not_found'
|
assert result['unmatched_events'][0]['failure_code'] == 'route_not_found'
|
||||||
|
|
||||||
|
|
||||||
|
class TestBotServiceDispatchTestEventRoute:
|
||||||
|
"""Tests for synthetic Bot route test dispatch."""
|
||||||
|
|
||||||
|
async def test_dispatch_test_event_route_rejects_invalid_event_type(self):
|
||||||
|
"""Missing event_type returns a non-dispatched test result."""
|
||||||
|
service = BotService(SimpleNamespace())
|
||||||
|
|
||||||
|
result = await service.dispatch_test_event_route('bot-1', '', {})
|
||||||
|
|
||||||
|
assert result['dispatched'] is False
|
||||||
|
assert result['failure_code'] == 'invalid_event'
|
||||||
|
assert result['reason'] == 'event_type is required'
|
||||||
|
assert result['route_status']['routes'] == []
|
||||||
|
|
||||||
|
async def test_dispatch_test_event_route_rejects_non_object_payload(self):
|
||||||
|
"""Payload must be a JSON object."""
|
||||||
|
service = BotService(SimpleNamespace())
|
||||||
|
|
||||||
|
result = await service.dispatch_test_event_route('bot-1', 'message.received', []) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
assert result['dispatched'] is False
|
||||||
|
assert result['failure_code'] == 'invalid_event'
|
||||||
|
assert result['reason'] == 'payload must be an object'
|
||||||
|
assert result['route_status']['routes'] == []
|
||||||
|
|
||||||
|
async def test_dispatch_test_event_route_calls_runtime_and_returns_status(self):
|
||||||
|
"""Synthetic route tests run against the runtime bot and return route status."""
|
||||||
|
ap = SimpleNamespace()
|
||||||
|
ap.platform_mgr = SimpleNamespace()
|
||||||
|
runtime_bot = SimpleNamespace()
|
||||||
|
runtime_bot.dispatch_test_event = AsyncMock(
|
||||||
|
return_value={
|
||||||
|
'event_type': 'message.received',
|
||||||
|
'dispatched': True,
|
||||||
|
'status': 'delivered',
|
||||||
|
'binding_id': 'binding-1',
|
||||||
|
'failure_code': None,
|
||||||
|
'reason': 'Delivered to processor',
|
||||||
|
'suppressed_outputs': [{'method': 'send_message'}],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
runtime_bot.bot_entity = SimpleNamespace(event_bindings=[])
|
||||||
|
runtime_bot.logger = SimpleNamespace(logs=[])
|
||||||
|
ap.platform_mgr.get_bot_by_uuid = AsyncMock(return_value=runtime_bot)
|
||||||
|
|
||||||
|
service = BotService(ap)
|
||||||
|
result = await service.dispatch_test_event_route('bot-1', 'message.received', {'message_text': 'hello'})
|
||||||
|
|
||||||
|
runtime_bot.dispatch_test_event.assert_awaited_once_with('message.received', {'message_text': 'hello'})
|
||||||
|
assert result['dispatched'] is True
|
||||||
|
assert result['status'] == 'delivered'
|
||||||
|
assert result['binding_id'] == 'binding-1'
|
||||||
|
assert result['reason'] == 'Delivered to processor'
|
||||||
|
assert result['event_type'] == 'message.received'
|
||||||
|
assert result['suppressed_outputs'] == [{'method': 'send_message'}]
|
||||||
|
assert result['route_status']['routes'] == []
|
||||||
|
|
||||||
|
|
||||||
class TestBotServiceSendMessage:
|
class TestBotServiceSendMessage:
|
||||||
"""Tests for send_message method."""
|
"""Tests for send_message method."""
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from langbot.pkg.api.mcp.server import LangBotMCPServer
|
||||||
|
|
||||||
|
|
||||||
|
def _make_app() -> SimpleNamespace:
|
||||||
|
app = SimpleNamespace()
|
||||||
|
app.instance_config = SimpleNamespace(data={'system': {'edition': 'community', 'instance_id': 'inst-1'}})
|
||||||
|
app.ver_mgr = SimpleNamespace(get_current_version=lambda: 'test-version')
|
||||||
|
app.bot_service = SimpleNamespace(
|
||||||
|
get_bots=AsyncMock(return_value=[]),
|
||||||
|
get_bot=AsyncMock(return_value={'uuid': 'bot-1'}),
|
||||||
|
create_bot=AsyncMock(return_value='bot-1'),
|
||||||
|
update_bot=AsyncMock(),
|
||||||
|
delete_bot=AsyncMock(),
|
||||||
|
list_event_route_statuses=AsyncMock(return_value={'routes': [], 'unmatched_events': [], 'stale_routes': []}),
|
||||||
|
dispatch_test_event_route=AsyncMock(
|
||||||
|
return_value={
|
||||||
|
'dispatched': True,
|
||||||
|
'event_type': 'message.received',
|
||||||
|
'suppressed_outputs': [],
|
||||||
|
'route_status': {
|
||||||
|
'routes': [],
|
||||||
|
'unmatched_events': [],
|
||||||
|
'stale_routes': [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
app.pipeline_service = SimpleNamespace(
|
||||||
|
get_pipelines=AsyncMock(return_value=[]),
|
||||||
|
get_pipeline=AsyncMock(return_value={}),
|
||||||
|
create_pipeline=AsyncMock(return_value='pipeline-1'),
|
||||||
|
update_pipeline=AsyncMock(),
|
||||||
|
delete_pipeline=AsyncMock(),
|
||||||
|
)
|
||||||
|
app.agent_service = SimpleNamespace(
|
||||||
|
get_agents=AsyncMock(return_value=[]),
|
||||||
|
get_agent=AsyncMock(return_value={}),
|
||||||
|
create_agent=AsyncMock(return_value={'uuid': 'agent-1', 'kind': 'agent'}),
|
||||||
|
update_agent=AsyncMock(),
|
||||||
|
delete_agent=AsyncMock(),
|
||||||
|
)
|
||||||
|
app.llm_model_service = SimpleNamespace(
|
||||||
|
get_llm_models=AsyncMock(return_value=[]),
|
||||||
|
get_llm_model=AsyncMock(return_value={}),
|
||||||
|
)
|
||||||
|
app.embedding_models_service = SimpleNamespace(get_embedding_models=AsyncMock(return_value=[]))
|
||||||
|
app.provider_service = SimpleNamespace(get_providers=AsyncMock(return_value=[]))
|
||||||
|
app.knowledge_service = SimpleNamespace(
|
||||||
|
get_knowledge_bases=AsyncMock(return_value=[]),
|
||||||
|
get_knowledge_base=AsyncMock(return_value={}),
|
||||||
|
retrieve_knowledge_base=AsyncMock(return_value=[]),
|
||||||
|
)
|
||||||
|
app.mcp_service = SimpleNamespace(get_mcp_servers=AsyncMock(return_value=[]))
|
||||||
|
app.skill_service = SimpleNamespace(
|
||||||
|
list_skills=AsyncMock(return_value=[]),
|
||||||
|
get_skill=AsyncMock(return_value={}),
|
||||||
|
)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mcp_server_exposes_bot_event_route_tools():
|
||||||
|
app = _make_app()
|
||||||
|
server = LangBotMCPServer(app)
|
||||||
|
|
||||||
|
tools = await server.mcp.list_tools()
|
||||||
|
tool_names = {tool.name for tool in tools}
|
||||||
|
|
||||||
|
assert 'list_bot_event_route_statuses' in tool_names
|
||||||
|
assert 'test_bot_event_route' in tool_names
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mcp_test_bot_event_route_calls_service_layer():
|
||||||
|
app = _make_app()
|
||||||
|
server = LangBotMCPServer(app)
|
||||||
|
|
||||||
|
result_blocks, _ = await server.mcp.call_tool(
|
||||||
|
'test_bot_event_route',
|
||||||
|
{
|
||||||
|
'bot_uuid': 'bot-1',
|
||||||
|
'event_type': 'message.received',
|
||||||
|
'payload': {'message_text': 'hello'},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
app.bot_service.dispatch_test_event_route.assert_awaited_once_with(
|
||||||
|
bot_uuid='bot-1',
|
||||||
|
event_type='message.received',
|
||||||
|
payload={'message_text': 'hello'},
|
||||||
|
)
|
||||||
|
data = json.loads(result_blocks[0].text)
|
||||||
|
assert data['dispatched'] is True
|
||||||
|
assert data['event_type'] == 'message.received'
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
"""
|
"""RuntimeBot event binding and route observability unit tests."""
|
||||||
RuntimeBot.resolve_pipeline_uuid and _match_operator unit tests
|
|
||||||
"""
|
|
||||||
|
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock, Mock
|
from unittest.mock import AsyncMock, Mock
|
||||||
@@ -8,54 +6,6 @@ from unittest.mock import AsyncMock, Mock
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
class TestMatchOperator:
|
|
||||||
"""Test the _match_operator static method."""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _get_class():
|
|
||||||
from langbot.pkg.platform.botmgr import RuntimeBot
|
|
||||||
|
|
||||||
return RuntimeBot
|
|
||||||
|
|
||||||
def test_eq(self):
|
|
||||||
cls = self._get_class()
|
|
||||||
assert cls._match_operator('hello', 'eq', 'hello') is True
|
|
||||||
assert cls._match_operator('hello', 'eq', 'world') is False
|
|
||||||
|
|
||||||
def test_neq(self):
|
|
||||||
cls = self._get_class()
|
|
||||||
assert cls._match_operator('hello', 'neq', 'world') is True
|
|
||||||
assert cls._match_operator('hello', 'neq', 'hello') is False
|
|
||||||
|
|
||||||
def test_contains(self):
|
|
||||||
cls = self._get_class()
|
|
||||||
assert cls._match_operator('hello world', 'contains', 'world') is True
|
|
||||||
assert cls._match_operator('hello world', 'contains', 'xyz') is False
|
|
||||||
|
|
||||||
def test_not_contains(self):
|
|
||||||
cls = self._get_class()
|
|
||||||
assert cls._match_operator('hello world', 'not_contains', 'xyz') is True
|
|
||||||
assert cls._match_operator('hello world', 'not_contains', 'world') is False
|
|
||||||
|
|
||||||
def test_starts_with(self):
|
|
||||||
cls = self._get_class()
|
|
||||||
assert cls._match_operator('hello world', 'starts_with', 'hello') is True
|
|
||||||
assert cls._match_operator('hello world', 'starts_with', 'world') is False
|
|
||||||
|
|
||||||
def test_regex(self):
|
|
||||||
cls = self._get_class()
|
|
||||||
assert cls._match_operator('hello123', 'regex', r'\d+') is True
|
|
||||||
assert cls._match_operator('hello', 'regex', r'\d+') is False
|
|
||||||
|
|
||||||
def test_regex_invalid_pattern(self):
|
|
||||||
cls = self._get_class()
|
|
||||||
assert cls._match_operator('hello', 'regex', r'[invalid') is False
|
|
||||||
|
|
||||||
def test_unknown_operator(self):
|
|
||||||
cls = self._get_class()
|
|
||||||
assert cls._match_operator('hello', 'unknown_op', 'hello') is False
|
|
||||||
|
|
||||||
|
|
||||||
class TestEventRouteTrace:
|
class TestEventRouteTrace:
|
||||||
"""Test structured event route trace logging."""
|
"""Test structured event route trace logging."""
|
||||||
|
|
||||||
@@ -116,6 +66,176 @@ class TestEventRouteTrace:
|
|||||||
assert metadata['target_uuid'] == 'agent-1'
|
assert metadata['target_uuid'] == 'agent-1'
|
||||||
assert metadata['status'] == 'failed'
|
assert metadata['status'] == 'failed'
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dispatch_test_event_suppresses_agent_output_delivery(self):
|
||||||
|
"""Synthetic test dispatch runs the route but does not call the real adapter."""
|
||||||
|
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||||
|
|
||||||
|
async def fake_run(envelope, binding):
|
||||||
|
yield provider_message.Message(role='assistant', content='test response')
|
||||||
|
|
||||||
|
bot = self._make_bot(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
'id': 'agent-binding',
|
||||||
|
'enabled': True,
|
||||||
|
'event_pattern': 'message.received',
|
||||||
|
'target_type': 'agent',
|
||||||
|
'target_uuid': 'agent-1',
|
||||||
|
'priority': 0,
|
||||||
|
'order': 0,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
bot.ap = SimpleNamespace(
|
||||||
|
agent_service=SimpleNamespace(
|
||||||
|
get_agent=AsyncMock(
|
||||||
|
return_value={
|
||||||
|
'uuid': 'agent-1',
|
||||||
|
'kind': 'agent',
|
||||||
|
'enabled': True,
|
||||||
|
'supported_event_patterns': ['message.received'],
|
||||||
|
'config': {'runner': {'id': 'runner-1'}, 'runner_config': {'runner-1': {}}},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
),
|
||||||
|
agent_run_orchestrator=SimpleNamespace(run=fake_run),
|
||||||
|
)
|
||||||
|
bot.adapter = SimpleNamespace(
|
||||||
|
bot_account_id='bot-account',
|
||||||
|
config={},
|
||||||
|
logger=bot.logger,
|
||||||
|
send_message=AsyncMock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await bot.dispatch_test_event('message.received', {'chat_id': 'user-1', 'message_text': 'hello'})
|
||||||
|
|
||||||
|
bot.adapter.send_message.assert_not_awaited()
|
||||||
|
assert result['dispatched'] is True
|
||||||
|
assert result['status'] == 'delivered'
|
||||||
|
assert result['suppressed_outputs'][0]['method'] == 'send_message'
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dispatch_test_event_pipeline_receives_synthetic_adapter(self):
|
||||||
|
"""Pipeline route tests enqueue queries with the no-op adapter."""
|
||||||
|
bot = self._make_bot(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
'id': 'pipeline-binding',
|
||||||
|
'enabled': True,
|
||||||
|
'event_pattern': 'message.received',
|
||||||
|
'target_type': 'pipeline',
|
||||||
|
'target_uuid': 'pipeline-1',
|
||||||
|
'priority': 0,
|
||||||
|
'order': 0,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
bot.ap = SimpleNamespace(
|
||||||
|
msg_aggregator=SimpleNamespace(add_message=AsyncMock()),
|
||||||
|
)
|
||||||
|
bot.adapter = SimpleNamespace(
|
||||||
|
bot_account_id='bot-account',
|
||||||
|
config={},
|
||||||
|
logger=bot.logger,
|
||||||
|
send_message=AsyncMock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await bot.dispatch_test_event(
|
||||||
|
'message.received',
|
||||||
|
{'chat_id': 'user-1', 'message_text': 'hello'},
|
||||||
|
)
|
||||||
|
|
||||||
|
bot.adapter.send_message.assert_not_awaited()
|
||||||
|
bot.ap.msg_aggregator.add_message.assert_awaited_once()
|
||||||
|
_, kwargs = bot.ap.msg_aggregator.add_message.await_args
|
||||||
|
query_adapter = kwargs['adapter']
|
||||||
|
assert query_adapter is not bot.adapter
|
||||||
|
assert getattr(query_adapter, 'source') is bot.adapter
|
||||||
|
assert result['dispatched'] is True
|
||||||
|
assert result['status'] == 'delivered'
|
||||||
|
assert result['suppressed_outputs'] == []
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dispatch_test_event_reports_unmatched_route_as_failure(self):
|
||||||
|
"""Synthetic dispatch does not report success when no saved route matches."""
|
||||||
|
bot = self._make_bot([])
|
||||||
|
bot.adapter = SimpleNamespace(
|
||||||
|
bot_account_id='bot-account',
|
||||||
|
config={},
|
||||||
|
logger=bot.logger,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await bot.dispatch_test_event(
|
||||||
|
'message.received',
|
||||||
|
{'chat_id': 'user-1', 'message_text': 'hello'},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result['dispatched'] is False
|
||||||
|
assert result['status'] == 'not_matched'
|
||||||
|
assert result['failure_code'] == 'route_not_found'
|
||||||
|
assert result['reason'] == 'No event route matched'
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_synthetic_adapter_suppresses_platform_side_effect_apis(self):
|
||||||
|
"""Synthetic adapter blocks optional platform APIs that mutate external state."""
|
||||||
|
from langbot.pkg.platform.botmgr import SyntheticRouteTestAdapter
|
||||||
|
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||||
|
|
||||||
|
source = SimpleNamespace(
|
||||||
|
bot_account_id='bot-account',
|
||||||
|
config={},
|
||||||
|
logger=Mock(),
|
||||||
|
get_supported_apis=Mock(
|
||||||
|
return_value=[
|
||||||
|
'send_message',
|
||||||
|
'delete_message',
|
||||||
|
'get_group_info',
|
||||||
|
'call_platform_api',
|
||||||
|
]
|
||||||
|
),
|
||||||
|
delete_message=AsyncMock(),
|
||||||
|
call_platform_api=AsyncMock(),
|
||||||
|
)
|
||||||
|
adapter = SyntheticRouteTestAdapter(source)
|
||||||
|
|
||||||
|
await adapter.delete_message('group', 'group-1', 'message-1')
|
||||||
|
await adapter.call_platform_api('set_title', {'name': 'New Title'})
|
||||||
|
upload_result = await adapter.upload_file(b'data', 'test.txt')
|
||||||
|
|
||||||
|
source.delete_message.assert_not_awaited()
|
||||||
|
source.call_platform_api.assert_not_awaited()
|
||||||
|
assert upload_result == 'suppressed:test.txt'
|
||||||
|
assert [item['method'] for item in adapter.suppressed_outputs] == [
|
||||||
|
'delete_message',
|
||||||
|
'call_platform_api',
|
||||||
|
'upload_file',
|
||||||
|
]
|
||||||
|
assert adapter.get_supported_apis() == ['get_group_info']
|
||||||
|
assert adapter._message_to_payload(platform_message.MessageChain([platform_message.Plain(text='ok')]))
|
||||||
|
|
||||||
|
def test_build_test_platform_event_message_received_uses_payload(self):
|
||||||
|
"""Synthetic message events preserve common route filter fields."""
|
||||||
|
from langbot.pkg.platform.botmgr import RuntimeBot
|
||||||
|
|
||||||
|
event = RuntimeBot._build_test_platform_event(
|
||||||
|
'message.received',
|
||||||
|
{
|
||||||
|
'chat_type': 'group',
|
||||||
|
'chat_id': 'group-1',
|
||||||
|
'group_name': 'QA Group',
|
||||||
|
'user_id': 'user-1',
|
||||||
|
'user_name': 'QA User',
|
||||||
|
'message_text': 'hello',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert event.type == 'message.received'
|
||||||
|
assert str(event.chat_id) == 'group-1'
|
||||||
|
assert event.group.name == 'QA Group'
|
||||||
|
assert event.sender.nickname == 'QA User'
|
||||||
|
assert str(event.message_chain) == 'hello'
|
||||||
|
|
||||||
|
|
||||||
class TestEventLoggerMetadata:
|
class TestEventLoggerMetadata:
|
||||||
"""Test platform EventLogger metadata compatibility."""
|
"""Test platform EventLogger metadata compatibility."""
|
||||||
@@ -140,231 +260,26 @@ class TestEventLoggerMetadata:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class TestResolvePipelineUuid:
|
class TestRuntimeBotLifecycle:
|
||||||
"""Test the resolve_pipeline_uuid method."""
|
"""Test RuntimeBot startup and shutdown lifecycle edges."""
|
||||||
|
|
||||||
@staticmethod
|
@pytest.mark.asyncio
|
||||||
def _make_bot(default_pipeline: str, rules: list):
|
async def test_shutdown_before_run_is_idempotent(self):
|
||||||
|
"""A newly loaded Bot can be removed even before its task starts."""
|
||||||
from langbot.pkg.platform.botmgr import RuntimeBot
|
from langbot.pkg.platform.botmgr import RuntimeBot
|
||||||
|
|
||||||
bot_entity = Mock()
|
task_mgr = SimpleNamespace(cancel_task=Mock())
|
||||||
bot_entity.use_pipeline_uuid = default_pipeline
|
bot = RuntimeBot(
|
||||||
bot_entity.pipeline_routing_rules = rules
|
ap=SimpleNamespace(task_mgr=task_mgr),
|
||||||
|
bot_entity=SimpleNamespace(enable=True),
|
||||||
|
adapter=SimpleNamespace(kill=AsyncMock()),
|
||||||
|
logger=Mock(),
|
||||||
|
)
|
||||||
|
|
||||||
bot = object.__new__(RuntimeBot)
|
await bot.shutdown()
|
||||||
bot.bot_entity = bot_entity
|
|
||||||
return bot
|
|
||||||
|
|
||||||
def test_no_rules_returns_default(self):
|
bot.adapter.kill.assert_awaited_once()
|
||||||
bot = self._make_bot('default-uuid', [])
|
task_mgr.cancel_task.assert_not_called()
|
||||||
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hi')
|
|
||||||
assert uuid == 'default-uuid'
|
|
||||||
assert routed is False
|
|
||||||
|
|
||||||
def test_none_rules_returns_default(self):
|
|
||||||
bot = self._make_bot('default-uuid', None)
|
|
||||||
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hi')
|
|
||||||
assert uuid == 'default-uuid'
|
|
||||||
assert routed is False
|
|
||||||
|
|
||||||
def test_launcher_type_match(self):
|
|
||||||
rules = [
|
|
||||||
{
|
|
||||||
'type': 'launcher_type',
|
|
||||||
'operator': 'eq',
|
|
||||||
'value': 'group',
|
|
||||||
'pipeline_uuid': 'group-pipeline',
|
|
||||||
}
|
|
||||||
]
|
|
||||||
bot = self._make_bot('default-uuid', rules)
|
|
||||||
|
|
||||||
uuid, routed = bot.resolve_pipeline_uuid('group', '123', 'hi')
|
|
||||||
assert uuid == 'group-pipeline'
|
|
||||||
assert routed is True
|
|
||||||
|
|
||||||
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hi')
|
|
||||||
assert uuid == 'default-uuid'
|
|
||||||
assert routed is False
|
|
||||||
|
|
||||||
def test_launcher_id_match(self):
|
|
||||||
rules = [
|
|
||||||
{
|
|
||||||
'type': 'launcher_id',
|
|
||||||
'operator': 'eq',
|
|
||||||
'value': '12345',
|
|
||||||
'pipeline_uuid': 'vip-pipeline',
|
|
||||||
}
|
|
||||||
]
|
|
||||||
bot = self._make_bot('default-uuid', rules)
|
|
||||||
|
|
||||||
uuid, routed = bot.resolve_pipeline_uuid('person', '12345', 'hi')
|
|
||||||
assert uuid == 'vip-pipeline'
|
|
||||||
assert routed is True
|
|
||||||
|
|
||||||
uuid, routed = bot.resolve_pipeline_uuid('person', '99999', 'hi')
|
|
||||||
assert uuid == 'default-uuid'
|
|
||||||
assert routed is False
|
|
||||||
|
|
||||||
def test_message_content_contains(self):
|
|
||||||
rules = [
|
|
||||||
{
|
|
||||||
'type': 'message_content',
|
|
||||||
'operator': 'contains',
|
|
||||||
'value': '紧急',
|
|
||||||
'pipeline_uuid': 'urgent-pipeline',
|
|
||||||
}
|
|
||||||
]
|
|
||||||
bot = self._make_bot('default-uuid', rules)
|
|
||||||
|
|
||||||
uuid, routed = bot.resolve_pipeline_uuid('person', '123', '这是紧急消息')
|
|
||||||
assert uuid == 'urgent-pipeline'
|
|
||||||
assert routed is True
|
|
||||||
|
|
||||||
uuid, routed = bot.resolve_pipeline_uuid('person', '123', '普通消息')
|
|
||||||
assert uuid == 'default-uuid'
|
|
||||||
assert routed is False
|
|
||||||
|
|
||||||
def test_message_content_regex(self):
|
|
||||||
rules = [
|
|
||||||
{
|
|
||||||
'type': 'message_content',
|
|
||||||
'operator': 'regex',
|
|
||||||
'value': r'^/admin\b',
|
|
||||||
'pipeline_uuid': 'admin-pipeline',
|
|
||||||
}
|
|
||||||
]
|
|
||||||
bot = self._make_bot('default-uuid', rules)
|
|
||||||
|
|
||||||
uuid, routed = bot.resolve_pipeline_uuid('person', '123', '/admin help')
|
|
||||||
assert uuid == 'admin-pipeline'
|
|
||||||
assert routed is True
|
|
||||||
|
|
||||||
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hello /admin')
|
|
||||||
assert uuid == 'default-uuid'
|
|
||||||
assert routed is False
|
|
||||||
|
|
||||||
def test_message_has_element_eq(self):
|
|
||||||
rules = [
|
|
||||||
{
|
|
||||||
'type': 'message_has_element',
|
|
||||||
'operator': 'eq',
|
|
||||||
'value': 'Image',
|
|
||||||
'pipeline_uuid': 'image-pipeline',
|
|
||||||
}
|
|
||||||
]
|
|
||||||
bot = self._make_bot('default-uuid', rules)
|
|
||||||
|
|
||||||
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hi', ['Plain', 'Image'])
|
|
||||||
assert uuid == 'image-pipeline'
|
|
||||||
assert routed is True
|
|
||||||
|
|
||||||
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hi', ['Plain'])
|
|
||||||
assert uuid == 'default-uuid'
|
|
||||||
assert routed is False
|
|
||||||
|
|
||||||
def test_message_has_element_neq(self):
|
|
||||||
rules = [
|
|
||||||
{
|
|
||||||
'type': 'message_has_element',
|
|
||||||
'operator': 'neq',
|
|
||||||
'value': 'Image',
|
|
||||||
'pipeline_uuid': 'text-only-pipeline',
|
|
||||||
}
|
|
||||||
]
|
|
||||||
bot = self._make_bot('default-uuid', rules)
|
|
||||||
|
|
||||||
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hi', ['Plain'])
|
|
||||||
assert uuid == 'text-only-pipeline'
|
|
||||||
assert routed is True
|
|
||||||
|
|
||||||
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hi', ['Plain', 'Image'])
|
|
||||||
assert uuid == 'default-uuid'
|
|
||||||
assert routed is False
|
|
||||||
|
|
||||||
def test_message_has_element_no_types_provided(self):
|
|
||||||
"""When element types are not provided, should not match."""
|
|
||||||
rules = [
|
|
||||||
{
|
|
||||||
'type': 'message_has_element',
|
|
||||||
'operator': 'eq',
|
|
||||||
'value': 'Image',
|
|
||||||
'pipeline_uuid': 'image-pipeline',
|
|
||||||
}
|
|
||||||
]
|
|
||||||
bot = self._make_bot('default-uuid', rules)
|
|
||||||
|
|
||||||
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hi')
|
|
||||||
assert uuid == 'default-uuid'
|
|
||||||
assert routed is False
|
|
||||||
|
|
||||||
def test_first_match_wins(self):
|
|
||||||
rules = [
|
|
||||||
{
|
|
||||||
'type': 'launcher_type',
|
|
||||||
'operator': 'eq',
|
|
||||||
'value': 'group',
|
|
||||||
'pipeline_uuid': 'first-pipeline',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'type': 'launcher_type',
|
|
||||||
'operator': 'eq',
|
|
||||||
'value': 'group',
|
|
||||||
'pipeline_uuid': 'second-pipeline',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
bot = self._make_bot('default-uuid', rules)
|
|
||||||
|
|
||||||
uuid, routed = bot.resolve_pipeline_uuid('group', '123', 'hi')
|
|
||||||
assert uuid == 'first-pipeline'
|
|
||||||
assert routed is True
|
|
||||||
|
|
||||||
def test_skip_invalid_rules(self):
|
|
||||||
rules = [
|
|
||||||
{'type': '', 'operator': 'eq', 'value': 'x', 'pipeline_uuid': 'p1'},
|
|
||||||
{'type': 'launcher_type', 'operator': 'eq', 'value': 'person', 'pipeline_uuid': ''},
|
|
||||||
{'type': 'launcher_type', 'operator': 'eq', 'value': 'person', 'pipeline_uuid': 'valid'},
|
|
||||||
]
|
|
||||||
bot = self._make_bot('default-uuid', rules)
|
|
||||||
|
|
||||||
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hi')
|
|
||||||
assert uuid == 'valid'
|
|
||||||
assert routed is True
|
|
||||||
|
|
||||||
def test_default_operator_is_eq(self):
|
|
||||||
rules = [
|
|
||||||
{
|
|
||||||
'type': 'launcher_type',
|
|
||||||
'value': 'person',
|
|
||||||
'pipeline_uuid': 'person-pipeline',
|
|
||||||
}
|
|
||||||
]
|
|
||||||
bot = self._make_bot('default-uuid', rules)
|
|
||||||
|
|
||||||
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hi')
|
|
||||||
assert uuid == 'person-pipeline'
|
|
||||||
assert routed is True
|
|
||||||
|
|
||||||
def test_discard_pipeline(self):
|
|
||||||
"""When pipeline_uuid is __discard__, the message should be discarded."""
|
|
||||||
from langbot.pkg.platform.botmgr import RuntimeBot
|
|
||||||
|
|
||||||
rules = [
|
|
||||||
{
|
|
||||||
'type': 'message_content',
|
|
||||||
'operator': 'contains',
|
|
||||||
'value': 'spam',
|
|
||||||
'pipeline_uuid': RuntimeBot.PIPELINE_DISCARD,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
bot = self._make_bot('default-uuid', rules)
|
|
||||||
|
|
||||||
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'this is spam')
|
|
||||||
assert uuid == RuntimeBot.PIPELINE_DISCARD
|
|
||||||
assert routed is True
|
|
||||||
|
|
||||||
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'normal message')
|
|
||||||
assert uuid == 'default-uuid'
|
|
||||||
assert routed is False
|
|
||||||
|
|
||||||
|
|
||||||
class TestEBAEventBindings:
|
class TestEBAEventBindings:
|
||||||
|
|||||||
@@ -381,6 +381,8 @@ export default function BotForm({
|
|||||||
description: form.getValues().description ?? '',
|
description: form.getValues().description ?? '',
|
||||||
adapter: form.getValues().adapter,
|
adapter: form.getValues().adapter,
|
||||||
adapter_config: form.getValues().adapter_config,
|
adapter_config: form.getValues().adapter_config,
|
||||||
|
enable: form.getValues().enable,
|
||||||
|
event_bindings: form.getValues().event_bindings ?? [],
|
||||||
};
|
};
|
||||||
httpClient
|
httpClient
|
||||||
.createBot(newBot)
|
.createBot(newBot)
|
||||||
@@ -448,27 +450,7 @@ export default function BotForm({
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Card 2: Event Routing (edit mode only) */}
|
{/* Card 2: Adapter Configuration */}
|
||||||
{initBotId && (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>{t('bots.eventRouting')}</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
{t('bots.eventRoutingDescription')}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<EventBindingsEditor
|
|
||||||
form={form}
|
|
||||||
botId={initBotId}
|
|
||||||
supportedEvents={adapterSupportedEvents[currentAdapter] || []}
|
|
||||||
agentOptions={agentNameList}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Card 4: Adapter Configuration */}
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('bots.adapterConfig')}</CardTitle>
|
<CardTitle>{t('bots.adapterConfig')}</CardTitle>
|
||||||
@@ -672,6 +654,26 @@ export default function BotForm({
|
|||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Card 3: Event Routing */}
|
||||||
|
{currentAdapter && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t('bots.eventRouting')}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t('bots.eventRoutingDescription')}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<EventBindingsEditor
|
||||||
|
form={form}
|
||||||
|
botId={initBotId}
|
||||||
|
supportedEvents={adapterSupportedEvents[currentAdapter] || []}
|
||||||
|
agentOptions={agentNameList}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ import {
|
|||||||
Agent,
|
Agent,
|
||||||
BotRouteDryRunResult,
|
BotRouteDryRunResult,
|
||||||
BotEventRouteStatus,
|
BotEventRouteStatus,
|
||||||
|
BotRouteTestResult,
|
||||||
} from '@/app/infra/entities/api';
|
} from '@/app/infra/entities/api';
|
||||||
import { backendClient } from '@/app/infra/http';
|
import { backendClient } from '@/app/infra/http';
|
||||||
|
|
||||||
@@ -240,11 +241,98 @@ function routeStatusBadgeClass(
|
|||||||
return 'border-border bg-muted/40 text-muted-foreground';
|
return 'border-border bg-muted/40 text-muted-foreground';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function localizedFailureReason(
|
||||||
|
failureCode: string | null | undefined,
|
||||||
|
fallback: string | null | undefined,
|
||||||
|
t: TFunction,
|
||||||
|
) {
|
||||||
|
if (!failureCode) return fallback || '';
|
||||||
|
const key = `bots.routeFailure.${failureCode}`;
|
||||||
|
const label = t(key);
|
||||||
|
return label === key ? fallback || failureCode : label;
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeStatusDetail(
|
||||||
|
status: BotEventRouteStatus | undefined,
|
||||||
|
t: TFunction,
|
||||||
|
) {
|
||||||
|
if (!status) return '';
|
||||||
|
if (status.failure_code) {
|
||||||
|
return localizedFailureReason(status.failure_code, status.reason, t);
|
||||||
|
}
|
||||||
|
if (status.last_status) {
|
||||||
|
const key = `bots.routeStatusDetail.${status.last_status}`;
|
||||||
|
const label = t(key);
|
||||||
|
if (label !== key) return label;
|
||||||
|
}
|
||||||
|
return status.reason || formatRouteStatusTime(status.timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
function diagnosticDetailLabel(
|
||||||
|
detail: Record<string, unknown>,
|
||||||
|
fallbackIndex: number,
|
||||||
|
t: TFunction,
|
||||||
|
) {
|
||||||
|
const order =
|
||||||
|
typeof detail.binding_index === 'number'
|
||||||
|
? detail.binding_index
|
||||||
|
: typeof detail.order === 'number'
|
||||||
|
? detail.order
|
||||||
|
: fallbackIndex;
|
||||||
|
const route = t('bots.dryRunRuleIndex', { index: order + 1 });
|
||||||
|
if (detail.selected) {
|
||||||
|
return t('bots.dryRunDiagnosticSelected', { route });
|
||||||
|
}
|
||||||
|
const failureCode =
|
||||||
|
typeof detail.failure_code === 'string' ? detail.failure_code : null;
|
||||||
|
const reason = localizedFailureReason(
|
||||||
|
failureCode,
|
||||||
|
typeof detail.reason === 'string' ? detail.reason : null,
|
||||||
|
t,
|
||||||
|
);
|
||||||
|
if (detail.matched) {
|
||||||
|
return t('bots.dryRunDiagnosticMatched', { route, reason });
|
||||||
|
}
|
||||||
|
return t('bots.dryRunDiagnosticSkipped', { route, reason });
|
||||||
|
}
|
||||||
|
|
||||||
function formatRouteStatusTime(timestamp: number | null | undefined) {
|
function formatRouteStatusTime(timestamp: number | null | undefined) {
|
||||||
if (!timestamp) return '';
|
if (!timestamp) return '';
|
||||||
return new Date(timestamp * 1000).toLocaleString();
|
return new Date(timestamp * 1000).toLocaleString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function samplePayloadForEvent(eventType: string): Record<string, unknown> {
|
||||||
|
if (eventType === 'message.received') {
|
||||||
|
return {
|
||||||
|
message_text: 'Hello',
|
||||||
|
chat_type: 'private',
|
||||||
|
chat_id: 'test-user',
|
||||||
|
user_id: 'test-user',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (eventType === 'group.member_joined') {
|
||||||
|
return {
|
||||||
|
group_id: 'test-group',
|
||||||
|
group_name: 'Test Group',
|
||||||
|
user_id: 'test-user',
|
||||||
|
user_name: 'Test User',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (eventType === 'group.member_left') {
|
||||||
|
return {
|
||||||
|
group_id: 'test-group',
|
||||||
|
group_name: 'Test Group',
|
||||||
|
user_id: 'test-user',
|
||||||
|
user_name: 'Test User',
|
||||||
|
is_kicked: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (eventType === 'platform.specific') {
|
||||||
|
return { action: 'test', data: {} };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
// ── target combobox (type + target merged, with search + groups) ───────────────
|
// ── target combobox (type + target merged, with search + groups) ───────────────
|
||||||
|
|
||||||
// Encoded value: "discard", "agent:<uuid>", "pipeline:<uuid>"
|
// Encoded value: "discard", "agent:<uuid>", "pipeline:<uuid>"
|
||||||
@@ -639,21 +727,29 @@ function RouteDryRunDialog({
|
|||||||
bindings,
|
bindings,
|
||||||
eventOptions,
|
eventOptions,
|
||||||
agentOptions,
|
agentOptions,
|
||||||
|
onRouteStatusUpdate,
|
||||||
}: {
|
}: {
|
||||||
botId?: string;
|
botId?: string;
|
||||||
bindings: EventBinding[];
|
bindings: EventBinding[];
|
||||||
eventOptions: string[];
|
eventOptions: string[];
|
||||||
agentOptions: Agent[];
|
agentOptions: Agent[];
|
||||||
|
onRouteStatusUpdate?: (statuses: BotEventRouteStatus[]) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const firstEvent = eventOptions[0] ?? DEFAULT_EVENTS[0];
|
const firstEvent = eventOptions[0] ?? DEFAULT_EVENTS[0];
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [eventType, setEventType] = useState(firstEvent);
|
const [eventType, setEventType] = useState(firstEvent);
|
||||||
const [payloadText, setPayloadText] = useState('{\n "message_text": ""\n}');
|
const [payloadText, setPayloadText] = useState(() =>
|
||||||
|
JSON.stringify(samplePayloadForEvent(firstEvent), null, 2),
|
||||||
|
);
|
||||||
|
const [advancedPayloadOpen, setAdvancedPayloadOpen] = useState(false);
|
||||||
const [isRunning, setIsRunning] = useState(false);
|
const [isRunning, setIsRunning] = useState(false);
|
||||||
|
const [isDispatching, setIsDispatching] = useState(false);
|
||||||
const [payloadError, setPayloadError] = useState<string | null>(null);
|
const [payloadError, setPayloadError] = useState<string | null>(null);
|
||||||
const [runError, setRunError] = useState<string | null>(null);
|
const [runError, setRunError] = useState<string | null>(null);
|
||||||
const [result, setResult] = useState<BotRouteDryRunResult | null>(null);
|
const [result, setResult] = useState<BotRouteDryRunResult | null>(null);
|
||||||
|
const [dispatchResult, setDispatchResult] =
|
||||||
|
useState<BotRouteTestResult | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!eventOptions.includes(eventType)) {
|
if (!eventOptions.includes(eventType)) {
|
||||||
@@ -661,6 +757,13 @@ function RouteDryRunDialog({
|
|||||||
}
|
}
|
||||||
}, [eventOptions, eventType, firstEvent]);
|
}, [eventOptions, eventType, firstEvent]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPayloadText(JSON.stringify(samplePayloadForEvent(eventType), null, 2));
|
||||||
|
setPayloadError(null);
|
||||||
|
setResult(null);
|
||||||
|
setDispatchResult(null);
|
||||||
|
}, [eventType]);
|
||||||
|
|
||||||
function resolveTargetName(resultTarget?: BotRouteDryRunResult['target']) {
|
function resolveTargetName(resultTarget?: BotRouteDryRunResult['target']) {
|
||||||
if (!resultTarget) return '';
|
if (!resultTarget) return '';
|
||||||
if (resultTarget.target_name) return resultTarget.target_name;
|
if (resultTarget.target_name) return resultTarget.target_name;
|
||||||
@@ -669,11 +772,8 @@ function RouteDryRunDialog({
|
|||||||
return agent ? targetLabel(agent) : (resultTarget.target_uuid ?? '');
|
return agent ? targetLabel(agent) : (resultTarget.target_uuid ?? '');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runDryRun() {
|
function parsePayload(): Record<string, unknown> | null {
|
||||||
setPayloadError(null);
|
setPayloadError(null);
|
||||||
setRunError(null);
|
|
||||||
setResult(null);
|
|
||||||
|
|
||||||
let payload: Record<string, unknown> = {};
|
let payload: Record<string, unknown> = {};
|
||||||
if (payloadText.trim()) {
|
if (payloadText.trim()) {
|
||||||
try {
|
try {
|
||||||
@@ -684,14 +784,24 @@ function RouteDryRunDialog({
|
|||||||
Array.isArray(parsed)
|
Array.isArray(parsed)
|
||||||
) {
|
) {
|
||||||
setPayloadError(t('bots.dryRunPayloadObjectError'));
|
setPayloadError(t('bots.dryRunPayloadObjectError'));
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
payload = parsed as Record<string, unknown>;
|
payload = parsed as Record<string, unknown>;
|
||||||
} catch {
|
} catch {
|
||||||
setPayloadError(t('bots.dryRunPayloadJsonError'));
|
setPayloadError(t('bots.dryRunPayloadJsonError'));
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runDryRun() {
|
||||||
|
setRunError(null);
|
||||||
|
setResult(null);
|
||||||
|
setDispatchResult(null);
|
||||||
|
|
||||||
|
const payload = parsePayload();
|
||||||
|
if (payload === null) return;
|
||||||
|
|
||||||
if (!botId) {
|
if (!botId) {
|
||||||
setRunError(t('bots.dryRunNeedsSavedBot'));
|
setRunError(t('bots.dryRunNeedsSavedBot'));
|
||||||
@@ -717,6 +827,43 @@ function RouteDryRunDialog({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function dispatchTestEvent() {
|
||||||
|
setRunError(null);
|
||||||
|
setDispatchResult(null);
|
||||||
|
|
||||||
|
const payload = parsePayload();
|
||||||
|
if (payload === null) return;
|
||||||
|
|
||||||
|
if (!botId) {
|
||||||
|
setRunError(t('bots.dryRunNeedsSavedBot'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsDispatching(true);
|
||||||
|
try {
|
||||||
|
const testResult = await backendClient.testBotEventRoute(botId, {
|
||||||
|
event_type: eventType,
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
setDispatchResult(testResult);
|
||||||
|
onRouteStatusUpdate?.(testResult.route_status?.routes || []);
|
||||||
|
if (!testResult.dispatched) {
|
||||||
|
setRunError(
|
||||||
|
localizedFailureReason(
|
||||||
|
testResult.failure_code,
|
||||||
|
testResult.reason,
|
||||||
|
t,
|
||||||
|
) || t('bots.routeTestFailed'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as { msg?: string };
|
||||||
|
setRunError(err.msg || t('bots.routeTestFailed'));
|
||||||
|
} finally {
|
||||||
|
setIsDispatching(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const targetName = result ? resolveTargetName(result.target) : '';
|
const targetName = result ? resolveTargetName(result.target) : '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -738,7 +885,7 @@ function RouteDryRunDialog({
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid gap-3 sm:grid-cols-[220px_1fr]">
|
<div className="space-y-3">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-sm font-medium">
|
<label className="text-sm font-medium">
|
||||||
{t('bots.dryRunEventType')}
|
{t('bots.dryRunEventType')}
|
||||||
@@ -756,23 +903,55 @@ function RouteDryRunDialog({
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="rounded-md border bg-muted/20 px-3 py-2.5">
|
||||||
<label className="text-sm font-medium">
|
<div className="flex items-start justify-between gap-3">
|
||||||
{t('bots.dryRunPayload')}
|
<div className="min-w-0">
|
||||||
</label>
|
<p className="text-sm font-medium">
|
||||||
<Textarea
|
{t('bots.dryRunSampleReady')}
|
||||||
value={payloadText}
|
</p>
|
||||||
onChange={(e) => setPayloadText(e.target.value)}
|
<p className="mt-0.5 text-xs leading-relaxed text-muted-foreground">
|
||||||
className="min-h-[118px] font-mono text-xs"
|
{t('bots.dryRunSampleDescription', {
|
||||||
spellCheck={false}
|
event: eventLabel(eventType, t),
|
||||||
placeholder='{"message_text": "hello"}'
|
})}
|
||||||
/>
|
</p>
|
||||||
{payloadError ? (
|
</div>
|
||||||
<p className="text-xs text-destructive">{payloadError}</p>
|
<Button
|
||||||
) : (
|
type="button"
|
||||||
<p className="text-xs text-muted-foreground">
|
variant="ghost"
|
||||||
{t('bots.dryRunPayloadHint')}
|
size="sm"
|
||||||
</p>
|
className="h-8 shrink-0 px-2 text-xs"
|
||||||
|
onClick={() => setAdvancedPayloadOpen((value) => !value)}
|
||||||
|
>
|
||||||
|
{advancedPayloadOpen ? (
|
||||||
|
<ChevronDown className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
{advancedPayloadOpen
|
||||||
|
? t('bots.dryRunHidePayload')
|
||||||
|
: t('bots.dryRunEditPayload')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{advancedPayloadOpen && (
|
||||||
|
<div className="mt-3 space-y-1.5 border-t pt-3">
|
||||||
|
<label className="text-xs font-medium">
|
||||||
|
{t('bots.dryRunPayload')}
|
||||||
|
</label>
|
||||||
|
<Textarea
|
||||||
|
value={payloadText}
|
||||||
|
onChange={(e) => setPayloadText(e.target.value)}
|
||||||
|
className="min-h-[118px] font-mono text-xs"
|
||||||
|
spellCheck={false}
|
||||||
|
placeholder='{"message_text": "hello"}'
|
||||||
|
/>
|
||||||
|
{payloadError ? (
|
||||||
|
<p className="text-xs text-destructive">{payloadError}</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t('bots.dryRunPayloadHint')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -832,23 +1011,54 @@ function RouteDryRunDialog({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{result.diagnostic_steps.length > 0 && (
|
{(result.diagnostic_details?.length ||
|
||||||
|
result.diagnostic_steps.length > 0) && (
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<div className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
<div className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||||
<ListChecks className="h-3.5 w-3.5" />
|
<ListChecks className="h-3.5 w-3.5" />
|
||||||
{t('bots.dryRunDiagnostics')}
|
{t('bots.dryRunDiagnostics')}
|
||||||
</div>
|
</div>
|
||||||
<ol className="space-y-1 pl-5 text-xs text-muted-foreground">
|
<ol className="space-y-1 pl-5 text-xs text-muted-foreground">
|
||||||
{result.diagnostic_steps.map((step, index) => (
|
{result.diagnostic_details?.length
|
||||||
<li key={`${index}:${step}`} className="list-decimal">
|
? result.diagnostic_details.map((detail, index) => (
|
||||||
{step}
|
<li
|
||||||
</li>
|
key={`${index}:${String(detail.binding_id ?? '')}`}
|
||||||
))}
|
className="list-decimal"
|
||||||
|
>
|
||||||
|
{diagnosticDetailLabel(detail, index, t)}
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
: result.diagnostic_steps.map((step, index) => (
|
||||||
|
<li
|
||||||
|
key={`${index}:${step}`}
|
||||||
|
className="list-decimal"
|
||||||
|
>
|
||||||
|
{step}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{dispatchResult?.dispatched && (
|
||||||
|
<Alert>
|
||||||
|
<CheckCircle2 className="h-4 w-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
{t('bots.routeTestDispatched', {
|
||||||
|
count: dispatchResult.suppressed_outputs?.length || 0,
|
||||||
|
})}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Alert className="border-amber-200 bg-amber-50/60 text-amber-900 dark:border-amber-900/50 dark:bg-amber-950/20 dark:text-amber-200">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
{t('bots.routeTestSideEffectWarning')}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
@@ -859,10 +1069,25 @@ function RouteDryRunDialog({
|
|||||||
>
|
>
|
||||||
{t('common.close')}
|
{t('common.close')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" onClick={runDryRun} disabled={isRunning}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={runDryRun}
|
||||||
|
disabled={isRunning || isDispatching}
|
||||||
|
>
|
||||||
<Play className="h-4 w-4 mr-1" />
|
<Play className="h-4 w-4 mr-1" />
|
||||||
{isRunning ? t('bots.dryRunRunning') : t('bots.dryRunAction')}
|
{isRunning ? t('bots.dryRunRunning') : t('bots.dryRunAction')}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={dispatchTestEvent}
|
||||||
|
disabled={isRunning || isDispatching}
|
||||||
|
>
|
||||||
|
<Activity className="h-4 w-4 mr-1" />
|
||||||
|
{isDispatching
|
||||||
|
? t('bots.routeTestRunning')
|
||||||
|
: t('bots.routeTestAction')}
|
||||||
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
@@ -905,6 +1130,7 @@ function BindingCardContent({
|
|||||||
const filterCount = (binding.filters as FilterRow[] | undefined)?.length ?? 0;
|
const filterCount = (binding.filters as FilterRow[] | undefined)?.length ?? 0;
|
||||||
const pipelineAllowed = isMessageEventPattern(binding.event_pattern);
|
const pipelineAllowed = isMessageEventPattern(binding.event_pattern);
|
||||||
const statusTime = formatRouteStatusTime(routeStatus?.timestamp);
|
const statusTime = formatRouteStatusTime(routeStatus?.timestamp);
|
||||||
|
const statusDetail = routeStatusDetail(routeStatus, t);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border bg-card">
|
<div className="rounded-lg border bg-card">
|
||||||
@@ -1002,9 +1228,11 @@ function BindingCardContent({
|
|||||||
</Badge>
|
</Badge>
|
||||||
<span
|
<span
|
||||||
className="max-w-full truncate text-[11px] text-muted-foreground"
|
className="max-w-full truncate text-[11px] text-muted-foreground"
|
||||||
title={routeStatus?.reason || routeStatus?.message || statusTime}
|
title={
|
||||||
|
statusTime ? `${statusDetail} · ${statusTime}` : statusDetail
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{routeStatus?.failure_code || routeStatus?.reason || statusTime}
|
{statusDetail || statusTime}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1325,6 +1553,7 @@ export default function EventBindingsEditor({
|
|||||||
bindings={bindings}
|
bindings={bindings}
|
||||||
eventOptions={dryRunEventOptions}
|
eventOptions={dryRunEventOptions}
|
||||||
agentOptions={agentOptions}
|
agentOptions={agentOptions}
|
||||||
|
onRouteStatusUpdate={setRouteStatuses}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -252,6 +252,11 @@ export interface BotRouteDryRunRequest {
|
|||||||
event_bindings?: EventBinding[];
|
event_bindings?: EventBinding[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BotRouteTestRequest {
|
||||||
|
event_type: string;
|
||||||
|
payload?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface BotRouteDryRunTarget {
|
export interface BotRouteDryRunTarget {
|
||||||
target_type: EventBinding['target_type'];
|
target_type: EventBinding['target_type'];
|
||||||
target_uuid?: string | null;
|
target_uuid?: string | null;
|
||||||
@@ -306,6 +311,17 @@ export interface BotEventRouteStatusResponse {
|
|||||||
stale_routes: BotEventRouteStatus[];
|
stale_routes: BotEventRouteStatus[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BotRouteTestResult {
|
||||||
|
dispatched: boolean;
|
||||||
|
event_type: string;
|
||||||
|
status?: BotEventRouteStatus['last_status'];
|
||||||
|
binding_id?: string | null;
|
||||||
|
failure_code?: string | null;
|
||||||
|
reason?: string | null;
|
||||||
|
suppressed_outputs: Array<Record<string, unknown>>;
|
||||||
|
route_status: BotEventRouteStatusResponse;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ApiRespKnowledgeBases {
|
export interface ApiRespKnowledgeBases {
|
||||||
bases: KnowledgeBase[];
|
bases: KnowledgeBase[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ import {
|
|||||||
ApiRespSkill,
|
ApiRespSkill,
|
||||||
BotRouteDryRunRequest,
|
BotRouteDryRunRequest,
|
||||||
BotRouteDryRunResult,
|
BotRouteDryRunResult,
|
||||||
|
BotRouteTestRequest,
|
||||||
|
BotRouteTestResult,
|
||||||
BotEventRouteStatusResponse,
|
BotEventRouteStatusResponse,
|
||||||
} from '@/app/infra/entities/api';
|
} from '@/app/infra/entities/api';
|
||||||
import { Plugin } from '@/app/infra/entities/plugin';
|
import { Plugin } from '@/app/infra/entities/plugin';
|
||||||
@@ -473,6 +475,16 @@ export class BackendClient extends BaseHttpClient {
|
|||||||
return this.get(`/api/v1/platform/bots/${botId}/event-routes/status`);
|
return this.get(`/api/v1/platform/bots/${botId}/event-routes/status`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public testBotEventRoute(
|
||||||
|
botId: string,
|
||||||
|
request: BotRouteTestRequest,
|
||||||
|
): Promise<BotRouteTestResult> {
|
||||||
|
return this.post(
|
||||||
|
`/api/v1/platform/bots/${botId}/event-routes/test`,
|
||||||
|
request,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public deleteBot(uuid: string): Promise<object> {
|
public deleteBot(uuid: string): Promise<object> {
|
||||||
return this.delete(`/api/v1/platform/bots/${uuid}`);
|
return this.delete(`/api/v1/platform/bots/${uuid}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -401,19 +401,53 @@ const enUS = {
|
|||||||
discarded: 'Discarded',
|
discarded: 'Discarded',
|
||||||
failed: 'Failed',
|
failed: 'Failed',
|
||||||
not_matched: 'Not matched',
|
not_matched: 'Not matched',
|
||||||
|
test_started: 'Testing',
|
||||||
},
|
},
|
||||||
|
routeStatusDetail: {
|
||||||
|
matched: 'This route matched the event.',
|
||||||
|
delivered: 'The processor received the event.',
|
||||||
|
discarded: 'The event was intentionally discarded.',
|
||||||
|
failed: 'The route could not finish.',
|
||||||
|
not_matched: 'No configured route matched the event.',
|
||||||
|
test_started: 'The saved route is running.',
|
||||||
|
},
|
||||||
|
routeFailure: {
|
||||||
|
binding_disabled: 'This route is disabled.',
|
||||||
|
event_pattern_mismatch: 'The event does not match this route.',
|
||||||
|
filters_mismatch: 'The sample data does not meet the route conditions.',
|
||||||
|
lower_priority: 'Another matching route has higher priority.',
|
||||||
|
route_not_found: 'No route matched this event.',
|
||||||
|
processor_incompatible:
|
||||||
|
'The selected processor cannot handle this event.',
|
||||||
|
processor_not_found: 'The selected processor is unavailable.',
|
||||||
|
processor_disabled: 'The selected processor is disabled.',
|
||||||
|
runner_failed: 'The Agent runner failed while processing the event.',
|
||||||
|
delivery_failed: 'The processor finished, but delivery failed.',
|
||||||
|
},
|
||||||
|
routeTestAction: 'Run saved route',
|
||||||
|
routeTestRunning: 'Running…',
|
||||||
|
routeTestFailed: 'Failed to run the saved route. Try again later.',
|
||||||
|
routeTestDispatched:
|
||||||
|
'The saved route ran successfully. {{count}} platform actions were blocked.',
|
||||||
|
routeTestSideEffectWarning:
|
||||||
|
'Running the saved route executes its processor. Platform messaging actions are blocked, but tools and external services may still have side effects.',
|
||||||
dryRunTitle: 'Test event route',
|
dryRunTitle: 'Test event route',
|
||||||
dryRunDescription:
|
dryRunDescription:
|
||||||
'Choose an event and provide a sample payload to see which processor the current route setup will use.',
|
'Preview the current form without running a processor, or run the saved route to validate the live configuration.',
|
||||||
dryRunEventType: 'Event type',
|
dryRunEventType: 'Event type',
|
||||||
dryRunPayload: 'Sample payload JSON',
|
dryRunSampleReady: 'Sample event is ready',
|
||||||
|
dryRunSampleDescription:
|
||||||
|
'LangBot prepared example data for {{event}}. Most route tests can use it as-is.',
|
||||||
|
dryRunEditPayload: 'Edit advanced data',
|
||||||
|
dryRunHidePayload: 'Hide advanced data',
|
||||||
|
dryRunPayload: 'Advanced event data (JSON)',
|
||||||
dryRunPayloadHint:
|
dryRunPayloadHint:
|
||||||
'Use simple fields such as message_text, chat_type, and chat_id to test conditions.',
|
'Use simple fields such as message_text, chat_type, and chat_id to test conditions.',
|
||||||
dryRunPayloadJsonError: 'Enter valid JSON.',
|
dryRunPayloadJsonError: 'Enter valid JSON.',
|
||||||
dryRunPayloadObjectError: 'Payload must be a JSON object.',
|
dryRunPayloadObjectError: 'Payload must be a JSON object.',
|
||||||
dryRunNeedsSavedBot: 'Save the bot before testing routes.',
|
dryRunNeedsSavedBot: 'Save the bot before testing routes.',
|
||||||
dryRunFailed: 'Route test failed. Try again later.',
|
dryRunFailed: 'Route test failed. Try again later.',
|
||||||
dryRunAction: 'Run test',
|
dryRunAction: 'Preview route',
|
||||||
dryRunRunning: 'Testing…',
|
dryRunRunning: 'Testing…',
|
||||||
dryRunMatched: 'Route matched',
|
dryRunMatched: 'Route matched',
|
||||||
dryRunNotMatched: 'No route matched',
|
dryRunNotMatched: 'No route matched',
|
||||||
@@ -423,6 +457,9 @@ const enUS = {
|
|||||||
dryRunRuleIndex: 'Route {{index}}',
|
dryRunRuleIndex: 'Route {{index}}',
|
||||||
dryRunNoRule: 'No matched rule',
|
dryRunNoRule: 'No matched rule',
|
||||||
dryRunDiagnostics: 'Diagnostic steps',
|
dryRunDiagnostics: 'Diagnostic steps',
|
||||||
|
dryRunDiagnosticSelected: '{{route}} was selected.',
|
||||||
|
dryRunDiagnosticMatched: '{{route}} matched. {{reason}}',
|
||||||
|
dryRunDiagnosticSkipped: '{{route}} was skipped. {{reason}}',
|
||||||
eventCustom: 'Custom event',
|
eventCustom: 'Custom event',
|
||||||
eventWildcard: 'All events',
|
eventWildcard: 'All events',
|
||||||
eventNamespaceWildcard: '{{namespace}}.*',
|
eventNamespaceWildcard: '{{namespace}}.*',
|
||||||
|
|||||||
@@ -408,12 +408,47 @@ const jaJP = {
|
|||||||
discarded: '破棄済み',
|
discarded: '破棄済み',
|
||||||
failed: '失敗',
|
failed: '失敗',
|
||||||
not_matched: '未一致',
|
not_matched: '未一致',
|
||||||
|
test_started: 'テスト中',
|
||||||
},
|
},
|
||||||
|
routeStatusDetail: {
|
||||||
|
matched: 'このルートがイベントに一致しました。',
|
||||||
|
delivered: 'プロセッサーがイベントを受信しました。',
|
||||||
|
discarded: '設定に従ってイベントを破棄しました。',
|
||||||
|
failed: 'ルートを完了できませんでした。',
|
||||||
|
not_matched: '設定済みルートに一致しませんでした。',
|
||||||
|
test_started: '保存済みルートを実行しています。',
|
||||||
|
},
|
||||||
|
routeFailure: {
|
||||||
|
binding_disabled: 'このルートは無効です。',
|
||||||
|
event_pattern_mismatch: 'イベントがこのルートに一致しません。',
|
||||||
|
filters_mismatch: 'サンプルデータがルート条件を満たしていません。',
|
||||||
|
lower_priority: '別の一致ルートの優先度が高く設定されています。',
|
||||||
|
route_not_found: 'このイベントに一致するルートがありません。',
|
||||||
|
processor_incompatible:
|
||||||
|
'選択したプロセッサーはこのイベントを処理できません。',
|
||||||
|
processor_not_found: '選択したプロセッサーを利用できません。',
|
||||||
|
processor_disabled: '選択したプロセッサーは無効です。',
|
||||||
|
runner_failed: 'Agent Runner がイベント処理中に失敗しました。',
|
||||||
|
delivery_failed: '処理は完了しましたが、結果の配信に失敗しました。',
|
||||||
|
},
|
||||||
|
routeTestAction: '保存済みルートを実行',
|
||||||
|
routeTestRunning: '実行中…',
|
||||||
|
routeTestFailed:
|
||||||
|
'保存済みルートの実行に失敗しました。後でもう一度お試しください。',
|
||||||
|
routeTestDispatched:
|
||||||
|
'保存済みルートを実行しました。{{count}} 件のプラットフォーム操作を抑制しました。',
|
||||||
|
routeTestSideEffectWarning:
|
||||||
|
'保存済みルートを実行するとプロセッサーが動作します。プラットフォームのメッセージ操作は抑制されますが、ツールや外部サービスには副作用が生じる場合があります。',
|
||||||
dryRunTitle: 'イベントルートをテスト',
|
dryRunTitle: 'イベントルートをテスト',
|
||||||
dryRunDescription:
|
dryRunDescription:
|
||||||
'イベントとサンプルペイロードを指定し、現在のルート設定でどのプロセッサーに送られるか確認します。',
|
'プロセッサーを実行せずに現在のフォームをプレビューするか、保存済みルートを実行して設定を検証します。',
|
||||||
dryRunEventType: 'イベントタイプ',
|
dryRunEventType: 'イベントタイプ',
|
||||||
dryRunPayload: 'サンプルペイロード JSON',
|
dryRunSampleReady: 'サンプルイベントを準備しました',
|
||||||
|
dryRunSampleDescription:
|
||||||
|
'LangBot が「{{event}}」用のサンプルデータを準備しました。通常はそのままテストできます。',
|
||||||
|
dryRunEditPayload: '詳細データを編集',
|
||||||
|
dryRunHidePayload: '詳細データを閉じる',
|
||||||
|
dryRunPayload: '詳細イベントデータ(JSON)',
|
||||||
dryRunPayloadHint:
|
dryRunPayloadHint:
|
||||||
'message_text、chat_type、chat_id などの簡単なフィールドで条件をテストできます。',
|
'message_text、chat_type、chat_id などの簡単なフィールドで条件をテストできます。',
|
||||||
dryRunPayloadJsonError: '有効な JSON を入力してください。',
|
dryRunPayloadJsonError: '有効な JSON を入力してください。',
|
||||||
@@ -421,7 +456,7 @@ const jaJP = {
|
|||||||
'ペイロードは JSON オブジェクトである必要があります。',
|
'ペイロードは JSON オブジェクトである必要があります。',
|
||||||
dryRunNeedsSavedBot: 'ルートをテストする前にボットを保存してください。',
|
dryRunNeedsSavedBot: 'ルートをテストする前にボットを保存してください。',
|
||||||
dryRunFailed: 'ルートテストに失敗しました。後でもう一度お試しください。',
|
dryRunFailed: 'ルートテストに失敗しました。後でもう一度お試しください。',
|
||||||
dryRunAction: 'テスト実行',
|
dryRunAction: 'ルートをプレビュー',
|
||||||
dryRunRunning: 'テスト中…',
|
dryRunRunning: 'テスト中…',
|
||||||
dryRunMatched: 'ルートに一致しました',
|
dryRunMatched: 'ルートに一致しました',
|
||||||
dryRunNotMatched: '一致するルートはありません',
|
dryRunNotMatched: '一致するルートはありません',
|
||||||
@@ -431,6 +466,9 @@ const jaJP = {
|
|||||||
dryRunRuleIndex: 'ルート {{index}}',
|
dryRunRuleIndex: 'ルート {{index}}',
|
||||||
dryRunNoRule: '一致したルールなし',
|
dryRunNoRule: '一致したルールなし',
|
||||||
dryRunDiagnostics: '診断ステップ',
|
dryRunDiagnostics: '診断ステップ',
|
||||||
|
dryRunDiagnosticSelected: '{{route}}を選択しました。',
|
||||||
|
dryRunDiagnosticMatched: '{{route}}が一致しました。{{reason}}',
|
||||||
|
dryRunDiagnosticSkipped: '{{route}}をスキップしました。{{reason}}',
|
||||||
eventCustom: 'カスタムイベント',
|
eventCustom: 'カスタムイベント',
|
||||||
eventWildcard: 'すべてのイベント',
|
eventWildcard: 'すべてのイベント',
|
||||||
eventNamespaceWildcard: '{{namespace}}.*',
|
eventNamespaceWildcard: '{{namespace}}.*',
|
||||||
|
|||||||
@@ -386,19 +386,51 @@ const zhHans = {
|
|||||||
discarded: '已丢弃',
|
discarded: '已丢弃',
|
||||||
failed: '失败',
|
failed: '失败',
|
||||||
not_matched: '未命中',
|
not_matched: '未命中',
|
||||||
|
test_started: '测试中',
|
||||||
},
|
},
|
||||||
|
routeStatusDetail: {
|
||||||
|
matched: '此路由已命中事件。',
|
||||||
|
delivered: '处理器已收到事件。',
|
||||||
|
discarded: '此事件已按路由配置丢弃。',
|
||||||
|
failed: '此路由未能完成。',
|
||||||
|
not_matched: '没有已配置路由命中此事件。',
|
||||||
|
test_started: '正在运行已保存路由。',
|
||||||
|
},
|
||||||
|
routeFailure: {
|
||||||
|
binding_disabled: '此路由已禁用。',
|
||||||
|
event_pattern_mismatch: '事件与此路由不匹配。',
|
||||||
|
filters_mismatch: '示例数据不满足路由条件。',
|
||||||
|
lower_priority: '另一条命中路由的优先级更高。',
|
||||||
|
route_not_found: '没有路由命中此事件。',
|
||||||
|
processor_incompatible: '所选处理器无法处理此事件。',
|
||||||
|
processor_not_found: '所选处理器不可用。',
|
||||||
|
processor_disabled: '所选处理器已禁用。',
|
||||||
|
runner_failed: 'Agent Runner 处理事件时失败。',
|
||||||
|
delivery_failed: '处理器已完成,但结果投递失败。',
|
||||||
|
},
|
||||||
|
routeTestAction: '运行已保存路由',
|
||||||
|
routeTestRunning: '运行中…',
|
||||||
|
routeTestFailed: '运行已保存路由失败,请稍后重试。',
|
||||||
|
routeTestDispatched: '已保存路由运行成功,{{count}} 个平台操作已被阻止。',
|
||||||
|
routeTestSideEffectWarning:
|
||||||
|
'运行已保存路由会执行处理器。平台消息操作会被阻止,但工具和外部服务仍可能产生副作用。',
|
||||||
dryRunTitle: '测试事件路由',
|
dryRunTitle: '测试事件路由',
|
||||||
dryRunDescription:
|
dryRunDescription:
|
||||||
'选择一个事件并提供示例载荷,检查当前路由配置会命中哪个处理器。',
|
'可以先预览当前表单而不运行处理器,也可以运行已保存路由来验证线上配置。',
|
||||||
dryRunEventType: '事件类型',
|
dryRunEventType: '事件类型',
|
||||||
dryRunPayload: '示例载荷 JSON',
|
dryRunSampleReady: '示例事件已准备好',
|
||||||
|
dryRunSampleDescription:
|
||||||
|
'LangBot 已为“{{event}}”准备示例数据,大多数路由测试可直接使用。',
|
||||||
|
dryRunEditPayload: '编辑高级数据',
|
||||||
|
dryRunHidePayload: '收起高级数据',
|
||||||
|
dryRunPayload: '高级事件数据(JSON)',
|
||||||
dryRunPayloadHint:
|
dryRunPayloadHint:
|
||||||
'可填写 message_text、chat_type、chat_id 等简单字段,用于匹配触发条件。',
|
'可填写 message_text、chat_type、chat_id 等简单字段,用于匹配触发条件。',
|
||||||
dryRunPayloadJsonError: '请输入合法 JSON。',
|
dryRunPayloadJsonError: '请输入合法 JSON。',
|
||||||
dryRunPayloadObjectError: '载荷必须是 JSON 对象。',
|
dryRunPayloadObjectError: '载荷必须是 JSON 对象。',
|
||||||
dryRunNeedsSavedBot: '请先保存机器人后再测试路由。',
|
dryRunNeedsSavedBot: '请先保存机器人后再测试路由。',
|
||||||
dryRunFailed: '路由测试失败,请稍后重试。',
|
dryRunFailed: '路由测试失败,请稍后重试。',
|
||||||
dryRunAction: '开始测试',
|
dryRunAction: '预览路由',
|
||||||
dryRunRunning: '测试中…',
|
dryRunRunning: '测试中…',
|
||||||
dryRunMatched: '已命中路由',
|
dryRunMatched: '已命中路由',
|
||||||
dryRunNotMatched: '未命中路由',
|
dryRunNotMatched: '未命中路由',
|
||||||
@@ -408,6 +440,9 @@ const zhHans = {
|
|||||||
dryRunRuleIndex: '第 {{index}} 条路由',
|
dryRunRuleIndex: '第 {{index}} 条路由',
|
||||||
dryRunNoRule: '无命中规则',
|
dryRunNoRule: '无命中规则',
|
||||||
dryRunDiagnostics: '诊断步骤',
|
dryRunDiagnostics: '诊断步骤',
|
||||||
|
dryRunDiagnosticSelected: '已选择{{route}}。',
|
||||||
|
dryRunDiagnosticMatched: '{{route}}已命中。{{reason}}',
|
||||||
|
dryRunDiagnosticSkipped: '{{route}}已跳过。{{reason}}',
|
||||||
eventCustom: '自定义事件',
|
eventCustom: '自定义事件',
|
||||||
eventWildcard: '全部事件',
|
eventWildcard: '全部事件',
|
||||||
eventNamespaceWildcard: '{{namespace}}.*',
|
eventNamespaceWildcard: '{{namespace}}.*',
|
||||||
|
|||||||
Reference in New Issue
Block a user