diff --git a/skills/skills/langbot-mcp-ops/SKILL.md b/skills/skills/langbot-mcp-ops/SKILL.md index c9bfb9f0d..9f1b3b437 100644 --- a/skills/skills/langbot-mcp-ops/SKILL.md +++ b/skills/skills/langbot-mcp-ops/SKILL.md @@ -58,6 +58,7 @@ The tools wrap the LangBot service layer. Current tools (v1): | --- | --- | | `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_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_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 | @@ -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 `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 1. Get an API key (web UI key, or set `api.global_api_key` in config.yaml). diff --git a/src/langbot/pkg/api/http/controller/groups/platform/bots.py b/src/langbot/pkg/api/http/controller/groups/platform/bots.py index e0c7d1252..61505b247 100644 --- a/src/langbot/pkg/api/http/controller/groups/platform/bots.py +++ b/src/langbot/pkg/api/http/controller/groups/platform/bots.py @@ -69,6 +69,18 @@ class BotsRouterGroup(group.RouterGroup): auth_type=group.AuthType.USER_TOKEN_OR_API_KEY, )(_dry_run_event_route) + @self.route('//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('//send_message', methods=['POST'], auth_type=group.AuthType.API_KEY) async def _(bot_uuid: str) -> str: json_data = await quart.request.json diff --git a/src/langbot/pkg/api/http/service/bot.py b/src/langbot/pkg/api/http/service/bot.py index 43d457ea4..e023a71a4 100644 --- a/src/langbot/pkg/api/http/service/bot.py +++ b/src/langbot/pkg/api/http/service/bot.py @@ -440,7 +440,7 @@ class BotService: 'reason': 'Agent target is disabled', } ], - ) + ) if not RuntimeBot._agent_supports_event_type(getattr(agent, 'supported_event_patterns', None), event_type): return self._diagnostic_result( matched=False, @@ -636,7 +636,9 @@ class BotService: 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'] @@ -745,6 +747,58 @@ class BotService: '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: """Send message to a specific target via bot diff --git a/src/langbot/pkg/api/mcp/server.py b/src/langbot/pkg/api/mcp/server.py index 883d4ade9..ca96db793 100644 --- a/src/langbot/pkg/api/mcp/server.py +++ b/src/langbot/pkg/api/mcp/server.py @@ -113,6 +113,29 @@ class LangBotMCPServer: await ap.bot_service.delete_bot(bot_uuid) 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 ----------------------------------------------- # @mcp.tool(description='List all pipelines.') async def list_pipelines() -> str: diff --git a/src/langbot/pkg/platform/botmgr.py b/src/langbot/pkg/platform/botmgr.py index 163f09793..538b08b0d 100644 --- a/src/langbot/pkg/platform/botmgr.py +++ b/src/langbot/pkg/platform/botmgr.py @@ -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 +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: """运行时机器人""" @@ -53,7 +271,7 @@ class RuntimeBot: adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter - task_wrapper: taskmgr.TaskWrapper + task_wrapper: taskmgr.TaskWrapper | None task_context: taskmgr.TaskContext @@ -70,6 +288,7 @@ class RuntimeBot: self.bot_entity = bot_entity self.enable = bot_entity.enable self.adapter = adapter + self.task_wrapper = None self.task_context = taskmgr.TaskContext() self.logger = logger @@ -305,6 +524,126 @@ class RuntimeBot: """Return the selected event binding plus per-binding diagnostic steps.""" 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( self, *, @@ -318,7 +657,7 @@ class RuntimeBot: failure_code: str | None = None, reason: str | None = None, run_id: str | None = None, - ) -> None: + ) -> dict[str, typing.Any]: """Record structured event routing state while preserving the human log.""" binding = binding or {} metadata = { @@ -335,6 +674,7 @@ class RuntimeBot: } log_method = getattr(self.logger, level, self.logger.info) await log_method(text, metadata=metadata) + return metadata 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.""" @@ -752,6 +1092,7 @@ class RuntimeBot: self, envelope: AgentEventEnvelope, outputs: list[provider_message.Message | provider_message.MessageChunk], + adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter | SyntheticRouteTestAdapter | None = None, ) -> None: if not outputs or not envelope.delivery.reply_target: return @@ -773,7 +1114,8 @@ class RuntimeBot: if not final_text: return - await self.adapter.send_message( + delivery_adapter = adapter or self.adapter + await delivery_adapter.send_message( str(target_type), str(target_id), platform_message.MessageChain([platform_message.Plain(text=final_text)]), @@ -799,19 +1141,18 @@ class RuntimeBot: self, event: platform_events.EBAEvent, adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter, - ) -> None: + ) -> dict[str, typing.Any]: event_type = getattr(event, 'type', None) or event.__class__.__name__ event_binding = self._resolve_eba_event_binding(event, event_type) if event_binding is None: - await self._record_event_route_trace( + return await self._record_event_route_trace( event_type=event_type, status='not_matched', failure_code='route_not_found', reason='No event route matched', text=f'Platform event {event_type} ignored: no event route matched', ) - return target_type = event_binding.get('target_type') await self._record_event_route_trace( @@ -830,25 +1171,23 @@ class RuntimeBot: pipeline_uuid=self.PIPELINE_DISCARD, routed_by_event_binding=True, ) - await self._record_event_route_trace( + return await self._record_event_route_trace( event_type=event_type, status='discarded', binding=event_binding, target_type=target_type, text=f'EBA event {event_type} discarded by event binding', ) - return - await self._record_event_route_trace( + return await self._record_event_route_trace( event_type=event_type, status='discarded', binding=event_binding, target_type=target_type, text=f'EBA event {event_type} discarded by event binding', ) - return if target_type == 'pipeline': 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, status='failed', level='warning', @@ -859,14 +1198,13 @@ class RuntimeBot: reason='Pipeline targets only support message events', text=f'EBA event {event_type} ignored Pipeline target for non-message event', ) - return await self._dispatch_eba_message_to_pipeline( event, adapter, pipeline_uuid=event_binding.get('target_uuid'), routed_by_event_binding=True, ) - await self._record_event_route_trace( + return await self._record_event_route_trace( event_type=event_type, status='delivered', binding=event_binding, @@ -874,9 +1212,8 @@ class RuntimeBot: target_uuid=event_binding.get('target_uuid'), text=f'EBA event {event_type} delivered to Pipeline {event_binding.get("target_uuid") or ""}'.strip(), ) - return if target_type != 'agent': - await self._record_event_route_trace( + return await self._record_event_route_trace( event_type=event_type, status='failed', level='warning', @@ -887,12 +1224,11 @@ class RuntimeBot: reason=f'Unsupported event binding target type: {target_type}', text=f'EBA event {event_type} ignored unsupported target type {target_type}', ) - return target_uuid = event_binding.get('target_uuid') agent = await self.ap.agent_service.get_agent(target_uuid) 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, status='failed', level='warning', @@ -903,9 +1239,8 @@ class RuntimeBot: reason='Agent target not found', text=f'EBA event {event_type} target agent not found: {target_uuid}', ) - return if not agent.get('enabled', True): - await self._record_event_route_trace( + return await self._record_event_route_trace( event_type=event_type, status='failed', binding=event_binding, @@ -915,9 +1250,8 @@ class RuntimeBot: reason='Agent target is disabled', 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): - await self._record_event_route_trace( + return await self._record_event_route_trace( event_type=event_type, status='failed', binding=event_binding, @@ -927,11 +1261,10 @@ class RuntimeBot: reason='Agent target does not support this event type', 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) if binding is None: - await self._record_event_route_trace( + return await self._record_event_route_trace( event_type=event_type, status='failed', level='warning', @@ -942,7 +1275,6 @@ class RuntimeBot: reason='Agent target has no runner', text=f'EBA event {event_type} target agent has no runner: {target_uuid}', ) - return envelope = self._eba_event_to_agent_envelope(event, adapter) 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): outputs.append(output) except Exception: - await self._record_event_route_trace( + return await self._record_event_route_trace( event_type=event_type, status='failed', level='error', @@ -961,12 +1293,11 @@ class RuntimeBot: reason='Agent runner failed', text=f'Failed to run Agent for EBA event {event_type}: {traceback.format_exc()}', ) - return try: - await self._deliver_agent_outputs(envelope, outputs) + await self._deliver_agent_outputs(envelope, outputs, adapter=adapter) except Exception: - await self._record_event_route_trace( + return await self._record_event_route_trace( event_type=event_type, status='failed', level='error', @@ -977,8 +1308,7 @@ class RuntimeBot: reason='Agent output delivery failed', text=f'Failed to deliver Agent output for EBA event {event_type}: {traceback.format_exc()}', ) - return - await self._record_event_route_trace( + return await self._record_event_route_trace( event_type=event_type, status='delivered', binding=event_binding, @@ -1244,7 +1574,9 @@ class RuntimeBot: async def shutdown(self): 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消息输入输出的类 diff --git a/tests/integration/api/test_bots.py b/tests/integration/api/test_bots.py index 07654b5fd..4238294d2 100644 --- a/tests/integration/api/test_bots.py +++ b/tests/integration/api/test_bots.py @@ -139,6 +139,18 @@ def fake_bot_app(): '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() # Platform manager @@ -309,6 +321,34 @@ class TestBotEventRouteStatusEndpoint: 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') class TestBotSendMessageEndpoint: """Tests for bot send message endpoint.""" diff --git a/tests/manual/mcp_smoke.py b/tests/manual/mcp_smoke.py index 197724fff..1827df9d2 100644 --- a/tests/manual/mcp_smoke.py +++ b/tests/manual/mcp_smoke.py @@ -37,7 +37,20 @@ def build_ap() -> SimpleNamespace: ap.apikey_service = SimpleNamespace(verify_api_key=verify_api_key) 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.llm_model_service = SimpleNamespace(get_llm_models=AsyncMock(return_value=[])) @@ -96,7 +109,7 @@ async def main() -> int: tools = await session.list_tools() names = [t.name for t in tools.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: failures.append(f'missing tool {required}') @@ -114,6 +127,20 @@ async def main() -> int: else: 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() with contextlib.suppress(Exception): await asyncio.wait_for(server_task, timeout=5) diff --git a/tests/unit_tests/api/service/test_bot_service.py b/tests/unit_tests/api/service/test_bot_service.py index d061f1678..8e629f532 100644 --- a/tests/unit_tests/api/service/test_bot_service.py +++ b/tests/unit_tests/api/service/test_bot_service.py @@ -54,11 +54,7 @@ def _create_mock_result(items: list = None, first_item=None): def _compiled_update_values(statement): """Return update values without SQLAlchemy WHERE bind params.""" - return { - key: value - for key, value in statement.compile().params.items() - if not key.startswith('uuid_') - } + return {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): @@ -363,7 +359,8 @@ class TestBotServiceCreateBot: } } 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 pipeline_result = Mock() @@ -394,6 +391,7 @@ class TestBotServiceCreateBot: # Verify assert bot_uuid is not None assert len(bot_uuid) == 36 # UUID format + runtime_bot.run.assert_awaited_once() class TestBotServiceUpdateBot: @@ -1125,6 +1123,64 @@ class TestBotServiceEventRouteStatuses: 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: """Tests for send_message method.""" diff --git a/tests/unit_tests/api/test_mcp_server.py b/tests/unit_tests/api/test_mcp_server.py new file mode 100644 index 000000000..d4651e1f3 --- /dev/null +++ b/tests/unit_tests/api/test_mcp_server.py @@ -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' diff --git a/tests/unit_tests/platform/test_routing_rules.py b/tests/unit_tests/platform/test_routing_rules.py index 04740b86f..8bcc88ad5 100644 --- a/tests/unit_tests/platform/test_routing_rules.py +++ b/tests/unit_tests/platform/test_routing_rules.py @@ -1,6 +1,4 @@ -""" -RuntimeBot.resolve_pipeline_uuid and _match_operator unit tests -""" +"""RuntimeBot event binding and route observability unit tests.""" from types import SimpleNamespace from unittest.mock import AsyncMock, Mock @@ -8,54 +6,6 @@ from unittest.mock import AsyncMock, Mock 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: """Test structured event route trace logging.""" @@ -116,6 +66,176 @@ class TestEventRouteTrace: assert metadata['target_uuid'] == 'agent-1' 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: """Test platform EventLogger metadata compatibility.""" @@ -140,231 +260,26 @@ class TestEventLoggerMetadata: } -class TestResolvePipelineUuid: - """Test the resolve_pipeline_uuid method.""" +class TestRuntimeBotLifecycle: + """Test RuntimeBot startup and shutdown lifecycle edges.""" - @staticmethod - def _make_bot(default_pipeline: str, rules: list): + @pytest.mark.asyncio + 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 - bot_entity = Mock() - bot_entity.use_pipeline_uuid = default_pipeline - bot_entity.pipeline_routing_rules = rules + task_mgr = SimpleNamespace(cancel_task=Mock()) + bot = RuntimeBot( + ap=SimpleNamespace(task_mgr=task_mgr), + bot_entity=SimpleNamespace(enable=True), + adapter=SimpleNamespace(kill=AsyncMock()), + logger=Mock(), + ) - bot = object.__new__(RuntimeBot) - bot.bot_entity = bot_entity - return bot + await bot.shutdown() - def test_no_rules_returns_default(self): - bot = self._make_bot('default-uuid', []) - 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 + bot.adapter.kill.assert_awaited_once() + task_mgr.cancel_task.assert_not_called() class TestEBAEventBindings: diff --git a/web/src/app/home/bots/components/bot-form/BotForm.tsx b/web/src/app/home/bots/components/bot-form/BotForm.tsx index f7d41f552..cca68783f 100644 --- a/web/src/app/home/bots/components/bot-form/BotForm.tsx +++ b/web/src/app/home/bots/components/bot-form/BotForm.tsx @@ -377,6 +377,8 @@ export default function BotForm({ description: form.getValues().description ?? '', adapter: form.getValues().adapter, adapter_config: form.getValues().adapter_config, + enable: form.getValues().enable, + event_bindings: form.getValues().event_bindings ?? [], }; httpClient .createBot(newBot) @@ -444,27 +446,7 @@ export default function BotForm({ - {/* Card 2: Event Routing (edit mode only) */} - {initBotId && ( - - - {t('bots.eventRouting')} - - {t('bots.eventRoutingDescription')} - - - - - - - )} - - {/* Card 4: Adapter Configuration */} + {/* Card 2: Adapter Configuration */} {t('bots.adapterConfig')} @@ -668,6 +650,26 @@ export default function BotForm({ )} + + {/* Card 3: Event Routing */} + {currentAdapter && ( + + + {t('bots.eventRouting')} + + {t('bots.eventRoutingDescription')} + + + + + + + )} ); diff --git a/web/src/app/home/bots/components/bot-form/EventBindingsEditor.tsx b/web/src/app/home/bots/components/bot-form/EventBindingsEditor.tsx index 75ce722a3..15866b721 100644 --- a/web/src/app/home/bots/components/bot-form/EventBindingsEditor.tsx +++ b/web/src/app/home/bots/components/bot-form/EventBindingsEditor.tsx @@ -81,6 +81,7 @@ import { Agent, BotRouteDryRunResult, BotEventRouteStatus, + BotRouteTestResult, } from '@/app/infra/entities/api'; import { backendClient } from '@/app/infra/http'; @@ -240,11 +241,98 @@ function routeStatusBadgeClass( 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, + 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) { if (!timestamp) return ''; return new Date(timestamp * 1000).toLocaleString(); } +function samplePayloadForEvent(eventType: string): Record { + 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) ─────────────── // Encoded value: "discard", "agent:", "pipeline:" @@ -639,21 +727,29 @@ function RouteDryRunDialog({ bindings, eventOptions, agentOptions, + onRouteStatusUpdate, }: { botId?: string; bindings: EventBinding[]; eventOptions: string[]; agentOptions: Agent[]; + onRouteStatusUpdate?: (statuses: BotEventRouteStatus[]) => void; }) { const { t } = useTranslation(); const firstEvent = eventOptions[0] ?? DEFAULT_EVENTS[0]; const [open, setOpen] = useState(false); 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 [isDispatching, setIsDispatching] = useState(false); const [payloadError, setPayloadError] = useState(null); const [runError, setRunError] = useState(null); const [result, setResult] = useState(null); + const [dispatchResult, setDispatchResult] = + useState(null); useEffect(() => { if (!eventOptions.includes(eventType)) { @@ -661,6 +757,13 @@ function RouteDryRunDialog({ } }, [eventOptions, eventType, firstEvent]); + useEffect(() => { + setPayloadText(JSON.stringify(samplePayloadForEvent(eventType), null, 2)); + setPayloadError(null); + setResult(null); + setDispatchResult(null); + }, [eventType]); + function resolveTargetName(resultTarget?: BotRouteDryRunResult['target']) { if (!resultTarget) return ''; if (resultTarget.target_name) return resultTarget.target_name; @@ -669,11 +772,8 @@ function RouteDryRunDialog({ return agent ? targetLabel(agent) : (resultTarget.target_uuid ?? ''); } - async function runDryRun() { + function parsePayload(): Record | null { setPayloadError(null); - setRunError(null); - setResult(null); - let payload: Record = {}; if (payloadText.trim()) { try { @@ -684,14 +784,24 @@ function RouteDryRunDialog({ Array.isArray(parsed) ) { setPayloadError(t('bots.dryRunPayloadObjectError')); - return; + return null; } payload = parsed as Record; } catch { 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) { 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) : ''; return ( @@ -738,7 +885,7 @@ function RouteDryRunDialog({
-
+
-
- -