diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index 3918c59b04c..9c36d205ac5 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -555,11 +555,6 @@ PLATFORM_HINTS = { "your response. Images are sent as native photos, and other files arrive as downloadable " "documents." ), - "ntfy": ( - "You are communicating via ntfy push notifications. " - "Use plain text by default — ntfy supports optional markdown (set markdown: true in config). " - "Keep responses concise; ntfy is a push notification service." - ), "yuanbao": ( "You are on Yuanbao (腾讯元宝), a Chinese AI assistant platform. " "Markdown formatting is supported (code blocks, tables, bold/italic). " diff --git a/cron/scheduler.py b/cron/scheduler.py index c4ed3fbb691..6b511d38b77 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -93,7 +93,7 @@ _KNOWN_DELIVERY_PLATFORMS = frozenset({ "telegram", "discord", "slack", "whatsapp", "signal", "matrix", "mattermost", "homeassistant", "dingtalk", "feishu", "wecom", "wecom_callback", "weixin", "sms", "email", "webhook", "bluebubbles", - "qqbot", "yuanbao", "ntfy", + "qqbot", "yuanbao", }) # Platforms that support a configured cron/notification home target, mapped to diff --git a/gateway/channel_directory.py b/gateway/channel_directory.py index b4a105159b9..ff4af85a89a 100644 --- a/gateway/channel_directory.py +++ b/gateway/channel_directory.py @@ -79,8 +79,7 @@ async def build_channel_directory(adapters: Dict[Any, Any]) -> Dict[str, Any]: # Platforms that don't support direct channel enumeration get session-based # discovery automatically. Skip infrastructure entries that aren't messaging # platforms — everything else falls through to _build_from_sessions(). - # ntfy and other push-only platforms use session-based discovery - _SKIP_SESSION_DISCOVERY = frozenset({"local", "api_server", "webhook", "ntfy"}) + _SKIP_SESSION_DISCOVERY = frozenset({"local", "api_server", "webhook"}) for plat in Platform: plat_name = plat.value if plat_name in _SKIP_SESSION_DISCOVERY or plat_name in platforms: diff --git a/gateway/config.py b/gateway/config.py index adc9c3b1198..bc077b1994e 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -127,7 +127,6 @@ class Platform(Enum): BLUEBUBBLES = "bluebubbles" QQBOT = "qqbot" YUANBAO = "yuanbao" - NTFY = "ntfy" @classmethod def _missing_(cls, value): """Accept unknown platform names only for known plugin adapters. @@ -444,7 +443,6 @@ _PLATFORM_CONNECTED_CHECKERS: dict[Platform, Callable[[PlatformConfig], bool]] = (cfg.extra.get("client_id") or os.getenv("DINGTALK_CLIENT_ID")) and (cfg.extra.get("client_secret") or os.getenv("DINGTALK_CLIENT_SECRET")) ), - Platform.NTFY: lambda cfg: bool(cfg.extra.get("topic")), } @@ -1791,33 +1789,6 @@ def _apply_env_overrides(config: GatewayConfig) -> None: if yuanbao_group_allow_from: extra["group_allow_from"] = yuanbao_group_allow_from - # ntfy - ntfy_topic = os.getenv("NTFY_TOPIC") - if ntfy_topic: - if Platform.NTFY not in config.platforms: - config.platforms[Platform.NTFY] = PlatformConfig() - config.platforms[Platform.NTFY].enabled = True - config.platforms[Platform.NTFY].extra["topic"] = ntfy_topic - ntfy_server = os.getenv("NTFY_SERVER_URL", "https://ntfy.sh") - config.platforms[Platform.NTFY].extra["server"] = ntfy_server - ntfy_token = os.getenv("NTFY_TOKEN") - if ntfy_token: - config.platforms[Platform.NTFY].token = ntfy_token - config.platforms[Platform.NTFY].extra["token"] = ntfy_token - ntfy_publish_topic = os.getenv("NTFY_PUBLISH_TOPIC") - if ntfy_publish_topic: - config.platforms[Platform.NTFY].extra["publish_topic"] = ntfy_publish_topic - ntfy_home = os.getenv("NTFY_HOME_CHANNEL") - if ntfy_home: - config.platforms[Platform.NTFY].home_channel = HomeChannel( - platform=Platform.NTFY, - chat_id=ntfy_home, - name=os.getenv("NTFY_HOME_CHANNEL_NAME", "Home"), - ) - ntfy_markdown = os.getenv("NTFY_MARKDOWN", "").strip().lower() - if ntfy_markdown: - config.platforms[Platform.NTFY].extra["markdown"] = ntfy_markdown in ("1", "true", "yes") - # Session settings idle_minutes = os.getenv("SESSION_IDLE_MINUTES") if idle_minutes: diff --git a/gateway/run.py b/gateway/run.py index 5288ccf9ea9..9ca87452f97 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -3772,7 +3772,6 @@ class GatewayRunner: "BLUEBUBBLES_ALLOWED_USERS", "QQ_ALLOWED_USERS", "YUANBAO_ALLOWED_USERS", - "NTFY_ALLOWED_USERS", "GATEWAY_ALLOWED_USERS", ) _builtin_allow_all_vars = ( @@ -3788,7 +3787,6 @@ class GatewayRunner: "BLUEBUBBLES_ALLOW_ALL_USERS", "QQ_ALLOW_ALL_USERS", "YUANBAO_ALLOW_ALL_USERS", - "NTFY_ALLOW_ALL_USERS", ) # Also pick up plugin-registered platforms — each entry can declare # its own allowed_users_env / allow_all_env, so the warning stays @@ -6166,12 +6164,6 @@ class GatewayRunner: return None return QQAdapter(config) - elif platform == Platform.NTFY: - from gateway.platforms.ntfy import NtfyAdapter, check_ntfy_requirements - if not check_ntfy_requirements(): - logger.warning("ntfy: dependencies not met") - return None - return NtfyAdapter(config) elif platform == Platform.YUANBAO: from gateway.platforms.yuanbao import YuanbaoAdapter, WEBSOCKETS_AVAILABLE if not WEBSOCKETS_AVAILABLE: @@ -6248,7 +6240,6 @@ class GatewayRunner: Platform.BLUEBUBBLES: "BLUEBUBBLES_ALLOWED_USERS", Platform.QQBOT: "QQ_ALLOWED_USERS", Platform.YUANBAO: "YUANBAO_ALLOWED_USERS", - Platform.NTFY: "NTFY_ALLOWED_USERS", } platform_group_user_env_map = { Platform.TELEGRAM: "TELEGRAM_GROUP_ALLOWED_USERS", @@ -6275,7 +6266,6 @@ class GatewayRunner: Platform.BLUEBUBBLES: "BLUEBUBBLES_ALLOW_ALL_USERS", Platform.QQBOT: "QQ_ALLOW_ALL_USERS", Platform.YUANBAO: "YUANBAO_ALLOW_ALL_USERS", - Platform.NTFY: "NTFY_ALLOW_ALL_USERS", } # Bots admitted by {PLATFORM}_ALLOW_BOTS bypass the human allowlist (#4466). platform_allow_bots_map = { diff --git a/hermes_cli/status.py b/hermes_cli/status.py index 14d9bb4e3c9..5629da03fe3 100644 --- a/hermes_cli/status.py +++ b/hermes_cli/status.py @@ -423,7 +423,6 @@ def show_status(args): "BlueBubbles": ("BLUEBUBBLES_SERVER_URL", "BLUEBUBBLES_HOME_CHANNEL"), "QQBot": ("QQ_APP_ID", "QQ_HOME_CHANNEL"), "Yuanbao": ("YUANBAO_APP_ID", "YUANBAO_HOME_CHANNEL"), - "ntfy": ("NTFY_TOPIC", "NTFY_HOME_CHANNEL"), } for name, (token_var, home_var) in platforms.items(): diff --git a/plugins/platforms/ntfy/__init__.py b/plugins/platforms/ntfy/__init__.py new file mode 100644 index 00000000000..d4f1d7bf0e3 --- /dev/null +++ b/plugins/platforms/ntfy/__init__.py @@ -0,0 +1,3 @@ +from .adapter import register + +__all__ = ["register"] diff --git a/gateway/platforms/ntfy.py b/plugins/platforms/ntfy/adapter.py similarity index 53% rename from gateway/platforms/ntfy.py rename to plugins/platforms/ntfy/adapter.py index abf94f967f6..9d77a4e4e1a 100644 --- a/gateway/platforms/ntfy.py +++ b/plugins/platforms/ntfy/adapter.py @@ -1,16 +1,18 @@ -""" -ntfy platform adapter. +"""ntfy platform adapter (Hermes plugin). -Uses httpx streaming to receive messages published to a subscribed topic, -and HTTP POST to publish replies. Works with ntfy.sh or any self-hosted -ntfy server. +Subscribes to a topic on ntfy.sh or any self-hosted ntfy server via +HTTP streaming (``/json`` endpoint with ``poll=false``) and publishes +replies via HTTP POST. No external SDK — only httpx, which is already +a Hermes dependency. -Requires: - pip install httpx (already a dependency) - NTFY_TOPIC env var (and optionally NTFY_SERVER_URL, NTFY_TOKEN, - NTFY_PUBLISH_TOPIC) +This adapter ships as a Hermes platform plugin under +``plugins/platforms/ntfy/``. The Hermes plugin loader scans the +directory at startup, calls :func:`register`, and the platform becomes +available to ``gateway/run.py`` and ``tools/send_message_tool`` through +the registry — no edits to core files required. + +Configuration in config.yaml:: -Configuration in config.yaml: platforms: ntfy: enabled: true @@ -19,7 +21,27 @@ Configuration in config.yaml: topic: "hermes-in" # subscribe topic (incoming) publish_topic: "hermes-out" # optional — defaults to topic token: "..." # optional Bearer / Basic auth token - markdown: true # optional — enable markdown formatting (default: false) + markdown: true # optional — enable markdown (default: false) + +Environment variables (all read at adapter construct time, env wins over +config.yaml ``extra``): + + NTFY_TOPIC Topic to subscribe to (required) + NTFY_SERVER_URL Server URL (default: https://ntfy.sh) + NTFY_TOKEN Bearer token or 'user:pass' for Basic auth + NTFY_PUBLISH_TOPIC Reply topic (defaults to NTFY_TOPIC) + NTFY_MARKDOWN "true"/"1"/"yes" enables X-Markdown header + NTFY_ALLOWED_USERS Allowlist (treated by gateway as user IDs; + on ntfy these are topic names) + NTFY_ALLOW_ALL_USERS Allow any topic — dev only + NTFY_HOME_CHANNEL Default topic for cron / notification delivery + NTFY_HOME_CHANNEL_NAME Human label for the home channel + +Identity model: ntfy has no native authenticated user identity. The +``title`` field is publisher-controlled and is NOT used for +authorization. Each topic is treated as a single trusted channel — +``user_id`` is fixed to the topic name. Use a private topic protected +by a read token for any real trust boundary. """ import asyncio @@ -29,7 +51,7 @@ import os import time import uuid from datetime import datetime, timezone -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional try: import httpx @@ -52,6 +74,7 @@ logger = logging.getLogger(__name__) class _FatalStreamError(Exception): """Raised when a stream error is unrecoverable (e.g. 401, 404).""" + DEFAULT_SERVER = "https://ntfy.sh" MAX_MESSAGE_LENGTH = 4096 # ntfy message body limit DEDUP_WINDOW_SECONDS = 300 @@ -60,27 +83,45 @@ RECONNECT_BACKOFF = [2, 5, 10, 30, 60] STREAM_TIMEOUT_SECONDS = 90 # ntfy keepalive default is 55s; give margin -def check_ntfy_requirements() -> bool: - """Check if ntfy adapter dependencies are available and configured.""" +def check_requirements() -> bool: + """Check whether the ntfy adapter is installable and minimally configured. + + Reads ``NTFY_TOPIC`` directly to avoid the cost of a full + ``load_gateway_config()`` (which also writes to ``os.environ``) on + every pre-flight check. + """ if not HTTPX_AVAILABLE: return False - # Check env var directly — avoids the full config load (which also - # writes to os.environ) on every adapter pre-check call. topic = os.getenv("NTFY_TOPIC", "").strip() return bool(topic) +def validate_config(config) -> bool: + """Validate that the configured ntfy platform has a topic set.""" + extra = getattr(config, "extra", {}) or {} + topic = extra.get("topic") or os.getenv("NTFY_TOPIC", "") + return bool(topic) + + +def is_connected(config) -> bool: + """Check whether ntfy is configured (env or config.yaml).""" + extra = getattr(config, "extra", {}) or {} + topic = os.getenv("NTFY_TOPIC") or extra.get("topic", "") + return bool(topic) + + class NtfyAdapter(BasePlatformAdapter): """ntfy adapter. - Subscribes to a topic via HTTP streaming (/json endpoint) and publishes - replies via HTTP POST. No external SDK — only httpx is required. + Subscribes to a topic via HTTP streaming (``/json`` endpoint) and + publishes replies via HTTP POST. No external SDK — only httpx. """ MAX_MESSAGE_LENGTH = MAX_MESSAGE_LENGTH def __init__(self, config: PlatformConfig): - super().__init__(config, Platform.NTFY) + platform = Platform("ntfy") + super().__init__(config=config, platform=platform) extra = config.extra or {} self._server: str = ( @@ -167,10 +208,16 @@ class NtfyAdapter(BasePlatformAdapter): timeout=httpx.Timeout(connect=15.0, read=STREAM_TIMEOUT_SECONDS, write=15.0, pool=15.0), ) as response: if response.status_code == 401: - logger.error("[%s] Authentication failed (401) — stopping reconnect loop. Check NTFY_TOKEN.", self.name) + logger.error( + "[%s] Authentication failed (401) — stopping reconnect loop. Check NTFY_TOKEN.", + self.name, + ) raise _FatalStreamError("401 Unauthorized") if response.status_code == 404: - logger.error("[%s] Topic not found (404): %s — stopping reconnect loop.", self.name, self._topic) + logger.error( + "[%s] Topic not found (404): %s — stopping reconnect loop.", + self.name, self._topic, + ) raise _FatalStreamError("404 Not Found") response.raise_for_status() @@ -226,8 +273,8 @@ class NtfyAdapter(BasePlatformAdapter): # publisher-controlled and must NOT be used for authorization — any # publisher who knows the topic can set title to an allowed username. # Treat ntfy as a single trusted channel; user_id is fixed to the - # topic name. Document that NTFY_ALLOWED_USERS is only a real trust - # boundary when the topic has a read token protecting it. + # topic name. NTFY_ALLOWED_USERS is only a real trust boundary when + # the topic itself is protected by a read token. user_id = topic user_name = topic @@ -239,10 +286,12 @@ class NtfyAdapter(BasePlatformAdapter): user_name=user_name, ) - # Parse timestamp unix_ts = event.get("time") try: - timestamp = datetime.fromtimestamp(int(unix_ts), tz=timezone.utc) if unix_ts else datetime.now(tz=timezone.utc) + timestamp = ( + datetime.fromtimestamp(int(unix_ts), tz=timezone.utc) + if unix_ts else datetime.now(tz=timezone.utc) + ) except (ValueError, OSError, TypeError): timestamp = datetime.now(tz=timezone.utc) @@ -302,7 +351,9 @@ class NtfyAdapter(BasePlatformAdapter): body = content[:self.MAX_MESSAGE_LENGTH] try: - resp = await self._http_client.post(url, content=body.encode("utf-8"), headers=headers, timeout=15.0) + resp = await self._http_client.post( + url, content=body.encode("utf-8"), headers=headers, timeout=15.0, + ) if resp.status_code < 300: try: data = resp.json() @@ -334,9 +385,169 @@ class NtfyAdapter(BasePlatformAdapter): if not self._token: return {} # ntfy supports both Bearer tokens and Base64-encoded Basic auth; - # prefer Bearer for API tokens, Basic for username:password pairs. + # '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}"} + + +# --------------------------------------------------------------------------- +# Plugin registration +# --------------------------------------------------------------------------- + + +def _env_enablement() -> dict | None: + """Seed ``PlatformConfig.extra`` from env vars during gateway config load. + + Called by the platform registry's env-enablement hook BEFORE adapter + construction, so ``gateway status`` and ``get_connected_platforms()`` + reflect env-only configuration without instantiating the HTTP client. + Returns ``None`` when ntfy isn't minimally configured; the caller skips + auto-enabling. + + The special ``home_channel`` key in the returned dict is handled by the + core hook — it becomes a proper ``HomeChannel`` dataclass on the + ``PlatformConfig`` rather than being merged into ``extra``. + """ + topic = os.getenv("NTFY_TOPIC", "").strip() + if not topic: + return None + seed: dict = { + "topic": topic, + "server": os.getenv("NTFY_SERVER_URL", DEFAULT_SERVER).rstrip("/"), + } + publish_topic = os.getenv("NTFY_PUBLISH_TOPIC", "").strip() + if publish_topic: + seed["publish_topic"] = publish_topic + token = os.getenv("NTFY_TOKEN", "").strip() + if token: + seed["token"] = token + markdown = os.getenv("NTFY_MARKDOWN", "").strip().lower() + if markdown: + seed["markdown"] = markdown in ("1", "true", "yes") + home = os.getenv("NTFY_HOME_CHANNEL", "").strip() or topic + if home: + seed["home_channel"] = { + "chat_id": home, + "name": os.getenv("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 fallbacks. + + Used by ``tools/send_message_tool._send_via_adapter`` and the cron + scheduler when the gateway runner is not in this process (e.g. + ``hermes cron`` running standalone). Without this hook, + ``deliver=ntfy`` cron jobs fail with ``No live adapter for platform``. + + ``thread_id`` and ``media_files`` are accepted for 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 = ( + extra.get("server") + or os.getenv("NTFY_SERVER_URL", DEFAULT_SERVER) + ).rstrip("/") + publish_topic = ( + chat_id + or extra.get("publish_topic") + or os.getenv("NTFY_PUBLISH_TOPIC", "").strip() + or extra.get("topic") + or os.getenv("NTFY_TOPIC", "").strip() + ) + if not publish_topic: + return {"error": "ntfy standalone send: NTFY_TOPIC not configured"} + + token = extra.get("token") or os.getenv("NTFY_TOKEN", "") + 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}" + 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] + + 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) + if resp.status_code >= 300: + return {"error": f"ntfy HTTP {resp.status_code}: {resp.text[:200]}"} + try: + data = resp.json() + msg_id = data.get("id") or uuid.uuid4().hex[:12] + except Exception: + msg_id = uuid.uuid4().hex[:12] + return {"success": True, "platform": "ntfy", "chat_id": publish_topic, "message_id": msg_id} + 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-driven auto-configuration: seeds PlatformConfig.extra so + # env-only setups show up in `hermes gateway status` without + # instantiating the HTTP client. + env_enablement_fn=_env_enablement, + # Cron home-channel delivery support — `deliver=ntfy` cron jobs + # route to NTFY_HOME_CHANNEL when set. + cron_deliver_env_var="NTFY_HOME_CHANNEL", + # Out-of-process cron delivery. Without this hook, deliver=ntfy + # cron jobs fail with "No live adapter" when cron runs separately + # from the gateway. + standalone_sender_fn=_standalone_send, + # Auth env vars for _is_user_authorized() integration. + allowed_users_env="NTFY_ALLOWED_USERS", + allow_all_env="NTFY_ALLOW_ALL_USERS", + max_message_length=MAX_MESSAGE_LENGTH, + emoji="🔔", + # ntfy publishers have no persistent identity — topic names are + # the only identifier, no phone numbers / emails to redact. + pii_safe=True, + 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." + ), + ) diff --git a/plugins/platforms/ntfy/plugin.yaml b/plugins/platforms/ntfy/plugin.yaml new file mode 100644 index 00000000000..e476a36235f --- /dev/null +++ b/plugins/platforms/ntfy/plugin.yaml @@ -0,0 +1,56 @@ +name: ntfy-platform +label: ntfy +kind: platform +version: 1.0.0 +description: > + ntfy push-notification gateway adapter for Hermes Agent. + Subscribes to a topic on ntfy.sh or any self-hosted ntfy server via + HTTP streaming, and publishes replies via HTTP POST. Lightweight — + no external SDK, only httpx (already a Hermes dependency). + + ntfy has no native user-identity primitive; the adapter treats each + topic as a single trusted channel and never derives user identity + from publisher-controlled fields. Use a private topic + read token + for any real trust boundary. +author: sprmn24 +# ``requires_env`` and ``optional_env`` entries are surfaced in the +# ``hermes config`` UI via the platform-plugin env var injector in +# ``hermes_cli/config.py``. +requires_env: + - name: NTFY_TOPIC + description: "Topic name to subscribe to (e.g. hermes-in)" + prompt: "ntfy subscribe topic" + password: false +optional_env: + - name: NTFY_SERVER_URL + description: "ntfy server URL (default: https://ntfy.sh)" + prompt: "ntfy server URL" + password: false + - name: NTFY_TOKEN + description: "Bearer token or 'user:pass' for Basic auth (optional)" + prompt: "ntfy auth token (or empty)" + password: true + - name: NTFY_PUBLISH_TOPIC + description: "Topic to publish replies to (defaults to NTFY_TOPIC)" + prompt: "ntfy publish topic (or empty)" + password: false + - name: NTFY_MARKDOWN + description: "Send replies with X-Markdown: true header (true/false, default: false)" + prompt: "Enable markdown formatting? (true/false)" + password: false + - name: NTFY_ALLOWED_USERS + description: "Comma-separated topic names allowed (allowlist)" + prompt: "Allowed topic names (comma-separated)" + password: false + - name: NTFY_ALLOW_ALL_USERS + description: "Allow any topic to talk to the bot (dev only — disables allowlist)" + prompt: "Allow all topics? (true/false)" + password: false + - name: NTFY_HOME_CHANNEL + description: "Default topic for cron / notification delivery" + prompt: "Home channel topic (or empty)" + password: false + - name: NTFY_HOME_CHANNEL_NAME + description: "Human label for the home channel (defaults to the topic name)" + prompt: "Home channel display name (or empty)" + password: false diff --git a/tests/gateway/test_ntfy.py b/tests/gateway/test_ntfy_plugin.py similarity index 60% rename from tests/gateway/test_ntfy.py rename to tests/gateway/test_ntfy_plugin.py index a510612ab9f..3f34c1e1c6b 100644 --- a/tests/gateway/test_ntfy.py +++ b/tests/gateway/test_ntfy_plugin.py @@ -1,4 +1,18 @@ -"""Tests for ntfy platform adapter and integration points.""" +"""Tests for the ntfy platform-plugin adapter. + +Loaded via the ``_plugin_adapter_loader`` helper so this lives under +``plugin_adapter_ntfy`` in ``sys.modules`` and cannot collide with +sibling platform-plugin tests on the same xdist worker. + +Most tests target the adapter class directly. The plugin-shape tests +(``register()``, ``_env_enablement``, ``_standalone_send``, registry +presence) replace the core-file grep tests from the original PR — the +ntfy adapter no longer modifies ``gateway/config.py``, ``gateway/run.py``, +``cron/scheduler.py``, ``toolsets.py``, etc. Everything routes through +the ``platform_registry``. +""" + +from __future__ import annotations import asyncio import os @@ -6,7 +20,22 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from gateway.config import Platform, PlatformConfig +from gateway.config import PlatformConfig +from tests.gateway._plugin_adapter_loader import load_plugin_adapter + +_ntfy = load_plugin_adapter("ntfy") + +NtfyAdapter = _ntfy.NtfyAdapter +check_requirements = _ntfy.check_requirements +validate_config = _ntfy.validate_config +is_connected = _ntfy.is_connected +register = _ntfy.register +_env_enablement = _ntfy._env_enablement +_standalone_send = _ntfy._standalone_send +DEFAULT_SERVER = _ntfy.DEFAULT_SERVER +DEDUP_WINDOW_SECONDS = _ntfy.DEDUP_WINDOW_SECONDS +DEDUP_MAX_SIZE = _ntfy.DEDUP_MAX_SIZE +MAX_MESSAGE_LENGTH = _ntfy.MAX_MESSAGE_LENGTH def _run(coro): @@ -15,22 +44,21 @@ def _run(coro): # --------------------------------------------------------------------------- -# Platform enum +# 1. Platform enum (plugin-discovered, not bundled) # --------------------------------------------------------------------------- -class TestPlatformEnum: - - def test_ntfy_value(self): - assert Platform.NTFY.value == "ntfy" - - def test_ntfy_in_all_platforms(self): - values = [p.value for p in Platform] - assert "ntfy" in values +def test_platform_enum_resolves_via_plugin_scan(): + """The plugin filesystem scan should expose Platform("ntfy").""" + from gateway.config import Platform + p = Platform("ntfy") + assert p.value == "ntfy" + # Identity stability — repeated lookups return the same pseudo-member + assert Platform("ntfy") is p # --------------------------------------------------------------------------- -# Requirements check +# 2. check_requirements / validate_config / is_connected # --------------------------------------------------------------------------- @@ -38,157 +66,67 @@ class TestNtfyRequirements: def test_returns_false_when_httpx_unavailable(self, monkeypatch): monkeypatch.setenv("NTFY_TOPIC", "hermes-test") - monkeypatch.setattr("gateway.platforms.ntfy.HTTPX_AVAILABLE", False) - from gateway.platforms.ntfy import check_ntfy_requirements - assert check_ntfy_requirements() is False + monkeypatch.setattr(_ntfy, "HTTPX_AVAILABLE", False) + assert check_requirements() is False def test_returns_false_when_topic_not_set(self, monkeypatch): - monkeypatch.setattr("gateway.platforms.ntfy.HTTPX_AVAILABLE", True) + monkeypatch.setattr(_ntfy, "HTTPX_AVAILABLE", True) monkeypatch.delenv("NTFY_TOPIC", raising=False) - from gateway.platforms.ntfy import check_ntfy_requirements - with patch("gateway.config.load_gateway_config") as mock_load: - mock_cfg = MagicMock() - mock_cfg.platforms = {} - mock_load.return_value = mock_cfg - assert check_ntfy_requirements() is False + assert check_requirements() is False def test_returns_true_when_topic_set_via_env(self, monkeypatch): - monkeypatch.setattr("gateway.platforms.ntfy.HTTPX_AVAILABLE", True) + monkeypatch.setattr(_ntfy, "HTTPX_AVAILABLE", True) monkeypatch.setenv("NTFY_TOPIC", "hermes-test") - from gateway.platforms.ntfy import check_ntfy_requirements - assert check_ntfy_requirements() is True - - def test_returns_true_when_topic_set_via_env(self, monkeypatch): - monkeypatch.setattr("gateway.platforms.ntfy.HTTPX_AVAILABLE", True) - monkeypatch.setenv("NTFY_TOPIC", "hermes-cfg") - from gateway.platforms.ntfy import check_ntfy_requirements - assert check_ntfy_requirements() is True - - -# --------------------------------------------------------------------------- -# Config loading from env vars -# --------------------------------------------------------------------------- - - -class TestNtfyConfigLoading: - - def test_ntfy_topic_enables_platform(self, monkeypatch): - from gateway.config import load_gateway_config - - monkeypatch.setenv("NTFY_TOPIC", "hermes-in") - config = load_gateway_config() - assert Platform.NTFY in config.platforms - pc = config.platforms[Platform.NTFY] - assert pc.enabled is True - assert pc.extra["topic"] == "hermes-in" - - def test_ntfy_server_url_stored_in_extra(self, monkeypatch): - from gateway.config import load_gateway_config - - monkeypatch.setenv("NTFY_TOPIC", "hermes-in") - monkeypatch.setenv("NTFY_SERVER_URL", "https://ntfy.example.com") - config = load_gateway_config() - pc = config.platforms[Platform.NTFY] - assert pc.extra.get("server") == "https://ntfy.example.com" - - def test_ntfy_token_stored_in_extra(self, monkeypatch): - from gateway.config import load_gateway_config - - monkeypatch.setenv("NTFY_TOPIC", "hermes-in") - monkeypatch.setenv("NTFY_TOKEN", "tk_secret") - config = load_gateway_config() - pc = config.platforms[Platform.NTFY] - assert pc.extra.get("token") == "tk_secret" - - def test_ntfy_publish_topic_stored_in_extra(self, monkeypatch): - from gateway.config import load_gateway_config - - monkeypatch.setenv("NTFY_TOPIC", "hermes-in") - monkeypatch.setenv("NTFY_PUBLISH_TOPIC", "hermes-out") - config = load_gateway_config() - pc = config.platforms[Platform.NTFY] - assert pc.extra.get("publish_topic") == "hermes-out" - - def test_ntfy_home_channel_set(self, monkeypatch): - from gateway.config import load_gateway_config - - monkeypatch.setenv("NTFY_TOPIC", "hermes-in") - monkeypatch.setenv("NTFY_HOME_CHANNEL", "hermes-home") - config = load_gateway_config() - pc = config.platforms[Platform.NTFY] - assert pc.home_channel is not None - assert pc.home_channel.chat_id == "hermes-home" - assert pc.home_channel.platform == Platform.NTFY - - def test_ntfy_home_channel_name_default(self, monkeypatch): - from gateway.config import load_gateway_config - - monkeypatch.setenv("NTFY_TOPIC", "hermes-in") - monkeypatch.setenv("NTFY_HOME_CHANNEL", "hermes-home") - monkeypatch.delenv("NTFY_HOME_CHANNEL_NAME", raising=False) - config = load_gateway_config() - pc = config.platforms[Platform.NTFY] - assert pc.home_channel.name == "Home" - - def test_ntfy_not_enabled_when_topic_absent(self, monkeypatch): - from gateway.config import load_gateway_config + assert check_requirements() is True + def test_validate_config_requires_topic(self, monkeypatch): monkeypatch.delenv("NTFY_TOPIC", raising=False) - config = load_gateway_config() - pc = config.platforms.get(Platform.NTFY) - if pc is not None: - assert not pc.enabled or pc.extra.get("topic", "") == "" + assert validate_config(PlatformConfig(enabled=True, extra={})) is False + assert validate_config( + PlatformConfig(enabled=True, extra={"topic": "t"}) + ) is True - def test_ntfy_in_connected_platforms_when_topic_set(self, monkeypatch): - from gateway.config import load_gateway_config + def test_is_connected_from_extra(self, monkeypatch): + monkeypatch.delenv("NTFY_TOPIC", raising=False) + assert is_connected(PlatformConfig(enabled=True, extra={"topic": "t"})) is True + assert is_connected(PlatformConfig(enabled=True, extra={})) is False - monkeypatch.setenv("NTFY_TOPIC", "hermes-in") - config = load_gateway_config() - connected = config.get_connected_platforms() - assert Platform.NTFY in connected + def test_is_connected_from_env(self, monkeypatch): + monkeypatch.setenv("NTFY_TOPIC", "env-topic") + assert is_connected(PlatformConfig(enabled=True, extra={})) is True # --------------------------------------------------------------------------- -# Adapter construction +# 3. Adapter init # --------------------------------------------------------------------------- class TestNtfyAdapterInit: def test_default_server_url(self, monkeypatch): - from gateway.platforms.ntfy import NtfyAdapter, DEFAULT_SERVER - monkeypatch.delenv("NTFY_SERVER_URL", raising=False) config = PlatformConfig(enabled=True, extra={"topic": "hermes-in"}) adapter = NtfyAdapter(config) assert adapter._server == DEFAULT_SERVER.rstrip("/") def test_topic_read_from_extra(self): - from gateway.platforms.ntfy import NtfyAdapter - config = PlatformConfig(enabled=True, extra={"topic": "my-topic"}) adapter = NtfyAdapter(config) assert adapter._topic == "my-topic" def test_topic_read_from_env(self, monkeypatch): - from gateway.platforms.ntfy import NtfyAdapter - monkeypatch.setenv("NTFY_TOPIC", "env-topic") config = PlatformConfig(enabled=True, extra={}) adapter = NtfyAdapter(config) assert adapter._topic == "env-topic" def test_publish_topic_falls_back_to_topic(self, monkeypatch): - from gateway.platforms.ntfy import NtfyAdapter - monkeypatch.delenv("NTFY_PUBLISH_TOPIC", raising=False) config = PlatformConfig(enabled=True, extra={"topic": "hermes-in"}) adapter = NtfyAdapter(config) assert adapter._publish_topic == "hermes-in" def test_publish_topic_uses_extra_value(self): - from gateway.platforms.ntfy import NtfyAdapter - config = PlatformConfig( enabled=True, extra={"topic": "hermes-in", "publish_topic": "hermes-out"}, @@ -197,23 +135,17 @@ class TestNtfyAdapterInit: assert adapter._publish_topic == "hermes-out" def test_token_read_from_extra(self): - from gateway.platforms.ntfy import NtfyAdapter - config = PlatformConfig(enabled=True, extra={"topic": "t", "token": "tok-123"}) adapter = NtfyAdapter(config) assert adapter._token == "tok-123" def test_token_read_from_env(self, monkeypatch): - from gateway.platforms.ntfy import NtfyAdapter - monkeypatch.setenv("NTFY_TOKEN", "env-token") config = PlatformConfig(enabled=True, extra={"topic": "t"}) adapter = NtfyAdapter(config) assert adapter._token == "env-token" def test_server_trailing_slash_stripped(self): - from gateway.platforms.ntfy import NtfyAdapter - config = PlatformConfig( enabled=True, extra={"topic": "t", "server": "https://ntfy.example.com/"}, @@ -221,16 +153,7 @@ class TestNtfyAdapterInit: adapter = NtfyAdapter(config) assert not adapter._server.endswith("/") - def test_name_is_ntfy(self): - from gateway.platforms.ntfy import NtfyAdapter - - config = PlatformConfig(enabled=True, extra={"topic": "t"}) - adapter = NtfyAdapter(config) - assert adapter.name == "Ntfy" - def test_initial_state(self): - from gateway.platforms.ntfy import NtfyAdapter - config = PlatformConfig(enabled=True, extra={"topic": "t"}) adapter = NtfyAdapter(config) assert adapter._stream_task is None @@ -239,15 +162,13 @@ class TestNtfyAdapterInit: # --------------------------------------------------------------------------- -# Auth headers +# 4. Auth headers # --------------------------------------------------------------------------- class TestAuthHeaders: def _make_adapter(self, token=""): - from gateway.platforms.ntfy import NtfyAdapter - config = PlatformConfig(enabled=True, extra={"topic": "t", "token": token}) return NtfyAdapter(config) @@ -258,15 +179,14 @@ class TestAuthHeaders: def test_bearer_token_for_plain_token(self): adapter = self._make_adapter(token="myapitoken") headers = adapter._auth_headers() - assert "Authorization" in headers assert headers["Authorization"] == "Bearer myapitoken" def test_basic_auth_for_user_colon_password(self): adapter = self._make_adapter(token="user:pass") headers = adapter._auth_headers() - assert "Authorization" in headers assert headers["Authorization"].startswith("Basic ") - expected = "Basic " + __import__("base64").b64encode(b"user:pass").decode() + import base64 + expected = "Basic " + base64.b64encode(b"user:pass").decode() assert headers["Authorization"] == expected def test_bearer_token_used_when_no_colon(self): @@ -281,15 +201,13 @@ class TestAuthHeaders: # --------------------------------------------------------------------------- -# Deduplication +# 5. Deduplication # --------------------------------------------------------------------------- class TestDeduplication: def _make_adapter(self): - from gateway.platforms.ntfy import NtfyAdapter - return NtfyAdapter(PlatformConfig(enabled=True, extra={"topic": "t"})) def test_first_message_not_duplicate(self): @@ -313,18 +231,14 @@ class TestDeduplication: assert len(adapter._seen_messages) == 50 def test_cache_pruned_on_overflow(self): - from gateway.platforms.ntfy import NtfyAdapter, DEDUP_MAX_SIZE - - adapter = NtfyAdapter(PlatformConfig(enabled=True, extra={"topic": "t"})) + adapter = self._make_adapter() for i in range(DEDUP_MAX_SIZE + 20): adapter._is_duplicate(f"msg-{i}") assert len(adapter._seen_messages) <= DEDUP_MAX_SIZE + 20 def test_expired_id_can_be_seen_again(self): import time - from gateway.platforms.ntfy import NtfyAdapter, DEDUP_WINDOW_SECONDS, DEDUP_MAX_SIZE - - adapter = NtfyAdapter(PlatformConfig(enabled=True, extra={"topic": "t"})) + adapter = self._make_adapter() adapter._seen_messages["old-msg"] = time.time() - DEDUP_WINDOW_SECONDS - 1 for i in range(DEDUP_MAX_SIZE + 1): adapter._is_duplicate(f"fill-{i}") @@ -332,39 +246,33 @@ class TestDeduplication: # --------------------------------------------------------------------------- -# connect() / disconnect() +# 6. connect() / disconnect() # --------------------------------------------------------------------------- class TestConnect: def test_connect_fails_when_httpx_unavailable(self, monkeypatch): - monkeypatch.setattr("gateway.platforms.ntfy.HTTPX_AVAILABLE", False) - from gateway.platforms.ntfy import NtfyAdapter - + monkeypatch.setattr(_ntfy, "HTTPX_AVAILABLE", False) adapter = NtfyAdapter(PlatformConfig(enabled=True, extra={"topic": "t"})) result = _run(adapter.connect()) assert result is False def test_connect_fails_when_no_topic(self, monkeypatch): - monkeypatch.setattr("gateway.platforms.ntfy.HTTPX_AVAILABLE", True) + monkeypatch.setattr(_ntfy, "HTTPX_AVAILABLE", True) monkeypatch.delenv("NTFY_TOPIC", raising=False) - from gateway.platforms.ntfy import NtfyAdapter - config = PlatformConfig(enabled=True, extra={}) adapter = NtfyAdapter(config) result = _run(adapter.connect()) assert result is False def test_connect_starts_stream_task(self, monkeypatch): - monkeypatch.setattr("gateway.platforms.ntfy.HTTPX_AVAILABLE", True) - from gateway.platforms.ntfy import NtfyAdapter - + monkeypatch.setattr(_ntfy, "HTTPX_AVAILABLE", True) config = PlatformConfig(enabled=True, extra={"topic": "hermes-test"}) adapter = NtfyAdapter(config) with patch.object(adapter, "_run_stream", new_callable=AsyncMock): - with patch("gateway.platforms.ntfy.httpx") as mock_httpx: + with patch.object(_ntfy, "httpx") as mock_httpx: mock_httpx.AsyncClient.return_value = MagicMock() result = _run(adapter.connect()) @@ -377,8 +285,6 @@ class TestConnect: pass def test_disconnect_clears_state(self): - from gateway.platforms.ntfy import NtfyAdapter - adapter = NtfyAdapter(PlatformConfig(enabled=True, extra={"topic": "t"})) adapter._seen_messages["x"] = 1.0 adapter._http_client = AsyncMock() @@ -392,8 +298,6 @@ class TestConnect: assert adapter._running is False def test_disconnect_cancels_stream_task(self): - from gateway.platforms.ntfy import NtfyAdapter - adapter = NtfyAdapter(PlatformConfig(enabled=True, extra={"topic": "t"})) async def _hang(): @@ -409,18 +313,18 @@ class TestConnect: # --------------------------------------------------------------------------- -# send() +# 7. send() # --------------------------------------------------------------------------- class TestSend: - def _make_adapter(self, topic="hermes-in", publish_topic="", token=""): - from gateway.platforms.ntfy import NtfyAdapter - - extra = {"topic": topic, "token": token} + def _make_adapter(self, topic="hermes-in", publish_topic="", token="", markdown=False): + extra: dict = {"topic": topic, "token": token} if publish_topic: extra["publish_topic"] = publish_topic + if markdown: + extra["markdown"] = True return NtfyAdapter(PlatformConfig(enabled=True, extra=extra)) def test_send_fails_without_http_client(self): @@ -444,8 +348,7 @@ class TestSend: assert result.success is True assert result.message_id == "abc123" - call_args = mock_client.post.call_args - posted_url = call_args[0][0] + posted_url = mock_client.post.call_args[0][0] assert posted_url.endswith("/hermes-out") def test_send_falls_back_to_subscribe_topic(self): @@ -498,8 +401,6 @@ class TestSend: assert "403" in result.error def test_send_handles_timeout(self): - import gateway.platforms.ntfy as ntfy_mod - adapter = self._make_adapter(topic="hermes-in") class _FakeTimeout(Exception): @@ -512,15 +413,13 @@ class TestSend: mock_client.post = AsyncMock(side_effect=_FakeTimeout("timed out")) adapter._http_client = mock_client - with patch.object(ntfy_mod, "httpx", fake_httpx): + with patch.object(_ntfy, "httpx", fake_httpx): result = _run(adapter.send("hermes-in", "Hello!")) assert result.success is False assert "timeout" in result.error.lower() def test_send_truncates_to_max_length(self): - from gateway.platforms.ntfy import NtfyAdapter, MAX_MESSAGE_LENGTH - adapter = self._make_adapter(topic="t") mock_resp = MagicMock() mock_resp.status_code = 200 @@ -537,15 +436,10 @@ class TestSend: assert len(posted_body.decode()) <= MAX_MESSAGE_LENGTH def test_send_typing_is_noop(self): - from gateway.platforms.ntfy import NtfyAdapter - adapter = NtfyAdapter(PlatformConfig(enabled=True, extra={"topic": "t"})) - # Should not raise - _run(adapter.send_typing("t")) + _run(adapter.send_typing("t")) # must not raise def test_get_chat_info_returns_dict(self): - from gateway.platforms.ntfy import NtfyAdapter - adapter = NtfyAdapter(PlatformConfig(enabled=True, extra={"topic": "t"})) info = _run(adapter.get_chat_info("hermes-in")) assert info["name"] == "hermes-in" @@ -567,19 +461,42 @@ class TestSend: call_headers = mock_client.post.call_args[1]["headers"] assert call_headers.get("Authorization") == "Bearer mytoken" + def test_send_emits_markdown_header_when_enabled(self): + adapter = self._make_adapter(topic="hermes-in", markdown=True) + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = {} + mock_client = AsyncMock() + mock_client.post = AsyncMock(return_value=mock_resp) + adapter._http_client = mock_client + + _run(adapter.send("hermes-in", "**bold**")) + call_headers = mock_client.post.call_args[1]["headers"] + assert call_headers.get("X-Markdown") == "true" + + def test_send_omits_markdown_header_when_disabled(self): + adapter = self._make_adapter(topic="hermes-in", markdown=False) + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = {} + mock_client = AsyncMock() + mock_client.post = AsyncMock(return_value=mock_resp) + adapter._http_client = mock_client + + _run(adapter.send("hermes-in", "plain")) + call_headers = mock_client.post.call_args[1]["headers"] + assert "X-Markdown" not in call_headers + # --------------------------------------------------------------------------- -# Inbound message processing +# 8. Inbound message processing (identity invariant — security-critical) # --------------------------------------------------------------------------- class TestOnMessage: def _make_adapter(self): - from gateway.platforms.ntfy import NtfyAdapter - - adapter = NtfyAdapter(PlatformConfig(enabled=True, extra={"topic": "hermes-in"})) - return adapter + return NtfyAdapter(PlatformConfig(enabled=True, extra={"topic": "hermes-in"})) def test_message_dispatched_to_handler(self): adapter = self._make_adapter() @@ -622,7 +539,6 @@ class TestOnMessage: calls.append(event) adapter.set_message_handler(handler) - event = {"id": "dup-1", "event": "message", "topic": "hermes-in", "message": "hi", "time": None} _run(adapter._on_message(event)) _run(adapter._on_message(event)) @@ -630,7 +546,6 @@ class TestOnMessage: def test_timestamp_parsed_from_event(self): from datetime import timezone - adapter = self._make_adapter() captured = [] @@ -638,7 +553,6 @@ class TestOnMessage: captured.append(event) adapter.set_message_handler(handler) - _run(adapter._on_message({ "id": "ts-1", "event": "message", @@ -667,8 +581,7 @@ class TestOnMessage: assert captured[0].message_id == "ntfy-id-42" def test_title_not_used_as_user_id(self): - """title field must not be used for identity — it is publisher-controlled - and cannot be trusted as an authentication signal.""" + """title field must not be used for identity — it is publisher-controlled.""" adapter = self._make_adapter() captured = [] @@ -684,13 +597,11 @@ class TestOnMessage: "title": "Alice", "time": None, })) - # user_id must be the topic, never the spoofable title field assert captured[0].source.user_id == "hermes-in" assert captured[0].source.user_name == "hermes-in" def test_unknown_publisher_cannot_impersonate_allowed_user(self): - """An unknown publisher setting title to an allowed username must not - gain the identity of that user — identity is always the topic name.""" + """An unknown publisher setting title=admin must not gain admin identity.""" adapter = self._make_adapter() captured = [] @@ -728,166 +639,203 @@ class TestOnMessage: # --------------------------------------------------------------------------- -# Integration: send_message_tool platform_map (source-level checks) +# 9. _env_enablement() — env-only auto-config # --------------------------------------------------------------------------- -class TestSendMessageToolIntegration: +class TestEnvEnablement: - def test_ntfy_in_platform_enum(self): - assert hasattr(Platform, "NTFY") - assert Platform.NTFY.value == "ntfy" + def test_returns_none_without_topic(self, monkeypatch): + monkeypatch.delenv("NTFY_TOPIC", raising=False) + assert _env_enablement() is None - def test_ntfy_in_platform_map_source(self): - src = open("tools/send_message_tool.py").read() - assert "Platform.NTFY" in src + def test_seeds_topic_and_server(self, monkeypatch): + monkeypatch.setenv("NTFY_TOPIC", "hermes-in") + monkeypatch.delenv("NTFY_SERVER_URL", raising=False) + seed = _env_enablement() + assert seed is not None + assert seed["topic"] == "hermes-in" + assert seed["server"] == DEFAULT_SERVER - def test_send_ntfy_function_in_source(self): - src = open("tools/send_message_tool.py").read() - assert "async def _send_ntfy" in src + def test_custom_server_url(self, monkeypatch): + monkeypatch.setenv("NTFY_TOPIC", "hermes-in") + monkeypatch.setenv("NTFY_SERVER_URL", "https://ntfy.example.com/") + seed = _env_enablement() + assert seed["server"] == "https://ntfy.example.com" # trailing slash stripped - def test_ntfy_branch_in_send_to_platform_source(self): - src = open("tools/send_message_tool.py").read() - assert "Platform.NTFY" in src - assert "_send_ntfy" in src + def test_publish_topic_seeded(self, monkeypatch): + monkeypatch.setenv("NTFY_TOPIC", "hermes-in") + monkeypatch.setenv("NTFY_PUBLISH_TOPIC", "hermes-out") + seed = _env_enablement() + assert seed["publish_topic"] == "hermes-out" - def test_send_ntfy_reads_server_from_extra(self): - src = open("tools/send_message_tool.py").read() - assert 'extra.get("server")' in src - assert "NTFY_SERVER_URL" in src + def test_token_seeded(self, monkeypatch): + monkeypatch.setenv("NTFY_TOPIC", "hermes-in") + monkeypatch.setenv("NTFY_TOKEN", "tk_abc") + seed = _env_enablement() + assert seed["token"] == "tk_abc" - def test_send_ntfy_reads_topic_from_extra(self): - src = open("tools/send_message_tool.py").read() - assert 'extra.get("topic")' in src - assert "NTFY_TOPIC" in src + def test_markdown_truthy_values(self, monkeypatch): + monkeypatch.setenv("NTFY_TOPIC", "hermes-in") + for val in ("true", "1", "yes", "TRUE"): + monkeypatch.setenv("NTFY_MARKDOWN", val) + assert _env_enablement()["markdown"] is True - def test_send_ntfy_reads_token_from_extra(self): - src = open("tools/send_message_tool.py").read() - assert 'extra.get("token")' in src - assert "NTFY_TOKEN" in src + def test_markdown_falsy_values(self, monkeypatch): + monkeypatch.setenv("NTFY_TOPIC", "hermes-in") + for val in ("false", "0", "no", "anything"): + monkeypatch.setenv("NTFY_MARKDOWN", val) + assert _env_enablement()["markdown"] is False + + def test_home_channel_defaults_to_topic(self, monkeypatch): + monkeypatch.setenv("NTFY_TOPIC", "hermes-in") + monkeypatch.delenv("NTFY_HOME_CHANNEL", raising=False) + seed = _env_enablement() + assert seed["home_channel"]["chat_id"] == "hermes-in" + assert seed["home_channel"]["name"] == "hermes-in" + + def test_home_channel_override(self, monkeypatch): + monkeypatch.setenv("NTFY_TOPIC", "hermes-in") + monkeypatch.setenv("NTFY_HOME_CHANNEL", "alerts") + monkeypatch.setenv("NTFY_HOME_CHANNEL_NAME", "Alerts Channel") + seed = _env_enablement() + assert seed["home_channel"]["chat_id"] == "alerts" + assert seed["home_channel"]["name"] == "Alerts Channel" # --------------------------------------------------------------------------- -# Integration: cron scheduler platform_map +# 10. _standalone_send() — out-of-process cron delivery # --------------------------------------------------------------------------- -class TestCronSchedulerIntegration: +class TestStandaloneSend: - def test_ntfy_in_scheduler_platform_map_source(self): - src = open("cron/scheduler.py").read() - # ntfy routing handled via Platform._missing_() dynamic dispatch - assert '"ntfy"' in src or "Platform._missing_" in src or "_missing_" in src + def test_errors_without_topic(self, monkeypatch): + monkeypatch.delenv("NTFY_TOPIC", raising=False) + monkeypatch.delenv("NTFY_PUBLISH_TOPIC", raising=False) + pconfig = MagicMock() + pconfig.extra = {} + result = _run(_standalone_send(pconfig, "", "hello")) + assert "error" in result + assert "NTFY_TOPIC" in result["error"] - def test_ntfy_in_cronjob_deliver_description(self): - src = open("cron/scheduler.py").read() - assert "ntfy" in src.lower() + def test_posts_to_server(self, monkeypatch): + monkeypatch.setenv("NTFY_TOPIC", "hermes-in") + pconfig = MagicMock() + pconfig.extra = {"server": "https://ntfy.example.com", "topic": "hermes-in"} + + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = {"id": "id-42"} + + 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 + result = _run(_standalone_send(pconfig, "hermes-in", "hello")) + + assert result.get("success") is True + assert result["platform"] == "ntfy" + assert result["message_id"] == "id-42" + posted_url = mock_client.post.call_args[0][0] + assert posted_url == "https://ntfy.example.com/hermes-in" + + def test_emits_bearer_token_when_configured(self, monkeypatch): + monkeypatch.setenv("NTFY_TOPIC", "hermes-in") + pconfig = MagicMock() + pconfig.extra = {"topic": "hermes-in", "token": "tk_xyz"} + + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = {} + 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["Authorization"] == "Bearer tk_xyz" + + def test_basic_auth_when_token_has_colon(self, monkeypatch): + monkeypatch.setenv("NTFY_TOPIC", "hermes-in") + pconfig = MagicMock() + pconfig.extra = {"topic": "hermes-in", "token": "user:pass"} + + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = {} + 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["Authorization"].startswith("Basic ") + + def test_returns_error_on_http_failure(self, monkeypatch): + monkeypatch.setenv("NTFY_TOPIC", "hermes-in") + pconfig = MagicMock() + pconfig.extra = {"topic": "hermes-in"} + + mock_resp = MagicMock() + mock_resp.status_code = 403 + mock_resp.text = "Forbidden" + 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 + result = _run(_standalone_send(pconfig, "hermes-in", "hi")) + + assert "error" in result + assert "403" in result["error"] # --------------------------------------------------------------------------- -# Integration: gateway/run.py authorization maps +# 11. register() — plugin-side metadata # --------------------------------------------------------------------------- -class TestRunAuthorizationMaps: - - def test_ntfy_allowed_users_in_allowlist_check(self): - src = open("gateway/run.py").read() - assert "NTFY_ALLOWED_USERS" in src - - def test_ntfy_allow_all_users_in_allowlist_check(self): - src = open("gateway/run.py").read() - assert "NTFY_ALLOW_ALL_USERS" in src - - def test_ntfy_in_platform_env_map(self): - src = open("gateway/run.py").read() - assert 'Platform.NTFY: "NTFY_ALLOWED_USERS"' in src - - def test_ntfy_in_allow_all_map(self): - src = open("gateway/run.py").read() - assert 'Platform.NTFY: "NTFY_ALLOW_ALL_USERS"' in src - - def test_ntfy_create_adapter_branch(self): - src = open("gateway/run.py").read() - assert "Platform.NTFY" in src - assert "NtfyAdapter" in src - - def test_ntfy_startup_allowlist_includes_ntfy_allowed_users(self): - src = open("gateway/run.py").read() - # Verify both env vars appear in the startup check tuples - assert '"NTFY_ALLOWED_USERS"' in src - assert '"NTFY_ALLOW_ALL_USERS"' in src +def test_register_calls_register_platform(): + ctx = MagicMock() + register(ctx) + ctx.register_platform.assert_called_once() + kwargs = ctx.register_platform.call_args.kwargs + assert kwargs["name"] == "ntfy" + assert kwargs["label"] == "ntfy" + assert kwargs["required_env"] == ["NTFY_TOPIC"] + assert kwargs["allowed_users_env"] == "NTFY_ALLOWED_USERS" + assert kwargs["allow_all_env"] == "NTFY_ALLOW_ALL_USERS" + assert kwargs["cron_deliver_env_var"] == "NTFY_HOME_CHANNEL" + assert kwargs["max_message_length"] == MAX_MESSAGE_LENGTH + assert callable(kwargs["check_fn"]) + assert callable(kwargs["validate_config"]) + assert callable(kwargs["is_connected"]) + assert callable(kwargs["env_enablement_fn"]) + assert callable(kwargs["standalone_sender_fn"]) + assert callable(kwargs["adapter_factory"]) + # ntfy has no user-identifying PII (only topic names) + assert kwargs["pii_safe"] is True + assert "ntfy" in kwargs["platform_hint"].lower() -# --------------------------------------------------------------------------- -# Integration: toolsets -# --------------------------------------------------------------------------- - - -class TestToolsets: - - def test_hermes_ntfy_toolset_exists(self): - from toolsets import get_toolset - - ts = get_toolset("hermes-ntfy") - assert ts is not None - assert "tools" in ts - - def test_hermes_ntfy_in_gateway_includes(self): - from toolsets import get_toolset - - gw = get_toolset("hermes-gateway") - assert "hermes-ntfy" in gw["includes"] - - def test_hermes_ntfy_resolves_tools(self): - from toolsets import resolve_toolset - - tools = resolve_toolset("hermes-ntfy") - assert len(tools) > 0 - - def test_hermes_ntfy_description_mentions_ntfy(self): - from toolsets import get_toolset - - ts = get_toolset("hermes-ntfy") - assert "ntfy" in ts["description"].lower() - - -# --------------------------------------------------------------------------- -# Integration: prompt_builder platform hints -# --------------------------------------------------------------------------- - - -class TestPromptBuilderHints: - - def test_ntfy_hint_exists(self): - from agent.prompt_builder import PLATFORM_HINTS - - assert "ntfy" in PLATFORM_HINTS - - def test_ntfy_hint_mentions_plain_text(self): - from agent.prompt_builder import PLATFORM_HINTS - - hint = PLATFORM_HINTS["ntfy"].lower() - assert "plain text" in hint - - def test_ntfy_hint_mentions_push_or_notifications(self): - from agent.prompt_builder import PLATFORM_HINTS - - hint = PLATFORM_HINTS["ntfy"].lower() - assert "push" in hint or "notification" in hint - - -# --------------------------------------------------------------------------- -# Integration: channel_directory -# --------------------------------------------------------------------------- - - -class TestChannelDirectory: - - def test_ntfy_in_session_based_platforms_source(self): - src = open("gateway/channel_directory.py").read() - assert '"ntfy"' in src - - def test_build_channel_directory_includes_ntfy_key(self): - src = open("gateway/channel_directory.py").read() - assert "ntfy" in src +def test_adapter_factory_returns_ntfy_adapter(): + ctx = MagicMock() + register(ctx) + factory = ctx.register_platform.call_args.kwargs["adapter_factory"] + cfg = PlatformConfig(enabled=True, extra={"topic": "t"}) + adapter = factory(cfg) + assert isinstance(adapter, NtfyAdapter) diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index 36f626752a8..0f83e40c3c9 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -777,8 +777,6 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, result = await _send_bluebubbles(pconfig.extra, chat_id, chunk) elif platform == Platform.QQBOT: result = await _send_qqbot(pconfig, chat_id, chunk) - elif platform == Platform.NTFY: - result = await _send_ntfy(pconfig, chat_id, chunk) elif platform == Platform.YUANBAO: result = await _send_yuanbao(chat_id, chunk) else: @@ -1772,28 +1770,6 @@ async def _send_qqbot(pconfig, chat_id, message): return _error(f"QQBot send failed: {e}") -async def _send_ntfy(pconfig, chat_id, message): - """Send a message via ntfy HTTP POST.""" - try: - extra = pconfig.extra or {} - server = extra.get("server") or os.getenv("NTFY_SERVER_URL", "https://ntfy.sh").rstrip("/") - topic = chat_id or extra.get("topic") or os.getenv("NTFY_TOPIC", "") - token = extra.get("token") or os.getenv("NTFY_TOKEN", "") - if not topic: - return _error("ntfy topic not configured.") - import httpx - headers = {"Content-Type": "text/plain; charset=utf-8"} - if token: - headers["Authorization"] = f"Bearer {token}" - url = f"{server}/{topic}" - async with httpx.AsyncClient(timeout=15.0) as client: - resp = await client.post(url, content=message.encode("utf-8"), headers=headers) - resp.raise_for_status() - return {"success": True, "platform": "ntfy", "chat_id": topic} - except Exception as e: - return _error(f"ntfy send failed: {e}") - - async def _send_yuanbao(chat_id, message, media_files=None): """Send via Yuanbao using the running gateway adapter's WebSocket connection. diff --git a/toolsets.py b/toolsets.py index 9f5e3e13702..5de07e4c7a1 100644 --- a/toolsets.py +++ b/toolsets.py @@ -270,11 +270,6 @@ TOOLSETS = { "includes": [], }, - "ntfy": { - "description": "ntfy push notification toolset", - "tools": [], - "includes": ["hermes-ntfy"], - }, "yuanbao": { "description": "Yuanbao platform tools - group info, member queries, DM, stickers", "tools": [ @@ -520,11 +515,6 @@ TOOLSETS = { "includes": [] }, - "hermes-ntfy": { - "description": "ntfy push notification bot toolset", - "tools": _HERMES_CORE_TOOLS, - "includes": [] - }, "hermes-sms": { "description": "SMS bot toolset - interact with Hermes via SMS (Twilio)", "tools": _HERMES_CORE_TOOLS, @@ -540,7 +530,7 @@ TOOLSETS = { "hermes-gateway": { "description": "Gateway toolset - union of all messaging platform tools", "tools": [], - "includes": ["hermes-telegram", "hermes-discord", "hermes-whatsapp", "hermes-slack", "hermes-signal", "hermes-bluebubbles", "hermes-homeassistant", "hermes-email", "hermes-sms", "hermes-mattermost", "hermes-matrix", "hermes-dingtalk", "hermes-feishu", "hermes-wecom", "hermes-wecom-callback", "hermes-weixin", "hermes-qqbot", "hermes-webhook", "hermes-yuanbao", "hermes-ntfy"] + "includes": ["hermes-telegram", "hermes-discord", "hermes-whatsapp", "hermes-slack", "hermes-signal", "hermes-bluebubbles", "hermes-homeassistant", "hermes-email", "hermes-sms", "hermes-mattermost", "hermes-matrix", "hermes-dingtalk", "hermes-feishu", "hermes-wecom", "hermes-wecom-callback", "hermes-weixin", "hermes-qqbot", "hermes-webhook", "hermes-yuanbao"] } }