Files
hermes-agent/plugins/platforms/ntfy/adapter.py
T
Teknium 2776813df3 compat(plugins): temporary import-path shims for external plugins — ONE commit, revert on schedule
The Sep 2026 decomposition (PR #102117) makes internal import paths a non-API: names now live in
the focused modules that define them. This commit is the ONLY thing keeping the old paths alive,
so external plugins have time to update. It is deliberately a single, unsquashed commit:

    git revert <this sha>

removes every shim, stub and manifest at once on the announced date. Nothing in-tree may depend on
these pointers: scripts/check_compat_pointers.py (wired into lint.yml) fails CI if it does.

What it adds (see COMPAT_MANIFEST.md, compat_manifest.json):
- 332 facade modules get one delimited `PLUGIN-COMPAT` block appended at the end of the file
- 1,172 moved names resolved lazily via a module `__getattr__` (PEP 562) — never a top-level import,
  so no import cycles; facades that already had `__getattr__` get a chained one
- 592 third-party/stdlib names the old modules used to expose, with their original import statements
- 266 public definitions that had been deleted as unused, restored byte-for-byte from the pre-decomposition
  tree (+40 private helpers and 16 imports pulled in only because a restored definition needs them)
- 3 deleted modules recreated as re-export stubs (gateway/startup_watchdog, hermes_cli/observability/
  relay_runtime, tools/environments/modal_utils)
- private names (`_x`) get no pointer: they were never API (3,792 skipped)

Verified: all 335 touched modules import under a fresh HERMES_HOME and every manifest name resolves;
the lint reports zero in-tree uses; ruff clean; targeted suites unchanged.
2026-09-03 17:13:22 -07:00

408 lines
18 KiB
Python

"""ntfy platform adapter: HTTP-streaming subscription (``/json``, ``poll=false``) in, POST out.
config.yaml ``platforms.ntfy.extra``: ``server`` (default https://ntfy.sh), ``topic`` (required),
``publish_topic`` (defaults to topic), ``token`` (Bearer or ``user:pass`` Basic), ``markdown``
(default false). Env (read at construct time; ``extra`` wins over env): NTFY_TOPIC, NTFY_SERVER_URL,
NTFY_TOKEN, NTFY_PUBLISH_TOPIC, NTFY_MARKDOWN ("true"/"1"/"yes"), NTFY_ALLOWED_USERS (topic names),
NTFY_ALLOW_ALL_USERS (dev only), NTFY_HOME_CHANNEL, NTFY_HOME_CHANNEL_NAME.
Identity: ntfy has no authenticated user; ``title`` is publisher-controlled and NOT used for
authorization. Each topic is one trusted channel (``user_id`` == topic). Protect it with a read token.
"""
import asyncio
import json
import logging
import time
import uuid
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
try:
import httpx
HTTPX_AVAILABLE = True
except ImportError:
HTTPX_AVAILABLE = False
httpx = None # type: ignore[assignment]
from gateway.config import Platform, PlatformConfig
from gateway.platforms.base import BasePlatformAdapter, MessageEvent, MessageType, SendResult
from gateway.platforms._shared import get_scoped_secret as _get_scoped_secret
logger = logging.getLogger(__name__)
class _FatalStreamError(Exception):
"""Unrecoverable stream error (401, 404)."""
DEFAULT_SERVER = "https://ntfy.sh"
MAX_MESSAGE_LENGTH = 4096 # ntfy message body limit
DEDUP_WINDOW_SECONDS = 300
DEDUP_MAX_SIZE = 1000
RECONNECT_BACKOFF = [2, 5, 10, 30, 60]
STREAM_TIMEOUT_SECONDS = 90 # ntfy keepalive default is 55s; give margin
_ECHO_TAG = "hermes-agent" # tag added to outgoing messages for echo-loop prevention
_MARKDOWN_TRUTHY = ("1", "true", "yes")
def _build_auth_header(token: str) -> Dict[str, str]:
"""``Authorization`` header from an ntfy token; ``{}`` when unset.
Tokens are whitespace-stripped (pasted tokens often carry newlines that
would malform the header). ``user:pass`` → Basic, anything else → Bearer.
"""
token = (token or "").strip()
if not token:
return {}
if ":" in token:
import base64
return {"Authorization": f"Basic {base64.b64encode(token.encode()).decode()}"}
return {"Authorization": f"Bearer {token}"}
def _publish_headers(token: str, markdown: bool, *, auth_first: bool = True) -> Dict[str, str]:
"""Headers for a publish POST: auth (if any), plain-text body, echo tag, optional X-Markdown.
``auth_first`` pins the header order each call site has always sent on the wire.
"""
auth = _build_auth_header(token)
base = {"Content-Type": "text/plain; charset=utf-8", "X-Tags": _ECHO_TAG}
headers = {**auth, **base} if auth_first else {**base, **auth}
if markdown:
headers["X-Markdown"] = "true"
return headers
def _truncate_body(message: str, *, context: str) -> bytes:
"""Apply the ntfy 4096-char limit, logging a warning (tagged ``context``) on truncation."""
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 _response_message_id(resp) -> str:
"""ntfy's returned message id, or a random 12-hex fallback."""
try:
return resp.json().get("id") or uuid.uuid4().hex[:12]
except Exception:
return uuid.uuid4().hex[:12]
def _setting(extra: Dict[str, Any], key: str, env: str, default: str = "") -> str:
"""config.yaml ``extra[key]`` wins over the env var ``env``."""
return extra.get(key) or _get_scoped_secret(env, default)
def _server_url(extra: Dict[str, Any]) -> str:
return _setting(extra, "server", "NTFY_SERVER_URL", DEFAULT_SERVER).rstrip("/")
def check_requirements() -> bool:
"""Installable and minimally configured (reads NTFY_TOPIC directly — no full config load)."""
return HTTPX_AVAILABLE and bool(_get_scoped_secret("NTFY_TOPIC", "").strip())
def validate_config(config) -> bool:
"""True when a topic is configured (config.yaml ``extra`` or env)."""
return bool(_setting(getattr(config, "extra", {}) or {}, "topic", "NTFY_TOPIC"))
def is_connected(config) -> bool:
"""Check whether ntfy is configured (env or config.yaml)."""
return bool(_get_scoped_secret("NTFY_TOPIC") or (getattr(config, "extra", {}) or {}).get("topic", ""))
class NtfyAdapter(BasePlatformAdapter):
"""ntfy adapter: HTTP-streaming subscription in, HTTP POST publish out."""
MAX_MESSAGE_LENGTH = MAX_MESSAGE_LENGTH
def __init__(self, config: PlatformConfig):
super().__init__(config=config, platform=Platform("ntfy"))
extra = config.extra or {}
self._server: str = _server_url(extra)
self._topic: str = _setting(extra, "topic", "NTFY_TOPIC")
self._publish_topic: str = _setting(extra, "publish_topic", "NTFY_PUBLISH_TOPIC") or self._topic
self._token: str = _setting(extra, "token", "NTFY_TOKEN")
self._stream_task: Optional[asyncio.Task] = None
self._http_client: Optional["httpx.AsyncClient"] = None
self._seen_messages: Dict[str, float] = {} # msg_id -> timestamp (dedup)
# -- Connection lifecycle -----------------------------------------------
async def connect(self, *, is_reconnect: bool = False) -> bool:
"""Connect to ntfy by starting the streaming subscription task."""
if not HTTPX_AVAILABLE:
logger.warning("[%s] httpx not installed. Run: pip install httpx", self.name)
return False
if not self._topic:
logger.warning("[%s] NTFY_TOPIC not configured", self.name)
return False
try:
self._http_client = httpx.AsyncClient(timeout=None)
self._stream_task = asyncio.create_task(self._run_stream())
self._mark_connected()
logger.info("[%s] Connected — subscribing to %s/%s", self.name, self._server, self._topic)
self._wire_plugin_handlers(None)
return True
except Exception as e:
logger.error("[%s] Failed to connect: %s", self.name, e)
return False
async def _run_stream(self) -> None:
"""Subscribe to the ntfy topic with automatic reconnection."""
backoff_idx = 0
stream_start: float = 0.0
url = f"{self._server}/{self._topic}/json"
headers = self._auth_headers()
while self._running:
try:
logger.debug("[%s] Opening stream to %s", self.name, url)
stream_start = time.monotonic()
await self._consume_stream(url, headers)
except asyncio.CancelledError:
return
except _FatalStreamError:
self._running = False
return
except Exception as e:
if not self._running:
return
logger.warning("[%s] Stream error: %s", self.name, e)
if not self._running:
return
# Reset backoff if stream stayed alive for at least 60s
if time.monotonic() - stream_start >= 60.0:
backoff_idx = 0
delay = RECONNECT_BACKOFF[min(backoff_idx, len(RECONNECT_BACKOFF) - 1)]
logger.info("[%s] Reconnecting in %ds...", self.name, delay)
await asyncio.sleep(delay)
backoff_idx += 1
def _fatal_status(self, status_code: int) -> None:
"""401/404 are unrecoverable: log, set the fatal state and raise ``_FatalStreamError``."""
if status_code == 401:
logger.error(
"[%s] Authentication failed (401) — stopping reconnect loop. Check NTFY_TOKEN.", self.name)
code, detail = "ntfy_unauthorized", "ntfy server rejected auth (401). Check NTFY_TOKEN."
reason = "401 Unauthorized"
elif status_code == 404:
logger.error("[%s] Topic not found (404): %s — stopping reconnect loop.", self.name, self._topic)
code, detail = "ntfy_topic_not_found", f"ntfy topic '{self._topic}' returned 404. Check NTFY_TOPIC."
reason = "404 Not Found"
else:
return
self._set_fatal_error(code, detail, retryable=False)
raise _FatalStreamError(reason)
async def _consume_stream(self, url: str, headers: Dict[str, str]) -> None:
"""Open an HTTP streaming connection and dispatch events."""
# poll=false keeps a persistent streaming connection alive with keepalive events
async with self._http_client.stream(
"GET", url, headers=headers, params={"poll": "false"},
timeout=httpx.Timeout(connect=15.0, read=STREAM_TIMEOUT_SECONDS, write=15.0, pool=15.0),
) as response:
self._fatal_status(response.status_code)
response.raise_for_status()
async for line in response.aiter_lines():
if not self._running:
return
line = line.strip()
if not line:
continue
try:
event = json.loads(line)
except json.JSONDecodeError:
continue
if event.get("event") == "message":
await self._on_message(event)
async def disconnect(self) -> None:
"""Disconnect from ntfy."""
self._running = False
self._mark_disconnected()
if self._stream_task:
self._stream_task.cancel()
try:
await self._stream_task
except asyncio.CancelledError:
pass
self._stream_task = None
if self._http_client:
await self._http_client.aclose()
self._http_client = None
self._seen_messages.clear()
logger.info("[%s] Disconnected", self.name)
# -- Inbound message processing -----------------------------------------
async def _on_message(self, event: Dict[str, Any]) -> None:
"""Process an incoming ntfy message event."""
msg_id = event.get("id") or uuid.uuid4().hex
if self._is_duplicate(msg_id):
logger.debug("[%s] Duplicate message %s, skipping", self.name, msg_id)
return
if _ECHO_TAG in (event.get("tags") or []):
logger.debug("[%s] Skipping own message (echo tag)", self.name)
return
text = (event.get("message") or "").strip()
if not text:
logger.debug("[%s] Empty message body, skipping", self.name)
return
# No native user identity on ntfy: the publisher-controlled title must
# NOT drive authorization, so user_id is fixed to the topic name.
topic = event.get("topic") or self._topic
source = self.build_source(
chat_id=topic, chat_name=topic, chat_type="dm", user_id=topic, user_name=topic)
unix_ts, timestamp = event.get("time"), datetime.now(tz=timezone.utc)
try:
timestamp = datetime.fromtimestamp(int(unix_ts), tz=timezone.utc) if unix_ts else timestamp
except (ValueError, OSError, TypeError):
pass
message_event = MessageEvent(
text=text, message_type=MessageType.TEXT, source=source, message_id=msg_id,
raw_message=event, timestamp=timestamp)
logger.debug("[%s] Message on topic %s: %s", self.name, topic, text[:80])
await self.handle_message(message_event)
def _is_duplicate(self, msg_id: str) -> bool:
"""True if this message ID was already seen within the dedup window."""
now = time.time()
if len(self._seen_messages) > DEDUP_MAX_SIZE:
cutoff = now - DEDUP_WINDOW_SECONDS
self._seen_messages = {k: v for k, v in self._seen_messages.items() if v > cutoff}
if msg_id in self._seen_messages:
return True
self._seen_messages[msg_id] = now
return False
# -- Outbound messaging -------------------------------------------------
async def send(
self, chat_id: str, content: str, reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Publish a message to the configured publish topic."""
publish_topic = (metadata or {}).get("publish_topic") or self._publish_topic or chat_id
if not self._http_client:
return SendResult(success=False, error="HTTP client not initialized")
headers = _publish_headers(self._token, bool((self.config.extra or {}).get("markdown", False)))
if len(content) > self.MAX_MESSAGE_LENGTH:
logger.warning(
"[%s] Message truncated from %d to %d chars (ntfy limit)",
self.name, len(content), self.MAX_MESSAGE_LENGTH)
body = content[:self.MAX_MESSAGE_LENGTH].encode("utf-8")
try:
resp = await self._http_client.post(
f"{self._server}/{publish_topic}", content=body, headers=headers, timeout=15.0)
if resp.status_code < 300:
return SendResult(success=True, message_id=_response_message_id(resp))
body_text = resp.text
logger.warning("[%s] Send failed HTTP %d: %s", self.name, resp.status_code, body_text[:200])
return SendResult(success=False, error=f"HTTP {resp.status_code}: {body_text[:200]}")
except httpx.TimeoutException:
return SendResult(success=False, error="Timeout publishing to ntfy")
except Exception as e:
logger.error("[%s] Send error: %s", self.name, e)
return SendResult(success=False, error=str(e))
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
return {"name": chat_id, "type": "dm"}
def _auth_headers(self) -> Dict[str, str]:
return _build_auth_header(self._token)
# -- Plugin registration -----------------------------------------------------
def _env_enablement() -> dict | None:
"""Seed ``PlatformConfig.extra`` from env vars during gateway config load.
Runs BEFORE adapter construction so ``gateway status`` reflects env-only
setups without instantiating the HTTP client. ``None`` = not configured.
The ``home_channel`` key is lifted by the core hook into a ``HomeChannel``
on the ``PlatformConfig`` instead of being merged into ``extra``.
"""
topic = _get_scoped_secret("NTFY_TOPIC", "").strip()
if not topic:
return None
seed: dict = {
"topic": topic, "server": _get_scoped_secret("NTFY_SERVER_URL", DEFAULT_SERVER).rstrip("/")}
for key, env in (("publish_topic", "NTFY_PUBLISH_TOPIC"), ("token", "NTFY_TOKEN")):
value = _get_scoped_secret(env, "").strip()
if value:
seed[key] = value
markdown = _get_scoped_secret("NTFY_MARKDOWN", "").strip().lower()
if markdown:
seed["markdown"] = markdown in _MARKDOWN_TRUTHY
home = _get_scoped_secret("NTFY_HOME_CHANNEL", "").strip() or topic
if home:
seed["home_channel"] = {"chat_id": home, "name": _get_scoped_secret("NTFY_HOME_CHANNEL_NAME", home)}
return seed
async def _standalone_send(
pconfig, chat_id: str, message: str, *,
thread_id: Optional[str] = None, media_files: Optional[List[str]] = None, force_document: bool = False,
) -> Dict[str, Any]:
"""Out-of-process publish for cron / send_message_tool when no gateway adapter is live.
``thread_id``/``media_files`` are signature parity only (ntfy has no thread
or attachment primitive). Markdown is honored if ``NTFY_MARKDOWN`` is set
OR ``pconfig.extra["markdown"]`` is True.
"""
if not HTTPX_AVAILABLE:
return {"error": "ntfy standalone send: httpx not installed"}
extra = getattr(pconfig, "extra", {}) or {}
server = _server_url(extra)
publish_topic = (
chat_id or extra.get("publish_topic") or _get_scoped_secret("NTFY_PUBLISH_TOPIC", "").strip()
or extra.get("topic") or _get_scoped_secret("NTFY_TOPIC", "").strip())
if not publish_topic:
return {"error": "ntfy standalone send: NTFY_TOPIC not configured"}
token = _setting(extra, "token", "NTFY_TOKEN")
markdown_env = _get_scoped_secret("NTFY_MARKDOWN", "").strip().lower()
markdown = bool(extra.get("markdown")) or markdown_env in _MARKDOWN_TRUTHY
headers = _publish_headers(token, markdown, auth_first=False)
body = _truncate_body(message, context="ntfy standalone")
try:
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.post(f"{server}/{publish_topic}", content=body, headers=headers)
if resp.status_code >= 300:
return {"error": f"ntfy HTTP {resp.status_code}: {resp.text[:200]}"}
return {"success": True, "platform": "ntfy", "chat_id": publish_topic, "message_id": _response_message_id(resp)}
except Exception as e:
return {"error": f"ntfy standalone send failed: {e}"}
def register(ctx) -> None:
"""Plugin entry point — called by the Hermes plugin system at startup."""
ctx.register_platform(
name="ntfy", label="ntfy", adapter_factory=lambda cfg: NtfyAdapter(cfg),
check_fn=check_requirements, validate_config=validate_config, is_connected=is_connected,
required_env=["NTFY_TOPIC"], install_hint="pip install httpx # already a Hermes dependency",
env_enablement_fn=_env_enablement, # env-only setups show in `gateway status`
cron_deliver_env_var="NTFY_HOME_CHANNEL",
standalone_sender_fn=_standalone_send, # out-of-process cron delivery
allowed_users_env="NTFY_ALLOWED_USERS", allow_all_env="NTFY_ALLOW_ALL_USERS",
max_message_length=MAX_MESSAGE_LENGTH, emoji="🔔",
pii_safe=True, # topic names only — no phone numbers / emails to redact
allow_update_command=True,
platform_hint=(
"You are communicating via ntfy push notifications. "
"Use plain text by default — ntfy supports optional markdown "
"(set markdown: true in config or NTFY_MARKDOWN=true). "
"Keep responses concise; ntfy is a push notification service "
"with a 4096-character per-message limit."
))
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
import os # noqa: F401,E402
# ---- END PLUGIN-COMPAT ----