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:
@ -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). "
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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 = {
|
||||||
|
|||||||
@ -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():
|
||||||
|
|||||||
3
plugins/platforms/ntfy/__init__.py
Normal file
3
plugins/platforms/ntfy/__init__.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
from .adapter import register
|
||||||
|
|
||||||
|
__all__ = ["register"]
|
||||||
@ -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."
|
||||||
|
),
|
||||||
|
)
|
||||||
56
plugins/platforms/ntfy/plugin.yaml
Normal file
56
plugins/platforms/ntfy/plugin.yaml
Normal 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
|
||||||
@ -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
|
|
||||||
@ -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.
|
||||||
|
|
||||||
|
|||||||
12
toolsets.py
12
toolsets.py
@ -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"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user