mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-29 06:31:32 +00:00
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:
parent
6a8e131a0a
commit
3b096d6f6d
6 changed files with 330 additions and 24 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
155
website/docs/user-guide/messaging/ntfy.md
Normal file
155
website/docs/user-guide/messaging/ntfy.md
Normal 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.
|
||||
|
|
@ -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',
|
||||
],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue