refactor(ntfy): convert built-in adapter to platform plugin

ntfy now ships as a self-contained plugin under plugins/platforms/ntfy/
instead of editing 8 core files (gateway/config.py Platform enum,
gateway/run.py factory + auth maps, cron/scheduler.py, toolsets.py,
hermes_cli/status.py, agent/prompt_builder.py, gateway/channel_directory.py,
tools/send_message_tool.py).

All routing goes through gateway/platform_registry via register_platform():
- adapter_factory, check_fn, validate_config, is_connected
- env_enablement_fn seeds PlatformConfig.extra from NTFY_* env vars so
  gateway status reflects env-only setups without instantiating httpx
- standalone_sender_fn handles deliver=ntfy cron jobs when cron runs
  out-of-process from the gateway
- allowed_users_env / allow_all_env hook into _is_user_authorized
- cron_deliver_env_var=NTFY_HOME_CHANNEL for cron home routing
- platform_hint surfaces in the system prompt
- pii_safe=True (topic names are the only identifier; no PII to redact)

Tests moved to tests/gateway/test_ntfy_plugin.py using _plugin_adapter_loader
so the module lives under plugin_adapter_ntfy in sys.modules and cannot
collide with sibling plugin-adapter tests on the same xdist worker. The
core-file grep tests (Platform.NTFY in source, hermes-ntfy in toolsets,
etc.) are replaced with plugin-shape tests covering register() metadata,
env_enablement_fn output, and standalone_sender_fn behavior.

68 tests pass under scripts/run_tests.sh.
This commit is contained in:
Teknium
2026-05-23 02:38:13 -07:00
parent b10f17bf1e
commit 6a8e131a0a
12 changed files with 582 additions and 444 deletions

View File

