From f2a9044a1917024cc20a0ae85fb8a6f2f347ea80 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 23:07:17 -0700 Subject: [PATCH] refactor(all): pack register() kwargs, table-drive Teams standalone preflight --- plugins/platforms/line/adapter.py | 42 +++++--------------- plugins/platforms/simplex/adapter.py | 27 ++++--------- plugins/platforms/teams/adapter.py | 59 ++++++++++------------------ 3 files changed, 38 insertions(+), 90 deletions(-) diff --git a/plugins/platforms/line/adapter.py b/plugins/platforms/line/adapter.py index bd36616d0c..1d336fa2dd 100644 --- a/plugins/platforms/line/adapter.py +++ b/plugins/platforms/line/adapter.py @@ -47,7 +47,6 @@ LINE_PUSH_URL = "https://api.line.me/v2/bot/message/push" LINE_LOADING_URL = "https://api.line.me/v2/bot/chat/loading/start" LINE_CONTENT_URL_FMT = "https://api-data.line.me/v2/bot/message/{message_id}/content" LINE_BOT_INFO_URL = "https://api.line.me/v2/bot/info" - LINE_PER_BUBBLE_CHARS = 5000 # LINE hard limit LINE_SAFE_BUBBLE_CHARS = 4500 # conservative chunking limit LINE_MAX_MESSAGES_PER_CALL = 5 @@ -56,38 +55,29 @@ WEBHOOK_BODY_MAX_BYTES = 1_048_576 # 1 MiB — webhooks are tiny JSON DEFAULT_WEBHOOK_PORT = 8646 DEFAULT_WEBHOOK_PATH = "/line/webhook" DEFAULT_MEDIA_PATH_PREFIX = "/line/media" - # ``None`` → asyncio binds BOTH address families (mirrors gateway/platforms/webhook.py). # "0.0.0.0" is unreachable on IPv6-only networks (Fly.io 6PN → 502s); "::" breaks IPv4 # loopback probes on IPV6_V6ONLY=1 hosts. Pin via ``LINE_HOST`` / ``extra.host``. DEFAULT_HOST = None - -# Bind hosts LINE's servers could never fetch media from → public URL required. -_WILDCARD_HOSTS = frozenset({"0.0.0.0", "::", ""}) - +_WILDCARD_HOSTS = frozenset({"0.0.0.0", "::", ""}) # LINE can't fetch media from these → public URL required DEFAULT_SLOW_RESPONSE_THRESHOLD = 45.0 # seconds; 0 disables the postback button DEFAULT_PENDING_REPLY_TEXT = "🤔 Still thinking. Tap below to fetch the answer when it's ready." DEFAULT_BUTTON_LABEL = "Get answer" DEFAULT_DELIVERED_TEXT = "Already replied ✅" DEFAULT_INTERRUPTED_TEXT = "Run was interrupted before completion." - MEDIA_TOKEN_TTL_SECONDS = 1800 # 30 minutes; LINE caches the URL aggressively LINE_IMAGE_MAX_BYTES = 10 * 1024 * 1024 # 10 MB per LINE docs LINE_AV_MAX_BYTES = 200 * 1024 * 1024 # 200 MB for voice/video - # LINE message type → normalized MessageType. LINE audio is recorded voice clips → # VOICE (STT path), like Telegram/WhatsApp. Unknown types fall back to TEXT. _LINE_MESSAGE_TYPES = { "text": MessageType.TEXT, "image": MessageType.PHOTO, "video": MessageType.VIDEO, "audio": MessageType.VOICE, "file": MessageType.DOCUMENT, "location": MessageType.LOCATION, "sticker": MessageType.STICKER} - # 1×1 transparent PNG: fallback video preview (LINE requires ``previewImageUrl``). _FALLBACK_PNG_PREVIEW = bytes.fromhex( "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4" "890000000d49444154789c63000100000005000100377a7ff20000000049454e" "44ae426082") - - # Markdown LINE can't render, applied in order (code blocks first so their content survives). _MD_STRIP_RULES: Tuple[Tuple[re.Pattern, Any], ...] = ( (re.compile(r"```[a-zA-Z0-9_+-]*\n?(.*?)```", re.DOTALL), lambda m: m.group(1).rstrip("\n")), @@ -111,10 +101,8 @@ def strip_markdown_preserving_urls(text: str) -> str: def split_for_line(text: str, max_chars: int = LINE_SAFE_BUBBLE_CHARS) -> List[str]: """Split into ≤5 LINE bubbles at paragraph/line/word breaks; overflow is ellipsised.""" - if not text: - return [] - if len(text) <= max_chars: - return [text] + if not text or len(text) <= max_chars: + return [text] if text else [] chunks: List[str] = [] remaining = text while remaining and len(chunks) < LINE_MAX_MESSAGES_PER_CALL: @@ -284,8 +272,8 @@ class _LineClient: async def fetch_content(self, message_id: str) -> bytes: """Download an inbound media message's binary content.""" - url = LINE_CONTENT_URL_FMT.format(message_id=message_id) async with self._session(30.0) as session: + url = LINE_CONTENT_URL_FMT.format(message_id=message_id) async with session.get(url, headers={"Authorization": f"Bearer {self._token}"}) as resp: if resp.status >= 400: raise RuntimeError(f"LINE content {resp.status}") @@ -972,24 +960,14 @@ def interactive_setup() -> None: def register(ctx) -> None: """Plugin entry point — called by the Hermes plugin system at startup.""" ctx.register_platform( - name="line", - label="LINE", - adapter_factory=lambda cfg: LineAdapter(cfg), - check_fn=check_requirements, - validate_config=validate_config, - is_connected=is_connected, - required_env=["LINE_CHANNEL_ACCESS_TOKEN", "LINE_CHANNEL_SECRET"], - install_hint="pip install aiohttp", - setup_fn=interactive_setup, - env_enablement_fn=_env_enablement, - cron_deliver_env_var="LINE_HOME_CHANNEL", - standalone_sender_fn=_standalone_send, - allowed_users_env="LINE_ALLOWED_USERS", + name="line", label="LINE", adapter_factory=lambda cfg: LineAdapter(cfg), check_fn=check_requirements, + validate_config=validate_config, is_connected=is_connected, + required_env=["LINE_CHANNEL_ACCESS_TOKEN", "LINE_CHANNEL_SECRET"], install_hint="pip install aiohttp", + setup_fn=interactive_setup, env_enablement_fn=_env_enablement, cron_deliver_env_var="LINE_HOME_CHANNEL", + standalone_sender_fn=_standalone_send, allowed_users_env="LINE_ALLOWED_USERS", allow_all_env="LINE_ALLOW_ALL_USERS", max_message_length=LINE_SAFE_BUBBLE_CHARS, # per-bubble cap is 5000; smart-chunker uses 4500 - emoji="💚", - pii_safe=False, - allow_update_command=True, + emoji="💚", pii_safe=False, allow_update_command=True, platform_hint=( "You are chatting via LINE Messaging API. LINE does NOT render " "Markdown — text bubbles show ** and # literally. Bare URLs are " diff --git a/plugins/platforms/simplex/adapter.py b/plugins/platforms/simplex/adapter.py index c3e88cebd2..6b7dcc10d0 100644 --- a/plugins/platforms/simplex/adapter.py +++ b/plugins/platforms/simplex/adapter.py @@ -34,10 +34,7 @@ WS_RETRY_DELAY_INITIAL = 2.0 WS_RETRY_DELAY_MAX = 60.0 HEALTH_CHECK_INTERVAL = 30.0 HEALTH_CHECK_STALE_THRESHOLD = 300.0 - -# Correlation ID prefix for requests we send so we can ignore our own echoes. -_CORR_PREFIX = "hermes-" - +_CORR_PREFIX = "hermes-" # marks requests we sent so our own echoes can be ignored _IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp"} _AUDIO_EXTS = {".mp3", ".wav", ".ogg", ".m4a", ".aac", ".opus"} _VOICE_TAG_EXTS = {".ogg", ".mp3", ".wav", ".m4a", ".opus"} # MEDIA: tags sent as voice notes @@ -705,24 +702,14 @@ def interactive_setup() -> None: def register(ctx) -> None: """Plugin entry point — called by the Hermes plugin system at startup.""" ctx.register_platform( - name="simplex", - label="SimpleX Chat", - adapter_factory=lambda cfg: SimplexAdapter(cfg), - check_fn=check_requirements, - validate_config=validate_config, - is_connected=is_connected, + name="simplex", label="SimpleX Chat", adapter_factory=lambda cfg: SimplexAdapter(cfg), + check_fn=check_requirements, validate_config=validate_config, is_connected=is_connected, required_env=["SIMPLEX_WS_URL"], install_hint=("pip install websockets # SimpleX adapter requires the websockets package"), - setup_fn=interactive_setup, - env_enablement_fn=_env_enablement, - cron_deliver_env_var="SIMPLEX_HOME_CHANNEL", - standalone_sender_fn=_standalone_send, - allowed_users_env="SIMPLEX_ALLOWED_USERS", - allow_all_env="SIMPLEX_ALLOW_ALL_USERS", - max_message_length=MAX_MESSAGE_LENGTH, - emoji="🔒", - # SimpleX uses opaque contact IDs only — nothing to redact. - pii_safe=True, + setup_fn=interactive_setup, env_enablement_fn=_env_enablement, cron_deliver_env_var="SIMPLEX_HOME_CHANNEL", + standalone_sender_fn=_standalone_send, allowed_users_env="SIMPLEX_ALLOWED_USERS", + allow_all_env="SIMPLEX_ALLOW_ALL_USERS", max_message_length=MAX_MESSAGE_LENGTH, emoji="🔒", + pii_safe=True, # SimpleX uses opaque contact IDs only — nothing to redact allow_update_command=True, platform_hint=( "You are chatting via SimpleX Chat, a private decentralised " diff --git a/plugins/platforms/teams/adapter.py b/plugins/platforms/teams/adapter.py index cec30e8b3f..cd3db59308 100644 --- a/plugins/platforms/teams/adapter.py +++ b/plugins/platforms/teams/adapter.py @@ -42,7 +42,7 @@ def _probe_teams_sdk_available() -> bool: TEAMS_SDK_AVAILABLE = _probe_teams_sdk_available() -# SDK symbols stay None until check_teams_requirements() binds them. +# SDK symbols stay None until check_teams_requirements() binds them (via _SDK_IMPORTS below). ClientOptions = App = ActivityContext = MessageActivity = ConversationReference = None # type: ignore[assignment,misc] TypingActivityInput = AdaptiveCardInvokeActivity = AdaptiveCardActionCardResponse = None # type: ignore[assignment,misc] AdaptiveCardActionMessageResponse = AdaptiveCardInvokeResponse = InvokeResponse = None # type: ignore[assignment,misc] @@ -195,22 +195,20 @@ async def _standalone_send( return {"error": "Teams standalone send: TEAMS_CLIENT_ID, TEAMS_CLIENT_SECRET, and TEAMS_TENANT_ID are all required"} raw_service_url = os.getenv("TEAMS_SERVICE_URL") or extra.get("service_url", "") or _DEFAULT_TEAMS_SERVICE_URL service_url = _validate_teams_service_url(raw_service_url) - if service_url is None: - return {"error": f"Teams standalone send: TEAMS_SERVICE_URL host is not on the Bot Framework allowlist; " - f"expected one of {sorted(_ALLOWED_TEAMS_SERVICE_HOSTS)}"} - if not chat_id: - return {"error": "Teams standalone send: chat_id (conversation ID) is required"} - if not _TEAMS_CONV_ID_RE.match(chat_id): - return {"error": "Teams standalone send: chat_id contains characters outside the Bot Framework conversation ID set"} - if not _TEAMS_CONV_ID_RE.match(tenant_id): - return {"error": "Teams standalone send: TEAMS_TENANT_ID contains characters outside the expected set"} + for failed, error in ( + (service_url is None, f"TEAMS_SERVICE_URL host is not on the Bot Framework allowlist; " + f"expected one of {sorted(_ALLOWED_TEAMS_SERVICE_HOSTS)}"), + (not chat_id, "chat_id (conversation ID) is required"), + (not _TEAMS_CONV_ID_RE.match(chat_id or ""), "chat_id contains characters outside the Bot Framework conversation ID set"), + (not _TEAMS_CONV_ID_RE.match(tenant_id), "TEAMS_TENANT_ID contains characters outside the expected set"), + (not AIOHTTP_AVAILABLE, "aiohttp not installed"), + ): + if failed: + return {"error": f"Teams standalone send: {error}"} token_url, token_form = _bf_token_request(tenant_id, client_id, client_secret) activities_url = f"{service_url}v3/conversations/{chat_id}/activities" - if not AIOHTTP_AVAILABLE: - return {"error": "Teams standalone send: aiohttp not installed"} try: import aiohttp as _aiohttp - # Per-request timeouts so a slow STS endpoint cannot starve the activity POST. per_request_timeout = _aiohttp.ClientTimeout(total=15.0) async with _aiohttp.ClientSession(trust_env=gateway_trust_env()) as session: @@ -733,17 +731,11 @@ _SETUP_CREDENTIALS = ( ("Client ID", "TEAMS_CLIENT_ID", {}), ("Client secret", "TEAMS_CLIENT_SECRET", {"password": True}), ("Tenant ID", "TEAMS_TENANT_ID", {})) -_SETUP_INTRO = ( - "You'll need the Teams CLI. If you haven't already:", - " npm install -g @microsoft/teams.cli@preview", - " teams login", - "", - "Then expose port 3978 publicly (devtunnel / ngrok / cloudflared),", - "and create your bot:", - ' teams app create --name "Hermes" --endpoint "https:///api/messages"', - "", - "The CLI will print CLIENT_ID, CLIENT_SECRET, and TENANT_ID. Paste them below.", - "") +_SETUP_INTRO = ( # "" → blank line + "You'll need the Teams CLI. If you haven't already:", " npm install -g @microsoft/teams.cli@preview", + " teams login", "", "Then expose port 3978 publicly (devtunnel / ngrok / cloudflared),", "and create your bot:", + ' teams app create --name "Hermes" --endpoint "https:///api/messages"', "", + "The CLI will print CLIENT_ID, CLIENT_SECRET, and TENANT_ID. Paste them below.", "") def interactive_setup() -> None: @@ -755,7 +747,6 @@ def interactive_setup() -> None: print_info(f"Teams: already configured (app ID: {existing_id})") if not prompt_yes_no("Reconfigure Teams?", False): return - for line in _SETUP_INTRO: print_info(line) if line else print() for label, env_key, prompt_kwargs in _SETUP_CREDENTIALS: @@ -764,7 +755,6 @@ def interactive_setup() -> None: print_warning(f"{label} is required — skipping Teams setup") return save_env_value(env_key, value.strip()) - print() print_info("To find your AAD object ID for the allowlist: teams status --verbose") if prompt_yes_no("Restrict access to specific users? (recommended)", True): @@ -777,7 +767,6 @@ def interactive_setup() -> None: else: save_env_value("TEAMS_ALLOW_ALL_USERS", "true") print_warning("⚠️ Open access — anyone who can message the bot can command it.") - print() print_success("Teams configuration saved to ~/.hermes/.env") print_info("Install the app in Teams: teams app install --id ") @@ -800,24 +789,18 @@ def _install_hint() -> str: def register(ctx) -> None: """Plugin entry point — called by the Hermes plugin system.""" ctx.register_platform( - name="teams", - label="Microsoft Teams", - adapter_factory=lambda cfg: TeamsAdapter(cfg), + name="teams", label="Microsoft Teams", adapter_factory=lambda cfg: TeamsAdapter(cfg), check_fn=check_requirements, # PASSIVE probe — never installs ensure_deps_fn=check_teams_requirements, # ACTIVE lazy-installer, run by create_adapter() - validate_config=validate_config, - is_connected=is_connected, + validate_config=validate_config, is_connected=is_connected, required_env=["TEAMS_CLIENT_ID", "TEAMS_CLIENT_SECRET", "TEAMS_TENANT_ID"], - install_hint=_install_hint(), - setup_fn=interactive_setup, + install_hint=_install_hint(), setup_fn=interactive_setup, env_enablement_fn=_env_enablement, # env-only setups show up in gateway status cron_deliver_env_var="TEAMS_HOME_CHANNEL", # deliver=teams cron home-channel routing standalone_sender_fn=_standalone_send, # out-of-process cron delivery via Bot Framework REST - allowed_users_env="TEAMS_ALLOWED_USERS", - allow_all_env="TEAMS_ALLOW_ALL_USERS", + allowed_users_env="TEAMS_ALLOWED_USERS", allow_all_env="TEAMS_ALLOW_ALL_USERS", max_message_length=28000, # Teams supports up to ~28 KB per message - emoji="💼", - allow_update_command=True, + emoji="💼", allow_update_command=True, platform_hint=( "You are chatting via Microsoft Teams. Teams renders a subset of " "markdown — bold (**text**), italic (*text*), and inline code "