From 8055d0f09246555d9a7d9b95295def612e500c70 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Fri, 29 May 2026 13:06:21 -0700 Subject: [PATCH] test(ntfy): cover echo-tag filter; tag standalone send path Adds tests for the echo-loop fix (outgoing X-Tags header, inbound skip on tagged events, genuine tags pass through) and extends the tag to the out-of-process _standalone_send() path so cron / send_message deliveries to a self-subscribed topic are also skipped. Maps both contributors in release.py AUTHOR_MAP. Co-authored-by: liuhao1024 --- plugins/platforms/ntfy/adapter.py | 2 +- scripts/release.py | 2 + tests/gateway/test_ntfy_plugin.py | 79 +++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 1 deletion(-) diff --git a/plugins/platforms/ntfy/adapter.py b/plugins/platforms/ntfy/adapter.py index e53d987bc..4ab46cecf 100644 --- a/plugins/platforms/ntfy/adapter.py +++ b/plugins/platforms/ntfy/adapter.py @@ -530,7 +530,7 @@ async def _standalone_send( markdown_env = os.getenv("NTFY_MARKDOWN", "").strip().lower() markdown_enabled = bool(extra.get("markdown")) or markdown_env in ("1", "true", "yes") - headers = {"Content-Type": "text/plain; charset=utf-8", **_build_auth_header(token)} + headers = {"Content-Type": "text/plain; charset=utf-8", "X-Tags": _ECHO_TAG, **_build_auth_header(token)} if markdown_enabled: headers["X-Markdown"] = "true" diff --git a/scripts/release.py b/scripts/release.py index 6001fe34a..e2d896309 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -129,6 +129,8 @@ AUTHOR_MAP = { "32711803+waefrebeorn@users.noreply.github.com": "waefrebeorn", "32869278+dusterbloom@users.noreply.github.com": "dusterbloom", "liuhao1024@users.noreply.github.com": "liuhao1024", + "annguyenNous@users.noreply.github.com": "annguyenNous", + "285874597+annguyenNous@users.noreply.github.com": "annguyenNous", "kylekahraman@users.noreply.github.com": "kylekahraman", "130975919+kylekahraman@users.noreply.github.com": "kylekahraman", "seppe@fushia.be": "seppegadeyne", diff --git a/tests/gateway/test_ntfy_plugin.py b/tests/gateway/test_ntfy_plugin.py index f2f24ae4a..f59ee2d6a 100644 --- a/tests/gateway/test_ntfy_plugin.py +++ b/tests/gateway/test_ntfy_plugin.py @@ -486,6 +486,22 @@ class TestSend: call_headers = mock_client.post.call_args[1]["headers"] assert "X-Markdown" not in call_headers + def test_send_emits_echo_tag_header(self): + """Outgoing messages carry the echo-prevention tag so the adapter + can recognise and skip its own replies when subscribe topic == + publish topic (the default config that causes the loop).""" + adapter = self._make_adapter(topic="hermes-in") + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = {"id": "abc123"} + mock_client = AsyncMock() + mock_client.post = AsyncMock(return_value=mock_resp) + adapter._http_client = mock_client + + _run(adapter.send("hermes-in", "Hello!")) + call_headers = mock_client.post.call_args[1]["headers"] + assert call_headers.get("X-Tags") == _ntfy._ECHO_TAG + # --------------------------------------------------------------------------- # 8. Inbound message processing (identity invariant — security-critical) @@ -543,6 +559,47 @@ class TestOnMessage: _run(adapter._on_message(event)) assert len(calls) == 1 + def test_own_tagged_message_skipped(self): + """An incoming event carrying the adapter's echo tag is the agent's + own reply echoed back by ntfy — it must not be dispatched, otherwise + the agent replies to itself forever (issue #34447).""" + adapter = self._make_adapter() + calls = [] + + async def handler(event): + calls.append(event) + + adapter.set_message_handler(handler) + _run(adapter._on_message({ + "id": "echo-1", + "event": "message", + "topic": "hermes-in", + "message": "my own reply", + "tags": [_ntfy._ECHO_TAG], + "time": None, + })) + assert calls == [] + + def test_message_with_other_tags_still_dispatched(self): + """Tags unrelated to the echo sentinel must not suppress genuine + user messages.""" + adapter = self._make_adapter() + calls = [] + + async def handler(event): + calls.append(event) + + adapter.set_message_handler(handler) + _run(adapter._on_message({ + "id": "user-1", + "event": "message", + "topic": "hermes-in", + "message": "hello", + "tags": ["warning", "skull"], + "time": None, + })) + assert len(calls) == 1 + def test_timestamp_parsed_from_event(self): from datetime import timezone adapter = self._make_adapter() @@ -742,6 +799,28 @@ class TestStandaloneSend: posted_url = mock_client.post.call_args[0][0] assert posted_url == "https://ntfy.example.com/hermes-in" + def test_emits_echo_tag_header(self, monkeypatch): + """Out-of-process cron / send_message deliveries also carry the echo + tag, so a gateway subscribed to the same topic skips them too.""" + monkeypatch.setenv("NTFY_TOPIC", "hermes-in") + pconfig = MagicMock() + pconfig.extra = {"topic": "hermes-in"} + + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = {"id": "id-99"} + mock_client = AsyncMock() + mock_client.post = AsyncMock(return_value=mock_resp) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + + with patch.object(_ntfy, "httpx") as mock_httpx: + mock_httpx.AsyncClient.return_value = mock_client + _run(_standalone_send(pconfig, "hermes-in", "hi")) + + headers = mock_client.post.call_args[1]["headers"] + assert headers.get("X-Tags") == _ntfy._ECHO_TAG + def test_emits_bearer_token_when_configured(self, monkeypatch): monkeypatch.setenv("NTFY_TOPIC", "hermes-in") pconfig = MagicMock()