From 338c07433699569c24c32df4a2d1a8b9472400a8 Mon Sep 17 00:00:00 2001 From: helix4u <4317663+helix4u@users.noreply.github.com> Date: Fri, 5 Jun 2026 19:15:47 -0600 Subject: [PATCH] fix(send-message): treat ntfy topic targets as explicit --- tests/tools/test_send_message_tool.py | 75 ++++++++++++++++++++++++++- tools/send_message_tool.py | 16 ++++-- 2 files changed, 87 insertions(+), 4 deletions(-) diff --git a/tests/tools/test_send_message_tool.py b/tests/tools/test_send_message_tool.py index 56b196a47c5..63f45e1e75c 100644 --- a/tests/tools/test_send_message_tool.py +++ b/tests/tools/test_send_message_tool.py @@ -4,7 +4,7 @@ import asyncio import json import os import sys -from types import SimpleNamespace +from types import ModuleType, SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -163,6 +163,48 @@ def _ensure_slack_mock(monkeypatch): class TestSendMessageTool: + 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") + ntfy_cfg = SimpleNamespace(enabled=True, token=None, extra={"topic": "hermes-in"}) + config = SimpleNamespace( + platforms={ntfy_platform: ntfy_cfg}, + get_home_channel=lambda _platform: None, + ) + + 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", side_effect=AssertionError("should not resolve ntfy topics")), \ + 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": "ntfy:alerts-channel", + "message": "done", + } + ) + ) + + assert result["success"] is True + send_mock.assert_awaited_once_with( + ntfy_platform, + ntfy_cfg, + "alerts-channel", + "done", + thread_id=None, + media_files=[], + force_document=False, + ) + def test_cron_duplicate_target_is_skipped_and_explained(self): home = SimpleNamespace(chat_id="-1001") config, _telegram_cfg = _make_config() @@ -2433,6 +2475,37 @@ class TestSendViaAdapterStandaloneFallback: standalone_sender_fn=send_fn, ) + @pytest.mark.asyncio + async def test_live_ntfy_adapter_receives_explicit_publish_topic(self, monkeypatch): + from tools.send_message_tool import _send_via_adapter + + platform = Platform("ntfy") + recorded = {} + + class Adapter: + async def send(self, *, chat_id, content, metadata=None): + recorded["chat_id"] = chat_id + recorded["content"] = content + recorded["metadata"] = metadata + return SimpleNamespace(success=True, message_id="ntfy-id") + + runner = SimpleNamespace(adapters={platform: Adapter()}) + fake_gateway_run = ModuleType("gateway.run") + fake_gateway_run._gateway_runner_ref = lambda: runner + monkeypatch.setitem(sys.modules, "gateway.run", fake_gateway_run) + + result = await _send_via_adapter( + platform, + SimpleNamespace(extra={"publish_topic": "configured-topic"}), + "alerts-channel", + "done", + ) + + assert result == {"success": True, "message_id": "ntfy-id"} + assert recorded["chat_id"] == "alerts-channel" + assert recorded["content"] == "done" + assert recorded["metadata"] == {"publish_topic": "alerts-channel"} + @pytest.mark.asyncio async def test_standalone_sender_fn_called_when_no_adapter(self, monkeypatch): """Registry has hook, runner ref returns None: the hook is awaited.""" diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index f8386a51e52..3009aac3b9b 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -143,7 +143,7 @@ SEND_MESSAGE_SCHEMA = { }, "target": { "type": "string", - "description": "Delivery target. Format: 'platform' (uses home channel), 'platform:#channel-name', 'platform:chat_id', or 'platform:chat_id:thread_id' for Telegram topics and Discord threads. Examples: 'telegram', 'telegram:-1001234567890:17585', 'discord:999888777:555444333', 'discord:#bot-home', 'slack:#engineering', 'signal:+155****4567', 'matrix:!roomid:server.org', 'matrix:@user:server.org', 'yuanbao:direct:' (DM), 'yuanbao:group:' (group chat)" + "description": "Delivery target. Format: 'platform' (uses home channel), 'platform:#channel-name', 'platform:chat_id', or 'platform:chat_id:thread_id' for Telegram topics and Discord threads. Examples: 'telegram', 'telegram:-1001234567890:17585', 'discord:999888777:555444333', 'discord:#bot-home', 'slack:#engineering', 'signal:+155****4567', 'matrix:!roomid:server.org', 'matrix:@user:server.org', 'ntfy:alerts-channel' (explicit ntfy topic), 'yuanbao:direct:' (DM), 'yuanbao:group:' (group chat)" }, "message": { "type": "string", @@ -395,6 +395,10 @@ def _parse_target_ref(platform_name: str, target_ref: str): if target_ref.strip().isdigit(): return f"group:{target_ref.strip()}", None, True return None, None, False + if platform_name == "ntfy": + topic = target_ref.strip() + if topic: + return topic, None, True if platform_name == "email": match = _EMAIL_TARGET_RE.fullmatch(target_ref) if match: @@ -502,6 +506,7 @@ async def _send_via_adapter( the runner weakref is ``None``). 3. A descriptive error explaining both options. """ + platform_name = platform.value if hasattr(platform, "value") else str(platform) runner = None try: from gateway.run import _gateway_runner_ref @@ -516,7 +521,13 @@ async def _send_via_adapter( adapter = None if adapter is not None: try: - metadata = {"thread_id": thread_id} if thread_id else None + metadata = {} + if thread_id: + metadata["thread_id"] = thread_id + if platform_name == "ntfy" and chat_id: + metadata["publish_topic"] = chat_id + if not metadata: + metadata = None result = await adapter.send(chat_id=chat_id, content=chunk, metadata=metadata) except asyncio.CancelledError: raise @@ -526,7 +537,6 @@ async def _send_via_adapter( return {"success": True, "message_id": result.message_id} return {"error": f"Adapter send failed: {result.error}"} - platform_name = platform.value if hasattr(platform, "value") else str(platform) entry = None try: from gateway.platform_registry import platform_registry