@ -555,11 +555,6 @@ PLATFORM_HINTS = {
"your response. Images are sent as native photos, and other files arrive as downloadable " "your response. Images are sent as native photos, and other files arrive as downloadable "
"documents." "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": ( "yuanbao": (
"You are on Yuanbao (腾讯元宝), a Chinese AI assistant platform. " "You are on Yuanbao (腾讯元宝), a Chinese AI assistant platform. "
"Markdown formatting is supported (code blocks, tables, bold/italic). " "Markdown formatting is supported (code blocks, tables, bold/italic). "

View File

@ -93,7 +93,7 @@ _KNOWN_DELIVERY_PLATFORMS = frozenset({
"telegram", "discord", "slack", "whatsapp", "signal", "telegram", "discord", "slack", "whatsapp", "signal",
"matrix", "mattermost", "homeassistant", "dingtalk", "feishu", "matrix", "mattermost", "homeassistant", "dingtalk", "feishu",
"wecom", "wecom_callback", "weixin", "sms", "email", "webhook", "bluebubbles", "wecom", "wecom_callback", "weixin", "sms", "email", "webhook", "bluebubbles",
"qqbot", "yuanbao", "ntfy", "qqbot", "yuanbao",
}) })
# Platforms that support a configured cron/notification home target, mapped to # Platforms that support a configured cron/notification home target, mapped to

View File

@ -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 # Platforms that don't support direct channel enumeration get session-based
# discovery automatically. Skip infrastructure entries that aren't messaging # discovery automatically. Skip infrastructure entries that aren't messaging
# platforms — everything else falls through to _build_from_sessions(). # 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"})
_SKIP_SESSION_DISCOVERY = frozenset({"local", "api_server", "webhook", "ntfy"})
for plat in Platform: for plat in Platform:
plat_name = plat.value plat_name = plat.value
if plat_name in _SKIP_SESSION_DISCOVERY or plat_name in platforms: if plat_name in _SKIP_SESSION_DISCOVERY or plat_name in platforms:

View File

@ -127,7 +127,6 @@ class Platform(Enum):
BLUEBUBBLES = "bluebubbles" BLUEBUBBLES = "bluebubbles"
QQBOT = "qqbot" QQBOT = "qqbot"
YUANBAO = "yuanbao" YUANBAO = "yuanbao"
NTFY = "ntfy"
@classmethod @classmethod
def _missing_(cls, value): def _missing_(cls, value):
"""Accept unknown platform names only for known plugin adapters. """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")) (cfg.extra.get("client_id") or os.getenv("DINGTALK_CLIENT_ID"))
and (cfg.extra.get("client_secret") or os.getenv("DINGTALK_CLIENT_SECRET")) 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: if yuanbao_group_allow_from:
extra["group_allow_from"] = 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 # Session settings
idle_minutes = os.getenv("SESSION_IDLE_MINUTES") idle_minutes = os.getenv("SESSION_IDLE_MINUTES")
if idle_minutes: if idle_minutes:

View File

@ -3772,7 +3772,6 @@ class GatewayRunner:
"BLUEBUBBLES_ALLOWED_USERS", "BLUEBUBBLES_ALLOWED_USERS",
"QQ_ALLOWED_USERS", "QQ_ALLOWED_USERS",
"YUANBAO_ALLOWED_USERS", "YUANBAO_ALLOWED_USERS",
"NTFY_ALLOWED_USERS",
"GATEWAY_ALLOWED_USERS", "GATEWAY_ALLOWED_USERS",
) )
_builtin_allow_all_vars = ( _builtin_allow_all_vars = (
@ -3788,7 +3787,6 @@ class GatewayRunner:
"BLUEBUBBLES_ALLOW_ALL_USERS", "BLUEBUBBLES_ALLOW_ALL_USERS",
"QQ_ALLOW_ALL_USERS", "QQ_ALLOW_ALL_USERS",
"YUANBAO_ALLOW_ALL_USERS", "YUANBAO_ALLOW_ALL_USERS",
"NTFY_ALLOW_ALL_USERS",
) )
# Also pick up plugin-registered platforms — each entry can declare # Also pick up plugin-registered platforms — each entry can declare
# its own allowed_users_env / allow_all_env, so the warning stays # its own allowed_users_env / allow_all_env, so the warning stays
@ -6166,12 +6164,6 @@ class GatewayRunner:
return None return None
return QQAdapter(config) 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: elif platform == Platform.YUANBAO:
from gateway.platforms.yuanbao import YuanbaoAdapter, WEBSOCKETS_AVAILABLE from gateway.platforms.yuanbao import YuanbaoAdapter, WEBSOCKETS_AVAILABLE
if not WEBSOCKETS_AVAILABLE: if not WEBSOCKETS_AVAILABLE:
@ -6248,7 +6240,6 @@ class GatewayRunner:
Platform.BLUEBUBBLES: "BLUEBUBBLES_ALLOWED_USERS", Platform.BLUEBUBBLES: "BLUEBUBBLES_ALLOWED_USERS",
Platform.QQBOT: "QQ_ALLOWED_USERS", Platform.QQBOT: "QQ_ALLOWED_USERS",
Platform.YUANBAO: "YUANBAO_ALLOWED_USERS", Platform.YUANBAO: "YUANBAO_ALLOWED_USERS",
Platform.NTFY: "NTFY_ALLOWED_USERS",
} }
platform_group_user_env_map = { platform_group_user_env_map = {
Platform.TELEGRAM: "TELEGRAM_GROUP_ALLOWED_USERS", Platform.TELEGRAM: "TELEGRAM_GROUP_ALLOWED_USERS",
@ -6275,7 +6266,6 @@ class GatewayRunner:
Platform.BLUEBUBBLES: "BLUEBUBBLES_ALLOW_ALL_USERS", Platform.BLUEBUBBLES: "BLUEBUBBLES_ALLOW_ALL_USERS",
Platform.QQBOT: "QQ_ALLOW_ALL_USERS", Platform.QQBOT: "QQ_ALLOW_ALL_USERS",
Platform.YUANBAO: "YUANBAO_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). # Bots admitted by {PLATFORM}_ALLOW_BOTS bypass the human allowlist (#4466).
platform_allow_bots_map = { platform_allow_bots_map = {

View File

@ -423,7 +423,6 @@ def show_status(args):
"BlueBubbles": ("BLUEBUBBLES_SERVER_URL", "BLUEBUBBLES_HOME_CHANNEL"), "BlueBubbles": ("BLUEBUBBLES_SERVER_URL", "BLUEBUBBLES_HOME_CHANNEL"),
"QQBot": ("QQ_APP_ID", "QQ_HOME_CHANNEL"), "QQBot": ("QQ_APP_ID", "QQ_HOME_CHANNEL"),
"Yuanbao": ("YUANBAO_APP_ID", "YUANBAO_HOME_CHANNEL"), "Yuanbao": ("YUANBAO_APP_ID", "YUANBAO_HOME_CHANNEL"),
"ntfy": ("NTFY_TOPIC", "NTFY_HOME_CHANNEL"),
} }
for name, (token_var, home_var) in platforms.items(): for name, (token_var, home_var) in platforms.items():

View File

@ -0,0 +1,3 @@
from .adapter import register
__all__ = ["register"]

View File

@ -1,16 +1,18 @@
""" """ntfy platform adapter (Hermes plugin).
ntfy platform adapter.
Uses httpx streaming to receive messages published to a subscribed topic, Subscribes to a topic on ntfy.sh or any self-hosted ntfy server via
and HTTP POST to publish replies. Works with ntfy.sh or any self-hosted HTTP streaming (``/json`` endpoint with ``poll=false``) and publishes
ntfy server. replies via HTTP POST. No external SDK only httpx, which is already
a Hermes dependency.
Requires: This adapter ships as a Hermes platform plugin under
pip install httpx (already a dependency) ``plugins/platforms/ntfy/``. The Hermes plugin loader scans the
NTFY_TOPIC env var (and optionally NTFY_SERVER_URL, NTFY_TOKEN, directory at startup, calls :func:`register`, and the platform becomes
NTFY_PUBLISH_TOPIC) 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: platforms:
ntfy: ntfy:
enabled: true enabled: true
@ -19,7 +21,27 @@ Configuration in config.yaml:
topic: "hermes-in" # subscribe topic (incoming) topic: "hermes-in" # subscribe topic (incoming)
publish_topic: "hermes-out" # optional — defaults to topic publish_topic: "hermes-out" # optional — defaults to topic
token: "..." # optional Bearer / Basic auth token 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 import asyncio
@ -29,7 +51,7 @@ import os
import time import time
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, Dict, Optional from typing import Any, Dict, List, Optional
try: try:
import httpx import httpx
@ -52,6 +74,7 @@ logger = logging.getLogger(__name__)
class _FatalStreamError(Exception): class _FatalStreamError(Exception):
"""Raised when a stream error is unrecoverable (e.g. 401, 404).""" """Raised when a stream error is unrecoverable (e.g. 401, 404)."""
DEFAULT_SERVER = "https://ntfy.sh" DEFAULT_SERVER = "https://ntfy.sh"
MAX_MESSAGE_LENGTH = 4096 # ntfy message body limit MAX_MESSAGE_LENGTH = 4096 # ntfy message body limit
DEDUP_WINDOW_SECONDS = 300 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 STREAM_TIMEOUT_SECONDS = 90 # ntfy keepalive default is 55s; give margin
def check_ntfy_requirements() -> bool: def check_requirements() -> bool:
"""Check if ntfy adapter dependencies are available and configured.""" """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: if not HTTPX_AVAILABLE:
return False 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() topic = os.getenv("NTFY_TOPIC", "").strip()
return bool(topic) 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): class NtfyAdapter(BasePlatformAdapter):
"""ntfy adapter. """ntfy adapter.
Subscribes to a topic via HTTP streaming (/json endpoint) and publishes Subscribes to a topic via HTTP streaming (``/json`` endpoint) and
replies via HTTP POST. No external SDK only httpx is required. publishes replies via HTTP POST. No external SDK only httpx.
""" """
MAX_MESSAGE_LENGTH = MAX_MESSAGE_LENGTH MAX_MESSAGE_LENGTH = MAX_MESSAGE_LENGTH
def __init__(self, config: PlatformConfig): def __init__(self, config: PlatformConfig):
super().__init__(config, Platform.NTFY) platform = Platform("ntfy")
super().__init__(config=config, platform=platform)
extra = config.extra or {} extra = config.extra or {}
self._server: str = ( 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), timeout=httpx.Timeout(connect=15.0, read=STREAM_TIMEOUT_SECONDS, write=15.0, pool=15.0),
) as response: ) as response:
if response.status_code == 401: 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") raise _FatalStreamError("401 Unauthorized")
if response.status_code == 404: 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") raise _FatalStreamError("404 Not Found")
response.raise_for_status() response.raise_for_status()
@ -226,8 +273,8 @@ class NtfyAdapter(BasePlatformAdapter):
# publisher-controlled and must NOT be used for authorization — any # publisher-controlled and must NOT be used for authorization — any
# publisher who knows the topic can set title to an allowed username. # 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 # 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 # topic name. NTFY_ALLOWED_USERS is only a real trust boundary when
# boundary when the topic has a read token protecting it. # the topic itself is protected by a read token.
user_id = topic user_id = topic
user_name = topic user_name = topic
@ -239,10 +286,12 @@ class NtfyAdapter(BasePlatformAdapter):
user_name=user_name, user_name=user_name,
) )
# Parse timestamp
unix_ts = event.get("time") unix_ts = event.get("time")
try: 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): except (ValueError, OSError, TypeError):
timestamp = datetime.now(tz=timezone.utc) timestamp = datetime.now(tz=timezone.utc)
@ -302,7 +351,9 @@ class NtfyAdapter(BasePlatformAdapter):
body = content[:self.MAX_MESSAGE_LENGTH] body = content[:self.MAX_MESSAGE_LENGTH]
try: 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: if resp.status_code < 300:
try: try:
data = resp.json() data = resp.json()
@ -334,9 +385,169 @@ class NtfyAdapter(BasePlatformAdapter):
if not self._token: if not self._token:
return {} return {}
# ntfy supports both Bearer tokens and Base64-encoded Basic auth; # 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: if ":" in self._token:
import base64 import base64
encoded = base64.b64encode(self._token.encode()).decode() encoded = base64.b64encode(self._token.encode()).decode()
return {"Authorization": f"Basic {encoded}"} return {"Authorization": f"Basic {encoded}"}
return {"Authorization": f"Bearer {self._token}"} 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."
),
)

View File

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

View File

@ -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 asyncio
import os import os
@ -6,7 +20,22 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest 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): def _run(coro):
@ -15,22 +44,21 @@ def _run(coro):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Platform enum # 1. Platform enum (plugin-discovered, not bundled)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestPlatformEnum: def test_platform_enum_resolves_via_plugin_scan():
"""The plugin filesystem scan should expose Platform("ntfy")."""
def test_ntfy_value(self): from gateway.config import Platform
assert Platform.NTFY.value == "ntfy" p = Platform("ntfy")
assert p.value == "ntfy"
def test_ntfy_in_all_platforms(self): # Identity stability — repeated lookups return the same pseudo-member
values = [p.value for p in Platform] assert Platform("ntfy") is p
assert "ntfy" in values
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Requirements check # 2. check_requirements / validate_config / is_connected
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -38,157 +66,67 @@ class TestNtfyRequirements:
def test_returns_false_when_httpx_unavailable(self, monkeypatch): def test_returns_false_when_httpx_unavailable(self, monkeypatch):
monkeypatch.setenv("NTFY_TOPIC", "hermes-test") monkeypatch.setenv("NTFY_TOPIC", "hermes-test")
monkeypatch.setattr("gateway.platforms.ntfy.HTTPX_AVAILABLE", False) monkeypatch.setattr(_ntfy, "HTTPX_AVAILABLE", False)
from gateway.platforms.ntfy import check_ntfy_requirements assert check_requirements() is False
assert check_ntfy_requirements() is False
def test_returns_false_when_topic_not_set(self, monkeypatch): 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) monkeypatch.delenv("NTFY_TOPIC", raising=False)
from gateway.platforms.ntfy import check_ntfy_requirements assert check_requirements() is False
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
def test_returns_true_when_topic_set_via_env(self, monkeypatch): 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") monkeypatch.setenv("NTFY_TOPIC", "hermes-test")
from gateway.platforms.ntfy import check_ntfy_requirements assert check_requirements() is True
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
def test_validate_config_requires_topic(self, monkeypatch):
monkeypatch.delenv("NTFY_TOPIC", raising=False) monkeypatch.delenv("NTFY_TOPIC", raising=False)
config = load_gateway_config() assert validate_config(PlatformConfig(enabled=True, extra={})) is False
pc = config.platforms.get(Platform.NTFY) assert validate_config(
if pc is not None: PlatformConfig(enabled=True, extra={"topic": "t"})
assert not pc.enabled or pc.extra.get("topic", "") == "" ) is True
def test_ntfy_in_connected_platforms_when_topic_set(self, monkeypatch): def test_is_connected_from_extra(self, monkeypatch):
from gateway.config import load_gateway_config 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") def test_is_connected_from_env(self, monkeypatch):
config = load_gateway_config() monkeypatch.setenv("NTFY_TOPIC", "env-topic")
connected = config.get_connected_platforms() assert is_connected(PlatformConfig(enabled=True, extra={})) is True
assert Platform.NTFY in connected
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Adapter construction # 3. Adapter init
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestNtfyAdapterInit: class TestNtfyAdapterInit:
def test_default_server_url(self, monkeypatch): def test_default_server_url(self, monkeypatch):
from gateway.platforms.ntfy import NtfyAdapter, DEFAULT_SERVER
monkeypatch.delenv("NTFY_SERVER_URL", raising=False) monkeypatch.delenv("NTFY_SERVER_URL", raising=False)
config = PlatformConfig(enabled=True, extra={"topic": "hermes-in"}) config = PlatformConfig(enabled=True, extra={"topic": "hermes-in"})
adapter = NtfyAdapter(config) adapter = NtfyAdapter(config)
assert adapter._server == DEFAULT_SERVER.rstrip("/") assert adapter._server == DEFAULT_SERVER.rstrip("/")
def test_topic_read_from_extra(self): def test_topic_read_from_extra(self):
from gateway.platforms.ntfy import NtfyAdapter
config = PlatformConfig(enabled=True, extra={"topic": "my-topic"}) config = PlatformConfig(enabled=True, extra={"topic": "my-topic"})
adapter = NtfyAdapter(config) adapter = NtfyAdapter(config)
assert adapter._topic == "my-topic" assert adapter._topic == "my-topic"
def test_topic_read_from_env(self, monkeypatch): def test_topic_read_from_env(self, monkeypatch):
from gateway.platforms.ntfy import NtfyAdapter
monkeypatch.setenv("NTFY_TOPIC", "env-topic") monkeypatch.setenv("NTFY_TOPIC", "env-topic")
config = PlatformConfig(enabled=True, extra={}) config = PlatformConfig(enabled=True, extra={})
adapter = NtfyAdapter(config) adapter = NtfyAdapter(config)
assert adapter._topic == "env-topic" assert adapter._topic == "env-topic"
def test_publish_topic_falls_back_to_topic(self, monkeypatch): def test_publish_topic_falls_back_to_topic(self, monkeypatch):
from gateway.platforms.ntfy import NtfyAdapter
monkeypatch.delenv("NTFY_PUBLISH_TOPIC", raising=False) monkeypatch.delenv("NTFY_PUBLISH_TOPIC", raising=False)
config = PlatformConfig(enabled=True, extra={"topic": "hermes-in"}) config = PlatformConfig(enabled=True, extra={"topic": "hermes-in"})
adapter = NtfyAdapter(config) adapter = NtfyAdapter(config)
assert adapter._publish_topic == "hermes-in" assert adapter._publish_topic == "hermes-in"
def test_publish_topic_uses_extra_value(self): def test_publish_topic_uses_extra_value(self):
from gateway.platforms.ntfy import NtfyAdapter
config = PlatformConfig( config = PlatformConfig(
enabled=True, enabled=True,
extra={"topic": "hermes-in", "publish_topic": "hermes-out"}, extra={"topic": "hermes-in", "publish_topic": "hermes-out"},
@ -197,23 +135,17 @@ class TestNtfyAdapterInit:
assert adapter._publish_topic == "hermes-out" assert adapter._publish_topic == "hermes-out"
def test_token_read_from_extra(self): def test_token_read_from_extra(self):
from gateway.platforms.ntfy import NtfyAdapter
config = PlatformConfig(enabled=True, extra={"topic": "t", "token": "tok-123"}) config = PlatformConfig(enabled=True, extra={"topic": "t", "token": "tok-123"})
adapter = NtfyAdapter(config) adapter = NtfyAdapter(config)
assert adapter._token == "tok-123" assert adapter._token == "tok-123"
def test_token_read_from_env(self, monkeypatch): def test_token_read_from_env(self, monkeypatch):
from gateway.platforms.ntfy import NtfyAdapter
monkeypatch.setenv("NTFY_TOKEN", "env-token") monkeypatch.setenv("NTFY_TOKEN", "env-token")
config = PlatformConfig(enabled=True, extra={"topic": "t"}) config = PlatformConfig(enabled=True, extra={"topic": "t"})
adapter = NtfyAdapter(config) adapter = NtfyAdapter(config)
assert adapter._token == "env-token" assert adapter._token == "env-token"
def test_server_trailing_slash_stripped(self): def test_server_trailing_slash_stripped(self):
from gateway.platforms.ntfy import NtfyAdapter
config = PlatformConfig( config = PlatformConfig(
enabled=True, enabled=True,
extra={"topic": "t", "server": "https://ntfy.example.com/"}, extra={"topic": "t", "server": "https://ntfy.example.com/"},
@ -221,16 +153,7 @@ class TestNtfyAdapterInit:
adapter = NtfyAdapter(config) adapter = NtfyAdapter(config)
assert not adapter._server.endswith("/") 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): def test_initial_state(self):
from gateway.platforms.ntfy import NtfyAdapter
config = PlatformConfig(enabled=True, extra={"topic": "t"}) config = PlatformConfig(enabled=True, extra={"topic": "t"})
adapter = NtfyAdapter(config) adapter = NtfyAdapter(config)
assert adapter._stream_task is None assert adapter._stream_task is None
@ -239,15 +162,13 @@ class TestNtfyAdapterInit:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Auth headers # 4. Auth headers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestAuthHeaders: class TestAuthHeaders:
def _make_adapter(self, token=""): def _make_adapter(self, token=""):
from gateway.platforms.ntfy import NtfyAdapter
config = PlatformConfig(enabled=True, extra={"topic": "t", "token": token}) config = PlatformConfig(enabled=True, extra={"topic": "t", "token": token})
return NtfyAdapter(config) return NtfyAdapter(config)
@ -258,15 +179,14 @@ class TestAuthHeaders:
def test_bearer_token_for_plain_token(self): def test_bearer_token_for_plain_token(self):
adapter = self._make_adapter(token="myapitoken") adapter = self._make_adapter(token="myapitoken")
headers = adapter._auth_headers() headers = adapter._auth_headers()
assert "Authorization" in headers
assert headers["Authorization"] == "Bearer myapitoken" assert headers["Authorization"] == "Bearer myapitoken"
def test_basic_auth_for_user_colon_password(self): def test_basic_auth_for_user_colon_password(self):
adapter = self._make_adapter(token="user:pass") adapter = self._make_adapter(token="user:pass")
headers = adapter._auth_headers() headers = adapter._auth_headers()
assert "Authorization" in headers
assert headers["Authorization"].startswith("Basic ") 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 assert headers["Authorization"] == expected
def test_bearer_token_used_when_no_colon(self): def test_bearer_token_used_when_no_colon(self):
@ -281,15 +201,13 @@ class TestAuthHeaders:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Deduplication # 5. Deduplication
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestDeduplication: class TestDeduplication:
def _make_adapter(self): def _make_adapter(self):
from gateway.platforms.ntfy import NtfyAdapter
return NtfyAdapter(PlatformConfig(enabled=True, extra={"topic": "t"})) return NtfyAdapter(PlatformConfig(enabled=True, extra={"topic": "t"}))
def test_first_message_not_duplicate(self): def test_first_message_not_duplicate(self):
@ -313,18 +231,14 @@ class TestDeduplication:
assert len(adapter._seen_messages) == 50 assert len(adapter._seen_messages) == 50
def test_cache_pruned_on_overflow(self): def test_cache_pruned_on_overflow(self):
from gateway.platforms.ntfy import NtfyAdapter, DEDUP_MAX_SIZE adapter = self._make_adapter()
adapter = NtfyAdapter(PlatformConfig(enabled=True, extra={"topic": "t"}))
for i in range(DEDUP_MAX_SIZE + 20): for i in range(DEDUP_MAX_SIZE + 20):
adapter._is_duplicate(f"msg-{i}") adapter._is_duplicate(f"msg-{i}")
assert len(adapter._seen_messages) <= DEDUP_MAX_SIZE + 20 assert len(adapter._seen_messages) <= DEDUP_MAX_SIZE + 20
def test_expired_id_can_be_seen_again(self): def test_expired_id_can_be_seen_again(self):
import time import time
from gateway.platforms.ntfy import NtfyAdapter, DEDUP_WINDOW_SECONDS, DEDUP_MAX_SIZE adapter = self._make_adapter()
adapter = NtfyAdapter(PlatformConfig(enabled=True, extra={"topic": "t"}))
adapter._seen_messages["old-msg"] = time.time() - DEDUP_WINDOW_SECONDS - 1 adapter._seen_messages["old-msg"] = time.time() - DEDUP_WINDOW_SECONDS - 1
for i in range(DEDUP_MAX_SIZE + 1): for i in range(DEDUP_MAX_SIZE + 1):
adapter._is_duplicate(f"fill-{i}") adapter._is_duplicate(f"fill-{i}")
@ -332,39 +246,33 @@ class TestDeduplication:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# connect() / disconnect() # 6. connect() / disconnect()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestConnect: class TestConnect:
def test_connect_fails_when_httpx_unavailable(self, monkeypatch): def test_connect_fails_when_httpx_unavailable(self, monkeypatch):
monkeypatch.setattr("gateway.platforms.ntfy.HTTPX_AVAILABLE", False) monkeypatch.setattr(_ntfy, "HTTPX_AVAILABLE", False)
from gateway.platforms.ntfy import NtfyAdapter
adapter = NtfyAdapter(PlatformConfig(enabled=True, extra={"topic": "t"})) adapter = NtfyAdapter(PlatformConfig(enabled=True, extra={"topic": "t"}))
result = _run(adapter.connect()) result = _run(adapter.connect())
assert result is False assert result is False
def test_connect_fails_when_no_topic(self, monkeypatch): 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) monkeypatch.delenv("NTFY_TOPIC", raising=False)
from gateway.platforms.ntfy import NtfyAdapter
config = PlatformConfig(enabled=True, extra={}) config = PlatformConfig(enabled=True, extra={})
adapter = NtfyAdapter(config) adapter = NtfyAdapter(config)
result = _run(adapter.connect()) result = _run(adapter.connect())
assert result is False assert result is False
def test_connect_starts_stream_task(self, monkeypatch): def test_connect_starts_stream_task(self, monkeypatch):
monkeypatch.setattr("gateway.platforms.ntfy.HTTPX_AVAILABLE", True) monkeypatch.setattr(_ntfy, "HTTPX_AVAILABLE", True)
from gateway.platforms.ntfy import NtfyAdapter
config = PlatformConfig(enabled=True, extra={"topic": "hermes-test"}) config = PlatformConfig(enabled=True, extra={"topic": "hermes-test"})
adapter = NtfyAdapter(config) adapter = NtfyAdapter(config)
with patch.object(adapter, "_run_stream", new_callable=AsyncMock): 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() mock_httpx.AsyncClient.return_value = MagicMock()
result = _run(adapter.connect()) result = _run(adapter.connect())
@ -377,8 +285,6 @@ class TestConnect:
pass pass
def test_disconnect_clears_state(self): def test_disconnect_clears_state(self):
from gateway.platforms.ntfy import NtfyAdapter
adapter = NtfyAdapter(PlatformConfig(enabled=True, extra={"topic": "t"})) adapter = NtfyAdapter(PlatformConfig(enabled=True, extra={"topic": "t"}))
adapter._seen_messages["x"] = 1.0 adapter._seen_messages["x"] = 1.0
adapter._http_client = AsyncMock() adapter._http_client = AsyncMock()
@ -392,8 +298,6 @@ class TestConnect:
assert adapter._running is False assert adapter._running is False
def test_disconnect_cancels_stream_task(self): def test_disconnect_cancels_stream_task(self):
from gateway.platforms.ntfy import NtfyAdapter
adapter = NtfyAdapter(PlatformConfig(enabled=True, extra={"topic": "t"})) adapter = NtfyAdapter(PlatformConfig(enabled=True, extra={"topic": "t"}))
async def _hang(): async def _hang():
@ -409,18 +313,18 @@ class TestConnect:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# send() # 7. send()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestSend: class TestSend:
def _make_adapter(self, topic="hermes-in", publish_topic="", token=""): def _make_adapter(self, topic="hermes-in", publish_topic="", token="", markdown=False):
from gateway.platforms.ntfy import NtfyAdapter extra: dict = {"topic": topic, "token": token}
extra = {"topic": topic, "token": token}
if publish_topic: if publish_topic:
extra["publish_topic"] = publish_topic extra["publish_topic"] = publish_topic
if markdown:
extra["markdown"] = True
return NtfyAdapter(PlatformConfig(enabled=True, extra=extra)) return NtfyAdapter(PlatformConfig(enabled=True, extra=extra))
def test_send_fails_without_http_client(self): def test_send_fails_without_http_client(self):
@ -444,8 +348,7 @@ class TestSend:
assert result.success is True assert result.success is True
assert result.message_id == "abc123" assert result.message_id == "abc123"
call_args = mock_client.post.call_args posted_url = mock_client.post.call_args[0][0]
posted_url = call_args[0][0]
assert posted_url.endswith("/hermes-out") assert posted_url.endswith("/hermes-out")
def test_send_falls_back_to_subscribe_topic(self): def test_send_falls_back_to_subscribe_topic(self):
@ -498,8 +401,6 @@ class TestSend:
assert "403" in result.error assert "403" in result.error
def test_send_handles_timeout(self): def test_send_handles_timeout(self):
import gateway.platforms.ntfy as ntfy_mod
adapter = self._make_adapter(topic="hermes-in") adapter = self._make_adapter(topic="hermes-in")
class _FakeTimeout(Exception): class _FakeTimeout(Exception):
@ -512,15 +413,13 @@ class TestSend:
mock_client.post = AsyncMock(side_effect=_FakeTimeout("timed out")) mock_client.post = AsyncMock(side_effect=_FakeTimeout("timed out"))
adapter._http_client = mock_client 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!")) result = _run(adapter.send("hermes-in", "Hello!"))
assert result.success is False assert result.success is False
assert "timeout" in result.error.lower() assert "timeout" in result.error.lower()
def test_send_truncates_to_max_length(self): def test_send_truncates_to_max_length(self):
from gateway.platforms.ntfy import NtfyAdapter, MAX_MESSAGE_LENGTH
adapter = self._make_adapter(topic="t") adapter = self._make_adapter(topic="t")
mock_resp = MagicMock() mock_resp = MagicMock()
mock_resp.status_code = 200 mock_resp.status_code = 200
@ -537,15 +436,10 @@ class TestSend:
assert len(posted_body.decode()) <= MAX_MESSAGE_LENGTH assert len(posted_body.decode()) <= MAX_MESSAGE_LENGTH
def test_send_typing_is_noop(self): def test_send_typing_is_noop(self):
from gateway.platforms.ntfy import NtfyAdapter
adapter = NtfyAdapter(PlatformConfig(enabled=True, extra={"topic": "t"})) adapter = NtfyAdapter(PlatformConfig(enabled=True, extra={"topic": "t"}))
# Should not raise _run(adapter.send_typing("t")) # must not raise
_run(adapter.send_typing("t"))
def test_get_chat_info_returns_dict(self): def test_get_chat_info_returns_dict(self):
from gateway.platforms.ntfy import NtfyAdapter
adapter = NtfyAdapter(PlatformConfig(enabled=True, extra={"topic": "t"})) adapter = NtfyAdapter(PlatformConfig(enabled=True, extra={"topic": "t"}))
info = _run(adapter.get_chat_info("hermes-in")) info = _run(adapter.get_chat_info("hermes-in"))
assert info["name"] == "hermes-in" assert info["name"] == "hermes-in"
@ -567,19 +461,42 @@ class TestSend:
call_headers = mock_client.post.call_args[1]["headers"] call_headers = mock_client.post.call_args[1]["headers"]
assert call_headers.get("Authorization") == "Bearer mytoken" 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: class TestOnMessage:
def _make_adapter(self): def _make_adapter(self):
from gateway.platforms.ntfy import NtfyAdapter return NtfyAdapter(PlatformConfig(enabled=True, extra={"topic": "hermes-in"}))
adapter = NtfyAdapter(PlatformConfig(enabled=True, extra={"topic": "hermes-in"}))
return adapter
def test_message_dispatched_to_handler(self): def test_message_dispatched_to_handler(self):
adapter = self._make_adapter() adapter = self._make_adapter()
@ -622,7 +539,6 @@ class TestOnMessage:
calls.append(event) calls.append(event)
adapter.set_message_handler(handler) adapter.set_message_handler(handler)
event = {"id": "dup-1", "event": "message", "topic": "hermes-in", "message": "hi", "time": None} event = {"id": "dup-1", "event": "message", "topic": "hermes-in", "message": "hi", "time": None}
_run(adapter._on_message(event)) _run(adapter._on_message(event))
_run(adapter._on_message(event)) _run(adapter._on_message(event))
@ -630,7 +546,6 @@ class TestOnMessage:
def test_timestamp_parsed_from_event(self): def test_timestamp_parsed_from_event(self):
from datetime import timezone from datetime import timezone
adapter = self._make_adapter() adapter = self._make_adapter()
captured = [] captured = []
@ -638,7 +553,6 @@ class TestOnMessage:
captured.append(event) captured.append(event)
adapter.set_message_handler(handler) adapter.set_message_handler(handler)
_run(adapter._on_message({ _run(adapter._on_message({
"id": "ts-1", "id": "ts-1",
"event": "message", "event": "message",
@ -667,8 +581,7 @@ class TestOnMessage:
assert captured[0].message_id == "ntfy-id-42" assert captured[0].message_id == "ntfy-id-42"
def test_title_not_used_as_user_id(self): def test_title_not_used_as_user_id(self):
"""title field must not be used for identity — it is publisher-controlled """title field must not be used for identity — it is publisher-controlled."""
and cannot be trusted as an authentication signal."""
adapter = self._make_adapter() adapter = self._make_adapter()
captured = [] captured = []
@ -684,13 +597,11 @@ class TestOnMessage:
"title": "Alice", "title": "Alice",
"time": None, "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_id == "hermes-in"
assert captured[0].source.user_name == "hermes-in" assert captured[0].source.user_name == "hermes-in"
def test_unknown_publisher_cannot_impersonate_allowed_user(self): def test_unknown_publisher_cannot_impersonate_allowed_user(self):
"""An unknown publisher setting title to an allowed username must not """An unknown publisher setting title=admin must not gain admin identity."""
gain the identity of that user identity is always the topic name."""
adapter = self._make_adapter() adapter = self._make_adapter()
captured = [] 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): def test_returns_none_without_topic(self, monkeypatch):
assert hasattr(Platform, "NTFY") monkeypatch.delenv("NTFY_TOPIC", raising=False)
assert Platform.NTFY.value == "ntfy" assert _env_enablement() is None
def test_ntfy_in_platform_map_source(self): def test_seeds_topic_and_server(self, monkeypatch):
src = open("tools/send_message_tool.py").read() monkeypatch.setenv("NTFY_TOPIC", "hermes-in")
assert "Platform.NTFY" in src 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): def test_custom_server_url(self, monkeypatch):
src = open("tools/send_message_tool.py").read() monkeypatch.setenv("NTFY_TOPIC", "hermes-in")
assert "async def _send_ntfy" in src 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): def test_publish_topic_seeded(self, monkeypatch):
src = open("tools/send_message_tool.py").read() monkeypatch.setenv("NTFY_TOPIC", "hermes-in")
assert "Platform.NTFY" in src monkeypatch.setenv("NTFY_PUBLISH_TOPIC", "hermes-out")
assert "_send_ntfy" in src seed = _env_enablement()
assert seed["publish_topic"] == "hermes-out"
def test_send_ntfy_reads_server_from_extra(self): def test_token_seeded(self, monkeypatch):
src = open("tools/send_message_tool.py").read() monkeypatch.setenv("NTFY_TOPIC", "hermes-in")
assert 'extra.get("server")' in src monkeypatch.setenv("NTFY_TOKEN", "tk_abc")
assert "NTFY_SERVER_URL" in src seed = _env_enablement()
assert seed["token"] == "tk_abc"
def test_send_ntfy_reads_topic_from_extra(self): def test_markdown_truthy_values(self, monkeypatch):
src = open("tools/send_message_tool.py").read() monkeypatch.setenv("NTFY_TOPIC", "hermes-in")
assert 'extra.get("topic")' in src for val in ("true", "1", "yes", "TRUE"):
assert "NTFY_TOPIC" in src monkeypatch.setenv("NTFY_MARKDOWN", val)
assert _env_enablement()["markdown"] is True
def test_send_ntfy_reads_token_from_extra(self): def test_markdown_falsy_values(self, monkeypatch):
src = open("tools/send_message_tool.py").read() monkeypatch.setenv("NTFY_TOPIC", "hermes-in")
assert 'extra.get("token")' in src for val in ("false", "0", "no", "anything"):
assert "NTFY_TOKEN" in src 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): def test_errors_without_topic(self, monkeypatch):
src = open("cron/scheduler.py").read() monkeypatch.delenv("NTFY_TOPIC", raising=False)
# ntfy routing handled via Platform._missing_() dynamic dispatch monkeypatch.delenv("NTFY_PUBLISH_TOPIC", raising=False)
assert '"ntfy"' in src or "Platform._missing_" in src or "_missing_" in src 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): def test_posts_to_server(self, monkeypatch):
src = open("cron/scheduler.py").read() monkeypatch.setenv("NTFY_TOPIC", "hermes-in")
assert "ntfy" in src.lower() 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_register_calls_register_platform():
ctx = MagicMock()
def test_ntfy_allowed_users_in_allowlist_check(self): register(ctx)
src = open("gateway/run.py").read() ctx.register_platform.assert_called_once()
assert "NTFY_ALLOWED_USERS" in src kwargs = ctx.register_platform.call_args.kwargs
assert kwargs["name"] == "ntfy"
def test_ntfy_allow_all_users_in_allowlist_check(self): assert kwargs["label"] == "ntfy"
src = open("gateway/run.py").read() assert kwargs["required_env"] == ["NTFY_TOPIC"]
assert "NTFY_ALLOW_ALL_USERS" in src assert kwargs["allowed_users_env"] == "NTFY_ALLOWED_USERS"
assert kwargs["allow_all_env"] == "NTFY_ALLOW_ALL_USERS"
def test_ntfy_in_platform_env_map(self): assert kwargs["cron_deliver_env_var"] == "NTFY_HOME_CHANNEL"
src = open("gateway/run.py").read() assert kwargs["max_message_length"] == MAX_MESSAGE_LENGTH
assert 'Platform.NTFY: "NTFY_ALLOWED_USERS"' in src assert callable(kwargs["check_fn"])
assert callable(kwargs["validate_config"])
def test_ntfy_in_allow_all_map(self): assert callable(kwargs["is_connected"])
src = open("gateway/run.py").read() assert callable(kwargs["env_enablement_fn"])
assert 'Platform.NTFY: "NTFY_ALLOW_ALL_USERS"' in src assert callable(kwargs["standalone_sender_fn"])
assert callable(kwargs["adapter_factory"])
def test_ntfy_create_adapter_branch(self): # ntfy has no user-identifying PII (only topic names)
src = open("gateway/run.py").read() assert kwargs["pii_safe"] is True
assert "Platform.NTFY" in src assert "ntfy" in kwargs["platform_hint"].lower()
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_adapter_factory_returns_ntfy_adapter():
# Integration: toolsets ctx = MagicMock()
# --------------------------------------------------------------------------- register(ctx)
factory = ctx.register_platform.call_args.kwargs["adapter_factory"]
cfg = PlatformConfig(enabled=True, extra={"topic": "t"})
class TestToolsets: adapter = factory(cfg)
assert isinstance(adapter, NtfyAdapter)
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

View File

@ -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) result = await _send_bluebubbles(pconfig.extra, chat_id, chunk)
elif platform == Platform.QQBOT: elif platform == Platform.QQBOT:
result = await _send_qqbot(pconfig, chat_id, chunk) result = await _send_qqbot(pconfig, chat_id, chunk)
elif platform == Platform.NTFY:
result = await _send_ntfy(pconfig, chat_id, chunk)
elif platform == Platform.YUANBAO: elif platform == Platform.YUANBAO:
result = await _send_yuanbao(chat_id, chunk) result = await _send_yuanbao(chat_id, chunk)
else: else:
@ -1772,28 +1770,6 @@ async def _send_qqbot(pconfig, chat_id, message):
return _error(f"QQBot send failed: {e}") 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): async def _send_yuanbao(chat_id, message, media_files=None):
"""Send via Yuanbao using the running gateway adapter's WebSocket connection. """Send via Yuanbao using the running gateway adapter's WebSocket connection.

View File

@ -270,11 +270,6 @@ TOOLSETS = {
"includes": [], "includes": [],
}, },
"ntfy": {
"description": "ntfy push notification toolset",
"tools": [],
"includes": ["hermes-ntfy"],
},
"yuanbao": { "yuanbao": {
"description": "Yuanbao platform tools - group info, member queries, DM, stickers", "description": "Yuanbao platform tools - group info, member queries, DM, stickers",
"tools": [ "tools": [
@ -520,11 +515,6 @@ TOOLSETS = {
"includes": [] "includes": []
}, },
"hermes-ntfy": {
"description": "ntfy push notification bot toolset",
"tools": _HERMES_CORE_TOOLS,
"includes": []
},
"hermes-sms": { "hermes-sms": {
"description": "SMS bot toolset - interact with Hermes via SMS (Twilio)", "description": "SMS bot toolset - interact with Hermes via SMS (Twilio)",
"tools": _HERMES_CORE_TOOLS, "tools": _HERMES_CORE_TOOLS,
@ -540,7 +530,7 @@ TOOLSETS = {
"hermes-gateway": { "hermes-gateway": {
"description": "Gateway toolset - union of all messaging platform tools", "description": "Gateway toolset - union of all messaging platform tools",
"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"]
} }
} }