From d3ebe14b030446aad1bee78da867e7212edea459 Mon Sep 17 00:00:00 2001 From: Mark Mennell Date: Tue, 21 Jul 2026 08:35:10 +0800 Subject: [PATCH] fix(send_message): constrain opaque plugin fallback --- tests/tools/test_send_message_tool.py | 137 ++++++++++++++++++++++++++ tools/send_message_tool.py | 6 +- 2 files changed, 140 insertions(+), 3 deletions(-) diff --git a/tests/tools/test_send_message_tool.py b/tests/tools/test_send_message_tool.py index aaf7d9d7b6..2e4eec156f 100644 --- a/tests/tools/test_send_message_tool.py +++ b/tests/tools/test_send_message_tool.py @@ -275,6 +275,143 @@ def _ensure_slack_mock(monkeypatch): class TestSendMessageTool: + def test_resolved_opaque_plugin_target_uses_directory_id(self): + from gateway.platform_registry import PlatformEntry, platform_registry + + platform_name = "opaque-resolved-test" + entry = PlatformEntry( + name=platform_name, + label="Opaque resolved test", + adapter_factory=lambda cfg: None, + check_fn=lambda: True, + ) + platform_registry.register(entry) + platform = Platform(platform_name) + pconfig = SimpleNamespace(enabled=True, token=None, extra={}) + config = SimpleNamespace( + platforms={platform: pconfig}, + get_home_channel=lambda _platform: None, + ) + try: + with patch("gateway.config.load_gateway_config", return_value=config), \ + patch("tools.interrupt.is_interrupted", return_value=False), \ + patch( + "gateway.channel_directory.resolve_channel_name", + return_value="opaque:directory-id", + ), \ + patch("model_tools._run_async", side_effect=_run_async_immediately), \ + patch( + "tools.send_message_tool._send_to_platform", + new=AsyncMock(return_value={"success": True}), + ) as send_mock, \ + patch("gateway.mirror.mirror_to_session", return_value=True): + result = json.loads( + send_message_tool( + { + "action": "send", + "target": f"{platform_name}:Friendly name", + "message": "hello", + } + ) + ) + finally: + platform_registry.unregister(platform_name) + + assert result["success"] is True + send_mock.assert_awaited_once_with( + platform, + pconfig, + "opaque:directory-id", + "hello", + thread_id=None, + media_files=[], + force_document=False, + ) + + def test_unresolved_opaque_plugin_target_passes_through_verbatim(self): + from gateway.platform_registry import PlatformEntry, platform_registry + + platform_name = "opaque-verbatim-test" + entry = PlatformEntry( + name=platform_name, + label="Opaque verbatim test", + adapter_factory=lambda cfg: None, + check_fn=lambda: True, + ) + platform_registry.register(entry) + platform = Platform(platform_name) + pconfig = SimpleNamespace(enabled=True, token=None, extra={}) + config = SimpleNamespace( + platforms={platform: pconfig}, + get_home_channel=lambda _platform: None, + ) + try: + with patch("gateway.config.load_gateway_config", return_value=config), \ + patch("tools.interrupt.is_interrupted", return_value=False), \ + patch( + "gateway.channel_directory.resolve_channel_name", + return_value=None, + ), \ + patch("model_tools._run_async", side_effect=_run_async_immediately), \ + patch( + "tools.send_message_tool._send_to_platform", + new=AsyncMock(return_value={"success": True}), + ) as send_mock, \ + patch("gateway.mirror.mirror_to_session", return_value=True): + result = json.loads( + send_message_tool( + { + "action": "send", + "target": f"{platform_name}:dm:panyaozhen", + "message": "hello", + } + ) + ) + finally: + platform_registry.unregister(platform_name) + + assert result["success"] is True + send_mock.assert_awaited_once_with( + platform, + pconfig, + "dm:panyaozhen", + "hello", + thread_id=None, + media_files=[], + force_document=False, + ) + + def test_unresolved_builtin_target_keeps_directory_error(self): + config, _telegram_cfg = _make_config() + + with patch("gateway.config.load_gateway_config", return_value=config), \ + patch("gateway.channel_directory.resolve_channel_name", return_value=None), \ + patch( + "tools.send_message_tool._send_to_platform", + new=AsyncMock(return_value={"success": True}), + ) as send_mock: + result = json.loads( + send_message_tool( + { + "action": "send", + "target": "telegram:missing-room", + "message": "hello", + } + ) + ) + + assert result == { + "error": "Could not resolve 'missing-room' on telegram. " + "Use send_message(action='list') to see available targets." + } + send_mock.assert_not_awaited() + + def test_ntfy_topic_target_is_explicit(self): + chat_id, thread_id, is_explicit = _parse_target_ref("ntfy", "alerts-channel") + + assert chat_id == "alerts-channel" + assert thread_id is None + assert is_explicit is True def test_ntfy_topic_target_bypasses_channel_directory(self): ntfy_platform = Platform("ntfy") diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index df9377195b..92d89027fb 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -453,10 +453,10 @@ def _handle_send(args): # core. Their adapter owns final target validation. chat_id = target_ref elif resolution_failed: - return json.dumps({ - "error": f"Could not resolve '{target_ref}' on {platform_name}. " + return tool_error( + f"Could not resolve '{target_ref}' on {platform_name}. " f"Try using a numeric channel ID instead." - }) + ) else: return tool_error( f"Could not resolve '{target_ref}' on {platform_name}. "