From 3b096d6f6dbe8253aed46a2bc6b1304aaa9097d4 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 23 May 2026 14:55:53 -0700 Subject: [PATCH] ntfy: tighten robustness, dedupe auth/truncation, add docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Robustness: - Surface 401/404 stream failures via _set_fatal_error() so the gateway's runtime status reflects 'fatal: ntfy_unauthorized' / 'ntfy_topic_not_found' instead of staying 'connected' when the reconnect loop halts. Matches the pattern in whatsapp / telegram / sms adapters. - Strip whitespace from auth tokens so pasted tokens with trailing newlines don't produce malformed Authorization headers. Simplicity: - Extract _build_auth_header() and _truncate_body() to module-level helpers, used by both NtfyAdapter and _standalone_send. Removes the duplicated auth/truncation logic between the two paths. Docs: - website/docs/user-guide/messaging/ntfy.md — full setup guide, identity-model warning, self-hosting, cron usage, troubleshooting. - website/docs/reference/environment-variables.md — all 9 NTFY_* vars. - website/docs/user-guide/messaging/index.md — platform comparison row. - website/sidebars.ts — sidebar entry between simplex and open-webui. Tests: 78/78 (+ 10 new robustness tests covering token hygiene, fatal error propagation for 401/404, and the _truncate_body helper). --- plugins/platforms/ntfy/adapter.py | 75 ++++++--- tests/gateway/test_ntfy_plugin.py | 102 ++++++++++++ .../docs/reference/environment-variables.md | 18 ++ website/docs/user-guide/messaging/index.md | 3 +- website/docs/user-guide/messaging/ntfy.md | 155 ++++++++++++++++++ website/sidebars.ts | 1 + 6 files changed, 330 insertions(+), 24 deletions(-) create mode 100644 website/docs/user-guide/messaging/ntfy.md diff --git a/plugins/platforms/ntfy/adapter.py b/plugins/platforms/ntfy/adapter.py index 9d77a4e4e1a..b9280ab9e6e 100644 --- a/plugins/platforms/ntfy/adapter.py +++ b/plugins/platforms/ntfy/adapter.py @@ -83,6 +83,44 @@ RECONNECT_BACKOFF = [2, 5, 10, 30, 60] STREAM_TIMEOUT_SECONDS = 90 # ntfy keepalive default is 55s; give margin +def _build_auth_header(token: str) -> Dict[str, str]: + """Build an ``Authorization`` header from an ntfy token. + + Shared by :class:`NtfyAdapter._auth_headers` and :func:`_standalone_send` + so both paths follow the same auth shape and whitespace-stripping rules. + + Tokens are stripped of surrounding whitespace — pasted tokens often + carry trailing newlines that would otherwise render the header + malformed (``Authorization: Bearer foo\\n``). ``user:pass`` tokens + become Basic auth; anything else is treated as a Bearer token. + Returns ``{}`` when no token is configured. + """ + if not token: + return {} + token = token.strip() + if not token: + return {} + if ":" in token: + import base64 + encoded = base64.b64encode(token.encode()).decode() + return {"Authorization": f"Basic {encoded}"} + return {"Authorization": f"Bearer {token}"} + + +def _truncate_body(message: str, *, context: str) -> bytes: + """Apply the ntfy 4096-char limit, logging a warning on truncation. + + ``context`` is included in the log message so adapter and standalone + truncations can be told apart in logs. + """ + if len(message) > MAX_MESSAGE_LENGTH: + logger.warning( + "%s: truncating message from %d to %d chars (ntfy limit)", + context, len(message), MAX_MESSAGE_LENGTH, + ) + return message[:MAX_MESSAGE_LENGTH].encode("utf-8") + + def check_requirements() -> bool: """Check whether the ntfy adapter is installable and minimally configured. @@ -212,12 +250,22 @@ class NtfyAdapter(BasePlatformAdapter): "[%s] Authentication failed (401) — stopping reconnect loop. Check NTFY_TOKEN.", self.name, ) + self._set_fatal_error( + "ntfy_unauthorized", + "ntfy server rejected auth (401). Check NTFY_TOKEN.", + retryable=False, + ) raise _FatalStreamError("401 Unauthorized") if response.status_code == 404: logger.error( "[%s] Topic not found (404): %s — stopping reconnect loop.", self.name, self._topic, ) + self._set_fatal_error( + "ntfy_topic_not_found", + f"ntfy topic '{self._topic}' returned 404. Check NTFY_TOPIC.", + retryable=False, + ) raise _FatalStreamError("404 Not Found") response.raise_for_status() @@ -382,15 +430,7 @@ class NtfyAdapter(BasePlatformAdapter): def _auth_headers(self) -> Dict[str, str]: """Build Authorization header if a token is configured.""" - if not self._token: - return {} - # ntfy supports both Bearer tokens and Base64-encoded Basic auth; - # 'user:pass' pairs become Basic, anything else is treated as Bearer. - if ":" in self._token: - import base64 - encoded = base64.b64encode(self._token.encode()).decode() - return {"Authorization": f"Basic {encoded}"} - return {"Authorization": f"Bearer {self._token}"} + return _build_auth_header(self._token) # --------------------------------------------------------------------------- @@ -479,27 +519,16 @@ 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"} - if token: - if ":" in token: - import base64 - headers["Authorization"] = f"Basic {base64.b64encode(token.encode()).decode()}" - else: - headers["Authorization"] = f"Bearer {token}" + headers = {"Content-Type": "text/plain; charset=utf-8", **_build_auth_header(token)} if markdown_enabled: headers["X-Markdown"] = "true" - if len(message) > MAX_MESSAGE_LENGTH: - logger.warning( - "ntfy standalone: truncating message from %d to %d chars", - len(message), MAX_MESSAGE_LENGTH, - ) - body = message[:MAX_MESSAGE_LENGTH] + body = _truncate_body(message, context="ntfy standalone") url = f"{server}/{publish_topic}" try: async with httpx.AsyncClient(timeout=15.0) as client: - resp = await client.post(url, content=body.encode("utf-8"), headers=headers) + resp = await client.post(url, content=body, headers=headers) if resp.status_code >= 300: return {"error": f"ntfy HTTP {resp.status_code}: {resp.text[:200]}"} try: diff --git a/tests/gateway/test_ntfy_plugin.py b/tests/gateway/test_ntfy_plugin.py index 3f34c1e1c6b..40cf148de44 100644 --- a/tests/gateway/test_ntfy_plugin.py +++ b/tests/gateway/test_ntfy_plugin.py @@ -839,3 +839,105 @@ def test_adapter_factory_returns_ntfy_adapter(): cfg = PlatformConfig(enabled=True, extra={"topic": "t"}) adapter = factory(cfg) assert isinstance(adapter, NtfyAdapter) + + +# --------------------------------------------------------------------------- +# 12. Robustness — token hygiene + fatal-state propagation +# --------------------------------------------------------------------------- + + +class TestTokenHygiene: + """``_build_auth_header`` must strip pasted-token whitespace; pasted + tokens often carry trailing newlines that break the Authorization line.""" + + def test_trailing_whitespace_stripped(self): + assert _ntfy._build_auth_header(" tok123 ") == {"Authorization": "Bearer tok123"} + + def test_trailing_newline_stripped(self): + assert _ntfy._build_auth_header("tok123\n") == {"Authorization": "Bearer tok123"} + + def test_whitespace_only_returns_empty(self): + assert _ntfy._build_auth_header(" \n ") == {} + + def test_basic_auth_token_also_stripped(self): + h = _ntfy._build_auth_header(" user:pass ") + assert h["Authorization"].startswith("Basic ") + import base64 + assert h["Authorization"] == "Basic " + base64.b64encode(b"user:pass").decode() + + def test_adapter_strips_token_via_helper(self): + """The adapter delegates to _build_auth_header, so token whitespace + passed via config.extra is also stripped.""" + config = PlatformConfig(enabled=True, extra={"topic": "t", "token": " tok\n"}) + adapter = NtfyAdapter(config) + assert adapter._auth_headers() == {"Authorization": "Bearer tok"} + + +class TestFatalErrorPropagation: + """When the stream hits 401/404, the adapter must transition to the + ``fatal`` state via ``_set_fatal_error`` so the gateway's runtime + status reflects reality instead of staying 'connected'.""" + + def test_401_sets_fatal_unauthorized(self): + adapter = NtfyAdapter(PlatformConfig(enabled=True, extra={"topic": "t"})) + adapter._http_client = MagicMock() + + # Mock the streaming response + mock_response = MagicMock() + mock_response.status_code = 401 + # async-context-manager flavor for httpx.stream + mock_cm = AsyncMock() + mock_cm.__aenter__ = AsyncMock(return_value=mock_response) + mock_cm.__aexit__ = AsyncMock(return_value=None) + adapter._http_client.stream = MagicMock(return_value=mock_cm) + + fake_httpx = MagicMock() + fake_httpx.Timeout = MagicMock() + with patch.object(_ntfy, "httpx", fake_httpx): + with pytest.raises(_ntfy._FatalStreamError): + _run(adapter._consume_stream("https://ntfy.example/t/json", {})) + + assert adapter.has_fatal_error is True + assert adapter._fatal_error_code == "ntfy_unauthorized" + assert adapter._fatal_error_retryable is False + + def test_404_sets_fatal_topic_not_found(self): + adapter = NtfyAdapter(PlatformConfig(enabled=True, extra={"topic": "missing-topic"})) + adapter._http_client = MagicMock() + + mock_response = MagicMock() + mock_response.status_code = 404 + mock_cm = AsyncMock() + mock_cm.__aenter__ = AsyncMock(return_value=mock_response) + mock_cm.__aexit__ = AsyncMock(return_value=None) + adapter._http_client.stream = MagicMock(return_value=mock_cm) + + fake_httpx = MagicMock() + fake_httpx.Timeout = MagicMock() + with patch.object(_ntfy, "httpx", fake_httpx): + with pytest.raises(_ntfy._FatalStreamError): + _run(adapter._consume_stream("https://ntfy.example/missing-topic/json", {})) + + assert adapter.has_fatal_error is True + assert adapter._fatal_error_code == "ntfy_topic_not_found" + assert "missing-topic" in adapter._fatal_error_message + assert adapter._fatal_error_retryable is False + + +class TestTruncateHelper: + """``_truncate_body`` is shared between adapter.send() (inline truncation + today, may migrate) and ``_standalone_send``. It must cap to + MAX_MESSAGE_LENGTH and return bytes.""" + + def test_short_message_passes_through(self): + assert _ntfy._truncate_body("hi", context="test") == b"hi" + + def test_long_message_truncated(self): + long = "x" * (MAX_MESSAGE_LENGTH + 50) + result = _ntfy._truncate_body(long, context="test") + assert isinstance(result, bytes) + assert len(result) == MAX_MESSAGE_LENGTH + + def test_unicode_message_encoded(self): + result = _ntfy._truncate_body("héllo 🔔", context="test") + assert result == "héllo 🔔".encode("utf-8") diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index df125c73206..fe3a8e4fbc1 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -483,6 +483,24 @@ Used by the bundled LINE platform plugin (`plugins/platforms/line/`). See [Messa | `LINE_DELIVERED_TEXT` | Reply when an already-delivered postback is tapped again (default: `Already replied ✅`). | | `LINE_INTERRUPTED_TEXT` | Reply when a `/stop`-orphaned postback button is tapped (default: `Run was interrupted before completion.`). | +### ntfy (push notifications) + +[ntfy](https://ntfy.sh/) is a lightweight HTTP-based push notification service. Subscribe to a topic from the [ntfy mobile app](https://ntfy.sh/docs/subscribe/phone/), publish to that topic to talk to the agent. + +| Variable | Description | +|----------|-------------| +| `NTFY_TOPIC` | Topic to subscribe to (incoming messages). Required. | +| `NTFY_SERVER_URL` | Server URL (default: `https://ntfy.sh`). Point at a self-hosted ntfy for privacy. | +| `NTFY_TOKEN` | Optional auth token. Bearer token (e.g. `tk_xyz`) or `user:pass` for Basic auth. | +| `NTFY_PUBLISH_TOPIC` | Topic for outgoing replies (defaults to `NTFY_TOPIC`). | +| `NTFY_MARKDOWN` | Set `true` to send replies with `X-Markdown: true` header. Default: `false`. | +| `NTFY_ALLOWED_USERS` | Allowlist (treated as user IDs; on ntfy these are topic names). Typically set to the same value as `NTFY_TOPIC`. | +| `NTFY_ALLOW_ALL_USERS` | Dev-only escape hatch — only safe on access-controlled private topics. Default: `false`. | +| `NTFY_HOME_CHANNEL` | Default delivery target for cron jobs with `deliver: ntfy`. | +| `NTFY_HOME_CHANNEL_NAME` | Human label for the home channel (defaults to the topic name). | + +See [the ntfy messaging guide](/docs/user-guide/messaging/ntfy) — particularly the **identity model** section — before deploying with untrusted topics. + ### Advanced Messaging Tuning Advanced per-platform knobs for throttling the outbound message batcher. Most users never need to touch these; defaults are set to respect each platform's rate limits without feeling sluggish. diff --git a/website/docs/user-guide/messaging/index.md b/website/docs/user-guide/messaging/index.md index 2dc130d8889..07d9158b65d 100644 --- a/website/docs/user-guide/messaging/index.md +++ b/website/docs/user-guide/messaging/index.md @@ -6,7 +6,7 @@ description: "Chat with Hermes from Telegram, Discord, Slack, WhatsApp, Signal, # Messaging Gateway -Chat with Hermes from Telegram, Discord, Slack, WhatsApp, Signal, SMS, Email, Home Assistant, Mattermost, Matrix, DingTalk, Feishu/Lark, WeCom, Weixin, BlueBubbles (iMessage), QQ, Yuanbao, Microsoft Teams, LINE, or your browser. The gateway is a single background process that connects to all your configured platforms, handles sessions, runs cron jobs, and delivers voice messages. +Chat with Hermes from Telegram, Discord, Slack, WhatsApp, Signal, SMS, Email, Home Assistant, Mattermost, Matrix, DingTalk, Feishu/Lark, WeCom, Weixin, BlueBubbles (iMessage), QQ, Yuanbao, Microsoft Teams, LINE, ntfy, or your browser. The gateway is a single background process that connects to all your configured platforms, handles sessions, runs cron jobs, and delivers voice messages. For the full voice feature set — including CLI microphone mode, spoken replies in messaging, and Discord voice-channel conversations — see [Voice Mode](/docs/user-guide/features/voice-mode) and [Use Voice Mode with Hermes](/docs/guides/use-voice-mode-with-hermes). @@ -35,6 +35,7 @@ For the full voice feature set — including CLI microphone mode, spoken replies | Yuanbao | ✅ | ✅ | ✅ | — | — | ✅ | ✅ | | Microsoft Teams | — | ✅ | — | ✅ | — | ✅ | — | | LINE | — | ✅ | ✅ | — | — | ✅ | — | +| ntfy | — | — | — | — | — | — | — | **Voice** = TTS audio replies and/or voice message transcription. **Images** = send/receive images. **Files** = send/receive file attachments. **Threads** = threaded conversations. **Reactions** = emoji reactions on messages. **Typing** = typing indicator while processing. **Streaming** = progressive message updates via editing. diff --git a/website/docs/user-guide/messaging/ntfy.md b/website/docs/user-guide/messaging/ntfy.md new file mode 100644 index 00000000000..c7ee2593e4c --- /dev/null +++ b/website/docs/user-guide/messaging/ntfy.md @@ -0,0 +1,155 @@ +# ntfy + +[ntfy](https://ntfy.sh/) is a simple HTTP-based pub-sub notification service. It works with the free public server at `ntfy.sh` or any self-hosted instance, and supports any client that can make HTTP requests — phones, browsers, scripts, watches. + +ntfy makes a great lightweight push channel for Hermes: subscribe to a topic from the [ntfy mobile app](https://ntfy.sh/docs/subscribe/phone/), send messages to the topic to talk to the agent, get the response back on your phone. + +## Prerequisites + +- A topic name (any unique string — `hermes-myname-2026` works fine) +- The [ntfy mobile app](https://ntfy.sh/docs/subscribe/phone/) installed and subscribed to that topic +- Optional: a self-hosted ntfy server, or an `ntfy.sh` account token for private/reserved topics + +That's it. No SDK, no daemon, no Node.js. The adapter uses `httpx` which is already a Hermes dependency. + +## Configure Hermes + +### Via setup wizard + +```bash +hermes setup gateway +``` + +Select **ntfy** and follow the prompts. + +### Via environment variables + +Add these to `~/.hermes/.env`: + +``` +NTFY_TOPIC=hermes-myname-2026 +NTFY_ALLOWED_USERS=hermes-myname-2026 +NTFY_HOME_CHANNEL=hermes-myname-2026 +``` + +| Variable | Required | Description | +|---|---|---| +| `NTFY_TOPIC` | Yes | Topic to subscribe to (incoming messages) | +| `NTFY_SERVER_URL` | Optional | Server URL (default: `https://ntfy.sh`) — point to a self-hosted ntfy for privacy | +| `NTFY_TOKEN` | Optional | Bearer token (e.g. `tk_xyz`) or `user:pass` for Basic auth | +| `NTFY_PUBLISH_TOPIC` | Optional | Different topic for outgoing replies (defaults to `NTFY_TOPIC`) | +| `NTFY_MARKDOWN` | Optional | Set `true` to send replies with `X-Markdown: true` header | +| `NTFY_ALLOWED_USERS` | Recommended | Comma-separated topic names allowed (treated as user IDs; see below) | +| `NTFY_ALLOW_ALL_USERS` | Optional | Set `true` to allow every publisher — only safe for private topics with read tokens | +| `NTFY_HOME_CHANNEL` | Optional | Default topic for cron / notification delivery | +| `NTFY_HOME_CHANNEL_NAME` | Optional | Human label for the home channel | + +## Identity model — read this before deploying + +ntfy has no native authenticated user identity. The `title` field on a published message is **publisher-controlled** and can be anything the sender wants. The Hermes adapter does NOT use `title` for authorization — it would let any publisher who knows the topic spoof an allowed user. + +Instead, **the topic name itself is the identity**. Every message published to the topic is treated as coming from the same logical user (the topic). `NTFY_ALLOWED_USERS` is therefore typically just the topic name itself — a single-entry allowlist that gates the whole channel. + +This means **anyone who knows the topic can talk to the agent**. To make that a real trust boundary: + +- **Self-host ntfy** and lock the topic down with [Access Control](https://docs.ntfy.sh/config/#access-control). Only authorized clients with the read/write token can publish. +- Or **use a private topic on ntfy.sh** ([reserved topics](https://docs.ntfy.sh/publish/#reserved-topics) require an account) and protect it with a `NTFY_TOKEN`. +- Or **pick a long, unguessable topic name** (`hermes-7d4f9c8b-2026`) and treat it as the shared secret. This is the lightest setup but the topic name leaks via any logs or screenshots. + +In all cases, do not put sensitive data through ntfy unless the underlying topic is access-controlled. + +## Quick start — talk to your agent from your phone + +1. Pick a topic name: `hermes-myname-2026` +2. On your phone: install the [ntfy app](https://ntfy.sh/docs/subscribe/phone/), tap **+**, enter `hermes-myname-2026` +3. On the host: + ```bash + echo 'NTFY_TOPIC=hermes-myname-2026' >> ~/.hermes/.env + echo 'NTFY_ALLOWED_USERS=hermes-myname-2026' >> ~/.hermes/.env + hermes gateway restart + ``` +4. From the ntfy app, send a message to the topic. The agent's reply lands as a push notification. + +## Using ntfy with cron jobs + +Once `NTFY_HOME_CHANNEL` is set, cron jobs can deliver to ntfy: + +```python +cronjob( + action="create", + schedule="every 1h", + deliver="ntfy", # uses NTFY_HOME_CHANNEL + prompt="Check for alerts and summarise." +) +``` + +Or target a specific topic explicitly: + +```python +send_message(target="ntfy:alerts-channel", message="Done!") +``` + +This works even when the cron runs out-of-process from the gateway — the plugin registers a `standalone_sender_fn` that opens its own HTTP connection. + +## Self-hosting ntfy + +If you want full control: + +```bash +# Docker +docker run -p 80:80 -it binwiederhier/ntfy serve + +# Native +go install heckel.io/ntfy/v2@latest +ntfy serve +``` + +Then point Hermes at it: + +``` +NTFY_SERVER_URL=https://ntfy.mydomain.com +NTFY_TOPIC=hermes +NTFY_TOKEN=tk_abc123 # if you've set up access control +``` + +Self-hosting gives you topic access control, message persistence policies, attachments, and emoji tags. See the [ntfy server docs](https://docs.ntfy.sh/install/). + +## Markdown formatting + +ntfy clients render markdown when the publisher sets the `X-Markdown: true` header. To enable for outgoing Hermes replies: + +``` +NTFY_MARKDOWN=true +``` + +Or in `config.yaml`: + +```yaml +platforms: + ntfy: + extra: + markdown: true +``` + +The mobile app supports a subset of CommonMark — bold, italic, lists, links, fenced code blocks. See [ntfy's markdown docs](https://docs.ntfy.sh/publish/#markdown-formatting) for the exact set. + +## Outgoing-only setup (notifications without inbound) + +If you only want Hermes to *push* notifications to ntfy (cron summaries, alerts) and never accept messages back, set both `NTFY_TOPIC` and `NTFY_PUBLISH_TOPIC` to the same value and skip `NTFY_ALLOWED_USERS` entirely. With no allowlist, the agent never responds to inbound messages — your phone gets the pushes, but the conversation is one-way. + +## Limits + +- **Message size**: ntfy caps message bodies at 4096 chars. Hermes truncates with a warning when this is exceeded. +- **No typing indicators**: the protocol doesn't expose one; `send_typing` is a no-op. +- **No threads or attachments**: ntfy is plain push notifications. Long replies stay in the message body, no thread fanout. +- **No native user identity**: see the identity-model section above. + +## Troubleshooting + +**Auth failure / 401** — `NTFY_TOKEN` is wrong, or the token doesn't have publish/subscribe rights on this topic. The adapter halts its reconnect loop on 401 and the gateway runtime status will show `fatal: ntfy_unauthorized`. Fix the token and restart the gateway. + +**Topic not found / 404** — `NTFY_TOPIC` doesn't exist on the configured server. For ntfy.sh, topics are auto-created on first publish, so a 404 means you're pointed at a self-hosted server that doesn't have the topic provisioned. The adapter halts its reconnect loop with `fatal: ntfy_topic_not_found`. + +**Connected but no messages** — Check that `NTFY_ALLOWED_USERS` includes the topic name itself. With ntfy's identity model, the topic IS the user; leaving the allowlist empty rejects everything. + +**Reconnects every 60s** — The stream keepalive default is 55s; ntfy may have intermittent network issues. The adapter applies exponential backoff (2 → 5 → 10 → 30 → 60s) and resets to 0 once a stream stays alive ≥60s. diff --git a/website/sidebars.ts b/website/sidebars.ts index 640c0a1614c..e690ae2afce 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -636,6 +636,7 @@ const sidebars: SidebarsConfig = { 'user-guide/messaging/msgraph-webhook', 'user-guide/messaging/line', 'user-guide/messaging/simplex', + 'user-guide/messaging/ntfy', 'user-guide/messaging/open-webui', 'user-guide/messaging/webhooks', ],