ntfy: tighten robustness, dedupe auth/truncation, add docs

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).
This commit is contained in:
Teknium 2026-05-23 14:55:53 -07:00
parent 6a8e131a0a
commit 3b096d6f6d
6 changed files with 330 additions and 24 deletions

View file

@ -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:

View file

@ -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")

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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',
],