From b8bea7199c68bf39ad2671dfe906dce0ec03e2a5 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:06:34 -0700 Subject: [PATCH] refactor(adapters/cn_group): 12368->8549; wecom split into streaming/media/send_queue mixins, dingtalk inbound parsers -> inbound.py, google_chat cards.py + setup_files.py, teams summary_writer.py; dedupe token/URL/credential helpers --- plugins/platforms/dingtalk/adapter.py | 1383 ++------ plugins/platforms/dingtalk/inbound.py | 242 ++ plugins/platforms/google_chat/adapter.py | 3012 ++++-------------- plugins/platforms/google_chat/cards.py | 201 ++ plugins/platforms/google_chat/oauth.py | 391 +-- plugins/platforms/google_chat/setup_files.py | 170 + plugins/platforms/teams/adapter.py | 1321 ++------ plugins/platforms/teams/summary_writer.py | 195 ++ plugins/platforms/wecom/adapter.py | 2950 ++--------------- plugins/platforms/wecom/callback_adapter.py | 243 +- plugins/platforms/wecom/media.py | 471 +++ plugins/platforms/wecom/send_queue.py | 108 + plugins/platforms/wecom/streaming.py | 562 ++++ 13 files changed, 3715 insertions(+), 7534 deletions(-) create mode 100644 plugins/platforms/dingtalk/inbound.py create mode 100644 plugins/platforms/google_chat/cards.py create mode 100644 plugins/platforms/google_chat/setup_files.py create mode 100644 plugins/platforms/teams/summary_writer.py create mode 100644 plugins/platforms/wecom/media.py create mode 100644 plugins/platforms/wecom/send_queue.py create mode 100644 plugins/platforms/wecom/streaming.py diff --git a/plugins/platforms/dingtalk/adapter.py b/plugins/platforms/dingtalk/adapter.py index fcca09e358..31a36cb8a5 100644 --- a/plugins/platforms/dingtalk/adapter.py +++ b/plugins/platforms/dingtalk/adapter.py @@ -1,13 +1,5 @@ -""" -DingTalk platform adapter using Stream Mode. - -Uses dingtalk-stream SDK (>=0.20) for real-time message reception without webhooks. -Responses are sent via DingTalk's session webhook (markdown format). -Supports: text, images, audio, video, rich text, files, and group @mentions. - -Requires: - pip install "dingtalk-stream>=0.20" httpx - DINGTALK_CLIENT_ID and DINGTALK_CLIENT_SECRET env vars +"""DingTalk platform adapter (Stream Mode via dingtalk-stream >=0.20; replies via session +webhook markdown or AI Cards). Requires ``pip install "dingtalk-stream>=0.20" httpx``. Configuration in config.yaml: platforms: @@ -36,25 +28,21 @@ import uuid from datetime import datetime, timezone from typing import Any, Dict, List, Optional, Set +# Optional SDKs: catch broad Exception, not just ImportError — their transitive +# cryptography dependency can raise AttributeError on version skew, and a broken +# optional SDK must degrade gracefully instead of killing the plugin import. try: import dingtalk_stream from dingtalk_stream import ChatbotMessage from dingtalk_stream.frames import CallbackMessage, AckMessage DINGTALK_STREAM_AVAILABLE = True -except Exception: # noqa: BLE001 — broad: optional SDK's transitive deps (cryptography) may raise non-ImportError; degrade gracefully (#41112) +except Exception: # noqa: BLE001 DINGTALK_STREAM_AVAILABLE = False dingtalk_stream = None # type: ignore[assignment] ChatbotMessage = None # type: ignore[assignment] CallbackMessage = None # type: ignore[assignment] - AckMessage = type( - "AckMessage", - (), - { - "STATUS_OK": 200, - "STATUS_SYSTEM_EXCEPTION": 500, - }, - ) # type: ignore[assignment] + AckMessage = type("AckMessage", (), {"STATUS_OK": 200, "STATUS_SYSTEM_EXCEPTION": 500}) # type: ignore[assignment] try: import httpx @@ -64,68 +52,30 @@ except ImportError: HTTPX_AVAILABLE = False httpx = None # type: ignore[assignment] -# Card SDK for AI Cards (following QwenPaw pattern). -# Catch broad Exception, not just ImportError: the alibabacloud_dingtalk SDK -# transitively imports cryptography and can raise AttributeError (not -# ImportError) when the installed cryptography version skews from what the SDK -# expects (e.g. `cryptography.utils.DeprecatedIn46` missing on older -# cryptography). An optional SDK with a broken dependency chain must degrade -# gracefully — same as a missing one — rather than crash the whole adapter -# (and therefore the whole plugin) import. #41112. try: - from alibabacloud_dingtalk.card_1_0 import ( - client as dingtalk_card_client, - models as dingtalk_card_models, - ) - from alibabacloud_dingtalk.robot_1_0 import ( - client as dingtalk_robot_client, - models as dingtalk_robot_models, - ) + from alibabacloud_dingtalk.card_1_0 import client as dingtalk_card_client, models as dingtalk_card_models + from alibabacloud_dingtalk.robot_1_0 import client as dingtalk_robot_client, models as dingtalk_robot_models from alibabacloud_tea_openapi import models as open_api_models from alibabacloud_tea_util import models as tea_util_models CARD_SDK_AVAILABLE = True except Exception: CARD_SDK_AVAILABLE = False - dingtalk_card_client = None - dingtalk_card_models = None - dingtalk_robot_client = None - dingtalk_robot_models = None - open_api_models = None - tea_util_models = None + dingtalk_card_client = dingtalk_card_models = dingtalk_robot_client = dingtalk_robot_models = None + open_api_models = tea_util_models = None from gateway.config import Platform, PlatformConfig from gateway.platforms.helpers import MessageDeduplicator, compile_mention_patterns -from gateway.platforms.base import ( - BasePlatformAdapter, - MessageEvent, - MessageType, - SendResult, +from gateway.platforms.base import BasePlatformAdapter, MessageEvent, SendResult +from gateway.platforms._shared import get_scoped_secret as _get_scoped_secret +from plugins.platforms.dingtalk.inbound import ( # noqa: F401 — re-exported names + DINGTALK_TYPE_MAPPING, + EXT_MAP, + collect_download_codes, + extract_media, + extract_text, ) -from agent.secret_scope import UnscopedSecretError as _UnscopedSecretError -from agent.secret_scope import get_secret as _scoped_get_secret - - -def _get_scoped_secret(name, default=None): - """Scope-aware credential read with the default-profile startup fallback. - - Secondary profiles construct their adapters under a profile secret - scope -- the scope is authoritative and a scoped miss returns ``default`` - (no cross-profile borrow from ``os.environ``, which may hold another - profile's value). The DEFAULT profile's adapter constructs and sends - *unscoped* under multiplexing, where a bare ``get_secret`` would raise - ``UnscopedSecretError`` and crash this path; there ``os.environ`` is that - profile's own value, so fall back to it. Same pattern as the Slack - ``SLACK_APP_TOKEN`` read (#59739) and - ``gateway/platforms/whatsapp_common.py::_get_wsecret``. - """ - try: - val = _scoped_get_secret(name, default) - except _UnscopedSecretError: - val = os.getenv(name) - return val if val is not None else default - logger = logging.getLogger(__name__) @@ -133,58 +83,39 @@ MAX_MESSAGE_LENGTH = 20000 RECONNECT_BACKOFF = [2, 5, 10, 30, 60] _SESSION_WEBHOOKS_MAX = 500 _DINGTALK_WEBHOOK_RE = re.compile(r'^https://(?:api|oapi)\.dingtalk\.com/') - -# DingTalk message type → runtime content type -DINGTALK_TYPE_MAPPING = { - "picture": "image", - "voice": "audio", +_TRUTHY = {"true", "1", "yes", "on"} +_EMOTION_ID = "2659900" +_EMOTION_BG = "im_bg_1" +# recall? -> (TextEmotion model, Request model, Headers model, robot SDK method) — resolved +# on ``dingtalk_robot_models`` at call time. +_EMOTION_SDK = { + True: ("RobotRecallEmotionRequestTextEmotion", "RobotRecallEmotionRequest", "RobotRecallEmotionHeaders", "robot_recall_emotion_with_options_async"), + False: ("RobotReplyEmotionRequestTextEmotion", "RobotReplyEmotionRequest", "RobotReplyEmotionHeaders", "robot_reply_emotion_with_options_async"), } -# File extension → MIME type mapping for DingTalk file/image messages. -# Image MIME types (image/*) are used below in _extract_media to classify -# incoming msgtype='image' payloads as MessageType.PHOTO (not DOCUMENT). -EXT_MAP = { - "pdf": "application/pdf", - "png": "image/png", - "jpg": "image/jpeg", - "jpeg": "image/jpeg", - "gif": "image/gif", - "webp": "image/webp", - "doc": "application/msword", - "docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - "xls": "application/vnd.ms-excel", - "xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - "md": "text/markdown", - "txt": "text/plain", - "csv": "text/csv", - "zip": "application/zip", - "mp4": "video/mp4", -} + +def _csv_set(raw: Any) -> Set[str]: + """Split a list or comma-separated string into a set of stripped, non-empty items.""" + if isinstance(raw, list): + return {str(part).strip() for part in raw if str(part).strip()} + return {part.strip() for part in str(raw).split(",") if part.strip()} def dingtalk_deps_present() -> bool: - """PASSIVE probe: are dingtalk-stream/httpx importable right now? + """PASSIVE registry ``check_fn``: are dingtalk-stream/httpx importable right now? - Registry ``check_fn`` — called from status displays and config loading, - so it must never install anything. The ACTIVE lazy-installer - (``check_dingtalk_requirements``) is registered as ``ensure_deps_fn`` - and runs from ``create_adapter()`` when this returns False (#79812). - Credentials are gated separately via ``is_connected``/``validate_config``. + Called from status displays and config loading, so it must never install anything; + ``ensure_dingtalk_deps`` is the ACTIVE installer. Credentials are gated separately. """ return DINGTALK_STREAM_AVAILABLE and HTTPX_AVAILABLE def ensure_dingtalk_deps() -> bool: - """ACTIVE deps-only installer (registry ``ensure_deps_fn``). + """ACTIVE deps-only installer (registry ``ensure_deps_fn``); rebinds module globals. - Lazy-installs dingtalk-stream/httpx and rebinds module globals. - Deliberately does NOT check credentials — ``ensure_deps_fn``'s contract - is deps-only ("Returns True once deps are importable"); credentials are - gated by ``is_connected``/``validate_config``. Otherwise a platform - configured via ``PlatformConfig.extra`` (which ``_is_connected`` - accepts) would pass enablement, reach ``create_adapter()``, and have - the installer veto on env-var grounds before ever installing — - re-creating the #79812 deadlock for extra-configured setups. + Deliberately does NOT check credentials — otherwise a platform configured via + ``PlatformConfig.extra`` would pass enablement and then be vetoed on env-var grounds + before ever installing (deadlock). Credentials are gated by ``is_connected``. """ global DINGTALK_STREAM_AVAILABLE, dingtalk_stream, ChatbotMessage, CallbackMessage, AckMessage global HTTPX_AVAILABLE, httpx @@ -202,81 +133,46 @@ def ensure_dingtalk_deps() -> bool: import httpx as _httpx except Exception: return False - dingtalk_stream = _ds - ChatbotMessage = _CM - CallbackMessage = _CBM - AckMessage = _AM - httpx = _httpx - DINGTALK_STREAM_AVAILABLE = True - HTTPX_AVAILABLE = True + dingtalk_stream, ChatbotMessage, CallbackMessage, AckMessage, httpx = _ds, _CM, _CBM, _AM, _httpx + DINGTALK_STREAM_AVAILABLE = HTTPX_AVAILABLE = True return True def check_dingtalk_requirements() -> bool: - """Check if DingTalk dependencies are available and configured. - - Lazy-installs dingtalk-stream via :func:`ensure_dingtalk_deps`, then - additionally requires credentials. Kept for setup/status callers that - want the combined deps+credentials answer; the registry uses the - deps-only :func:`ensure_dingtalk_deps` as ``ensure_deps_fn``. - """ + """Combined deps (lazy-installed) + credentials check for setup/status callers.""" if not ensure_dingtalk_deps(): return False - if not os.getenv("DINGTALK_CLIENT_ID") or not _get_scoped_secret("DINGTALK_CLIENT_SECRET"): - return False - return True + return bool(os.getenv("DINGTALK_CLIENT_ID") and _get_scoped_secret("DINGTALK_CLIENT_SECRET")) class DingTalkAdapter(BasePlatformAdapter): """DingTalk chatbot adapter using Stream Mode. - The dingtalk-stream SDK maintains a long-lived WebSocket connection. - Incoming messages arrive via a ChatbotHandler callback. Replies are - sent via the incoming message's session_webhook URL using httpx. - - Features: - - Text messages (plain + rich text) - - Images, audio, video, files (via download codes) - - Group chat @mention detection - - Session webhook caching with expiry tracking - - Markdown formatted replies + The dingtalk-stream SDK maintains a long-lived WebSocket; incoming messages arrive via a + ChatbotHandler callback. Replies go through the message's session_webhook (httpx) or, + when ``card_template_id`` is configured, streaming AI Cards. """ MAX_MESSAGE_LENGTH = MAX_MESSAGE_LENGTH @property def SUPPORTS_MESSAGE_EDITING(self) -> bool: # noqa: N802 - """Edits only meaningful when AI Cards are configured. - - The gateway gates streaming cursor + edit behaviour on this flag, - so we must reflect the actual adapter capability at runtime. - """ + """Edits only exist with AI Cards; the gateway gates streaming cursor/edit on this.""" return bool(self._card_template_id and self._card_sdk) @property def REQUIRES_EDIT_FINALIZE(self) -> bool: # noqa: N802 - """AI Card lifecycle requires an explicit ``finalize=True`` edit - to close the streaming indicator, even when the final content is - identical to the last streamed update. Enabled only when cards - are configured — webhook-only DingTalk doesn't need it. - """ + """AI Cards need an explicit ``finalize=True`` edit to close the streaming indicator.""" return bool(self._card_template_id and self._card_sdk) def __init__(self, config: PlatformConfig): super().__init__(config, Platform.DINGTALK) extra = config.extra or {} - self._client_id: str = extra.get("client_id") or os.getenv( - "DINGTALK_CLIENT_ID", "" - ) - self._client_secret: str = extra.get("client_secret") or _get_scoped_secret( - "DINGTALK_CLIENT_SECRET", "" - ) + self._client_id: str = extra.get("client_id") or os.getenv("DINGTALK_CLIENT_ID", "") + self._client_secret: str = extra.get("client_secret") or _get_scoped_secret("DINGTALK_CLIENT_SECRET", "") - # Group-chat gating (mirrors Slack/Telegram/Discord/WhatsApp conventions). - # Mention state is the structured ``is_in_at_list`` attribute from the - # dingtalk-stream SDK (set from the callback's ``isInAtList`` flag), - # not text parsing. + # Group-chat gating; mention state is the SDK's structured ``is_in_at_list``, not text parsing. self._mention_patterns: List[re.Pattern] = self._compile_mention_patterns() self._allowed_users: Set[str] = self._load_allowed_users() @@ -287,92 +183,58 @@ class DingTalkAdapter(BasePlatformAdapter): self._robot_sdk: Optional[Any] = None self._robot_code: str = extra.get("robot_code") or self._client_id - # Message deduplication self._dedup = MessageDeduplicator(max_size=1000) - # Map chat_id -> (session_webhook, expired_time_ms) for reply routing - self._session_webhooks: Dict[str, tuple[str, int]] = {} - # Map chat_id -> last inbound ChatbotMessage. Keyed by chat_id instead - # of a single class attribute to avoid cross-message clobbering when - # multiple conversations run concurrently. - self._message_contexts: Dict[str, Any] = {} + self._session_webhooks: Dict[str, tuple[str, int]] = {} # chat_id -> (webhook, expired_time_ms) + self._message_contexts: Dict[str, Any] = {} # chat_id -> last inbound ChatbotMessage (per-chat: no clobber) self._card_template_id: Optional[str] = extra.get("card_template_id") - - # Chats for which we've already fired the Done reaction — prevents - # double-firing across segment boundaries or parallel flows - # (tool-progress + stream-consumer both finalizing their cards). - # Reset each inbound message. + # Chats whose Done reaction already fired this turn — prevents double-firing across + # segment boundaries / parallel flows. Reset on each inbound message. self._done_emoji_fired: Set[str] = set() - # Cards in streaming state per chat: chat_id -> { out_track_id -> last_content }. - # Every `send()` creates+finalizes a card (closed state). A subsequent - # `edit_message(finalize=False)` re-opens the card (DingTalk's API - # allows streaming_update on a finalized card — it flips back to - # streaming). We track those reopened cards so the next `send()` can - # auto-close them as siblings — otherwise tool-progress cards get - # stuck in streaming state forever. + # Cards left open in streaming state: chat_id -> {out_track_id: last_content}. + # ``edit_message(finalize=False)`` re-opens a finalized card (DingTalk allows it), so we + # track them and auto-close as siblings on the next ``send()`` — otherwise tool-progress + # cards stay stuck in streaming state forever. self._streaming_cards: Dict[str, Dict[str, str]] = {} - # Track fire-and-forget emoji/reaction coroutines so Python's GC - # doesn't drop them mid-flight, and we can cancel them on disconnect. + # Fire-and-forget emoji tasks, kept referenced (GC) and cancellable on disconnect. self._bg_tasks: Set[asyncio.Task] = set() # -- Connection lifecycle ----------------------------------------------- async def connect(self, *, is_reconnect: bool = False) -> bool: """Connect to DingTalk via Stream Mode.""" - if not DINGTALK_STREAM_AVAILABLE: - logger.warning( - "[%s] dingtalk-stream not installed. Run: pip install 'dingtalk-stream>=0.20'", - self.name, - ) - return False - if not HTTPX_AVAILABLE: - logger.warning( - "[%s] httpx not installed. Run: pip install httpx", self.name - ) - return False - if not self._client_id or not self._client_secret: - logger.warning( - "[%s] DINGTALK_CLIENT_ID and DINGTALK_CLIENT_SECRET required", self.name - ) - return False + for ok, problem in ( + (DINGTALK_STREAM_AVAILABLE, "dingtalk-stream not installed. Run: pip install 'dingtalk-stream>=0.20'"), + (HTTPX_AVAILABLE, "httpx not installed. Run: pip install httpx"), + (self._client_id and self._client_secret, "DINGTALK_CLIENT_ID and DINGTALK_CLIENT_SECRET required"), + ): + if not ok: + logger.warning("[%s] " + problem, self.name) + return False try: - # Tighter keepalive so idle CLOSE_WAIT drains promptly (#18451). + # Tighter keepalive so idle CLOSE_WAIT drains promptly. from gateway.platforms._http_client_limits import platform_httpx_limits - self._http_client = httpx.AsyncClient( - timeout=30.0, limits=platform_httpx_limits(), - ) + self._http_client = httpx.AsyncClient(timeout=30.0, limits=platform_httpx_limits()) - credential = dingtalk_stream.Credential( - self._client_id, self._client_secret - ) + credential = dingtalk_stream.Credential(self._client_id, self._client_secret) self._stream_client = dingtalk_stream.DingTalkStreamClient(credential) - # Initialize card SDK if available and configured - if CARD_SDK_AVAILABLE and self._card_template_id: + if CARD_SDK_AVAILABLE: sdk_config = open_api_models.Config() sdk_config.protocol = "https" sdk_config.region_id = "central" - self._card_sdk = dingtalk_card_client.Client(sdk_config) - self._robot_sdk = dingtalk_robot_client.Client(sdk_config) - logger.info( - "[%s] Card SDK initialized with template: %s", - self.name, - self._card_template_id, - ) - elif CARD_SDK_AVAILABLE: - # Initialize robot SDK even without card template (for media download) - sdk_config = open_api_models.Config() - sdk_config.protocol = "https" - sdk_config.region_id = "central" - self._robot_sdk = dingtalk_robot_client.Client(sdk_config) - logger.info("[%s] Robot SDK initialized (media download)", self.name) + if self._card_template_id: + self._card_sdk = dingtalk_card_client.Client(sdk_config) + self._robot_sdk = dingtalk_robot_client.Client(sdk_config) + logger.info("[%s] Card SDK initialized with template: %s", self.name, self._card_template_id) + else: + # Robot SDK alone is still needed for media download. + self._robot_sdk = dingtalk_robot_client.Client(sdk_config) + logger.info("[%s] Robot SDK initialized (media download)", self.name) # Capture the current event loop for cross-thread dispatch - loop = asyncio.get_running_loop() - handler = _IncomingHandler(self, loop) - self._stream_client.register_callback_handler( - dingtalk_stream.ChatbotMessage.TOPIC, handler - ) + handler = _IncomingHandler(self, asyncio.get_running_loop()) + self._stream_client.register_callback_handler(dingtalk_stream.ChatbotMessage.TOPIC, handler) self._stream_task = asyncio.create_task(self._run_stream()) self._mark_connected() @@ -397,10 +259,8 @@ class DingTalkAdapter(BasePlatformAdapter): if not self._running: return logger.warning("[%s] Stream client error: %s", self.name, e) - if not self._running: return - delay = RECONNECT_BACKOFF[min(backoff_idx, len(RECONNECT_BACKOFF) - 1)] logger.info("[%s] Reconnecting in %ds...", self.name, delay) await asyncio.sleep(delay) @@ -411,9 +271,8 @@ class DingTalkAdapter(BasePlatformAdapter): self._running = False self._mark_disconnected() - # Close the active websocket first so the stream task sees the - # disconnection and exits cleanly, rather than getting stuck - # awaiting frames that will never arrive. + # Close the websocket first so the stream task sees the disconnect instead of + # awaiting frames that never arrive. websocket = getattr(self._stream_client, "websocket", None) if self._stream_client else None if websocket is not None: try: @@ -422,14 +281,11 @@ class DingTalkAdapter(BasePlatformAdapter): logger.debug("[%s] websocket close during disconnect failed: %s", self.name, e) if self._stream_task: - # Try graceful close first if SDK supports it. The SDK's close() - # is sync and may block on network I/O, so offload to a thread. if hasattr(self._stream_client, "close"): try: - await asyncio.to_thread(self._stream_client.close) + await asyncio.to_thread(self._stream_client.close) # sync close() may block on I/O except Exception: pass - self._stream_task.cancel() try: await asyncio.wait_for(self._stream_task, timeout=5.0) @@ -437,25 +293,19 @@ class DingTalkAdapter(BasePlatformAdapter): logger.debug("[%s] stream task did not exit cleanly during disconnect", self.name) self._stream_task = None - # Cancel any in-flight background tasks (emoji reactions, etc.) if self._bg_tasks: for task in list(self._bg_tasks): task.cancel() await asyncio.gather(*self._bg_tasks, return_exceptions=True) self._bg_tasks.clear() - # Finalize any open streaming cards before the HTTP client closes so - # they don't stay stuck in streaming state on DingTalk's UI after - # a gateway restart. _close_streaming_siblings handles its own - # per-card exceptions; the outer try is a safety net for token fetch. + # Finalize open streaming cards BEFORE the HTTP client closes so they don't stay stuck + # in streaming state after a gateway restart. Outer try guards the token fetch. for _chat_id in list(self._streaming_cards): try: await self._close_streaming_siblings(_chat_id) except Exception as _exc: - logger.debug( - "[%s] Failed to finalize streaming card on disconnect for %s: %s", - self.name, _chat_id, _exc, - ) + logger.debug("[%s] Failed to finalize streaming card on disconnect for %s: %s", self.name, _chat_id, _exc) if self._http_client: await self._http_client.aclose() @@ -472,39 +322,35 @@ class DingTalkAdapter(BasePlatformAdapter): # -- Group gating -------------------------------------------------------- def _dingtalk_require_mention(self) -> bool: - """Return whether group chats should require an explicit bot trigger.""" + """Whether group chats require an explicit bot trigger.""" configured = self.config.extra.get("require_mention") if configured is not None: if isinstance(configured, str): - return configured.lower() in {"true", "1", "yes", "on"} + return configured.lower() in _TRUTHY return bool(configured) - return os.getenv("DINGTALK_REQUIRE_MENTION", "false").lower() in {"true", "1", "yes", "on"} + return os.getenv("DINGTALK_REQUIRE_MENTION", "false").lower() in _TRUTHY def _dingtalk_free_response_chats(self) -> Set[str]: raw = self.config.extra.get("free_response_chats") if raw is None: raw = os.getenv("DINGTALK_FREE_RESPONSE_CHATS", "") - if isinstance(raw, list): - return {str(part).strip() for part in raw if str(part).strip()} - return {part.strip() for part in str(raw).split(",") if part.strip()} + return _csv_set(raw) + + def _extra_get(self, key: str): + return self.config.extra.get(key) if self.config.extra else None + + def _csv_setting(self, key: str, env_name: str) -> Set[str]: + """List/CSV setting from config.extra[key], falling back to the env var.""" + raw = self._extra_get(key) + return _csv_set(os.getenv(env_name, "") if raw is None else raw) def _dingtalk_allowed_chats(self) -> Set[str]: - """Return the whitelist of group chat IDs the bot will respond in. - - When non-empty, group messages from chats NOT in this set are silently - ignored — even if the bot is @mentioned. DMs are never filtered. - Empty set means no restriction (fully backward compatible). - """ - raw = self.config.extra.get("allowed_chats") if self.config.extra else None - if raw is None: - raw = os.getenv("DINGTALK_ALLOWED_CHATS", "") - if isinstance(raw, list): - return {str(part).strip() for part in raw if str(part).strip()} - return {part.strip() for part in str(raw).split(",") if part.strip()} + """Group chat whitelist; non-empty = hard gate even when @mentioned. DMs never filtered.""" + return self._csv_setting("allowed_chats", "DINGTALK_ALLOWED_CHATS") def _compile_mention_patterns(self) -> List[re.Pattern]: """Compile optional regex wake-word patterns for group triggers.""" - patterns = self.config.extra.get("mention_patterns") if self.config.extra else None + patterns = self._extra_get("mention_patterns") if patterns is None: raw = os.getenv("DINGTALK_MENTION_PATTERNS", "").strip() if raw: @@ -515,35 +361,16 @@ class DingTalkAdapter(BasePlatformAdapter): if not loaded: loaded = [part.strip() for part in raw.split(",") if part.strip()] patterns = loaded - if patterns is None: - # Parity with the historical inline implementation: return before - # evaluating ``self.name`` (avoids touching adapter attributes on - # the no-patterns path). + # Return before touching ``self.name`` on the no-patterns path (historical parity). return [] - return compile_mention_patterns( - patterns, - log_prefix=self.name, - platform_label="dingtalk", - display_label="DingTalk", - logger_=logger, + patterns, log_prefix=self.name, platform_label="dingtalk", display_label="DingTalk", logger_=logger, ) def _load_allowed_users(self) -> Set[str]: - """Load allowed-users list from config.extra or env var. - - IDs are matched case-insensitively against the sender's ``staff_id`` and - ``sender_id``. A wildcard ``*`` disables the check. - """ - raw = self.config.extra.get("allowed_users") if self.config.extra else None - if raw is None: - raw = os.getenv("DINGTALK_ALLOWED_USERS", "") - if isinstance(raw, list): - items = [str(part).strip() for part in raw if str(part).strip()] - else: - items = [part.strip() for part in str(raw).split(",") if part.strip()] - return {item.lower() for item in items} + """Allowed-users from config.extra or env; matched case-insensitively; ``*`` disables.""" + return {item.lower() for item in self._csv_setting("allowed_users", "DINGTALK_ALLOWED_USERS")} def _is_user_allowed(self, sender_id: str, sender_staff_id: str) -> bool: if not self._allowed_users or "*" in self._allowed_users: @@ -553,32 +380,18 @@ class DingTalkAdapter(BasePlatformAdapter): return bool(candidates & self._allowed_users) def _message_mentions_bot(self, message: "ChatbotMessage") -> bool: - """True if the bot was @-mentioned in a group message. - - dingtalk-stream sets ``is_in_at_list`` on the incoming ChatbotMessage - when the bot is addressed via @-mention. - """ + """True if the bot was @-mentioned (SDK sets ``is_in_at_list``).""" return bool(getattr(message, "is_in_at_list", False)) def _message_matches_mention_patterns(self, text: str) -> bool: - if not text or not self._mention_patterns: - return False - return any(pattern.search(text) for pattern in self._mention_patterns) + return bool(text and self._mention_patterns) and any(p.search(text) for p in self._mention_patterns) def _should_process_message(self, message: "ChatbotMessage", text: str, is_group: bool, chat_id: str) -> bool: - """Apply DingTalk group trigger rules. + """Group trigger rules (DMs always pass; ``allowed_users`` is enforced earlier). - DMs remain unrestricted (subject to ``allowed_users`` which is enforced - earlier). Group messages are accepted when: - - the chat passes the ``allowed_chats`` whitelist (when set) - - the chat is explicitly allowlisted in ``free_response_chats`` - - ``require_mention`` is disabled - - the bot is @mentioned (``is_in_at_list``) - - the text matches a configured regex wake-word pattern - - When ``allowed_chats`` is non-empty, it acts as a hard gate — messages - from any group chat not in the list are ignored regardless of the - other rules. + Group messages are accepted when the chat passes ``allowed_chats`` (hard gate when set) + and any of: chat in ``free_response_chats``, ``require_mention`` off, bot @mentioned, + or text matches a wake-word pattern. """ if not is_group: return True @@ -602,13 +415,10 @@ class DingTalkAdapter(BasePlatformAdapter): # -- AI Card lifecycle helpers ------------------------------------------ async def _close_streaming_siblings(self, chat_id: str) -> None: - """Finalize any previously-open streaming cards for this chat. + """Finalize previously-open streaming cards for this chat. - Called at the start of every ``send()`` so lingering tool-progress - cards that were reopened by ``edit_message(finalize=False)`` get - cleanly closed before the next card is created. Without this, - tool-progress cards stay stuck in streaming state after the agent - moves on (there is no explicit "turn end" signal from the gateway). + Called at the start of every ``send()`` — there is no explicit "turn end" signal from + the gateway, so this is what closes lingering tool-progress cards. """ cards = self._streaming_cards.pop(chat_id, None) if not cards: @@ -618,25 +428,13 @@ class DingTalkAdapter(BasePlatformAdapter): return for out_track_id, last_content in list(cards.items()): try: - await self._stream_card_content( - out_track_id, token, last_content, finalize=True, - ) - logger.debug( - "[%s] AI Card sibling closed: %s", - self.name, out_track_id, - ) + await self._stream_card_content(out_track_id, token, last_content, finalize=True) + logger.debug("[%s] AI Card sibling closed: %s", self.name, out_track_id) except Exception as e: - logger.debug( - "[%s] Sibling close failed for %s: %s", - self.name, out_track_id, e, - ) + logger.debug("[%s] Sibling close failed for %s: %s", self.name, out_track_id, e) def _fire_done_reaction(self, chat_id: str) -> None: - """Swap 🤔Thinking → 🥳Done on the original user message. - - Idempotent per chat_id — safe to call from segment-break flushes - and final-done flushes without double-firing. - """ + """Swap 🤔Thinking → 🥳Done on the original user message; idempotent per chat_id.""" if chat_id in self._done_emoji_fired: return self._done_emoji_fired.add(chat_id) @@ -649,39 +447,28 @@ class DingTalkAdapter(BasePlatformAdapter): return async def _swap() -> None: - await self._send_emotion( - msg_id, conversation_id, "🤔Thinking", recall=True, - ) - await self._send_emotion( - msg_id, conversation_id, "🥳Done", recall=False, - ) + await self._send_emotion(msg_id, conversation_id, "🤔Thinking", recall=True) + await self._send_emotion(msg_id, conversation_id, "🥳Done", recall=False) self._spawn_bg(_swap()) # -- Inbound message processing ----------------------------------------- - async def _on_message( - self, - message: "ChatbotMessage", - ) -> None: + async def _on_message(self, message: "ChatbotMessage") -> None: """Process an incoming DingTalk chatbot message.""" msg_id = getattr(message, "message_id", None) or uuid.uuid4().hex if self._dedup.is_duplicate(msg_id): logger.debug("[%s] Duplicate message %s, skipping", self.name, msg_id) return - # Chat context conversation_id = getattr(message, "conversation_id", "") or "" - conversation_type = getattr(message, "conversation_type", "1") - is_group = str(conversation_type) == "2" + is_group = str(getattr(message, "conversation_type", "1")) == "2" sender_id = getattr(message, "sender_id", "") or "" sender_nick = getattr(message, "sender_nick", "") or sender_id sender_staff_id = getattr(message, "sender_staff_id", "") or "" - chat_id = conversation_id or sender_id chat_type = "group" if is_group else "dm" - # Allowed-users gate (applies to both DM and group) if not self._is_user_allowed(sender_id, sender_staff_id): logger.debug( "[%s] Dropping message from non-allowlisted user staff_id=%s sender_id=%s", @@ -689,10 +476,7 @@ class DingTalkAdapter(BasePlatformAdapter): ) return - # Group mention/pattern gate. DMs pass through unconditionally. - # We need the message text for regex wake-word matching; extract it - # early but don't consume the rest of the pipeline until after the - # gate decides whether to process. + # Group mention/pattern gate needs the text early for wake-word matching. _early_text = self._extract_text(message) or "" if not self._should_process_message(message, _early_text, is_group, chat_id): logger.debug( @@ -701,53 +485,31 @@ class DingTalkAdapter(BasePlatformAdapter): ) return - # Stash the incoming message keyed by chat_id so concurrent - # conversations don't clobber each other's context. Also reset - # the per-chat "Done emoji fired" marker so a new inbound message - # gets its own Thinking→Done cycle. + # Per-chat context; reset the Done marker so this message gets its own Thinking→Done cycle. if chat_id: self._message_contexts[chat_id] = message self._done_emoji_fired.discard(chat_id) - # Store session webhook session_webhook = getattr(message, "session_webhook", None) or "" - session_webhook_expired_time = ( - getattr(message, "session_webhook_expired_time", 0) or 0 - ) + session_webhook_expired_time = getattr(message, "session_webhook_expired_time", 0) or 0 if session_webhook and chat_id and _DINGTALK_WEBHOOK_RE.match(session_webhook): if len(self._session_webhooks) >= _SESSION_WEBHOOKS_MAX: - try: - self._session_webhooks.pop(next(iter(self._session_webhooks))) - except StopIteration: - pass - self._session_webhooks[chat_id] = ( - session_webhook, - session_webhook_expired_time, - ) + self._session_webhooks.pop(next(iter(self._session_webhooks))) # evict oldest (dict is non-empty here) + self._session_webhooks[chat_id] = (session_webhook, session_webhook_expired_time) # Resolve media download codes to URLs so vision tools can use them await self._resolve_media_codes(message) - - # Extract text content text = self._extract_text(message) - - # Determine message type and build media list msg_type, media_urls, media_types = self._extract_media(message) - if not text and not media_urls: logger.debug("[%s] Empty message, skipping", self.name) return source = self.build_source( - chat_id=chat_id, - chat_name=getattr(message, "conversation_title", None), - chat_type=chat_type, - user_id=sender_id, - user_name=sender_nick, - user_id_alt=sender_staff_id if sender_staff_id else None, + chat_id=chat_id, chat_name=getattr(message, "conversation_title", None), chat_type=chat_type, + user_id=sender_id, user_name=sender_nick, user_id_alt=sender_staff_id if sender_staff_id else None, ) - # Parse timestamp create_at = getattr(message, "create_at", None) try: timestamp = ( @@ -759,288 +521,37 @@ class DingTalkAdapter(BasePlatformAdapter): timestamp = datetime.now(tz=timezone.utc) event = MessageEvent( - text=text, - message_type=msg_type, - source=source, - message_id=msg_id, - raw_message=message, - media_urls=media_urls, - media_types=media_types, - timestamp=timestamp, + text=text, message_type=msg_type, source=source, message_id=msg_id, raw_message=message, + media_urls=media_urls, media_types=media_types, timestamp=timestamp, ) - logger.debug( "[%s] Message from %s in %s: %s", - self.name, - sender_nick, - chat_id[:20] if chat_id else "?", - text[:80] if text else "(media)", + self.name, sender_nick, chat_id[:20] if chat_id else "?", text[:80] if text else "(media)", ) await self.handle_message(event) - @staticmethod - def _extract_text(message: "ChatbotMessage") -> str: - """Extract plain text from a DingTalk chatbot message. - - Handles both legacy and current dingtalk-stream SDK payload shapes: - * legacy: ``message.text`` was a dict ``{"content": "..."}`` - * >= 0.20: ``message.text`` is a ``TextContent`` dataclass whose - ``__str__`` returns ``"TextContent(content=...)"`` — never fall - back to ``str(text)`` without extracting ``.content`` first. - * rich text moved from ``message.rich_text`` (list) to - ``message.rich_text_content.rich_text_list`` (list of dicts). - """ - text = getattr(message, "text", None) or "" - - # Handle TextContent object (SDK style) - if hasattr(text, "content"): - content = (text.content or "").strip() - elif isinstance(text, dict): - content = text.get("content", "").strip() - else: - content = str(text).strip() - - if not content: - rich_text = getattr(message, "rich_text_content", None) or getattr( - message, "rich_text", None - ) - if rich_text: - rich_list = getattr(rich_text, "rich_text_list", None) or rich_text - if isinstance(rich_list, list): - parts = [] - for item in rich_list: - if isinstance(item, dict): - t = item.get("text") or item.get("content") or "" - if t: - parts.append(t) - elif hasattr(item, "text") and item.text: - parts.append(item.text) - content = " ".join(parts).strip() - - # Fallback: audio message → use recognition text - if not content: - msg_type = getattr(message, "message_type", "") - if msg_type == "audio": - extensions = getattr(message, "extensions", {}) or {} - audio_content = extensions.get("content", {}) - if isinstance(audio_content, dict): - recognition = audio_content.get("recognition", "") - if recognition: - content = recognition.strip() - - # Fallback: file message → use fileName as text - if not content: - msg_type = getattr(message, "message_type", "") - if msg_type == "file": - extensions = getattr(message, "extensions", {}) or {} - file_content = extensions.get("content", {}) - if isinstance(file_content, dict): - fname = file_content.get("fileName", "") - if fname: - content = f"[文件] {fname}" - - # Fallback: card message (钉钉文档分享卡片 / link card) - # When a user shares a DingTalk Doc to the bot, the msgtype is "card" - # and the card data lives in extensions['card'] (SDK's from_dict maps - # unhandled fields to extensions). Extract title + doc URL so the - # message isn't silently dropped as "empty". - if not content: - msg_type = getattr(message, "message_type", "") - # Handle card-type messages (文档分享卡片 / link card) - if msg_type == "card": - extensions = getattr(message, "extensions", {}) or {} - card = extensions.get("card", {}) - if isinstance(card, dict): - title = card.get("title", "") - raw_content = card.get("content", "") - doc_url = "" - if raw_content is None: - doc_url = "" - elif isinstance(raw_content, dict): - doc_url = raw_content.get("url", "") or raw_content.get("docUrl", "") - elif isinstance(raw_content, str): - stripped = raw_content.strip() - if not stripped: - doc_url = "" - else: - try: - parsed = json.loads(stripped) - if isinstance(parsed, dict): - doc_url = parsed.get("url", "") or parsed.get("docUrl", "") - except (ValueError, TypeError): - doc_url = raw_content - parts = [] - if title: - parts.append(f"[文档] {title}") - if doc_url: - parts.append(doc_url) - if parts: - content = " ".join(parts) - # Last-resort: raw text field from extensions (if present) - if not content: - ext_text = extensions.get("text", {}) - if isinstance(ext_text, dict): - content = (ext_text.get("content", "") or "").strip() - - # Handle interactiveCard messages (钉钉文档分享卡片 / doc link card) - # structure: extensions["content"]["biz_custom_action_url"] and - # extensions["content"]["title"] for the card title - if msg_type == "interactiveCard" and not content: - extensions = getattr(message, "extensions", {}) or {} - ext_content = extensions.get("content", {}) - if isinstance(ext_content, dict): - doc_url = ext_content.get("biz_custom_action_url", "") - title = ext_content.get("title", "") - if doc_url or title: - parts = [] - if title: - parts.append(f"[文档卡片] {title}") - else: - parts.append("[文档卡片]") - if doc_url: - parts.append(doc_url) - content = " ".join(parts) - - # Do NOT strip "@bot" from the text. The mention is a routing - # signal (delivered structurally via callback `isInAtList`), and - # regex-stripping @handles would collateral-damage e-mails - # (alice@example.com), SSH URLs (git@github.com), and literal - # references the user wrote ("what does @openai think"). Let the - # LLM see the raw text — it handles "@bot hello" cleanly. - return content + _extract_text = staticmethod(extract_text) def _extract_media(self, message: "ChatbotMessage"): - """Extract media info from message. Returns (MessageType, [urls], [mime_types]).""" - msg_type = MessageType.TEXT - media_urls = [] - media_types = [] - - # Check for image/picture - image_content = getattr(message, "image_content", None) - if image_content: - download_code = getattr(image_content, "download_code", None) - if download_code: - media_urls.append(download_code) - media_types.append("image") - msg_type = MessageType.PHOTO - - # Check for rich text with mixed content - rich_text = getattr(message, "rich_text_content", None) or getattr( - message, "rich_text", None - ) - if rich_text: - rich_list = getattr(rich_text, "rich_text_list", None) or rich_text - if isinstance(rich_list, list): - for item in rich_list: - if isinstance(item, dict): - dl_code = ( - item.get("downloadCode") or item.get("download_code") or "" - ) - item_type = item.get("type", "") - if dl_code: - mapped = DINGTALK_TYPE_MAPPING.get(item_type, "file") - media_urls.append(dl_code) - if mapped == "image": - media_types.append("image") - if msg_type == MessageType.TEXT: - msg_type = MessageType.PHOTO - elif mapped == "audio": - media_types.append("audio") - if msg_type == MessageType.TEXT: - # DingTalk's "voice" rich-text item is a - # native voice note — route through STT. - # "audio" comes from file uploads only; - # keep those as AUDIO (no auto-STT). - if item_type == "voice": - msg_type = MessageType.VOICE - else: - msg_type = MessageType.AUDIO - elif mapped == "video": - media_types.append("video") - if msg_type == MessageType.TEXT: - msg_type = MessageType.VIDEO - else: - media_types.append("application/octet-stream") - if msg_type == MessageType.TEXT: - msg_type = MessageType.DOCUMENT - - msg_type_str = getattr(message, "message_type", "") or "" - if msg_type_str == "picture" and not media_urls: - msg_type = MessageType.PHOTO - elif msg_type_str == "richText": - # Only re-derive the type when the rich-text scan above left it - # at TEXT. The scan may already have promoted it to VOICE/AUDIO/ - # VIDEO/DOCUMENT for embedded media items — resetting those here - # dropped native voice notes back to TEXT and skipped STT - # (#38211, #38219; analysis from #38276). - if msg_type == MessageType.TEXT and any( - "image" in t for t in media_types - ): - msg_type = MessageType.PHOTO - elif msg_type_str == "audio": - # Voice message — DingTalk already provides recognition text. - # Do NOT add media_urls here: if audio_paths is non-empty, - # run.py's _enrich_message_with_transcription will overwrite - # the recognition text with a failed STT attempt (whisper not installed). - # The recognition text from extensions['content']['recognition'] - # is sufficient and already extracted by _extract_text. - if msg_type == MessageType.TEXT: - msg_type = MessageType.VOICE - elif msg_type_str in ("file", "image"): - extensions = getattr(message, "extensions", {}) or {} - ext_content = extensions.get("content", {}) - if isinstance(ext_content, dict): - dl_code = ext_content.get("downloadCode") or "" - fname = ext_content.get("fileName", "") - if dl_code: - media_urls.append(dl_code) - mime = "application/octet-stream" - # Map common extensions - if fname: - ext = fname.rsplit(".", 1)[-1].lower() if "." in fname else "" - mime = EXT_MAP.get(ext, mime) - media_types.append(mime) - if msg_type == MessageType.TEXT: - # Image messages → PHOTO (distinct busy-session handling - # in gateway/platforms/base.py). - # File messages with image MIME types (e.g. a .png sent - # as a file attachment) are also classified as PHOTO — - # the user's intent is to share an image regardless of - # how DingTalk delivers it. - if msg_type_str == "image" or mime.startswith("image/"): - msg_type = MessageType.PHOTO - else: - msg_type = MessageType.DOCUMENT - - return msg_type, media_urls, media_types + return extract_media(message) # -- Outbound messaging ------------------------------------------------- async def send( - self, - chat_id: str, - content: str, - reply_to: Optional[str] = None, - metadata: Optional[Dict[str, Any]] = None, + self, chat_id: str, content: str, reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: - """Send a markdown reply via DingTalk session webhook.""" + """Send a reply via AI Card (when configured) or DingTalk session webhook markdown.""" metadata = metadata or {} logger.debug( "[%s] send() chat_id=%s card_enabled=%s", - self.name, - chat_id, - bool(self._card_template_id and self._card_sdk), + self.name, chat_id, bool(self._card_template_id and self._card_sdk), ) - # Check metadata first (for direct webhook sends) session_webhook = metadata.get("session_webhook") if not session_webhook: webhook_info = self._get_valid_webhook(chat_id) if not webhook_info: - logger.warning( - "[%s] No valid session_webhook for chat_id=%s", - self.name, chat_id, - ) + logger.warning("[%s] No valid session_webhook for chat_id=%s", self.name, chat_id) return SendResult( success=False, error="No valid session_webhook available. Reply must follow an incoming message.", @@ -1050,76 +561,44 @@ class DingTalkAdapter(BasePlatformAdapter): if not self._http_client: return SendResult(success=False, error="HTTP client not initialized") - # Look up the inbound message for this chat (for AI Card routing) current_message = self._message_contexts.get(chat_id) - # ``reply_to`` is the signal that this send is the FINAL response - # to an inbound user message — only `base.py:_send_with_retry` sets - # it. Tool-progress, commentary, and stream-consumer first-sends - # all leave it None. We use it for two orthogonal decisions: - # 1. finalize on create? Yes if final reply, No if intermediate - # (intermediate cards stay in streaming state so edit_message - # updates don't flicker closed→streaming→closed repeatedly). - # 2. fire Done reaction? Only when this is the final reply. + # ``reply_to`` is only set by base.py:_send_with_retry for the FINAL reply to an inbound + # message; tool-progress, commentary and stream first-sends leave it None. It decides + # (1) finalize-on-create (intermediate cards stay open so edits don't flicker) and + # (2) whether to fire the Done reaction. is_final_reply = reply_to is not None - # Try AI Card first (using alibabacloud_dingtalk.card_1_0 SDK). if self._card_template_id and current_message and self._card_sdk: - # Close any previously-open streaming cards for this chat - # before creating a new one (handles tool-progress → final- - # response handoff; also cleans up lingering commentary cards). + # Close lingering open cards (tool-progress → final handoff) before creating a new one. await self._close_streaming_siblings(chat_id) - - result = await self._create_and_stream_card( - chat_id, current_message, content, - finalize=is_final_reply, - ) + result = await self._create_and_stream_card(chat_id, current_message, content, finalize=is_final_reply) if result and result.success: if is_final_reply: - # Final reply: card closed, swap Thinking → Done. self._fire_done_reaction(chat_id) else: - # Intermediate (tool progress / commentary / streaming - # first chunk): keep the card open and track it so the - # next send() auto-closes it as a sibling, or - # edit_message(finalize=True) closes it explicitly. - self._streaming_cards.setdefault(chat_id, {})[ - result.message_id - ] = content + # Keep open + track so the next send() auto-closes it, or edit_message(finalize=True) does. + self._streaming_cards.setdefault(chat_id, {})[result.message_id] = content return result - logger.warning("[%s] AI Card send failed, falling back to webhook", self.name) logger.debug("[%s] Sending via webhook", self.name) - # Normalize markdown for DingTalk normalized = self._normalize_markdown(content[: self.MAX_MESSAGE_LENGTH]) - payload = { "msgtype": "markdown", "markdown": {"title": "Hermes", "text": normalized}, } - try: - resp = await self._http_client.post( - session_webhook, json=payload, timeout=15.0 - ) + resp = await self._http_client.post(session_webhook, json=payload, timeout=15.0) if resp.status_code < 300: - # Webhook path: fire Done only for final replies, same as - # the card path. if is_final_reply: self._fire_done_reaction(chat_id) return SendResult(success=True, message_id=uuid.uuid4().hex[:12]) body = resp.text - logger.warning( - "[%s] Send failed HTTP %d: %s", self.name, resp.status_code, body[:200] - ) - return SendResult( - success=False, error=f"HTTP {resp.status_code}: {body[:200]}" - ) + logger.warning("[%s] Send failed HTTP %d: %s", self.name, resp.status_code, body[:200]) + return SendResult(success=False, error=f"HTTP {resp.status_code}: {body[:200]}") except httpx.TimeoutException: - return SendResult( - success=False, error="Timeout sending message to DingTalk" - ) + return SendResult(success=False, error="Timeout sending message to DingTalk") except Exception as e: logger.error("[%s] Send error: %s", self.name, e) return SendResult(success=False, error=str(e)) @@ -1129,37 +608,17 @@ class DingTalkAdapter(BasePlatformAdapter): pass async def send_image( - self, - chat_id: str, - image_url: str, - caption: Optional[str] = None, - reply_to: Optional[str] = None, + self, chat_id: str, image_url: str, caption: Optional[str] = None, reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: - """Send an image via DingTalk markdown. - - DingTalk's session webhook only supports text/markdown payloads, not - native image/file attachments. For remote image URLs, render the image - inline with markdown so the user still sees the image. Local files need - OpenAPI media upload and are handled separately. - """ + """Render a remote image inline via markdown (session webhook has no native attachments).""" image_block = f"![image]({image_url})" content = f"{caption}\n\n{image_block}" if caption else image_block - return await self.send( - chat_id=chat_id, - content=content, - reply_to=reply_to, - metadata=metadata, - ) + return await self.send(chat_id=chat_id, content=content, reply_to=reply_to, metadata=metadata) async def send_image_file( - self, - chat_id: str, - image_path: str, - caption: Optional[str] = None, - reply_to: Optional[str] = None, - metadata: Optional[Dict[str, Any]] = None, - **kwargs, + self, chat_id: str, image_path: str, caption: Optional[str] = None, reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, **kwargs, ) -> SendResult: """DingTalk webhook replies cannot send local image files directly.""" return SendResult( @@ -1171,14 +630,8 @@ class DingTalkAdapter(BasePlatformAdapter): ) async def send_document( - self, - chat_id: str, - file_path: str, - caption: Optional[str] = None, - file_name: Optional[str] = None, - reply_to: Optional[str] = None, - metadata: Optional[Dict[str, Any]] = None, - **kwargs, + self, chat_id: str, file_path: str, caption: Optional[str] = None, file_name: Optional[str] = None, + reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, **kwargs, ) -> SendResult: """DingTalk webhook replies cannot send local file attachments directly.""" return SendResult( @@ -1191,42 +644,28 @@ class DingTalkAdapter(BasePlatformAdapter): async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: """Return basic info about a DingTalk conversation.""" - return { - "name": chat_id, - "type": "group" if "group" in chat_id.lower() else "dm", - } + return {"name": chat_id, "type": "group" if "group" in chat_id.lower() else "dm"} def _get_valid_webhook(self, chat_id: str) -> Optional[tuple[str, int]]: - """Get a valid (non-expired) session webhook for the given chat_id.""" + """Get a non-expired session webhook for chat_id (5-minute safety margin).""" info = self._session_webhooks.get(chat_id) if not info: return None webhook, expired_time_ms = info - # Check expiry with 5-minute safety margin if expired_time_ms and expired_time_ms > 0: now_ms = int(datetime.now(tz=timezone.utc).timestamp() * 1000) - safety_margin_ms = 5 * 60 * 1000 - if now_ms + safety_margin_ms >= expired_time_ms: - # Expired, remove from cache + if now_ms + 5 * 60 * 1000 >= expired_time_ms: self._session_webhooks.pop(chat_id, None) return None return info async def _create_and_stream_card( - self, - chat_id: str, - message: Any, - content: str, - *, - finalize: bool = True, + self, chat_id: str, message: Any, content: str, *, finalize: bool = True, ) -> Optional[SendResult]: """Create an AI Card, deliver it to the conversation, and stream initial content. - Always called with ``finalize=True`` from ``send()`` (closed state). - If the caller later issues ``edit_message(finalize=False)``, the - DingTalk streaming_update API reopens the card into streaming - state, and we track that in ``_streaming_cards`` for sibling - cleanup on the next send. + ``finalize=False`` leaves the card open for ``edit_message`` streaming updates keyed by + the returned out_track_id. """ try: token = await self._get_access_token() @@ -1234,158 +673,85 @@ class DingTalkAdapter(BasePlatformAdapter): return None out_track_id = f"hermes_{uuid.uuid4().hex[:12]}" - conversation_id = getattr(message, "conversation_id", "") or "" - conversation_type = getattr(message, "conversation_type", "1") - is_group = str(conversation_type) == "2" + is_group = str(getattr(message, "conversation_type", "1")) == "2" sender_staff_id = getattr(message, "sender_staff_id", "") or "" - runtime = tea_util_models.RuntimeOptions() # Step 1: Create card with STREAM callback type create_request = dingtalk_card_models.CreateCardRequest( card_template_id=self._card_template_id, out_track_id=out_track_id, - card_data=dingtalk_card_models.CreateCardRequestCardData( - card_param_map={"content": ""}, - ), + card_data=dingtalk_card_models.CreateCardRequestCardData(card_param_map={"content": ""}), callback_type="STREAM", - im_group_open_space_model=( - dingtalk_card_models.CreateCardRequestImGroupOpenSpaceModel( - support_forward=True, - ) - ), - im_robot_open_space_model=( - dingtalk_card_models.CreateCardRequestImRobotOpenSpaceModel( - support_forward=True, - ) - ), - ) - - create_headers = dingtalk_card_models.CreateCardHeaders( - x_acs_dingtalk_access_token=token, - ) - - await self._card_sdk.create_card_with_options_async( - create_request, create_headers, runtime + im_group_open_space_model=dingtalk_card_models.CreateCardRequestImGroupOpenSpaceModel(support_forward=True), + im_robot_open_space_model=dingtalk_card_models.CreateCardRequestImRobotOpenSpaceModel(support_forward=True), ) + create_headers = dingtalk_card_models.CreateCardHeaders(x_acs_dingtalk_access_token=token) + await self._card_sdk.create_card_with_options_async(create_request, create_headers, runtime) # Step 2: Deliver card to the conversation if is_group: open_space_id = f"dtv1.card//IM_GROUP.{conversation_id}" - deliver_request = dingtalk_card_models.DeliverCardRequest( - out_track_id=out_track_id, - user_id_type=1, - open_space_id=open_space_id, - im_group_open_deliver_model=( - dingtalk_card_models.DeliverCardRequestImGroupOpenDeliverModel( - robot_code=self._robot_code, - ) - ), - ) + deliver_model = {"im_group_open_deliver_model": ( + dingtalk_card_models.DeliverCardRequestImGroupOpenDeliverModel(robot_code=self._robot_code) + )} else: if not sender_staff_id: - logger.warning( - "[%s] AI Card skipped: missing sender_staff_id for DM", - self.name, - ) + logger.warning("[%s] AI Card skipped: missing sender_staff_id for DM", self.name) return None open_space_id = f"dtv1.card//IM_ROBOT.{sender_staff_id}" - deliver_request = dingtalk_card_models.DeliverCardRequest( - out_track_id=out_track_id, - user_id_type=1, - open_space_id=open_space_id, - im_robot_open_deliver_model=( - dingtalk_card_models.DeliverCardRequestImRobotOpenDeliverModel( - space_type="IM_ROBOT", - ) - ), - ) - - deliver_headers = dingtalk_card_models.DeliverCardHeaders( - x_acs_dingtalk_access_token=token, - ) - - await self._card_sdk.deliver_card_with_options_async( - deliver_request, deliver_headers, runtime - ) - - # Step 3: Stream initial content. finalize=True closes the - # card immediately (one-shot); finalize=False keeps it open - # for streaming edit_message updates by out_track_id. - await self._stream_card_content( - out_track_id, token, content, finalize=finalize, + deliver_model = {"im_robot_open_deliver_model": ( + dingtalk_card_models.DeliverCardRequestImRobotOpenDeliverModel(space_type="IM_ROBOT") + )} + deliver_request = dingtalk_card_models.DeliverCardRequest( + out_track_id=out_track_id, user_id_type=1, open_space_id=open_space_id, **deliver_model, ) + deliver_headers = dingtalk_card_models.DeliverCardHeaders(x_acs_dingtalk_access_token=token) + await self._card_sdk.deliver_card_with_options_async(deliver_request, deliver_headers, runtime) + # Step 3: Stream initial content (finalize=True closes the card immediately). + await self._stream_card_content(out_track_id, token, content, finalize=finalize) logger.info( "[%s] AI Card %s: %s", - self.name, - "created+finalized" if finalize else "created (streaming)", - out_track_id, + self.name, "created+finalized" if finalize else "created (streaming)", out_track_id, ) return SendResult(success=True, message_id=out_track_id) - except Exception as e: - logger.warning( - "[%s] AI Card create failed: %s\n%s", - self.name, e, traceback.format_exc(), - ) + logger.warning("[%s] AI Card create failed: %s\n%s", self.name, e, traceback.format_exc()) return None async def edit_message( - self, - chat_id: str, - message_id: str, - content: str, - *, - finalize: bool = False, + self, chat_id: str, message_id: str, content: str, *, finalize: bool = False, ) -> SendResult: """Edit an AI Card by streaming updated content. - ``message_id`` is the out_track_id returned by the initial ``send()`` - call that created this card. Callers (stream_consumer, tool - progress) track their own ids independently so two parallel flows - on the same chat_id don't interfere. + ``message_id`` is the out_track_id returned by the ``send()`` that created the card; + callers track their own ids so parallel flows on one chat don't interfere. """ if not message_id: return SendResult(success=False, error="message_id required") token = await self._get_access_token() if not token: return SendResult(success=False, error="No access token") - try: - await self._stream_card_content( - message_id, token, content, finalize=finalize, - ) + await self._stream_card_content(message_id, token, content, finalize=finalize) if finalize: - # Remove from streaming-cards tracking and fire Done. This - # is the canonical "response ended" signal from stream - # consumer's final edit. + # Canonical "response ended" signal from the stream consumer's final edit. self._streaming_cards.get(chat_id, {}).pop(message_id, None) if not self._streaming_cards.get(chat_id): self._streaming_cards.pop(chat_id, None) - logger.debug( - "[%s] AI Card finalized (edit): %s", - self.name, message_id, - ) + logger.debug("[%s] AI Card finalized (edit): %s", self.name, message_id) self._fire_done_reaction(chat_id) else: - # Non-final edit reopens the card into streaming state — - # track it so the next send() can auto-close it as a - # sibling. + # Non-final edit reopens the card into streaming state — track for sibling close. self._streaming_cards.setdefault(chat_id, {})[message_id] = content return SendResult(success=True, message_id=message_id) except Exception as e: logger.warning("[%s] Card edit failed: %s", self.name, e) return SendResult(success=False, error=str(e)) - async def _stream_card_content( - self, - out_track_id: str, - token: str, - content: str, - finalize: bool = False, - ) -> None: + async def _stream_card_content(self, out_track_id: str, token: str, content: str, finalize: bool = False) -> None: """Stream content to an existing AI Card.""" stream_request = dingtalk_card_models.StreamingUpdateRequest( out_track_id=out_track_id, @@ -1396,37 +762,24 @@ class DingTalkAdapter(BasePlatformAdapter): is_finalize=finalize, is_error=False, ) - - stream_headers = dingtalk_card_models.StreamingUpdateHeaders( - x_acs_dingtalk_access_token=token, - ) - + stream_headers = dingtalk_card_models.StreamingUpdateHeaders(x_acs_dingtalk_access_token=token) runtime = tea_util_models.RuntimeOptions() - await self._card_sdk.streaming_update_with_options_async( - stream_request, stream_headers, runtime - ) + await self._card_sdk.streaming_update_with_options_async(stream_request, stream_headers, runtime) async def _get_access_token(self) -> Optional[str]: - """Get access token using SDK's cached token.""" + """Get access token via the SDK's cached (sync, requests-based) getter.""" if not self._stream_client: return None try: - # SDK's get_access_token is sync and uses requests - token = await asyncio.to_thread(self._stream_client.get_access_token) - return token + return await asyncio.to_thread(self._stream_client.get_access_token) except Exception as e: logger.error("[%s] Failed to get access token: %s", self.name, e) return None async def _send_emotion( - self, - open_msg_id: str, - open_conversation_id: str, - emoji_name: str, - *, - recall: bool = False, + self, open_msg_id: str, open_conversation_id: str, emoji_name: str, *, recall: bool = False, ) -> None: - """Add or recall an emoji reaction on a message.""" + """Add (or recall) an emoji reaction on a message.""" if not self._robot_sdk or not open_msg_id or not open_conversation_id: return action = "recall" if recall else "reply" @@ -1434,7 +787,7 @@ class DingTalkAdapter(BasePlatformAdapter): token = await self._get_access_token() if not token: return - + text_emotion_cls, request_cls, headers_cls, sdk_method = _EMOTION_SDK[recall] emotion_kwargs = { "robot_code": self._robot_code, "open_msg_id": open_msg_id, @@ -1443,121 +796,45 @@ class DingTalkAdapter(BasePlatformAdapter): "emotion_name": emoji_name, } runtime = tea_util_models.RuntimeOptions() - - if recall: - emotion_kwargs["text_emotion"] = ( - dingtalk_robot_models.RobotRecallEmotionRequestTextEmotion( - emotion_id="2659900", - emotion_name=emoji_name, - text=emoji_name, - background_id="im_bg_1", - ) - ) - request = dingtalk_robot_models.RobotRecallEmotionRequest( - **emotion_kwargs, - ) - sdk_headers = dingtalk_robot_models.RobotRecallEmotionHeaders( - x_acs_dingtalk_access_token=token, - ) - await self._robot_sdk.robot_recall_emotion_with_options_async( - request, sdk_headers, runtime - ) - else: - emotion_kwargs["text_emotion"] = ( - dingtalk_robot_models.RobotReplyEmotionRequestTextEmotion( - emotion_id="2659900", - emotion_name=emoji_name, - text=emoji_name, - background_id="im_bg_1", - ) - ) - request = dingtalk_robot_models.RobotReplyEmotionRequest( - **emotion_kwargs, - ) - sdk_headers = dingtalk_robot_models.RobotReplyEmotionHeaders( - x_acs_dingtalk_access_token=token, - ) - await self._robot_sdk.robot_reply_emotion_with_options_async( - request, sdk_headers, runtime - ) - logger.info( - "[%s] _send_emotion: %s %s on msg=%s", - self.name, action, emoji_name, open_msg_id[:24], + emotion_kwargs["text_emotion"] = getattr(dingtalk_robot_models, text_emotion_cls)( + emotion_id=_EMOTION_ID, + emotion_name=emoji_name, + text=emoji_name, + background_id=_EMOTION_BG, ) + request = getattr(dingtalk_robot_models, request_cls)(**emotion_kwargs) + sdk_headers = getattr(dingtalk_robot_models, headers_cls)(x_acs_dingtalk_access_token=token) + await getattr(self._robot_sdk, sdk_method)(request, sdk_headers, runtime) + logger.info("[%s] _send_emotion: %s %s on msg=%s", self.name, action, emoji_name, open_msg_id[:24]) except Exception: - logger.debug( - "[%s] _send_emotion %s failed", self.name, action, exc_info=True - ) + logger.debug("[%s] _send_emotion %s failed", self.name, action, exc_info=True) async def _resolve_media_codes(self, message: "ChatbotMessage") -> None: - """Resolve download codes in message to actual URLs.""" + """Resolve download codes in the message to real URLs (in place, in parallel).""" token = await self._get_access_token() if not token: return - robot_code = getattr(message, "robot_code", None) or self._client_id - codes_to_resolve = [] - - # Collect codes and references to update - # 1. Single image content - img_content = getattr(message, "image_content", None) - if img_content and getattr(img_content, "download_code", None): - codes_to_resolve.append((img_content, "download_code")) - - # 2. Rich text list - rich_text = getattr(message, "rich_text_content", None) - if rich_text: - rich_list = getattr(rich_text, "rich_text_list", []) or [] - for item in rich_list: - if isinstance(item, dict): - for key in ("downloadCode", "pictureDownloadCode", "download_code"): - if item.get(key): - codes_to_resolve.append((item, key)) - - # 3. File/image message (msgtype='file' or 'image', codes in extensions) - msg_type_str = getattr(message, "message_type", "") or "" - if msg_type_str in ("file", "image"): - extensions = getattr(message, "extensions", {}) or {} - ext_content = extensions.get("content", {}) - if isinstance(ext_content, dict) and ext_content.get("downloadCode"): - codes_to_resolve.append((ext_content, "downloadCode")) - + codes_to_resolve = collect_download_codes(message) if not codes_to_resolve: return - - # Resolve all codes in parallel tasks = [] for obj, key in codes_to_resolve: code = getattr(obj, key, None) if hasattr(obj, key) else obj.get(key) if code: - tasks.append( - self._fetch_download_url(code, robot_code, token, obj, key) - ) - + tasks.append(self._fetch_download_url(code, robot_code, token, obj, key)) await asyncio.gather(*tasks, return_exceptions=True) - async def _fetch_download_url( - self, code: str, robot_code: str, token: str, obj, key: str - ) -> None: - """Fetch download URL for a single code using the robot SDK.""" + async def _fetch_download_url(self, code: str, robot_code: str, token: str, obj, key: str) -> None: + """Fetch the download URL for one code via the robot SDK and write it back to ``obj[key]``.""" if not self._robot_sdk: - logger.warning( - "[%s] Robot SDK not initialized, cannot resolve media code", - self.name, - ) + logger.warning("[%s] Robot SDK not initialized, cannot resolve media code", self.name) return try: - request = dingtalk_robot_models.RobotMessageFileDownloadRequest( - download_code=code, - robot_code=robot_code, - ) - headers = dingtalk_robot_models.RobotMessageFileDownloadHeaders( - x_acs_dingtalk_access_token=token, - ) + request = dingtalk_robot_models.RobotMessageFileDownloadRequest(download_code=code, robot_code=robot_code) + headers = dingtalk_robot_models.RobotMessageFileDownloadHeaders(x_acs_dingtalk_access_token=token) runtime = tea_util_models.RuntimeOptions() - response = await self._robot_sdk.robot_message_file_download_with_options_async( - request, headers, runtime - ) + response = await self._robot_sdk.robot_message_file_download_with_options_async(request, headers, runtime) body = response.body if response else None if body: url = getattr(body, "download_url", None) @@ -1567,35 +844,23 @@ class DingTalkAdapter(BasePlatformAdapter): elif isinstance(obj, dict): obj[key] = url else: - logger.warning( - "[%s] Failed to download media: empty response for code %s", - self.name, - code, - ) + logger.warning("[%s] Failed to download media: empty response for code %s", self.name, code) except Exception as e: logger.error("[%s] Error resolving media code %s: %s", self.name, code, e) @staticmethod def _normalize_markdown(text: str) -> str: - """Normalize markdown for DingTalk's parser. - - DingTalk's markdown renderer has quirks: - - Numbered lists need blank line before them - - Indented code blocks may render incorrectly - """ + """Work around DingTalk renderer quirks: blank line before numbered lists, dedent ``` fences.""" lines = text.split("\n") out = [] for i, line in enumerate(lines): - # Ensure blank line before numbered list items is_numbered = re.match(r"^\d+\.\s", line.strip()) if is_numbered and i > 0: prev = lines[i - 1] if prev.strip() and not re.match(r"^\d+\.\s", prev.strip()): out.append("") - # Dedent fenced code blocks if line.strip().startswith("```") and line != line.lstrip(): - indent = len(line) - len(line.lstrip()) - line = line[indent:] + line = line[len(line) - len(line.lstrip()):] out.append(line) return "\n".join(out) @@ -1605,14 +870,11 @@ class DingTalkAdapter(BasePlatformAdapter): # --------------------------------------------------------------------------- -class _IncomingHandler( - dingtalk_stream.ChatbotHandler if DINGTALK_STREAM_AVAILABLE else object -): +class _IncomingHandler(dingtalk_stream.ChatbotHandler if DINGTALK_STREAM_AVAILABLE else object): """dingtalk-stream ChatbotHandler that forwards messages to the adapter. - SDK >= 0.20 changed process() from sync to async, and the message - parameter from ChatbotMessage to CallbackMessage. We parse the - CallbackMessage.data dict into a ChatbotMessage before forwarding. + SDK >= 0.20: ``process()`` is async and receives a CallbackMessage whose ``.data`` dict we + parse into a ChatbotMessage before forwarding. """ def __init__(self, adapter: DingTalkAdapter, loop: Optional[asyncio.AbstractEventLoop] = None): @@ -1622,123 +884,60 @@ class _IncomingHandler( self._loop = loop def pre_start(self) -> None: - """No-op pre-start hook required by dingtalk-stream SDK. - - The SDK calls ``pre_start()`` on every registered handler before - opening the WebSocket connection. Without this method, the SDK - raises ``AttributeError: '_IncomingHandler' object has no - attribute 'pre_start'`` and kills the stream connection. - """ + """No-op hook the SDK calls on every handler before opening the WebSocket (missing → AttributeError).""" return async def process(self, message: "CallbackMessage"): - """Called by dingtalk-stream (>=0.20) when a message arrives. + """SDK callback: convert to ChatbotMessage, then ACK immediately. - dingtalk-stream >= 0.24 passes a CallbackMessage whose ``.data`` contains - the chatbot payload. Convert it to ChatbotMessage via - ``ChatbotMessage.from_dict()``. - - Message processing is dispatched as a background task so that this - method returns the ACK immediately — blocking here would prevent the - SDK from sending heartbeats, eventually causing a disconnect. + Processing is dispatched as a background task — blocking here would stall the SDK's + heartbeats and eventually disconnect the stream. """ try: - # CallbackMessage.data is a dict containing the raw DingTalk payload data = message.data if isinstance(data, str): data = json.loads(data) - - # Parse dict into ChatbotMessage using SDK's from_dict chatbot_msg = ChatbotMessage.from_dict(data) - # Ensure session_webhook is populated even if the SDK's - # from_dict() did not map it (field name mismatch across - # SDK versions). + # Backfill fields from_dict() may not map (field names vary across SDK versions). if not getattr(chatbot_msg, "session_webhook", None): - webhook = ( - data.get("sessionWebhook") - or data.get("session_webhook") - or "" - ) if isinstance(data, dict) else "" + webhook = (data.get("sessionWebhook") or data.get("session_webhook") or "") if isinstance(data, dict) else "" if webhook: chatbot_msg.session_webhook = webhook - - # Ensure is_in_at_list is populated from the structured callback - # flag even if from_dict() did not map it. DingTalk sends - # ``isInAtList`` in the raw payload; the adapter's mention check - # reads the ChatbotMessage attribute ``is_in_at_list``. - if not getattr(chatbot_msg, "is_in_at_list", False): - raw_flag = ( - data.get("isInAtList") if isinstance(data, dict) else False - ) - if raw_flag: - chatbot_msg.is_in_at_list = True + if not getattr(chatbot_msg, "is_in_at_list", False) and isinstance(data, dict) and data.get("isInAtList"): + chatbot_msg.is_in_at_list = True msg_id = getattr(chatbot_msg, "message_id", None) or "" conversation_id = getattr(chatbot_msg, "conversation_id", None) or "" - - # Thinking reaction — fire-and-forget, tracked if msg_id and conversation_id: - self._adapter._spawn_bg( - self._adapter._send_emotion( - msg_id, conversation_id, "🤔Thinking", recall=False, - ) - ) + self._adapter._spawn_bg(self._adapter._send_emotion(msg_id, conversation_id, "🤔Thinking", recall=False)) - # Fire-and-forget: return ACK immediately, process in background. - # Blocking here would prevent the SDK from sending heartbeats, - # eventually causing a disconnect. _on_message is wrapped so - # exceptions inside the task surface in logs instead of - # disappearing into the event loop. + # _safe_on_message surfaces exceptions in logs instead of losing them in the loop. asyncio.create_task(self._safe_on_message(chatbot_msg)) except Exception: - logger.exception( - "[%s] Error preparing incoming message", self._adapter.name - ) + logger.exception("[%s] Error preparing incoming message", self._adapter.name) return AckMessage.STATUS_SYSTEM_EXCEPTION, "error" - return AckMessage.STATUS_OK, "OK" async def _safe_on_message(self, chatbot_msg: "ChatbotMessage") -> None: - """Wrapper that catches exceptions from _on_message.""" try: await self._adapter._on_message(chatbot_msg) except Exception: - logger.exception( - "[%s] Error processing incoming message", self._adapter.name - ) + logger.exception("[%s] Error processing incoming message", self._adapter.name) -# ────────────────────────────────────────────────────────────────────────── -# Plugin migration glue (#41112 / #3823) -# -# Added when the DingTalk adapter moved from gateway/platforms/dingtalk.py into -# this bundled plugin. Mirrors the Discord (#24356) / Slack migrations: a -# register(ctx) entry point plus hook implementations that replace the -# per-platform core touchpoints (the Platform.DINGTALK elif in gateway/run.py, -# the dingtalk_cfg YAML→env block + _PLATFORM_CONNECTED_CHECKERS entry in -# gateway/config.py, the _setup_dingtalk wizard + _PLATFORMS["dingtalk"] static -# dict in hermes_cli/gateway.py, and the _send_dingtalk dispatch in -# tools/send_message_tool.py). -# ────────────────────────────────────────────────────────────────────────── +# --------------------------------------------------------------------------- +# Plugin glue: register(ctx) + hook implementations replacing the former +# per-platform core touchpoints (gateway/run.py, gateway/config.py, +# hermes_cli/gateway.py, tools/send_message_tool.py). +# --------------------------------------------------------------------------- -async def _standalone_send( - pconfig, - chat_id, - message, - *, - thread_id=None, - media_files=None, - force_document=False, -): - """Out-of-process DingTalk delivery via a static robot webhook URL. +async def _standalone_send(pconfig, chat_id, message, *, thread_id=None, media_files=None, force_document=False): + """Out-of-process delivery (standalone_sender_fn) via the static robot webhook URL. - Implements the standalone_sender_fn contract so deliver=dingtalk cron jobs - succeed when cron runs separately from the gateway. The live adapter uses - per-session webhook URLs from incoming messages, which aren't available - out-of-process; this path uses the static DINGTALK_WEBHOOK_URL / extra - webhook_url instead. Replaces the legacy _send_dingtalk helper. + Per-session webhooks aren't available out-of-process (deliver=dingtalk cron jobs), so this + uses DINGTALK_WEBHOOK_URL / extra ``webhook_url``. """ extra = getattr(pconfig, "extra", {}) or {} try: @@ -1750,20 +949,15 @@ async def _standalone_send( if not webhook_url: return {"error": "DingTalk not configured. Set DINGTALK_WEBHOOK_URL env var or webhook_url in dingtalk platform extra config."} async with httpx.AsyncClient(timeout=30.0) as client: - resp = await client.post( - webhook_url, - json={"msgtype": "text", "text": {"content": message}}, - ) + resp = await client.post(webhook_url, json={"msgtype": "text", "text": {"content": message}}) resp.raise_for_status() data = resp.json() if data.get("errcode", 0) != 0: return {"error": f"DingTalk API error: {data.get('errmsg', 'unknown')}"} return {"success": True, "platform": "dingtalk", "chat_id": chat_id} except Exception as e: - # Redact the access_token from webhook URLs that may appear in the - # exception text. Reuse send_message_tool._error's redaction so the - # logic stays single-sourced (lazy import avoids a circular at module - # load). Falls back to a plain message if that helper is unavailable. + # Redact access_token from webhook URLs in the exception text via send_message_tool._error + # (lazy import avoids a circular at module load). try: from tools.send_message_tool import _error as _redact_error return _redact_error(f"DingTalk send failed: {e}") @@ -1772,21 +966,10 @@ async def _standalone_send( def interactive_setup() -> None: - """Configure DingTalk — QR scan (recommended) or manual credential entry. - - Replaces hermes_cli/setup.py-era _setup_dingtalk + the static - _PLATFORMS["dingtalk"] dict in hermes_cli/gateway.py. CLI helpers are - lazy-imported so the plugin's module-load surface stays minimal. - """ + """Configure DingTalk — QR scan (recommended) or manual credential entry.""" from hermes_cli.config import get_env_value, save_env_value from hermes_cli.setup import prompt_choice - from hermes_cli.cli_output import ( - prompt, - prompt_yes_no, - print_header, - print_success, - print_warning, - ) + from hermes_cli.cli_output import prompt, prompt_yes_no, print_header, print_success, print_warning print_header("DingTalk") existing = get_env_value("DINGTALK_CLIENT_ID") @@ -1797,13 +980,9 @@ def interactive_setup() -> None: method = prompt_choice( "Choose setup method", - [ - "QR Code Scan (Recommended, auto-obtain Client ID and Client Secret)", - "Manual Input (Client ID and Client Secret)", - ], + ["QR Code Scan (Recommended, auto-obtain Client ID and Client Secret)", "Manual Input (Client ID and Client Secret)"], default=0, ) - if method == 0: try: from hermes_cli.dingtalk_auth import dingtalk_qr_auth @@ -1835,41 +1014,32 @@ def _manual_credential_entry(prompt, save_env_value, print_success) -> None: print_success("DingTalk credentials saved") -def _apply_yaml_config(yaml_cfg: dict, dingtalk_cfg: dict) -> dict | None: - """Translate config.yaml dingtalk: keys into DINGTALK_* env vars. +def _bridge_list_env(env_name: str, value) -> None: + """Export a YAML list/scalar as a comma-joined env var unless the env var is already set.""" + if value is not None and not os.getenv(env_name): + if isinstance(value, list): + value = ",".join(str(v) for v in value) + os.environ[env_name] = str(value) - Implements the apply_yaml_config_fn contract (#24849). Mirrors the legacy - dingtalk_cfg block from gateway/config.py::load_gateway_config(). Env vars - take precedence over YAML (each assignment guarded by not os.getenv(...)). - Returns None — everything flows through env. + +def _apply_yaml_config(yaml_cfg: dict, dingtalk_cfg: dict) -> dict | None: + """Translate config.yaml dingtalk: keys into DINGTALK_* env vars (apply_yaml_config_fn). + + Env vars take precedence over YAML. Returns None — everything flows through env. """ import json as _json if "require_mention" in dingtalk_cfg and not os.getenv("DINGTALK_REQUIRE_MENTION"): os.environ["DINGTALK_REQUIRE_MENTION"] = str(dingtalk_cfg["require_mention"]).lower() if "mention_patterns" in dingtalk_cfg and not os.getenv("DINGTALK_MENTION_PATTERNS"): os.environ["DINGTALK_MENTION_PATTERNS"] = _json.dumps(dingtalk_cfg["mention_patterns"]) - frc = dingtalk_cfg.get("free_response_chats") - if frc is not None and not os.getenv("DINGTALK_FREE_RESPONSE_CHATS"): - if isinstance(frc, list): - frc = ",".join(str(v) for v in frc) - os.environ["DINGTALK_FREE_RESPONSE_CHATS"] = str(frc) - ac = dingtalk_cfg.get("allowed_chats") - if ac is not None and not os.getenv("DINGTALK_ALLOWED_CHATS"): - if isinstance(ac, list): - ac = ",".join(str(v) for v in ac) - os.environ["DINGTALK_ALLOWED_CHATS"] = str(ac) + _bridge_list_env("DINGTALK_FREE_RESPONSE_CHATS", dingtalk_cfg.get("free_response_chats")) + _bridge_list_env("DINGTALK_ALLOWED_CHATS", dingtalk_cfg.get("allowed_chats")) allowed = dingtalk_cfg.get("allowed_users") if allowed is None: - # Fall back to the documented nested paths (#44928). The docs - # (website/docs/user-guide/messaging/dingtalk.md) configure the - # allowlist at gateway.platforms.dingtalk.extra.allowed_users; the - # adapter reads it from PlatformConfig.extra, but gateway - # authorization (_is_user_authorized in gateway/authz_mixin.py) - # only consults DINGTALK_ALLOWED_USERS — without this bridge a - # nested-only allowlist passes the adapter and is then denied at - # the gateway. Check this block's own extra first (the dispatch - # loop passes the platforms block here when no top-level - # ``dingtalk:`` section exists), then both nested containers. + # The docs configure the allowlist at gateway.platforms.dingtalk.extra.allowed_users; the + # adapter reads PlatformConfig.extra but gateway authz only consults DINGTALK_ALLOWED_USERS, + # so bridge nested-only allowlists too: this block's own extra first (the dispatch loop + # passes the platforms block when no top-level ``dingtalk:`` exists), then both containers. _extra = dingtalk_cfg.get("extra") if isinstance(_extra, dict): allowed = _extra.get("allowed_users") @@ -1884,19 +1054,12 @@ def _apply_yaml_config(yaml_cfg: dict, dingtalk_cfg: dict) -> dict | None: if isinstance(_dt_extra, dict) and _dt_extra.get("allowed_users") is not None: allowed = _dt_extra.get("allowed_users") break - if allowed is not None and not os.getenv("DINGTALK_ALLOWED_USERS"): - if isinstance(allowed, list): - allowed = ",".join(str(v) for v in allowed) - os.environ["DINGTALK_ALLOWED_USERS"] = str(allowed) + _bridge_list_env("DINGTALK_ALLOWED_USERS", allowed) return None def _is_connected(config) -> bool: - """DingTalk is connected when client_id + client_secret are present. - - Mirrors the legacy _PLATFORM_CONNECTED_CHECKERS[Platform.DINGTALK] entry. - Reads from PlatformConfig.extra first, then env vars. - """ + """Connected when client_id + client_secret are present (PlatformConfig.extra first, then env).""" extra = getattr(config, "extra", {}) or {} return bool( (extra.get("client_id") or os.getenv("DINGTALK_CLIENT_ID")) diff --git a/plugins/platforms/dingtalk/inbound.py b/plugins/platforms/dingtalk/inbound.py new file mode 100644 index 0000000000..65af2f09b1 --- /dev/null +++ b/plugins/platforms/dingtalk/inbound.py @@ -0,0 +1,242 @@ +"""Pure parsers for inbound DingTalk ``ChatbotMessage`` payloads (no I/O, no adapter state).""" + +import json +from typing import Any, List, Optional, Tuple + +from gateway.platforms.base import MessageType + +# DingTalk rich-text item type → runtime content type +DINGTALK_TYPE_MAPPING = {"picture": "image", "voice": "audio"} + +# File extension → MIME type for DingTalk file/image messages. image/* MIMEs +# make ``extract_media`` classify msgtype='image'/'file' payloads as PHOTO. +EXT_MAP = { + "pdf": "application/pdf", "doc": "application/msword", "xls": "application/vnd.ms-excel", + "docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg", "gif": "image/gif", "webp": "image/webp", + "md": "text/markdown", "txt": "text/plain", "csv": "text/csv", "zip": "application/zip", "mp4": "video/mp4", +} + +# rich-text runtime type → (media_types entry, MessageType promotion when still TEXT) +_RICH_MEDIA = { + "image": ("image", MessageType.PHOTO), + "video": ("video", MessageType.VIDEO), + "file": ("application/octet-stream", MessageType.DOCUMENT), +} + + +def _extensions(message: Any) -> Any: + return getattr(message, "extensions", {}) or {} + + +def _ext_content(message: Any) -> Optional[dict]: + """``extensions['content']`` when it is a dict, else None.""" + content = _extensions(message).get("content", {}) + return content if isinstance(content, dict) else None + + +def _rich_list(message: Any) -> Optional[list]: + """Rich-text item list from either SDK shape (``rich_text_content.rich_text_list`` or legacy ``rich_text``).""" + rich_text = getattr(message, "rich_text_content", None) or getattr(message, "rich_text", None) + if not rich_text: + return None + rich_list = getattr(rich_text, "rich_text_list", None) or rich_text + return rich_list if isinstance(rich_list, list) else None + + +def _card_text(message: Any) -> str: + """msgtype='card' (钉钉文档分享卡片 / link card): title + doc URL from ``extensions['card']``.""" + extensions = _extensions(message) + content = "" + card = extensions.get("card", {}) + if isinstance(card, dict): + title = card.get("title", "") + raw_content = card.get("content", "") + doc_url = "" + if isinstance(raw_content, dict): + doc_url = raw_content.get("url", "") or raw_content.get("docUrl", "") + elif isinstance(raw_content, str) and raw_content.strip(): + try: + parsed = json.loads(raw_content.strip()) + if isinstance(parsed, dict): + doc_url = parsed.get("url", "") or parsed.get("docUrl", "") + except (ValueError, TypeError): + doc_url = raw_content + parts = ([f"[文档] {title}"] if title else []) + ([doc_url] if doc_url else []) + if parts: + content = " ".join(parts) + if not content: + # Last-resort: raw text field from extensions (if present) + ext_text = extensions.get("text", {}) + if isinstance(ext_text, dict): + content = (ext_text.get("content", "") or "").strip() + return content + + +def _interactive_card_text(message: Any) -> str: + """msgtype='interactiveCard': ``extensions['content']`` carries title + biz_custom_action_url.""" + ext_content = _ext_content(message) + if not ext_content: + return "" + doc_url = ext_content.get("biz_custom_action_url", "") + title = ext_content.get("title", "") + if not (doc_url or title): + return "" + parts = [f"[文档卡片] {title}" if title else "[文档卡片]"] + ([doc_url] if doc_url else []) + return " ".join(parts) + + +def _ext_field(message: Any, field: str) -> Any: + ext_content = _ext_content(message) + return ext_content.get(field, "") if ext_content else "" + + +def _audio_text(message: Any) -> str: + """msgtype='audio': DingTalk-provided speech recognition text.""" + recognition = _ext_field(message, "recognition") + return recognition.strip() if recognition else "" + + +def _file_text(message: Any) -> str: + """msgtype='file': use fileName as text.""" + fname = _ext_field(message, "fileName") + return f"[文件] {fname}" if fname else "" + + +# Fallbacks by msgtype when no plain/rich text was found (types are exclusive). +_EMPTY_TEXT_FALLBACKS = ( + ("audio", _audio_text), + ("file", _file_text), + ("card", _card_text), + ("interactiveCard", _interactive_card_text), +) + + +def extract_text(message: Any) -> str: + """Extract plain text from a DingTalk chatbot message. + + Handles both SDK payload shapes: legacy ``message.text`` dict ``{"content": ...}`` and + >= 0.20 ``TextContent`` (whose ``__str__`` is ``"TextContent(content=...)"`` — always read + ``.content`` first); rich text via ``rich_text_content.rich_text_list`` or legacy ``rich_text``. + """ + text = getattr(message, "text", None) or "" + if hasattr(text, "content"): + content = (text.content or "").strip() + elif isinstance(text, dict): + content = text.get("content", "").strip() + else: + content = str(text).strip() + + if not content: + rich_list = _rich_list(message) + if rich_list is not None: + parts = [] + for item in rich_list: + if isinstance(item, dict): + t = item.get("text") or item.get("content") or "" + if t: + parts.append(t) + elif hasattr(item, "text") and item.text: + parts.append(item.text) + content = " ".join(parts).strip() + + if not content: + msg_type = getattr(message, "message_type", "") + for kind, fallback in _EMPTY_TEXT_FALLBACKS: + if msg_type == kind: + content = fallback(message) + break + + # Do NOT strip "@bot": the mention is routed structurally (callback ``isInAtList``), and + # regex-stripping @handles would damage e-mails, SSH URLs and literal "@openai" references. + return content + + +def extract_media(message: Any) -> Tuple[MessageType, List[str], List[str]]: + """Return ``(MessageType, [download codes/urls], [mime types])`` for a message.""" + msg_type = MessageType.TEXT + media_urls: List[str] = [] + media_types: List[str] = [] + + image_content = getattr(message, "image_content", None) + if image_content: + download_code = getattr(image_content, "download_code", None) + if download_code: + media_urls.append(download_code) + media_types.append("image") + msg_type = MessageType.PHOTO + + for item in _rich_list(message) or (): + if not isinstance(item, dict): + continue + dl_code = item.get("downloadCode") or item.get("download_code") or "" + item_type = item.get("type", "") + if not dl_code: + continue + mapped = DINGTALK_TYPE_MAPPING.get(item_type, "file") + media_urls.append(dl_code) + if mapped == "audio": + media_types.append("audio") + if msg_type == MessageType.TEXT: + # "voice" items are native voice notes → STT (VOICE); "audio" file uploads stay AUDIO. + msg_type = MessageType.VOICE if item_type == "voice" else MessageType.AUDIO + else: + mime, promoted = _RICH_MEDIA[mapped] + media_types.append(mime) + if msg_type == MessageType.TEXT: + msg_type = promoted + + msg_type_str = getattr(message, "message_type", "") or "" + if msg_type_str == "picture" and not media_urls: + msg_type = MessageType.PHOTO + elif msg_type_str == "richText": + # Only re-derive when the scan above left TEXT — resetting a VOICE/AUDIO/VIDEO/DOCUMENT + # promotion here dropped native voice notes back to TEXT and skipped STT. + if msg_type == MessageType.TEXT and any("image" in t for t in media_types): + msg_type = MessageType.PHOTO + elif msg_type_str == "audio": + # Voice message: recognition text is already in the text. Do NOT add media_urls, or + # run.py's transcription enrichment overwrites it with a failed STT attempt. + if msg_type == MessageType.TEXT: + msg_type = MessageType.VOICE + elif msg_type_str in ("file", "image"): + ext_content = _ext_content(message) + if ext_content: + dl_code = ext_content.get("downloadCode") or "" + fname = ext_content.get("fileName", "") + if dl_code: + media_urls.append(dl_code) + mime = "application/octet-stream" + if fname: + ext = fname.rsplit(".", 1)[-1].lower() if "." in fname else "" + mime = EXT_MAP.get(ext, mime) + media_types.append(mime) + if msg_type == MessageType.TEXT: + # Image messages, and files with image MIME (a .png sent as attachment), → PHOTO. + if msg_type_str == "image" or mime.startswith("image/"): + msg_type = MessageType.PHOTO + else: + msg_type = MessageType.DOCUMENT + + return msg_type, media_urls, media_types + + +def collect_download_codes(message: Any) -> List[Tuple[Any, str]]: + """Return ``(container, key)`` pairs whose download code should be resolved to a URL.""" + codes: List[Tuple[Any, str]] = [] + img_content = getattr(message, "image_content", None) + if img_content and getattr(img_content, "download_code", None): + codes.append((img_content, "download_code")) + rich_text = getattr(message, "rich_text_content", None) + if rich_text: + for item in getattr(rich_text, "rich_text_list", []) or []: + if isinstance(item, dict): + for key in ("downloadCode", "pictureDownloadCode", "download_code"): + if item.get(key): + codes.append((item, key)) + if (getattr(message, "message_type", "") or "") in ("file", "image"): + ext_content = _ext_content(message) + if ext_content and ext_content.get("downloadCode"): + codes.append((ext_content, "downloadCode")) + return codes diff --git a/plugins/platforms/google_chat/adapter.py b/plugins/platforms/google_chat/adapter.py index 7be34ceee2..2c79593ab8 100644 --- a/plugins/platforms/google_chat/adapter.py +++ b/plugins/platforms/google_chat/adapter.py @@ -1,43 +1,24 @@ """ Google Chat platform adapter. -Uses authenticated HTTP callbacks or Google Cloud Pub/Sub for inbound -events and the Google Chat REST API for outbound messages. Pub/Sub remains -available for no-public-URL deployments. +Inbound: authenticated HTTP callbacks or a Cloud Pub/Sub pull subscription +(for no-public-URL deployments). Outbound: Google Chat REST API. -Concurrency model ------------------ -The Pub/Sub SubscriberClient invokes its message callback in a background -thread (managed by the client's internal executor). The adapter's -``handle_message`` coroutine must run on the asyncio event loop, so the -callback uses ``asyncio.run_coroutine_threadsafe`` with -``add_done_callback`` (never ``.result()`` — that would block the callback -thread and saturate the Pub/Sub executor under load). +Concurrency model: the Pub/Sub SubscriberClient invokes its callback in a +background thread; ``handle_message`` must run on the asyncio loop, so the +callback schedules it thread-safely and never blocks on ``.result()`` (that +would saturate the Pub/Sub executor). All outbound Chat REST calls go through +``asyncio.to_thread`` because googleapiclient is synchronous. -All outbound Chat REST calls go through ``asyncio.to_thread`` because the -googleapiclient is synchronous. This keeps the event loop responsive. - -Pub/Sub delivery diagram:: - - Pub/Sub stream -> callback thread -> asyncio loop - (streaming_pull) (_on_pubsub_message) (handle_message) - | | | - | at-least-once | parse + dedup | agent work - | delivery | _submit_on_loop | send() response - | | message.ack() | - v v v - -Event type routing ------------------- -Inbound envelope carries ``type`` in [MESSAGE, ADDED_TO_SPACE, REMOVED_FROM_SPACE, -CARD_CLICKED]. Only MESSAGE dispatches to the agent. ADDED_TO_SPACE caches the -bot's resource name (belt-and-suspenders on top of eager resolution in connect()). -CARD_CLICKED is ACK'd only in v1 (follow-up PR implements interactivity). +Event routing: only MESSAGE events dispatch to the agent. ADDED_TO_SPACE caches +the bot's resource name; CARD_CLICKED is only ACK'd (interactivity deferred). """ from __future__ import annotations import asyncio +import contextlib +import importlib import json import logging import os @@ -48,59 +29,29 @@ import time from pathlib import Path as _Path from typing import Any, Callable, Dict, List, Optional, Tuple -from agent.secret_scope import UnscopedSecretError as _UnscopedSecretError -from agent.secret_scope import get_secret as _scoped_get_secret from agent.secret_scope import is_multiplex_active +from gateway.platforms._shared import get_scoped_secret as _get_scoped_secret - -def _get_scoped_secret(name: str, default: Optional[str] = None) -> Optional[str]: - """Scope-aware config/credential read with the default-profile fallback. - - Secondary profiles construct their adapters under a profile secret - scope -- the scope is authoritative and a scoped miss returns ``default`` - (no cross-profile borrow from ``os.environ``, which may hold another - profile's value). The DEFAULT profile's adapter constructs and connects - *unscoped* under multiplexing, where a bare ``get_secret`` would raise - ``UnscopedSecretError`` and crash startup/reconnect (#70652 class); there - ``os.environ`` is that profile's own value, so fall back to it. Same - pattern as ``whatsapp_common._get_wsecret`` and the WeCom/IRC/ntfy - plugin adapters. - """ - try: - val = _scoped_get_secret(name, default) - except _UnscopedSecretError: - val = os.getenv(name) - return val if val is not None else default +from .cards import card_spec_to_cards_v2, format_message as _format_message # noqa: F401 (re-exported) def _adc_would_borrow_foreign_credentials() -> bool: """True when ADC would silently read another profile's SA from process env. - ``google.auth.default()`` consults ``os.environ`` directly. Under - multiplexing a scoped profile only reaches the ADC branch after its own - scope had no service-account setting -- if the process env still carries - one (the default profile's), ADC would authenticate this profile as that - other identity. Fail closed instead. + ``google.auth.default()`` consults ``os.environ`` directly; under multiplexing + a scoped profile reaching the ADC branch would authenticate as the default + profile's identity if the process env carries one. Fail closed instead. """ return is_multiplex_active() and bool( os.environ.get("GOOGLE_CHAT_SERVICE_ACCOUNT_JSON") or os.environ.get("GOOGLE_APPLICATION_CREDENTIALS") ) -# Heavy google-cloud + googleapiclient imports are deferred to first -# adapter use. Importing them eagerly here added ~110ms wall and ~33MB -# RSS to *every* CLI invocation (the plugin loader imports this module at -# ``model_tools`` import time, so ``hermes status``, ``hermes chat``, etc. -# all paid the cost even though they never instantiate the adapter). -# -# All names below are module globals that ``_load_google_modules()`` -# rebinds on first call. The ``HttpError = Exception`` placeholder is -# important: ``except HttpError as exc:`` clauses elsewhere in this -# module bind the *current* module-global at try/except evaluation time, -# so as long as ``_load_google_modules()`` runs before any such -# ``try`` block executes (which it does — ``__init__`` calls it), the -# rebound real ``googleapiclient.errors.HttpError`` is what actually -# matches at runtime. +# Heavy google-cloud + googleapiclient imports are deferred to first adapter use +# (eager import cost ~110ms / ~33MB on every CLI invocation via plugin discovery). +# ``_load_google_modules()`` rebinds these globals. ``HttpError = Exception`` is +# the placeholder: ``except HttpError`` clauses bind the *current* global at +# evaluation time, and ``__init__`` always loads the real class first. GOOGLE_CHAT_AVAILABLE: bool = False httplib2: Any = None # type: ignore pubsub_v1: Any = None # type: ignore @@ -112,6 +63,17 @@ HttpError: Any = Exception # type: ignore MediaFileUpload: Any = None # type: ignore _google_modules_loaded: bool = False +# (global name, module, attribute-or-None) rebound by ``_load_google_modules``. +_GOOGLE_IMPORTS = ( + ("httplib2", "httplib2", None), + ("pubsub_v1", "google.cloud.pubsub_v1", None), + ("gax_exceptions", "google.api_core.exceptions", None), + ("service_account", "google.oauth2.service_account", None), + ("AuthorizedHttp", "google_auth_httplib2", "AuthorizedHttp"), + ("build_service", "googleapiclient.discovery", "build"), + ("HttpError", "googleapiclient.errors", "HttpError"), + ("MediaFileUpload", "googleapiclient.http", "MediaFileUpload"), +) _GOOGLE_ID_TOKEN_CERTS_TTL_SECONDS = 300 _google_id_token_request: Any = None _google_id_token_request_lock = threading.Lock() @@ -128,13 +90,11 @@ class _CachedGoogleAuthRequest: cache_key = (method.upper(), url) if cache_key[0] != "GET": return self._request(url=url, method=method, **kwargs) - now = time.monotonic() with self._lock: cached = self._cache.get(cache_key) if cached and cached[0] > now: return cached[1] - response = self._request(url=url, method=method, **kwargs) if getattr(response, "status", None) == 200: with self._lock: @@ -159,206 +119,105 @@ def _verify_google_id_token(token: str, audience: str) -> Dict[str, Any]: from google.oauth2 import id_token except ImportError as exc: raise RuntimeError("google-auth is required for Google Chat HTTP callbacks") from exc - - return id_token.verify_oauth2_token( - token, - _get_google_id_token_request(), - audience, - ) + return id_token.verify_oauth2_token(token, _get_google_id_token_request(), audience) def _load_google_modules() -> bool: - """Lazily import the heavy google-cloud + googleapiclient stack. + """Lazily import the google-cloud + googleapiclient stack; idempotent. - Idempotent. Returns True if the optional deps are installed and - were successfully imported, False otherwise. On success, mutates - the module globals so existing code using ``pubsub_v1``, - ``service_account``, ``HttpError``, etc. transparently uses the - real classes. - - Why deferred: the import chain pulls in google.cloud.pubsub_v1, - googleapiclient, grpc, and friends — about 33MB RSS and 110ms wall - on a fresh interpreter. Plugin discovery imports this module on - every CLI invocation, even ones that never touch a gateway. + Returns True when the optional deps imported; rebinds the module globals so + code using ``pubsub_v1``, ``service_account``, ``HttpError`` sees real classes. """ global GOOGLE_CHAT_AVAILABLE, _google_modules_loaded - global httplib2, pubsub_v1, gax_exceptions, service_account - global AuthorizedHttp, build_service, HttpError, MediaFileUpload if _google_modules_loaded: return GOOGLE_CHAT_AVAILABLE _google_modules_loaded = True try: - import httplib2 as _httplib2 - from google.cloud import pubsub_v1 as _pubsub_v1 - from google.api_core import exceptions as _gax_exceptions - from google.oauth2 import service_account as _service_account - from google_auth_httplib2 import AuthorizedHttp as _AuthorizedHttp - from googleapiclient.discovery import build as _build_service - from googleapiclient.errors import HttpError as _HttpError - from googleapiclient.http import MediaFileUpload as _MediaFileUpload + loaded = { + name: getattr(importlib.import_module(module), attr) if attr else importlib.import_module(module) + for name, module, attr in _GOOGLE_IMPORTS + } except ImportError: GOOGLE_CHAT_AVAILABLE = False return False - httplib2 = _httplib2 - pubsub_v1 = _pubsub_v1 - gax_exceptions = _gax_exceptions - service_account = _service_account - AuthorizedHttp = _AuthorizedHttp - build_service = _build_service - HttpError = _HttpError - MediaFileUpload = _MediaFileUpload + globals().update(loaded) GOOGLE_CHAT_AVAILABLE = True return True from gateway.config import Platform, PlatformConfig -# Trigger registration of the dynamic ``google_chat`` enum member at module -# import time. ``_missing_()`` caches the pseudo-member in -# ``_value2member_map_`` *and* ``_member_map_``, so after this call -# ``Platform.GOOGLE_CHAT`` resolves via attribute access too. Without this -# line, any code (including tests) that references ``Platform.GOOGLE_CHAT`` -# before an adapter instance is constructed would hit ``AttributeError``. -# Built-ins avoid this because they have explicit enum members; plugin -# platforms earn the attribute by asking for it once. +# Register the dynamic ``google_chat`` enum member at import time: ``_missing_()`` +# caches the pseudo-member so ``Platform.GOOGLE_CHAT`` attribute access works +# before any adapter instance is constructed. Platform("google_chat") from gateway.platforms.helpers import MessageDeduplicator from gateway.platforms.base import ( - gateway_trust_env, - BasePlatformAdapter, - MessageEvent, - MessageType, - ProcessingOutcome, - SendResult, - cache_audio_from_bytes, - cache_document_from_bytes, - cache_image_from_bytes, - cache_video_from_bytes, + gateway_trust_env, BasePlatformAdapter, MessageEvent, MessageType, ProcessingOutcome, SendResult, + cache_audio_from_bytes, cache_document_from_bytes, cache_image_from_bytes, cache_video_from_bytes, ) -# Pin the logger name to the legacy module path so operator log filters, -# grep aliases, and the gateway's bundled log views keep matching after -# the in-tree → plugin migration. ``__name__`` resolves to -# ``hermes_plugins.platforms__google_chat.adapter`` once the plugin -# loader namespaces this module, which would silently break every -# downstream log-monitor that greps for ``gateway.platforms.google_chat``. +# Pinned to the legacy module path so operator log filters keep matching after +# the in-tree → plugin migration (``__name__`` is namespaced by the plugin loader). logger = logging.getLogger("gateway.platforms.google_chat") +_SUBSCRIPTION_PATH_RE = re.compile(r"^projects/(?P[^/]+)/subscriptions/(?P[^/]+)$") -# Regex validating Pub/Sub subscription path format. -_SUBSCRIPTION_PATH_RE = re.compile( - r"^projects/(?P[^/]+)/subscriptions/(?P[^/]+)$" -) - -# SA scopes — chat.bot is sufficient for the bot's own messaging operations -# (messages.create / patch / delete, spaces metadata, memberships, -# media.download for inbound user attachments). The bot CANNOT call -# media.upload — Google requires user OAuth for that endpoint, no scope -# adjustment changes it. -# -# Native attachment delivery (bot → user) is handled via a separate user- -# OAuth flow in ``oauth.py`` (this plugin's helper module): the user grants the bot -# the chat.messages.create scope ONCE via an in-chat consent flow; the -# bot then calls media.upload on the user's behalf when sending files. -# See https://developers.google.com/chat/api/guides/auth/users -_CHAT_SCOPES = [ - "https://www.googleapis.com/auth/chat.bot", - "https://www.googleapis.com/auth/pubsub", -] +# chat.bot covers the bot's own messaging ops (create/patch/delete, spaces, +# memberships, media.download). The bot CANNOT call media.upload — Google +# requires user OAuth for it; see ``oauth.py`` for the per-user consent flow. +_CHAT_SCOPES = ["https://www.googleapis.com/auth/chat.bot", "https://www.googleapis.com/auth/pubsub"] # Google Chat text-message size limit is 4096; leave margin. _MAX_TEXT_LENGTH = 4000 - # Per-space rate-limit hit counter threshold; warn if exceeded. _RATE_LIMIT_WARN_THRESHOLD = 5 -# Outbound retry parameters. Google's Chat REST API returns transient 5xx -# and 429 occasionally — without a retry wrapper, single hiccups drop -# user-visible messages. Backoff stays bounded so a true outage is still -# surfaced quickly. Pattern lifted from PR #14965. +# Outbound retry: transient 429/5xx would otherwise drop user-visible messages; +# backoff stays bounded so a true outage surfaces quickly. _RETRY_MAX_ATTEMPTS = 3 _RETRY_BASE_DELAY = 1.0 _RETRY_MAX_DELAY = 8.0 _RETRY_JITTER = 0.3 _RETRYABLE_HTTP_STATUSES = frozenset({429, 500, 502, 503, 504}) -_CARD_WIDGET_TYPES = frozenset({ - "text", - "text_paragraph", - "decorated_text", - "buttons", - "button_list", - "selection", - "selection_input", - "image", - "divider", -}) + + +def _http_status(exc: BaseException) -> Any: + """``resp.status`` of a googleapiclient HttpError, or None.""" + return getattr(getattr(exc, "resp", None), "status", None) def _is_retryable_error(exc: BaseException) -> bool: - """Classify outbound API errors as transient (retryable) vs permanent. - - Retries are applied to: - - HTTP 429 (rate-limited) - - HTTP 5xx (server errors) - - Network/transport failures (timeout, connection reset, DNS) - - Authentication errors (401/403), client errors (4xx other than 429), - and well-formed non-retryable failures are NOT retried — those - indicate a misconfiguration or revoked token, not a hiccup. - """ - # googleapiclient.errors.HttpError carries resp.status - resp = getattr(exc, "resp", None) - status = getattr(resp, "status", None) + """True for transient failures (429, 5xx, transport errors); auth/4xx are permanent.""" + status = _http_status(exc) if isinstance(status, int): return status in _RETRYABLE_HTTP_STATUSES - # Fallback heuristics for SSL/socket errors that don't carry an - # HTTP status: text matches against common transport-layer wording. + # SSL/socket errors carry no HTTP status: match common transport wording. text = str(exc).lower() if "timeout" in text or "timed out" in text: return True if "connection" in text and ("reset" in text or "refused" in text or "aborted" in text): return True - if "broken pipe" in text or "remote disconnected" in text: - return True - return False + return "broken pipe" in text or "remote disconnected" in text -# Sentinel kept in ``_typing_messages`` after ``send()`` patches the typing -# marker into the agent's real response. Two purposes: -# * ``send_typing`` checks for any value before posting — sentinel keeps -# ``_keep_typing`` (running on the base-class timer) from creating a -# fresh "Hermes is thinking…" card during the small window between -# ``send()`` finishing and the base-class cancelling its typing_task. -# * ``stop_typing`` checks for the sentinel and skips the API delete — -# otherwise the safety-net cleanup at base.py:_process_message_background -# would delete the response we just patched and leave a tombstone. +# Left in ``_typing_messages`` after ``send()`` patches the typing marker into the +# real response: keeps ``_keep_typing`` from creating a fresh card before the base +# class cancels its typing task, and tells ``stop_typing`` to skip the API delete +# (which would tombstone the response we just patched). _TYPING_CONSUMED_SENTINEL = "" def check_google_chat_requirements() -> bool: - """Check if Google Chat optional dependencies are installed. - - Triggers the lazy import of the google-cloud + googleapiclient stack - on first call. Subsequent calls hit the cached result. This is the - canonical "are the deps available" probe used by the plugin registry - and the adapter's own startup gate. - """ + """Canonical "are the optional deps available" probe; triggers the lazy import.""" return _load_google_modules() -# Hostnames we trust to host Google Chat attachment download URIs. Anything -# else gets rejected by _is_google_owned_host to block SSRF scenarios where -# a crafted event points downloadUri at a non-Google endpoint (e.g. the -# GCE/GKE metadata service at 169.254.169.254) and the bot's Service Account -# bearer token would be attached to the outbound request. +# Only these hosts may serve attachment download URIs: anything else is rejected +# to block SSRF (e.g. downloadUri pointing at the GCE metadata service with the +# SA bearer token attached). _TRUSTED_ATTACHMENT_HOSTS = ( - "googleapis.com", - "chat.google.com", - "drive.google.com", - "docs.google.com", - "lh3.googleusercontent.com", - "lh4.googleusercontent.com", - "lh5.googleusercontent.com", - "lh6.googleusercontent.com", + "googleapis.com", "chat.google.com", "drive.google.com", "docs.google.com", + "lh3.googleusercontent.com", "lh4.googleusercontent.com", "lh5.googleusercontent.com", "lh6.googleusercontent.com", ) @@ -379,24 +238,11 @@ def _is_google_owned_host(url: str) -> bool: def _redact_sensitive(text: str) -> str: - """Sanitize subscription paths and email-like tokens from an error string. - - Covers project IDs leaking via Pub/Sub exception messages, plus SA-ish - email addresses. agent/redact.py handles log-level redaction elsewhere; - this helper is for user-facing error messages. - """ + """Redact Pub/Sub resource paths and SA emails from user-facing error strings.""" if not text: return text - text = re.sub( - r"projects/[^/\s]+/subscriptions/[^/\s]+", - "projects//subscriptions/", - text, - ) - text = re.sub( - r"projects/[^/\s]+/topics/[^/\s]+", - "projects//topics/", - text, - ) + text = re.sub(r"projects/[^/\s]+/subscriptions/[^/\s]+", "projects//subscriptions/", text) + text = re.sub(r"projects/[^/\s]+/topics/[^/\s]+", "projects//topics/", text) text = re.sub( r"[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.iam\.gserviceaccount\.com", "@.iam.gserviceaccount.com", @@ -405,195 +251,81 @@ def _redact_sensitive(text: str) -> str: return text -def _mime_for_message_type(mime: str) -> MessageType: - """Map a MIME string to a hermes MessageType. +_MIME_MESSAGE_TYPES = (("image/", MessageType.PHOTO), ("audio/", MessageType.AUDIO), ("video/", MessageType.VIDEO)) - Anything not image/audio/video falls through to DOCUMENT so the agent - still receives the file. - """ - if not mime: - return MessageType.DOCUMENT - if mime.startswith("image/"): - return MessageType.PHOTO - if mime.startswith("audio/"): - return MessageType.AUDIO - if mime.startswith("video/"): - return MessageType.VIDEO + +def _mime_for_message_type(mime: str) -> MessageType: + """Map a MIME string to a MessageType; non-media falls through to DOCUMENT.""" + for prefix, message_type in _MIME_MESSAGE_TYPES: + if mime and mime.startswith(prefix): + return message_type return MessageType.DOCUMENT -def _required_str(mapping: Dict[str, Any], key: str, context: str) -> str: - value = mapping.get(key) - if value is None: - raise ValueError(f"{context}.{key} is required") - value = str(value).strip() - if not value: - raise ValueError(f"{context}.{key} is required") - return value +class _SACredentialError(Exception): + """Internal: classified SA-credential load failure (``kind`` + optional cause).""" + + def __init__(self, kind: str, detail: Optional[BaseException] = None) -> None: + super().__init__(kind) + self.kind = kind + self.detail = detail -def _button_to_chat(button: Dict[str, Any]) -> Dict[str, Any]: - text = _required_str(button, "text", "button") - action = _required_str(button, "action", "button") - raw_params = button.get("parameters") or {} - if not isinstance(raw_params, dict): - raise ValueError("button.parameters must be an object") - parameters = [ - {"key": str(key), "value": str(value)} - for key, value in sorted(raw_params.items()) - ] - return { - "text": text, - "onClick": {"action": {"function": action, "parameters": parameters}}, - } +def _load_sa_credentials_from(sa_value: Optional[str]) -> Any: + """Build SA credentials from a path / inline JSON, or fall back to ADC. - -def _widget_to_chat(widget: Dict[str, Any]) -> Dict[str, Any]: - if not isinstance(widget, dict): - raise ValueError("card widgets must be objects") - widget_type = str(widget.get("type") or "").strip() - if widget_type not in _CARD_WIDGET_TYPES: - raise ValueError(f"unsupported widget type: {widget_type or ''}") - - if widget_type in {"text", "text_paragraph"}: - return { - "textParagraph": { - "text": GoogleChatAdapter.format_message( - _required_str(widget, "text", "widget") - ) - } - } - if widget_type == "decorated_text": - decorated: Dict[str, Any] = { - "text": GoogleChatAdapter.format_message( - _required_str(widget, "text", "widget") - ), - "wrapText": bool(widget.get("wrap_text", True)), - } - if widget.get("top_label"): - decorated["topLabel"] = str(widget["top_label"]) - if widget.get("bottom_label"): - decorated["bottomLabel"] = str(widget["bottom_label"]) - return {"decoratedText": decorated} - if widget_type == "divider": - return {"divider": {}} - if widget_type == "image": - image = {"imageUrl": _required_str(widget, "image_url", "widget")} - if widget.get("alt_text"): - image["altText"] = str(widget["alt_text"]) - return {"image": image} - if widget_type in {"buttons", "button_list"}: - raw_buttons = widget.get("buttons") or [] - if not isinstance(raw_buttons, list) or not raw_buttons: - raise ValueError("button widgets require at least one button") - return {"buttonList": {"buttons": [_button_to_chat(btn) for btn in raw_buttons]}} - if widget_type in {"selection", "selection_input"}: - name = _required_str(widget, "name", "widget") - raw_items = widget.get("items") or [] - if not isinstance(raw_items, list) or not raw_items: - raise ValueError("selection widgets require at least one item") - items: List[Dict[str, Any]] = [] - for item in raw_items: - if not isinstance(item, dict): - raise ValueError("selection items must be objects") - items.append({ - "text": _required_str(item, "text", "selection item"), - "value": _required_str(item, "value", "selection item"), - "selected": bool(item.get("selected", False)), - }) - return { - "selectionInput": { - "name": name, - "label": str(widget.get("label") or name), - "type": str(widget.get("selection_type") or "CHECK_BOX"), - "items": items, - } - } - raise ValueError(f"unsupported widget type: {widget_type}") - - -def card_spec_to_cards_v2(card_spec: Dict[str, Any]) -> Dict[str, Any]: - if not isinstance(card_spec, dict): - raise ValueError("card must be an object") - - raw_sections = card_spec.get("sections") or [] - if not isinstance(raw_sections, list) or not raw_sections: - raise ValueError("card.sections must contain at least one section") - - sections: List[Dict[str, Any]] = [] - for section in raw_sections: - if not isinstance(section, dict): - raise ValueError("card sections must be objects") - widgets = section.get("widgets") or [] - if not isinstance(widgets, list) or not widgets: - raise ValueError("card section widgets must contain at least one widget") - rendered: Dict[str, Any] = {"widgets": [_widget_to_chat(w) for w in widgets]} - if section.get("header"): - rendered["header"] = str(section["header"]) - sections.append(rendered) - - card: Dict[str, Any] = {"sections": sections} - header = card_spec.get("header") - if header: - if not isinstance(header, dict): - raise ValueError("card.header must be an object") - rendered_header: Dict[str, Any] = { - "title": _required_str(header, "title", "card.header") - } - if header.get("subtitle"): - rendered_header["subtitle"] = str(header["subtitle"]) - if header.get("image_url"): - rendered_header["imageUrl"] = str(header["image_url"]) - rendered_header["imageType"] = str(header.get("image_type") or "SQUARE") - if header.get("image_alt_text"): - rendered_header["imageAltText"] = str(header["image_alt_text"]) - card["header"] = rendered_header - - return {"cardId": str(card_spec.get("card_id") or "hermes-card"), "card": card} + Raises ``_SACredentialError`` with kind in {inline_invalid, not_found, + file_invalid, adc_foreign, adc_no_auth, adc_failed}; callers map kinds to + their own user-facing messages. + """ + if sa_value: + if sa_value.lstrip().startswith("{"): + try: + info = json.loads(sa_value) + except json.JSONDecodeError as exc: + raise _SACredentialError("inline_invalid", exc) from exc + else: + if not os.path.exists(sa_value): + raise _SACredentialError("not_found") + try: + with open(sa_value, "r", encoding="utf-8") as fh: + info = json.load(fh) + except json.JSONDecodeError as exc: + raise _SACredentialError("file_invalid", exc) from exc + return service_account.Credentials.from_service_account_info(info, scopes=_CHAT_SCOPES) + # No explicit SA — ADC (Cloud Run / GCE workload identity, or gcloud ADC login). + if _adc_would_borrow_foreign_credentials(): + raise _SACredentialError("adc_foreign") + try: + import google.auth as google_auth + except ImportError: + raise _SACredentialError("adc_no_auth") + try: + credentials, _project = google_auth.default(scopes=_CHAT_SCOPES) + except Exception as exc: + raise _SACredentialError("adc_failed", exc) from exc + return credentials class _ThreadCountStore: """Per-(chat_id, thread_name) inbound message counter, persisted to disk. - Drives the DM main-flow vs side-thread heuristic: + Drives the DM main-flow vs side-thread heuristic: prev_count == 0 means Chat + just auto-created the thread for a top-level message (shared DM session, reply + top-level); prev_count >= 1 means the user engaged an existing thread (isolate + session, reply in-thread). Persistence matters: a restart that wiped counts + would demote active side-threads to main flow and leak context. - - prev_count == 0 (first time we see this thread) → "main flow": - Google Chat just auto-created a fresh thread for the user's - top-level message. Treat it as part of the shared DM session; - bot replies at top-level (no thread.name on outbound). - - prev_count >= 1 (we've already seen this thread) → "side thread": - user explicitly engaged a thread that's been around. Isolate - session by thread, route bot reply into the same thread. - - Persistence is essential: without it, every gateway restart wipes - counts and active side-threads silently demote to "main flow", - which leaks main-flow context into the user's isolated thread - (the bug Ramón reported across 4 iterations of the in-memory - version). - - File format (JSON): - {"": {"": , ...}, ...} - - Failure modes are non-fatal: a missing or corrupt file resets to - empty (logged as warning) so the adapter never crashes on disk - issues. The next ``incr`` will write a fresh file. - - Save strategy: write-through after every ``incr``. The file is - tiny (a few KB even for very active bots), so the simplicity of - write-through outweighs the cost of debouncing for now. + File format: ``{"": {"": }}``. Missing or + corrupt file resets to empty (warned); write-through on every ``incr``. """ def __init__(self, path: _Path): self._path = path self._counts: Dict[str, Dict[str, int]] = {} - self._loaded = False def load(self) -> None: - """Load counts from disk. Safe to call multiple times. - - Missing file → empty store. Corrupt JSON → empty store + warn. - """ - self._loaded = True + """Load counts from disk; missing file → empty, corrupt JSON → empty + warn.""" if not self._path.exists(): self._counts = {} return @@ -601,18 +333,11 @@ class _ThreadCountStore: raw = self._path.read_text(encoding="utf-8") data = json.loads(raw) if raw.strip() else {} except json.JSONDecodeError as exc: - logger.warning( - "[GoogleChat] thread-count store at %s is corrupt; " - "starting fresh: %s", - self._path, exc, - ) + logger.warning("[GoogleChat] thread-count store at %s is corrupt; starting fresh: %s", self._path, exc) self._counts = {} return except OSError as exc: - logger.warning( - "[GoogleChat] could not read thread-count store at %s: %s", - self._path, exc, - ) + logger.warning("[GoogleChat] could not read thread-count store at %s: %s", self._path, exc) self._counts = {} return # Validate shape — anything off-schema gets dropped silently. @@ -621,22 +346,16 @@ class _ThreadCountStore: for chat_id, threads in data.items(): if not isinstance(chat_id, str) or not isinstance(threads, dict): continue - clean_threads: Dict[str, int] = {} - for thread_name, count in threads.items(): - if isinstance(thread_name, str) and isinstance(count, int): - clean_threads[thread_name] = count + clean_threads = {t: c for t, c in threads.items() if isinstance(t, str) and isinstance(c, int)} if clean_threads: clean[chat_id] = clean_threads self._counts = clean def get(self, chat_id: str, thread_name: str) -> int: - """Return the current count for (chat_id, thread_name), or 0.""" return self._counts.get(chat_id, {}).get(thread_name, 0) def incr(self, chat_id: str, thread_name: str) -> int: - """Increment count and write through to disk. Returns the - PRE-increment value (the heuristic input — "have we seen this - thread before this message?").""" + """Increment and write through; returns the PRE-increment value.""" chat_counts = self._counts.setdefault(chat_id, {}) prev = chat_counts.get(thread_name, 0) chat_counts[thread_name] = prev + 1 @@ -644,22 +363,34 @@ class _ThreadCountStore: return prev def _save(self) -> None: - """Atomic write of the counts dict to disk. - - Failure is non-fatal — log warning and continue. The in-memory - counts stay consistent within the running process; only restart - recovery is affected. - """ + """Atomic write; failure is non-fatal (in-memory counts stay consistent).""" try: self._path.parent.mkdir(parents=True, exist_ok=True) tmp = self._path.with_suffix(self._path.suffix + ".tmp") tmp.write_text(json.dumps(self._counts, separators=(",", ":")), encoding="utf-8") os.replace(tmp, self._path) except OSError as exc: - logger.warning( - "[GoogleChat] could not persist thread-count store to %s: %s", - self._path, exc, - ) + logger.warning("[GoogleChat] could not persist thread-count store to %s: %s", self._path, exc) + + +_SA_ERROR_MESSAGES = { + "inline_invalid": "Inline SA JSON is not valid JSON: {exc}", + "not_found": "Service Account JSON file not found at configured path.", + "file_invalid": "Service Account JSON file is not valid JSON: {exc}", + "adc_foreign": ( + "Google Chat ADC skipped for this profile: service-account credentials are set in the process " + "environment but not in this profile's secret scope. Set GOOGLE_CHAT_SERVICE_ACCOUNT_JSON in this " + "profile's .env." + ), + "adc_no_auth": ( + "No Service Account credentials configured. Set GOOGLE_CHAT_SERVICE_ACCOUNT_JSON or " + "GOOGLE_APPLICATION_CREDENTIALS, or install google-auth to use Application Default Credentials." + ), + "adc_failed": ( + "No Service Account credentials configured and Application Default Credentials are unavailable. Set " + "GOOGLE_CHAT_SERVICE_ACCOUNT_JSON or run ``gcloud auth application-default login``. ADC error: {exc}" + ), +} class GoogleChatAdapter(BasePlatformAdapter): @@ -685,49 +416,25 @@ class GoogleChatAdapter(BasePlatformAdapter): _RECONNECT_MAX_DELAY = 120.0 def __init__(self, config: PlatformConfig): - # ``Platform("google_chat")`` resolves via ``_missing_()`` → pseudo-member - # cached in ``_value2member_map_``. We deliberately do NOT add an enum - # attribute to ``gateway.config.Platform`` — bundled platform plugins - # are looked up by value, not attribute (matches Teams, IRC). + # ``Platform("google_chat")`` resolves via ``_missing_()``; bundled platform + # plugins are looked up by value, not enum attribute (matches Teams, IRC). super().__init__(config, Platform("google_chat")) - # Trigger the deferred google-cloud + googleapiclient import here so - # that any code path which constructs the adapter and then calls - # methods directly (notably the test suite, which builds an adapter - # and invokes ``_send_file`` / ``_create_message`` / etc. without - # going through ``connect()``) sees real classes for ``MediaFileUpload``, - # ``service_account``, ``HttpError``, and friends. The module-level - # globals were previously eager-imported; making this lazy saved - # ~110ms / ~33MB on every CLI invocation. Idempotent — pays the cost - # exactly once per process. + # Load the google stack here (not only in connect()) so code paths that + # construct the adapter and call methods directly see real classes for + # ``MediaFileUpload`` / ``HttpError`` / etc. Idempotent. _load_google_modules() self._subscriber: Optional[Any] = None self._chat_api: Optional[Any] = None - # User-authed Chat API client built lazily from the OAuth refresh - # token persisted by the plugin's ``oauth.py`` helper. Required for - # native ``media.upload`` (bot identity is rejected by that - # endpoint). - # - # Multi-user mode: each user runs ``/setup-files`` ONCE in their - # own DM and the resulting refresh token is stored under their - # email. ``_send_file`` looks up the requesting user's email via - # ``_last_sender_by_chat`` and uses THAT user's token, so when - # User B asks for a file in B's DM the bot uploads as B (not as - # whoever first set up files long ago). - # - # ``_user_credentials`` / ``_user_chat_api`` keep their old names - # but now hold the LEGACY single-user token (if any) — used as a - # last-ditch fallback when the requesting user has no per-user - # token yet. Pre-multi-user installs continue to work unchanged. + # User-authed Chat clients for native ``media.upload`` (bot identity is + # rejected there). Multi-user: each user's token lives under their email + # and ``_send_file`` picks the requesting user's via ``_last_sender_by_chat``. + # ``_user_credentials`` / ``_user_chat_api`` hold the LEGACY single-user + # token as a last-ditch fallback for pre-multi-user installs. self._user_chat_api: Optional[Any] = None self._user_credentials: Optional[Any] = None - # Per-email caches. Populated lazily by ``_get_user_chat_for_chat``. self._user_creds_by_email: Dict[str, Any] = {} self._user_chat_api_by_email: Dict[str, Any] = {} - # chat_id → most-recent inbound sender's email. Populated in - # ``_build_message_event`` whenever the inbound event carries a - # non-empty ``sender.email``. Drives the per-user token lookup - # in ``_send_file`` so the bot uploads as the user who triggered - # the request, not as some other authorized user. + # chat_id → most-recent inbound sender email (drives per-user token lookup). self._last_sender_by_chat: Dict[str, str] = {} self._credentials: Optional[Any] = None self._project_id: Optional[str] = None @@ -741,174 +448,75 @@ class GoogleChatAdapter(BasePlatformAdapter): self._clarify_state: Dict[str, str] = {} self._shutting_down = False self._rate_limit_hits: Dict[str, int] = {} - # Last-seen inbound thread name per chat_id (space). Google Chat - # DMs create a NEW thread per top-level user message but the user - # views them as one logical conversation. We: - # (a) drop thread_id from the source for DMs (so session_key - # stays stable across top-level messages — see - # gateway/session.py:build_session_key). - # (b) cache the most recent inbound thread name here so outbound - # replies still land in the right visual thread without - # re-coupling sessions to threads. + # Last inbound thread per space. DMs create a NEW thread per top-level + # message but users see one conversation: thread_id is dropped from the + # source (stable session key) and cached here so replies still land in + # the right visual thread. self._last_inbound_thread: Dict[str, str] = {} - # Inbound message count per (chat_id, thread_name). Drives the - # DM main-flow vs side-thread heuristic in _build_message_event - # and the outbound thread routing in _resolve_thread_id. - # Persisted to ${HERMES_HOME}/google_chat_thread_counts.json so - # active side-threads survive gateway restarts (the bug that - # made the in-memory version of this heuristic flaky for - # multi-restart sessions). + # Persisted per-(chat, thread) inbound counts for the side-thread heuristic + # (survives gateway restarts). try: from hermes_constants import get_hermes_home as _get_hermes_home _hermes_home = _get_hermes_home() except (ModuleNotFoundError, ImportError): _hermes_home = _Path.home() / ".hermes" - self._thread_count_store = _ThreadCountStore( - _hermes_home / "google_chat_thread_counts.json" - ) - # In-flight typing-card creates per chat_id. send_typing() reserves - # an Event here BEFORE starting the API call so concurrent calls - # from base.py's _keep_typing wait instead of duplicating cards. - # Cleared in the create_and_record finally. + self._thread_count_store = _ThreadCountStore(_hermes_home / "google_chat_thread_counts.json") + # In-flight typing-card creates per chat_id: send_typing() reserves an Event + # BEFORE the API call so concurrent _keep_typing calls wait instead of + # duplicating cards. self._typing_card_inflight: Dict[str, asyncio.Event] = {} - # Orphaned typing cards (created by background tasks that lost a - # race with send() / another concurrent create). Cleaned up at - # end-of-turn by on_processing_complete via patch-to-empty so - # they don't sit in the chat forever as "Hermes is thinking…". + # Typing cards that lost a race with send(); patched away at end of turn. self._orphan_typing_messages: Dict[str, List[str]] = {} - # Snapshot profile-scoped settings while adapter construction still - # runs inside _profile_runtime_scope. Pub/Sub invokes callbacks from - # its own threads, where the ContextVar secret scope is intentionally - # unavailable; callbacks must use these instance values rather than - # consulting process-global environment state. + # Snapshot profile-scoped settings now: Pub/Sub callbacks run on threads + # where the ContextVar secret scope is unavailable. extra = self.config.extra - try: - self._max_messages = int( - extra.get("max_messages") - or _get_scoped_secret("GOOGLE_CHAT_MAX_MESSAGES", "1") - ) - except (ValueError, TypeError): - self._max_messages = 1 - try: - self._max_bytes = int( - extra.get("max_bytes") - or _get_scoped_secret("GOOGLE_CHAT_MAX_BYTES", str(16 * 1024 * 1024)) - ) - except (ValueError, TypeError): - self._max_bytes = 16 * 1024 * 1024 + self._max_messages = self._int_setting(extra, "max_messages", "GOOGLE_CHAT_MAX_MESSAGES", 1) + self._max_bytes = self._int_setting(extra, "max_bytes", "GOOGLE_CHAT_MAX_BYTES", 16 * 1024 * 1024) self._bootstrap_spaces = str( - extra.get("bootstrap_spaces") - or _get_scoped_secret("GOOGLE_CHAT_BOOTSTRAP_SPACES", "") - or "" + extra.get("bootstrap_spaces") or _get_scoped_secret("GOOGLE_CHAT_BOOTSTRAP_SPACES", "") or "" ).strip() - self._debug_raw = bool( - extra.get("debug_raw") - or _get_scoped_secret("GOOGLE_CHAT_DEBUG_RAW") + self._debug_raw = bool(extra.get("debug_raw") or _get_scoped_secret("GOOGLE_CHAT_DEBUG_RAW")) + self._http_events_url = self._str_setting(extra, "http_events_url", "GOOGLE_CHAT_HTTP_EVENTS_URL") + self._http_events_audience = self._str_setting( + extra, "http_events_audience", "GOOGLE_CHAT_HTTP_EVENTS_AUDIENCE", self._http_events_url ) - self._http_events_url = ( - extra.get("http_events_url") - or _get_scoped_secret("GOOGLE_CHAT_HTTP_EVENTS_URL", "") - or "" - ).strip() - self._http_events_audience = ( - extra.get("http_events_audience") - or _get_scoped_secret("GOOGLE_CHAT_HTTP_EVENTS_AUDIENCE", "") - or self._http_events_url - ).strip() - self._http_events_service_account_email = ( - extra.get("http_events_service_account_email") - or _get_scoped_secret("GOOGLE_CHAT_HTTP_EVENTS_SERVICE_ACCOUNT_EMAIL", "") - or "" - ).strip().lower() + self._http_events_service_account_email = self._str_setting( + extra, "http_events_service_account_email", "GOOGLE_CHAT_HTTP_EVENTS_SERVICE_ACCOUNT_EMAIL" + ).lower() + + @staticmethod + def _int_setting(extra: Dict[str, Any], key: str, env_name: str, default: int) -> int: + try: + return int(extra.get(key) or _get_scoped_secret(env_name, str(default))) + except (ValueError, TypeError): + return default + + @staticmethod + def _str_setting(extra: Dict[str, Any], key: str, env_name: str, fallback: str = "") -> str: + return (extra.get(key) or _get_scoped_secret(env_name, "") or fallback).strip() # ------------------------------------------------------------------ # Configuration loading and validation # ------------------------------------------------------------------ def _load_sa_credentials(self) -> Any: - """Load Service Account credentials from env or config.extra, - falling back to Application Default Credentials. - - Priority: - 1. Explicit ``extra['service_account_json']`` (path or inline JSON) - 2. ``GOOGLE_APPLICATION_CREDENTIALS`` env var (path) - 3. Application Default Credentials via ``google.auth.default()`` - — works on Cloud Run / GCE / GKE with a workload identity - attached, or locally via ``gcloud auth application-default - login``. Lets operators run the gateway in GCP without - managing SA key files. Pattern lifted from PR #14965. - """ - sa_path = ( - self.config.extra.get("service_account_json") - or _get_scoped_secret("GOOGLE_APPLICATION_CREDENTIALS") - ) - if sa_path: - # Inline JSON (rare, but supported). - if sa_path.lstrip().startswith("{"): - try: - info = json.loads(sa_path) - except json.JSONDecodeError as exc: - raise ValueError( - f"Inline SA JSON is not valid JSON: {exc}" - ) from exc - return service_account.Credentials.from_service_account_info( - info, scopes=_CHAT_SCOPES - ) - if not os.path.exists(sa_path): - raise FileNotFoundError( - "Service Account JSON file not found at configured path." - ) - # Validate file parses before handing to google-auth for nicer error. - try: - with open(sa_path, "r", encoding="utf-8") as fh: - info = json.load(fh) - except json.JSONDecodeError as exc: - raise ValueError( - f"Service Account JSON file is not valid JSON: {exc}" - ) from exc - return service_account.Credentials.from_service_account_info( - info, scopes=_CHAT_SCOPES - ) - - # No explicit SA configured — try ADC. This is the Cloud Run / GCE - # path; google-auth picks up the workload identity automatically. - if _adc_would_borrow_foreign_credentials(): - raise ValueError( - "Google Chat ADC skipped for this profile: service-account " - "credentials are set in the process environment but not in " - "this profile's secret scope. Set " - "GOOGLE_CHAT_SERVICE_ACCOUNT_JSON in this profile's .env." - ) + """Load SA credentials: ``extra['service_account_json']`` (path or inline + JSON) → ``GOOGLE_APPLICATION_CREDENTIALS`` → Application Default Credentials.""" + sa_path = self.config.extra.get("service_account_json") or _get_scoped_secret("GOOGLE_APPLICATION_CREDENTIALS") try: - import google.auth as google_auth - except ImportError: - google_auth = None # type: ignore[assignment] - if google_auth is None: - raise ValueError( - "No Service Account credentials configured. Set " - "GOOGLE_CHAT_SERVICE_ACCOUNT_JSON or GOOGLE_APPLICATION_CREDENTIALS, " - "or install google-auth to use Application Default Credentials." - ) - try: - credentials, _project = google_auth.default(scopes=_CHAT_SCOPES) - except Exception as exc: - raise ValueError( - "No Service Account credentials configured and Application " - "Default Credentials are unavailable. Set " - "GOOGLE_CHAT_SERVICE_ACCOUNT_JSON or run " - "``gcloud auth application-default login``. " - f"ADC error: {exc}" - ) from exc - logger.info( - "[GoogleChat] No SA JSON configured; using Application " - "Default Credentials" - ) + credentials = _load_sa_credentials_from(sa_path) + except _SACredentialError as err: + message = _SA_ERROR_MESSAGES[err.kind].format(exc=err.detail) + if err.kind == "not_found": + raise FileNotFoundError(message) + raise ValueError(message) from err.detail + if not sa_path: + logger.info("[GoogleChat] No SA JSON configured; using Application Default Credentials") return credentials def _validate_config(self) -> Tuple[str, Optional[str]]: - """Return (project_id, subscription_path) after validation. + """Return (project_id, subscription_path); the latter is None for HTTP inbound. - ``subscription_path`` is ``None`` for HTTP-inbound deployments. Raises - ValueError with a sanitized message on any config problem. + Raises ValueError with a sanitized message on any config problem. """ project_id = (self.config.extra.get("project_id") or "").strip() subscription = (self.config.extra.get("subscription_name") or "").strip() @@ -917,10 +525,7 @@ class GoogleChatAdapter(BasePlatformAdapter): if subscription: match = _SUBSCRIPTION_PATH_RE.match(subscription) if not match: - raise ValueError( - "GOOGLE_CHAT_SUBSCRIPTION_NAME must match " - "'projects//subscriptions/'." - ) + raise ValueError("GOOGLE_CHAT_SUBSCRIPTION_NAME must match 'projects//subscriptions/'.") subscription_project = match.group("project") if project_id and subscription_project != project_id: raise ValueError( @@ -928,14 +533,10 @@ class GoogleChatAdapter(BasePlatformAdapter): "project embedded in GOOGLE_CHAT_SUBSCRIPTION_NAME." ) return project_id or subscription_project, subscription - if http_events_url: return project_id, None - if not project_id: - raise ValueError( - "GOOGLE_CHAT_PROJECT_ID (or GOOGLE_CLOUD_PROJECT) is not set." - ) + raise ValueError("GOOGLE_CHAT_PROJECT_ID (or GOOGLE_CLOUD_PROJECT) is not set.") raise ValueError( "GOOGLE_CHAT_SUBSCRIPTION_NAME (or GOOGLE_CHAT_SUBSCRIPTION) is not set. " "Set GOOGLE_CHAT_HTTP_EVENTS_URL for HTTP callback mode." @@ -959,8 +560,7 @@ class GoogleChatAdapter(BasePlatformAdapter): """Schedule a coroutine on the adapter loop from a Pub/Sub callback thread.""" loop = self._loop if not self._loop_accepts_callbacks(loop): - # Loop already closed (shutdown race). Safe to drop; Pub/Sub will - # redeliver on next reconnect. + # Shutdown race: safe to drop, Pub/Sub redelivers on next reconnect. logger.warning("[GoogleChat] Loop not accepting callbacks; dropping event") return try: @@ -982,10 +582,8 @@ class GoogleChatAdapter(BasePlatformAdapter): # Bot identity resolution # ------------------------------------------------------------------ def _bot_id_cache_path(self) -> _Path: - """Location where the resolved bot user_id is cached across restarts.""" - # Resolve at call time (connect() runs inside the profile scope) so - # multiplexed profiles do not share one bot-identity cache file; the - # thread-count store above already resolves the same way. + """Cache location for the resolved bot user_id; resolved at call time so + multiplexed profiles (connect() runs in-scope) don't share one file.""" from hermes_constants import get_hermes_home as _get_hermes_home return _get_hermes_home() / "google_chat_bot_id.json" @@ -1004,30 +602,21 @@ class GoogleChatAdapter(BasePlatformAdapter): try: path = self._bot_id_cache_path() path.parent.mkdir(parents=True, exist_ok=True) - path.write_text( - json.dumps({"bot_user_id": bot_user_id}), - encoding="utf-8", - ) + path.write_text(json.dumps({"bot_user_id": bot_user_id}), encoding="utf-8") except OSError: logger.debug("[GoogleChat] Could not persist bot_user_id cache", exc_info=True) async def _resolve_bot_user_id(self) -> Optional[str]: - """Resolve ``users/{id}`` via Chat API members.list on a known space. + """Resolve ``users/{id}`` via members.list on the home channel, then bootstrap spaces. - Tries the home channel first, then any space from the allowlist. - If no space is known, returns None and self-filter falls back to - filtering ``sender.type == 'BOT'`` (which is still safe but less - precise — own messages and other bots look alike). + Returns None when no space is known; the self-filter then falls back to + ``sender.type == 'BOT'`` (safe but less precise). """ candidate_spaces: List[str] = [] if self.config.home_channel and self.config.home_channel.chat_id: candidate_spaces.append(self.config.home_channel.chat_id) - # Env-configured allowed spaces (comma-separated). Optional. - extra_spaces = self._bootstrap_spaces - if extra_spaces: - candidate_spaces.extend( - s.strip() for s in extra_spaces.split(",") if s.strip() - ) + if self._bootstrap_spaces: + candidate_spaces.extend(s.strip() for s in self._bootstrap_spaces.split(",") if s.strip()) for space in candidate_spaces: try: members = await asyncio.to_thread( @@ -1037,11 +626,7 @@ class GoogleChatAdapter(BasePlatformAdapter): .execute(http=self._new_authed_http()) ) except HttpError as exc: - logger.debug( - "[GoogleChat] members.list failed on %s: %s", - space, - _redact_sensitive(str(exc)), - ) + logger.debug("[GoogleChat] members.list failed on %s: %s", space, _redact_sensitive(str(exc))) continue for member in members.get("memberships", []): if member.get("member", {}).get("type") == "BOT": @@ -1055,12 +640,7 @@ class GoogleChatAdapter(BasePlatformAdapter): # ------------------------------------------------------------------ async def connect(self, *, is_reconnect: bool = False) -> bool: """Validate config, authenticate, start Pub/Sub pull, resolve bot id.""" - # First call into the heavy google-cloud stack — trigger the lazy - # import. ``_load_google_modules()`` is idempotent and rebinds the - # module globals (``pubsub_v1``, ``service_account``, ``HttpError``, - # …) used throughout this file. Anything that runs *before* this - # call would see the placeholders, so connect() is the natural - # gate. + # connect() is the gate: everything after sees the real google classes. if not _load_google_modules(): self._set_fatal_error( code="missing_deps", @@ -1083,15 +663,9 @@ class GoogleChatAdapter(BasePlatformAdapter): self._subscription_path = subscription_path self._credentials = credentials - # Build Chat REST client (sync; wrap calls in asyncio.to_thread). try: self._chat_api = await asyncio.to_thread( - lambda: build_service( - "chat", - "v1", - credentials=credentials, - cache_discovery=False, - ) + lambda: build_service("chat", "v1", credentials=credentials, cache_discovery=False) ) except Exception as exc: msg = _redact_sensitive(str(exc)) @@ -1099,13 +673,8 @@ class GoogleChatAdapter(BasePlatformAdapter): self._set_fatal_error(code="chat_api_init", message=msg, retryable=False) return False - # Attempt to load LEGACY single-user OAuth credentials at startup. - # In multi-user mode each user's token is loaded lazily by - # ``_load_per_user_chat_api`` on first send. The legacy slot is - # kept as a last-ditch fallback for pre-multi-user installs and - # for groups where the asker has no per-user token yet. Failure - # here is NON-fatal: text messaging continues to work; only - # attachments degrade to a setup-instructions text notice. + # Legacy single-user OAuth (per-user tokens load lazily on first send). + # Failure is NON-fatal: only attachments degrade to a text notice. try: from .oauth import ( load_user_credentials as _load_user_creds, @@ -1115,19 +684,11 @@ class GoogleChatAdapter(BasePlatformAdapter): user_creds = await asyncio.to_thread(_load_user_creds) if user_creds is not None: self._user_credentials = user_creds - self._user_chat_api = await asyncio.to_thread( - lambda: _build_user_chat(user_creds) - ) - logger.info( - "[GoogleChat] Legacy user OAuth loaded — fallback " - "attachment delivery enabled" - ) + self._user_chat_api = await asyncio.to_thread(lambda: _build_user_chat(user_creds)) + logger.info("[GoogleChat] Legacy user OAuth loaded — fallback attachment delivery enabled") authorized = await asyncio.to_thread(_list_emails) if authorized: - logger.info( - "[GoogleChat] %d per-user OAuth tokens on disk: %s", - len(authorized), ", ".join(authorized), - ) + logger.info("[GoogleChat] %d per-user OAuth tokens on disk: %s", len(authorized), ", ".join(authorized)) elif user_creds is None: logger.info( "[GoogleChat] No user OAuth tokens at setup — file " @@ -1137,48 +698,35 @@ class GoogleChatAdapter(BasePlatformAdapter): ) except Exception as exc: logger.warning( - "[GoogleChat] User OAuth load failed (attachments will " - "degrade to text-only fallback): %s", + "[GoogleChat] User OAuth load failed (attachments will degrade to text-only fallback): %s", _redact_sensitive(str(exc)), ) self._user_credentials = None self._user_chat_api = None - # Load the persistent thread-count store so the side-thread - # heuristic in _build_message_event survives gateway restarts. try: await asyncio.to_thread(self._thread_count_store.load) except Exception: - logger.warning( - "[GoogleChat] thread-count store load failed (treating " - "all threads as fresh)", exc_info=True, - ) + logger.warning("[GoogleChat] thread-count store load failed (treating all threads as fresh)", exc_info=True) if subscription_path is not None: # Sanity check: subscription exists / SA has access. + subscription_fatals = { + gax_exceptions.NotFound: dict( + code="subscription_not_found", message="Pub/Sub subscription not found at configured path" + ), + gax_exceptions.PermissionDenied: dict( + code="subscription_permission", + message="Service Account lacks roles/pubsub.subscriber on the subscription", + ), + } self._subscriber = pubsub_v1.SubscriberClient(credentials=credentials) try: await asyncio.to_thread( - lambda: self._subscriber.get_subscription( - request={"subscription": subscription_path} - ) - ) - except gax_exceptions.NotFound: - self._set_fatal_error( - code="subscription_not_found", - message="Pub/Sub subscription not found at configured path", - retryable=False, - ) - return False - except gax_exceptions.PermissionDenied: - self._set_fatal_error( - code="subscription_permission", - message=( - "Service Account lacks roles/pubsub.subscriber on the " - "subscription" - ), - retryable=False, + lambda: self._subscriber.get_subscription(request={"subscription": subscription_path}) ) + except (gax_exceptions.NotFound, gax_exceptions.PermissionDenied) as exc: + self._set_fatal_error(**subscription_fatals[type(exc)], retryable=False) return False except Exception as exc: msg = _redact_sensitive(str(exc)) @@ -1193,14 +741,9 @@ class GoogleChatAdapter(BasePlatformAdapter): if self._bot_user_id: self._save_cached_bot_id(self._bot_user_id) else: - logger.info( - "[GoogleChat] bot_user_id not yet resolved; " - "will resolve on first addedToSpace or member lookup" - ) + logger.info("[GoogleChat] bot_user_id not yet resolved; will resolve on first addedToSpace or member lookup") if subscription_path is not None: - # Start the supervisor task that runs the Pub/Sub pull with exponential - # backoff + jitter on transient errors, bails out after N retries. self._supervisor_task = asyncio.create_task(self._run_supervisor()) inbound = "pubsub" else: @@ -1227,22 +770,16 @@ class GoogleChatAdapter(BasePlatformAdapter): self._shutting_down = True if self._supervisor_task and not self._supervisor_task.done(): self._supervisor_task.cancel() - try: + with contextlib.suppress(asyncio.CancelledError, asyncio.TimeoutError): await asyncio.wait_for(self._supervisor_task, timeout=5.0) - except (asyncio.CancelledError, asyncio.TimeoutError): - pass if self._streaming_pull_future is not None: - try: + with contextlib.suppress(Exception): self._streaming_pull_future.cancel() await asyncio.to_thread(self._streaming_pull_future.result, 10.0) - except Exception: - pass self._streaming_pull_future = None if self._subscriber is not None: - try: + with contextlib.suppress(Exception): await asyncio.to_thread(self._subscriber.close) - except Exception: - pass self._subscriber = None self._mark_disconnected() logger.info("[GoogleChat] Disconnected") @@ -1251,18 +788,14 @@ class GoogleChatAdapter(BasePlatformAdapter): # Pub/Sub supervisor (reconnect loop) # ------------------------------------------------------------------ async def _run_supervisor(self) -> None: - """Run the streaming_pull with exponential backoff; fatal after 10 attempts. + """Run streaming_pull with exponential backoff + full jitter; fatal after N attempts. - ``subscribe()`` returns a concurrent.futures.Future that resolves when - the stream dies. We await ``future.result()`` in a worker thread and - react to exceptions. + ``subscribe()`` returns a Future that resolves when the stream dies; we + await ``future.result()`` in a worker thread and react to exceptions. """ attempt = 0 while not self._shutting_down: - flow = pubsub_v1.types.FlowControl( - max_messages=self._max_messages, - max_bytes=self._max_bytes, - ) + flow = pubsub_v1.types.FlowControl(max_messages=self._max_messages, max_bytes=self._max_bytes) try: future = self._subscriber.subscribe( self._subscription_path, @@ -1273,35 +806,27 @@ class GoogleChatAdapter(BasePlatformAdapter): if attempt > 0: logger.info("[GoogleChat] Pub/Sub stream reconnected after %d attempts", attempt) attempt = 0 - # Blocks until stream dies or cancel(). + # Blocks until stream dies or cancel(); normal completion = disconnect. await asyncio.to_thread(future.result) - # Normal completion = disconnect requested. if self._shutting_down: return except asyncio.CancelledError: return except gax_exceptions.Unauthenticated: self._set_fatal_error( - code="pubsub_auth", - message="Pub/Sub authentication failed (SA key invalid/revoked)", - retryable=False, + code="pubsub_auth", message="Pub/Sub authentication failed (SA key invalid/revoked)", retryable=False ) return except gax_exceptions.PermissionDenied: self._set_fatal_error( - code="pubsub_permission", - message="SA lacks pubsub.subscriber on the subscription", - retryable=False, + code="pubsub_permission", message="SA lacks pubsub.subscriber on the subscription", retryable=False ) return except Exception as exc: attempt += 1 msg = _redact_sensitive(str(exc)) logger.warning( - "[GoogleChat] Pub/Sub stream died (attempt %d/%d): %s", - attempt, - self._MAX_RECONNECT_ATTEMPTS, - msg, + "[GoogleChat] Pub/Sub stream died (attempt %d/%d): %s", attempt, self._MAX_RECONNECT_ATTEMPTS, msg ) if attempt >= self._MAX_RECONNECT_ATTEMPTS: self._set_fatal_error( @@ -1310,14 +835,9 @@ class GoogleChatAdapter(BasePlatformAdapter): retryable=False, ) return - delay = min( - self._RECONNECT_MAX_DELAY, - self._RECONNECT_BASE_DELAY * (2 ** (attempt - 1)), - ) - # Full jitter: pick uniformly in [0, delay]. - sleep_for = random.uniform(0, delay) + delay = min(self._RECONNECT_MAX_DELAY, self._RECONNECT_BASE_DELAY * (2 ** (attempt - 1))) try: - await asyncio.sleep(sleep_for) + await asyncio.sleep(random.uniform(0, delay)) except asyncio.CancelledError: return @@ -1328,38 +848,16 @@ class GoogleChatAdapter(BasePlatformAdapter): def _extract_message_payload( envelope: Dict[str, Any], ce_type: str = "" ) -> Optional[Tuple[Dict[str, Any], Dict[str, Any], str]]: - """Detect Pub/Sub envelope format and return ``(message, space, format_name)``. + """Detect the envelope format and return ``(message, space, format_name)``. - Three known formats are accepted. Returns ``None`` when the envelope - is unrecognized, is a non-MESSAGE event, or otherwise should be - silently dropped. - - Format 1 — Workspace Add-ons (canonical, ce-type-driven):: - - {"chat": {"messagePayload": {"message": {...}, "space": {...}}}} - - Format 2 — Native Chat API Pub/Sub (alternative configuration where - the Chat app publishes events directly without the Workspace - Add-ons wrapper):: - - {"type": "MESSAGE", "message": {...}, "space": {...}} - - Format 3 — Relay / flat (a custom Cloud Run relay that flattens the - Chat event into top-level fields):: - - {"event_type": "MESSAGE", "sender_email": "...", "text": "...", - "space_name": "spaces/X", "thread_name": "spaces/X/threads/Y", - "message_name": "spaces/X/messages/M.M"} - - For format 3 the helper synthesizes a Chat-API-shaped ``message`` - dict so downstream code (``_dispatch_message`` → - ``_build_message_event``) can consume it without branching. + Returns None for unrecognized envelopes or non-MESSAGE events. Formats: + 1. Workspace Add-ons: ``{"chat": {"messagePayload": {"message", "space"}}}`` + 2. Native Chat API Pub/Sub: ``{"type": "MESSAGE", "message", "space"}`` + 3. Relay / flat: ``{"event_type", "sender_email", "text", "space_name", + "thread_name", "message_name"}`` — a Chat-API-shaped ``message`` is + synthesized so downstream code needs no branching. """ - # Format 1: Workspace Add-ons. The chat block carries one of - # messagePayload / membershipPayload / cardClickedPayload depending - # on the ce-type. ``_on_pubsub_message`` handles the membership and - # card branches before reaching this helper, so here we only accept - # message payloads. + # Format 1: membership/card payloads are handled by _on_pubsub_message first. chat_block = envelope.get("chat") or {} msg_payload_wrapper = chat_block.get("messagePayload") if chat_block else None if msg_payload_wrapper: @@ -1367,9 +865,7 @@ class GoogleChatAdapter(BasePlatformAdapter): space = msg_payload_wrapper.get("space") or msg.get("space") or {} return msg, space, "workspace_addons" - # Format 2: Native Chat API Pub/Sub. Detected by a top-level - # ``message`` object plus a ``type`` field; only MESSAGE events - # flow through here. + # Format 2. if isinstance(envelope.get("message"), dict): if envelope.get("type", "") != "MESSAGE": return None @@ -1377,41 +873,20 @@ class GoogleChatAdapter(BasePlatformAdapter): space = envelope.get("space") or msg.get("space") or {} return msg, space, "native_chat_api" - # Format 3: Relay / flat. A custom Cloud Run relay typically - # forwards Chat events with this shape so the bot can run without - # direct GCP credentials. + # Format 3. if "event_type" in envelope or "sender_email" in envelope: if envelope.get("event_type", "MESSAGE") != "MESSAGE": return None sender_email = (envelope.get("sender_email") or "").strip() - sender_display = ( - envelope.get("sender_display_name") - or sender_email - or "Unknown" - ) - # The Chat resource name is unknown for relay events; synthesize - # a stable surrogate from the sender email so dedup keys and - # session IDs stay deterministic across redelivery. - sender_name_surrogate = ( - "users/relay-" - + (sender_email or "unknown").replace("@", "_at_").replace(".", "_") - ) + sender_display = envelope.get("sender_display_name") or sender_email or "Unknown" + # No Chat resource name for relay events: synthesize a stable surrogate + # from the email so dedup keys / session IDs are deterministic. + sender_name_surrogate = "users/relay-" + (sender_email or "unknown").replace("@", "_at_").replace(".", "_") text = envelope.get("text", "") or "" - # Honor the relay's declared sender_type when present so the - # downstream BOT self-filter (sender_type == "BOT") fires for - # bot-originated messages forwarded by the relay. Hardcoding - # "HUMAN" here meant the bot would re-process its own replies - # if the relay forwarded them, and allowed a relay envelope to - # impersonate any allowlisted user without ever being marked - # as a bot. Default to "HUMAN" for backward compatibility when - # the relay does not provide the field. - # - # Operator contract: the relay MUST forward sender.type from - # the upstream Chat event as ``sender_type``. Relays that - # forward bot replies as HUMAN (or omit the field) cannot be - # distinguished from genuine humans here. - sender_type_raw = (envelope.get("sender_type") or "HUMAN") - sender_type = str(sender_type_raw).strip().upper() or "HUMAN" + # Honor the relay's ``sender_type`` so the BOT self-filter fires for + # forwarded bot replies (relays MUST forward sender.type); default to + # HUMAN for backward compatibility when absent. + sender_type = str(envelope.get("sender_type") or "HUMAN").strip().upper() or "HUMAN" if sender_type not in {"HUMAN", "BOT"}: sender_type = "HUMAN" msg: Dict[str, Any] = { @@ -1436,24 +911,49 @@ class GoogleChatAdapter(BasePlatformAdapter): return None + def _prepare_inbound( + self, envelope: Dict[str, Any], ce_type: Optional[str] = None + ) -> Optional[Tuple[Dict[str, Any], Dict[str, Any]]]: + """Extract + self-filter + dedup an inbound envelope. + + Returns ``(msg_with_space, enriched_envelope)`` ready for + ``_dispatch_message``, or None when the event must be dropped. Debug + logs only on the Pub/Sub path (``ce_type`` given). + """ + extracted = self._extract_message_payload(envelope, ce_type or "") + if extracted is None: + if ce_type is not None: + logger.debug( + "[GoogleChat] Envelope did not match a known message format; " + "ce-type=%s, keys=%s", ce_type, list(envelope.keys()) + ) + return None + msg, space, _fmt = extracted + # Self-filter: drop bot-sourced messages (own replies and other bots). + if (msg.get("sender") or {}).get("type") == "BOT": + return None + # Dedup guard — Pub/Sub is at-least-once. + msg_name = msg.get("name") or "" + if msg_name and self._dedup.is_duplicate(msg_name): + if ce_type is not None: + logger.debug("[GoogleChat] Dedup drop for %s", msg_name) + return None + # Give both dicts a top-level "space" so the dispatch side has one shape. + msg_with_space = dict(msg) + if "space" not in msg_with_space and space: + msg_with_space["space"] = space + enriched_env = dict(envelope) + if "space" not in enriched_env and space: + enriched_env["space"] = space + return msg_with_space, enriched_env + def _on_pubsub_message(self, message: Any) -> None: - """Pub/Sub callback — parse envelope and dispatch to asyncio loop. + """Pub/Sub callback — parse envelope and dispatch to the asyncio loop. - Runs in a Pub/Sub SubscriberClient worker thread, NOT the event loop. - Never block this function; never raise out of it (that triggers - Pub/Sub nack + infinite redelivery). - - Google Chat Events API uses CloudEvents-style Pub/Sub messages. The - event type is carried in Pub/Sub message attributes (``ce-type``), - not in the JSON body. The body is wrapped in a ``chat`` object whose - keys depend on the event type: - - - google.workspace.chat.message.v1.created - -> envelope["chat"]["messagePayload"] = {space, message} - - google.workspace.chat.membership.v1.created - -> envelope["chat"]["membershipPayload"] = {space, membership} - - google.workspace.chat.membership.v1.deleted - -> envelope["chat"]["membershipPayload"] = {space, membership} + Runs in a SubscriberClient worker thread: never block, never raise (that + triggers nack + infinite redelivery). Event type comes from the ``ce-type`` + attribute; the body's ``chat`` object carries ``messagePayload`` or + ``membershipPayload`` accordingly. """ if self._shutting_down: message.nack() @@ -1467,16 +967,10 @@ class GoogleChatAdapter(BasePlatformAdapter): attrs = dict(getattr(message, "attributes", {}) or {}) ce_type = attrs.get("ce-type") or "" - logger.debug( - "[GoogleChat] Envelope keys=%s, ce-type=%s", - list(envelope.keys()), - ce_type, - ) + logger.debug("[GoogleChat] Envelope keys=%s, ce-type=%s", list(envelope.keys()), ce_type) if self._debug_raw: - # Dangerous flag: contains message text and sender email. Route - # through the global redaction filter and gate at DEBUG level so - # default log configurations never surface it. Operators must - # enable DEBUG logging AND set this env var to see the dump. + # Contains message text + sender email: redact and gate at DEBUG so + # default log configs never surface it. try: from agent.redact import redact_sensitive_text @@ -1487,8 +981,6 @@ class GoogleChatAdapter(BasePlatformAdapter): try: chat_block = envelope.get("chat") or {} - - # --- Membership events --- if "membership" in ce_type or "MEMBERSHIP" in ce_type: mpl = chat_block.get("membershipPayload") or {} space = mpl.get("space") or {} @@ -1501,151 +993,62 @@ class GoogleChatAdapter(BasePlatformAdapter): if name: self._bot_user_id = name self._save_cached_bot_id(name) - logger.info( - "[GoogleChat] ADDED_TO_SPACE %s", space.get("name", "?") - ) + logger.info("[GoogleChat] ADDED_TO_SPACE %s", space.get("name", "?")) else: - logger.info( - "[GoogleChat] REMOVED_FROM_SPACE %s", space.get("name", "?") - ) + logger.info("[GoogleChat] REMOVED_FROM_SPACE %s", space.get("name", "?")) message.ack() return - - # --- Card-click events (v2 follow-up) --- if "widget" in ce_type or "card" in ce_type.lower(): - logger.info( - "[GoogleChat] Card/widget event ack'd (v2 feature, deferred)" - ) + logger.info("[GoogleChat] Card/widget event ack'd (v2 feature, deferred)") message.ack() return - - # --- Message events --- - extracted = self._extract_message_payload(envelope, ce_type) - if extracted is None: - logger.debug( - "[GoogleChat] Envelope did not match a known message format; " - "ce-type=%s, keys=%s", ce_type, list(envelope.keys()) - ) - message.ack() - return - - msg, space, _fmt = extracted - sender = msg.get("sender") or {} - sender_type = sender.get("type") or "" - - # Self-filter: drop bot-sourced messages (own replies and other bots). - if sender_type == "BOT": - message.ack() - return - - # Dedup guard — Pub/Sub is at-least-once. - msg_name = msg.get("name") or "" - if msg_name and self._dedup.is_duplicate(msg_name): - logger.debug("[GoogleChat] Dedup drop for %s", msg_name) - message.ack() - return - - # Wrap msg with parent-level space so _build_message_event can find it. - msg_with_space = dict(msg) - if "space" not in msg_with_space and space: - msg_with_space["space"] = space - - # Enrich envelope with a synthetic top-level "space" field so the - # dispatch side has a consistent shape regardless of format. - enriched_env = dict(envelope) - if "space" not in enriched_env and space: - enriched_env["space"] = space - - self._submit_on_loop(self._dispatch_message(msg_with_space, enriched_env)) + prepared = self._prepare_inbound(envelope, ce_type) + if prepared is not None: + self._submit_on_loop(self._dispatch_message(*prepared)) message.ack() except Exception: logger.exception("[GoogleChat] Error in _on_pubsub_message") - try: + with contextlib.suppress(Exception): message.ack() - except Exception: - pass async def dispatch_http_event(self, envelope: Dict[str, Any]) -> Dict[str, Any]: - extracted = self._extract_message_payload(envelope) - if extracted is None: - return {} - - msg, space, _fmt = extracted - sender = msg.get("sender") or {} - if sender.get("type") == "BOT": - return {} - - msg_name = msg.get("name") or "" - if msg_name and self._dedup.is_duplicate(msg_name): - return {} - - msg_with_space = dict(msg) - if "space" not in msg_with_space and space: - msg_with_space["space"] = space - - enriched_env = dict(envelope) - if "space" not in enriched_env and space: - enriched_env["space"] = space - - await self._dispatch_message(msg_with_space, enriched_env) + prepared = self._prepare_inbound(envelope) + if prepared is not None: + await self._dispatch_message(*prepared) return {} def verify_http_event_request(self, auth_header: str) -> Tuple[bool, str]: if not self._http_events_audience or not self._http_events_service_account_email: return False, "google_chat_http_events_not_configured" - if not auth_header.startswith("Bearer "): return False, "missing_google_bearer" - token = auth_header[7:].strip() if not token: return False, "missing_google_bearer" - try: claims = _verify_google_id_token(token, self._http_events_audience) except Exception as exc: - logger.warning( - "[GoogleChat] HTTP event bearer verification failed: %s", - _redact_sensitive(str(exc)), - ) + logger.warning("[GoogleChat] HTTP event bearer verification failed: %s", _redact_sensitive(str(exc))) return False, "invalid_google_bearer" - - expected = { - item.strip().lower() - for item in self._http_events_service_account_email.split(",") - if item.strip() - } + expected = {item.strip().lower() for item in self._http_events_service_account_email.split(",") if item.strip()} claim_email = str(claims.get("email") or "").strip().lower() if not claim_email or claim_email not in expected: return False, "unexpected_google_bearer_identity" - return True, "" async def _dispatch_message(self, msg: Dict[str, Any], envelope: Dict[str, Any]) -> None: - """Translate a Chat message payload to a MessageEvent and hand off. + """Translate a Chat message to a MessageEvent and hand off. - Intercepts the ``/setup-files`` admin command BEFORE the agent - sees it — that's a bot-local OAuth setup flow, not a prompt. - Everything else flows to ``handle_message`` as normal. + ``/setup-files`` is intercepted BEFORE the agent sees it (bot-local OAuth flow). """ try: event = await self._build_message_event(msg, envelope) if event is None: return - - # Short-circuit /setup-files before the agent dispatch. text = (event.text or "").strip() if text.startswith("/setup-files") and event.source is not None: - # The sender's email (user_id_alt) is the per-user OAuth - # key — the bot stores this user's token at - # ${HERMES_HOME}/google_chat_user_tokens/.json - # so when User B asks for a file later in B's DM, B's - # token gets used (not the first person who set up files). - sender_email = ( - event.source.user_id_alt - if event.source and event.source.user_id_alt - else None - ) + # The sender email (user_id_alt) is the per-user OAuth token key. + sender_email = event.source.user_id_alt if event.source and event.source.user_id_alt else None handled = await self._handle_setup_files_command( chat_id=event.source.chat_id, thread_id=event.source.thread_id, @@ -1654,238 +1057,19 @@ class GoogleChatAdapter(BasePlatformAdapter): ) if handled: return - await self.handle_message(event) except Exception: logger.exception("[GoogleChat] _dispatch_message failed") async def _handle_setup_files_command( - self, - chat_id: str, - thread_id: Optional[str], - raw_text: str, - sender_email: Optional[str] = None, + self, chat_id: str, thread_id: Optional[str], raw_text: str, sender_email: Optional[str] = None ) -> bool: - """Run the in-chat OAuth setup flow for native attachment delivery. + """In-chat OAuth setup flow; see ``setup_files.handle_setup_files_command``.""" + from .setup_files import handle_setup_files_command - Returns ``True`` if the message was consumed (no agent dispatch), - ``False`` if it should fall through. + return await handle_setup_files_command(self, chat_id, thread_id, raw_text, sender_email) - Multi-user mode: ``sender_email`` is the asker's identity, which - is also the per-user OAuth key. ``status`` / ``start`` / ``revoke`` - / code-exchange all operate on THIS user's token slot. When - ``sender_email`` is ``None`` (e.g. tests, or older inbound events - without a populated email field) the handler falls back to the - legacy single-user path so pre-multi-user installs keep working. - - Subcommands: - /setup-files → show status + next step - /setup-files start → print OAuth URL - /setup-files revoke → revoke and delete stored token - /setup-files → exchange auth code for token - - Pre-requisite: client_secret.json must already be on the host - (one-time terminal step). The status reply tells the user how to - do that if it's missing. - """ - from . import oauth as oauth_helper - - # Normalize the email: lowercase + strip. The on-disk token path - # is sanitized further inside the helper, but having the same - # normalization at both ends keeps cache lookups consistent. - sender_key = sender_email.strip().lower() if sender_email else None - - parts = raw_text.split(maxsplit=1) - # parts[0] is "/setup-files"; parts[1..] is the optional argument - arg = parts[1].strip() if len(parts) > 1 else "" - - async def _reply(text: str) -> None: - body: Dict[str, Any] = {"text": text} - if thread_id: - body["thread"] = {"name": thread_id} - try: - await self._create_message(chat_id, body) - except Exception: - logger.debug( - "[GoogleChat] /setup-files reply send failed", - exc_info=True, - ) - - # Status / no-arg: show what's set up and what to do next. - if not arg: - client_secret_present = ( - oauth_helper._client_secret_path().exists() - ) - token_path = oauth_helper._token_path(sender_key) - token_present = token_path.exists() - creds = ( - oauth_helper.load_user_credentials(sender_key) - if token_present else None - ) - if creds is not None: - who = sender_key or "shared (legacy)" - await _reply( - "✅ Native attachment delivery is **active** for " - f"`{who}`.\n" - f"Token: `{token_path}`\n" - "Send `/setup-files revoke` to disable." - ) - return True - if not client_secret_present: - await _reply( - "🔧 Native attachment delivery is **not configured**.\n" - "**Step 1 (one-time, on the host):** create OAuth client " - "credentials at " - "https://console.cloud.google.com/apis/credentials → " - "*Create credentials* → *OAuth client ID* → *Desktop app*. " - "Download the JSON. Then on the host run:\n" - "```\n" - "python -m plugins.platforms.google_chat.oauth " - "--client-secret /path/to/client_secret.json\n" - "```\n" - "**Step 2:** come back here and send `/setup-files start`." - ) - return True - await _reply( - "🔧 Client credentials are stored but you haven't " - "authorized yet. Send `/setup-files start` to begin." - ) - return True - - if arg == "start": - if not oauth_helper._client_secret_path().exists(): - await _reply( - "⚠️ No client credentials stored for this profile. Send " - "`/setup-files` (no args) for setup instructions." - ) - return True - try: - # Reuse the helper logic but capture stdout via a sync - # thread so we don't print to the gateway terminal. - import io - import contextlib - buf = io.StringIO() - with contextlib.redirect_stdout(buf): - await asyncio.to_thread( - oauth_helper.get_auth_url, sender_key, - ) - auth_url = buf.getvalue().strip().splitlines()[-1] - except SystemExit: - await _reply( - "❌ Couldn't generate the OAuth URL. Check the gateway " - "logs and verify the client_secret.json is valid." - ) - return True - except Exception as exc: - logger.warning( - "[GoogleChat] /setup-files start failed: %s", exc, - ) - await _reply(f"❌ Error: {exc}") - return True - await _reply( - "1. Open this URL in your browser and authorize:\n" - f"{auth_url}\n\n" - "2. After clicking *Allow*, your browser will fail to load " - "`http://localhost:1/?...&code=...`. That's expected.\n\n" - "3. Copy the entire failed URL from the browser's URL bar " - "and paste it back here as: `/setup-files ` " - "(or just the `code=...` value).\n\n" - "Tip: the URL contains your access grant — keep it private." - ) - return True - - if arg == "revoke": - try: - import io - import contextlib - buf = io.StringIO() - with contextlib.redirect_stdout(buf): - await asyncio.to_thread(oauth_helper.revoke, sender_key) - output = buf.getvalue().strip() or "Revoked." - except SystemExit: - output = "Revoke completed (some steps may have been skipped)." - except Exception as exc: - logger.warning( - "[GoogleChat] /setup-files revoke failed: %s", exc, - ) - await _reply(f"❌ Error revoking: {exc}") - return True - # Wipe in-memory creds so subsequent uploads fall through to - # the setup-instructions text notice immediately. Scope the - # eviction to the sender's slot — Bob revoking shouldn't - # break Alice's per-user token nor wipe the shared legacy - # fallback that other users may still depend on. - if sender_key: - self._user_creds_by_email.pop(sender_key, None) - self._user_chat_api_by_email.pop(sender_key, None) - else: - self._user_credentials = None - self._user_chat_api = None - await _reply(f"✅ Done.\n```\n{output}\n```") - return True - - # Anything else is treated as the auth code or the failed-redirect - # URL the user pasted. - try: - import io - import contextlib - buf = io.StringIO() - with contextlib.redirect_stdout(buf): - await asyncio.to_thread( - oauth_helper.exchange_auth_code, arg, sender_key, - ) - output = buf.getvalue().strip() - except SystemExit: - await _reply( - "❌ Token exchange failed. The code may have expired or " - "the URL is malformed. Send `/setup-files start` to get " - "a fresh OAuth URL." - ) - return True - except Exception as exc: - logger.warning( - "[GoogleChat] /setup-files exchange failed: %s", exc, - ) - await _reply(f"❌ Error: {exc}") - return True - - # Re-load credentials into the adapter so the next file send uses - # them WITHOUT a gateway restart. - try: - new_creds = await asyncio.to_thread( - oauth_helper.load_user_credentials, sender_key, - ) - if new_creds is not None: - new_api = await asyncio.to_thread( - lambda: oauth_helper.build_user_chat_service(new_creds) - ) - if sender_key: - self._user_creds_by_email[sender_key] = new_creds - self._user_chat_api_by_email[sender_key] = new_api - else: - self._user_credentials = new_creds - self._user_chat_api = new_api - await _reply( - "✅ Authorized! Native attachment delivery is now " - "active. Try asking me to send you a PDF." - ) - return True - except Exception as exc: - logger.warning( - "[GoogleChat] post-exchange creds load failed: %s", exc, - ) - - await _reply( - "⚠️ Token exchanged but the gateway couldn't load the new " - "credentials in-memory. Restart the gateway and the token " - f"at `{oauth_helper._token_path(sender_key)}` will be picked " - f"up.\nHelper output:\n```\n{output}\n```" - ) - return True - - async def _build_message_event( - self, msg: Dict[str, Any], envelope: Dict[str, Any] - ) -> Optional[MessageEvent]: + async def _build_message_event(self, msg: Dict[str, Any], envelope: Dict[str, Any]) -> Optional[MessageEvent]: """Parse a Chat API message into a hermes MessageEvent.""" space = envelope.get("space") or msg.get("space") or {} space_name = space.get("name") or "" # "spaces/XXX" @@ -1897,16 +1081,13 @@ class GoogleChatAdapter(BasePlatformAdapter): sender_display = sender.get("displayName") or sender.get("email") or sender_name sender_email = sender.get("email") or "" - # Cache the asker's email per chat_id so _send_file can pick the - # right per-user OAuth token when the agent later wants to send - # an attachment in this conversation. Lower-cased so cache hits - # match the sanitized token-file lookup. + # Cache the asker's email per space so _send_file picks the right per-user + # OAuth token (lower-cased to match the sanitized token-file lookup). if sender_email and space_name: self._last_sender_by_chat[space_name] = sender_email.strip().lower() chat_type = "dm" if space_type in {"DIRECT_MESSAGE", "DM"} else "group" - text = msg.get("argumentText") or msg.get("text") or "" - text = text.strip() + text = (msg.get("argumentText") or msg.get("text") or "").strip() # Slash command: emit MessageType.COMMAND with normalized text. slash = msg.get("slashCommand") or {} @@ -1916,12 +1097,10 @@ class GoogleChatAdapter(BasePlatformAdapter): if command_id and not text.startswith("/"): text = f"/cmd_{command_id} {text}".strip() - # Attachments: download and cache. media_urls: List[str] = [] media_types: List[str] = [] message_type = MessageType.TEXT - attachments = msg.get("attachment") or [] - for att in attachments: + for att in msg.get("attachment") or []: try: local_path, mime = await self._download_attachment(att) except Exception: @@ -1934,50 +1113,29 @@ class GoogleChatAdapter(BasePlatformAdapter): # Prefer the first-seen type for MessageType if no text present. if message_type == MessageType.TEXT and not text: message_type = _mime_for_message_type(mime or "") - if is_slash: message_type = MessageType.COMMAND - # Increment the persistent inbound count for this thread. - # The PRE-increment value (==0 for the very first time we see - # this thread, persisted across gateway restarts) drives the - # main-flow-vs-side-thread heuristic below. + # PRE-increment count (persisted) drives the main-flow-vs-side-thread heuristic. prev_thread_count = 0 if thread_name and space_name: - prev_thread_count = self._thread_count_store.incr( - space_name, thread_name - ) + prev_thread_count = self._thread_count_store.incr(space_name, thread_name) - # Session-thread + outbound-thread routing for DMs: - # - prev_count == 0 → first message in this thread. Google Chat - # creates a fresh thread per top-level message in the DM input - # box; treat as "main flow" so all top-level messages share - # one DM session and the user keeps continuity. The bot's - # reply ALSO must NOT thread with the user message — if we - # pass thread.name on outbound, Chat displays the pair as an - # expandable thread under the user's message instead of two - # adjacent top-level cards. - # - prev_count >= 1 → user explicitly engaged a thread that - # already had messages (clicked "Reply in thread" on a prior - # message). Isolate session by chat_id+thread_id, AND keep - # the bot's reply inside that thread. - # - # For groups, threads ARE meaningful conversational containers - # (Telegram forum / Discord thread parity); always isolate AND - # always reply in-thread. + # DMs: prev_count == 0 → Chat auto-created this thread for a top-level + # message; share one DM session and reply top-level (passing thread.name + # would render the pair as an expandable thread). prev_count >= 1 → user + # engaged an existing thread; isolate the session and reply in-thread. + # Groups: threads are real containers; always isolate AND reply in-thread. if chat_type == "dm": is_side_thread = prev_thread_count > 0 session_thread_id = thread_name if is_side_thread else None - # Outbound thread cache: populated only when side-thread, so - # _resolve_thread_id falls through to "no thread" on main - # flow and the bot reply lands as a top-level sibling. + # Outbound cache only for side-threads so main-flow replies land top-level. if thread_name and space_name and is_side_thread: self._last_inbound_thread[space_name] = thread_name elif space_name: self._last_inbound_thread.pop(space_name, None) else: session_thread_id = thread_name - # Groups always reply in-thread. if thread_name and space_name: self._last_inbound_thread[space_name] = thread_name @@ -1985,15 +1143,9 @@ class GoogleChatAdapter(BasePlatformAdapter): chat_id=space_name, chat_name=space.get("displayName") or space.get("name") or "", chat_type=chat_type, - # ``user_id`` is the canonical identity used by allowlists, - # session keys, and audit. Operators configure - # ``GOOGLE_CHAT_ALLOWED_USERS`` with email addresses (the - # value Google Chat surfaces in its UI), so the email is - # the natural canonical id. The Chat resource name - # ``users/{id}`` moves to ``user_id_alt`` for traceability - # and Chat-API operations that need it. Falls back to the - # resource name when sender has no email (rare — bot-to-bot - # or system events). Pattern lifted from PR #14965. + # Email is the canonical id (allowlists are configured with emails); + # the ``users/{id}`` resource name moves to user_id_alt. Falls back to + # the resource name when the sender has no email. user_id=(sender_email or sender_name), user_name=sender_display, thread_id=session_thread_id, @@ -2009,55 +1161,30 @@ class GoogleChatAdapter(BasePlatformAdapter): media_types=media_types, ) - async def _download_attachment( - self, attachment: Dict[str, Any] - ) -> Tuple[Optional[str], Optional[str]]: + async def _download_attachment(self, attachment: Dict[str, Any]) -> Tuple[Optional[str], Optional[str]]: """Download an inbound attachment to the local cache; return (path, mime). - Priority for bot Service Accounts: - - 1. ``attachmentDataRef.resourceName`` via ``chat.media.download`` — - the supported bot path. The Service Account bearer token has - ``chat.bot`` scope which the Chat API authorises against the - space membership. - 2. Drive-hosted files (``source == 'DRIVE_FILE'``) require user - OAuth and Drive scope; skip with a log. - 3. Direct HTTP fetch of ``downloadUri`` only as a last resort — - that URL is meant for user OAuth tokens (chat.google.com - returns 401 for SA bearer tokens) and is unlikely to work, - but we keep the path for forward-compat with Google changes. + Bot SA path is ``media.download`` via ``attachmentDataRef.resourceName``. + Drive-picker shares without a resourceName need user OAuth + Drive scope + (skipped). ``downloadUri`` is a last resort — it is meant for user OAuth + tokens and usually 401s for SA tokens, kept for forward-compat. """ mime = attachment.get("contentType") or "" source = attachment.get("source") or "" name = attachment.get("name") or "" - attachment_data_ref = attachment.get("attachmentDataRef") or {} - resource_name = attachment_data_ref.get("resourceName") or "" + resource_name = (attachment.get("attachmentDataRef") or {}).get("resourceName") or "" download_uri = attachment.get("downloadUri") or "" - # NOTE on ``source == "DRIVE_FILE"``: Google Chat tags BOTH - # drag-and-drop chat uploads AND Drive-picker shares with this - # source string, but the two have different access models. - # Drag-and-drop uploads come with an ``attachmentDataRef.resourceName`` - # that bot SA tokens CAN download via ``media.download_media``. - # Pure Drive-picker shares often lack that field and require - # user OAuth + Drive scope (which we deliberately don't request). - # So we only short-circuit when there's nothing the bot path - # can use — otherwise try the bot path first. + # Chat tags BOTH drag-and-drop uploads AND Drive-picker shares as DRIVE_FILE; + # only the former carry a resourceName the bot path can use. if source == "DRIVE_FILE" and not resource_name: - logger.info( - "[GoogleChat] Skipping Drive-picker attachment (no " - "resourceName, would need user-OAuth Drive scope)" - ) + logger.info("[GoogleChat] Skipping Drive-picker attachment (no resourceName, would need user-OAuth Drive scope)") return None, mime data: Optional[bytes] = None - - # Path 1: media.download with attachmentDataRef.resourceName (bot-path). if resource_name: def _fetch_media() -> bytes: - req = self._chat_api.media().download_media( - resourceName=resource_name, - ) + req = self._chat_api.media().download_media(resourceName=resource_name) from googleapiclient.http import MediaIoBaseDownload import io @@ -2071,18 +1198,12 @@ class GoogleChatAdapter(BasePlatformAdapter): try: data = await asyncio.to_thread(_fetch_media) except HttpError as exc: - logger.warning( - "[GoogleChat] media.download_media failed: %s", - _redact_sensitive(str(exc)), - ) + logger.warning("[GoogleChat] media.download_media failed: %s", _redact_sensitive(str(exc))) data = None - # Path 2: downloadUri fallback (rarely works with SA tokens, but try). if data is None and download_uri: if not _is_google_owned_host(download_uri): - logger.warning( - "[GoogleChat] Rejecting attachment fetch: non-Google host" - ) + logger.warning("[GoogleChat] Rejecting attachment fetch: non-Google host") return None, mime def _fetch_uri() -> bytes: @@ -2107,13 +1228,9 @@ class GoogleChatAdapter(BasePlatformAdapter): if data is None: return None, mime - # Cache based on MIME. Upstream's cache_* helpers expect `ext` for - # media (image/audio/video) and a positional `filename` for docs. + # cache_* helpers take ``ext`` for media and a positional filename for docs. filename = name.split("/")[-1] if name else "attachment" - if "." in filename: - ext = "." + filename.rsplit(".", 1)[-1].lower() - else: - ext = "" + ext = "." + filename.rsplit(".", 1)[-1].lower() if "." in filename else "" if mime.startswith("image/"): local = cache_image_from_bytes(data, ext=ext or ".jpg") elif mime.startswith("audio/"): @@ -2127,52 +1244,37 @@ class GoogleChatAdapter(BasePlatformAdapter): # ------------------------------------------------------------------ # Outbound send paths # ------------------------------------------------------------------ + def _note_rate_limit(self, chat_id: str) -> int: + self._rate_limit_hits[chat_id] = self._rate_limit_hits.get(chat_id, 0) + 1 + return self._rate_limit_hits[chat_id] + async def send( - self, - chat_id: str, - content: str, - reply_to: Optional[str] = None, - metadata: Optional[Dict[str, Any]] = None, + self, chat_id: str, content: str, reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None ) -> SendResult: - """Send a text message. + """Send a text message; ``metadata`` may carry ``thread_id``. - Signature matches ``BasePlatformAdapter.send``: ``content`` is the - message body, ``reply_to`` is an optional message_id (the inbound - message to thread under), and ``metadata`` may carry ``thread_id`` - (the resolved Google Chat ``spaces/X/threads/Y`` resource name). - - If a typing card is tracked for this chat, transform it in-place via - ``messages.patch`` — NO delete+create. Google Chat shows a tombstone - ("Message deleted by its author") on delete, which is visual noise. - Patch rewrites the text of the existing message seamlessly. - - Also pauses the base class's ``_keep_typing`` loop for this chat so - it can't post a racing typing card between the patch and the reply. - - If ``content`` exceeds MAX_MESSAGE_LENGTH, the first chunk patches - the typing card (if any), subsequent chunks are new messages. + A tracked typing card is transformed in-place via ``messages.patch`` (delete + would leave a "Message deleted" tombstone). The base class's ``_keep_typing`` + is paused for this chat meanwhile. Over-long content: the first chunk patches + the typing card, subsequent chunks are new messages. """ thread_id = self._resolve_thread_id(reply_to, metadata, chat_id=chat_id) self.pause_typing_for_chat(chat_id) try: - # Convert standard Markdown emitted by the LLM to Chat's dialect - # and strip invisible Unicode that renders as tofu (□). Runs - # BEFORE chunking so the size limit applies to the rendered - # form, not the source markdown. + # Format BEFORE chunking so the size limit applies to the rendered form. chunks = self._chunk_text(self.format_message(content)) if not chunks: return SendResult(success=False, error="empty message") last_result: Optional[SendResult] = None typing_msg_name = self._typing_messages.pop(chat_id, None) - # Treat any earlier sentinel as "no real card to patch" — defensive. if typing_msg_name == _TYPING_CONSUMED_SENTINEL: typing_msg_name = None patched_typing = False for idx, chunk in enumerate(chunks): body: Dict[str, Any] = {"text": chunk} - # Only set thread on new-message create path. Patch inherits. + # Only set thread on the create path; patch inherits. if thread_id and (idx > 0 or not typing_msg_name): body["thread"] = {"name": thread_id} try: @@ -2183,7 +1285,7 @@ class GoogleChatAdapter(BasePlatformAdapter): result = await self._create_message(chat_id, body) last_result = result except HttpError as exc: - status = getattr(getattr(exc, "resp", None), "status", None) + status = _http_status(exc) if status == 403: self._set_fatal_error( code="chat_forbidden", @@ -2192,13 +1294,9 @@ class GoogleChatAdapter(BasePlatformAdapter): ) return SendResult(success=False, error=str(exc)) if status == 404: - # Typing card was deleted out from under us, or space - # is gone. Fall through to creating a new message on - # the first-chunk patch failure. + # Typing card deleted under us: fall back to a fresh message. if idx == 0 and typing_msg_name: - logger.info( - "[GoogleChat] Typing card disappeared; creating new message" - ) + logger.info("[GoogleChat] Typing card disappeared; creating new message") typing_msg_name = None result = await self._create_message(chat_id, body) last_result = result @@ -2206,35 +1304,23 @@ class GoogleChatAdapter(BasePlatformAdapter): logger.info("[GoogleChat] send target 404; skipping") return SendResult(success=False, error="target not found") if status == 429: - self._rate_limit_hits[chat_id] = ( - self._rate_limit_hits.get(chat_id, 0) + 1 - ) - if self._rate_limit_hits[chat_id] >= _RATE_LIMIT_WARN_THRESHOLD: - logger.warning( - "[GoogleChat] Rate limit hit %d times on chat; throttling", - self._rate_limit_hits[chat_id], - ) + hits = self._note_rate_limit(chat_id) + if hits >= _RATE_LIMIT_WARN_THRESHOLD: + logger.warning("[GoogleChat] Rate limit hit %d times on chat; throttling", hits) raise raise if last_result is None: return SendResult(success=False, error="empty message") - # Mark the chat's typing slot as "consumed" so the base class's - # _keep_typing loop (which may iterate one more time before - # typing_task.cancel() lands) does not post a fresh marker that - # the safety-net stop_typing would then delete and tombstone. - # Cleared in on_processing_complete. + # Sentinel keeps a trailing _keep_typing tick from posting a fresh marker + # that stop_typing would then delete and tombstone. Cleared in + # on_processing_complete. if patched_typing: self._typing_messages[chat_id] = _TYPING_CONSUMED_SENTINEL return last_result finally: self.resume_typing_for_chat(chat_id) - async def send_card( - self, - chat_id: str, - card: Dict[str, Any], - metadata: Optional[Dict[str, Any]] = None, - ) -> SendResult: + async def send_card(self, chat_id: str, card: Dict[str, Any], metadata: Optional[Dict[str, Any]] = None) -> SendResult: body: Dict[str, Any] = {"cardsV2": [card]} thread_id = self._resolve_thread_id(None, metadata, chat_id=chat_id) if thread_id: @@ -2244,33 +1330,21 @@ class GoogleChatAdapter(BasePlatformAdapter): result.raw_response = result.raw_response or {"cardsV2": body["cardsV2"]} return result except HttpError as exc: - status = getattr(getattr(exc, "resp", None), "status", None) return SendResult( success=False, error=_redact_sensitive(str(exc)), - retryable=status in _RETRYABLE_HTTP_STATUSES, + retryable=_http_status(exc) in _RETRYABLE_HTTP_STATUSES, ) except Exception as exc: logger.debug("[GoogleChat] send_card failed", exc_info=True) - return SendResult( - success=False, - error=_redact_sensitive(str(exc)), - retryable=_is_retryable_error(exc), - ) + return SendResult(success=False, error=_redact_sensitive(str(exc)), retryable=_is_retryable_error(exc)) async def send_clarify( - self, - chat_id: str, - question: str, - choices: Optional[list], - clarify_id: str, - session_key: str, + self, chat_id: str, question: str, choices: Optional[list], clarify_id: str, session_key: str, metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: if not choices: - return await super().send_clarify( - chat_id, question, choices, clarify_id, session_key, metadata - ) + return await super().send_clarify(chat_id, question, choices, clarify_id, session_key, metadata) buttons: List[Dict[str, Any]] = [] for choice in choices: @@ -2278,146 +1352,79 @@ class GoogleChatAdapter(BasePlatformAdapter): if not choice_text: continue label = choice_text if len(choice_text) <= 80 else choice_text[:77] + "..." - buttons.append( - { - "text": label, - "action": "hermes_clarify", - "parameters": { - "clarify_id": clarify_id, - "choice": choice_text, - }, - } - ) - buttons.append( - { - "text": "Other / type answer", + buttons.append({ + "text": label, "action": "hermes_clarify", - "parameters": { - "clarify_id": clarify_id, - "choice": "__other__", - }, - } - ) - if not buttons: - return await super().send_clarify( - chat_id, question, choices, clarify_id, session_key, metadata - ) - - card = card_spec_to_cards_v2( - { - "card_id": f"clarify-{clarify_id}", - "header": {"title": "Question"}, - "sections": [ - { - "widgets": [ - {"type": "text", "text": f"❓ {question}"}, - {"type": "buttons", "buttons": buttons}, - ] - } - ], - } - ) + "parameters": {"clarify_id": clarify_id, "choice": choice_text}, + }) + buttons.append({ + "text": "Other / type answer", + "action": "hermes_clarify", + "parameters": {"clarify_id": clarify_id, "choice": "__other__"}, + }) + card = card_spec_to_cards_v2({ + "card_id": f"clarify-{clarify_id}", + "header": {"title": "Question"}, + "sections": [{ + "widgets": [ + {"type": "text", "text": f"❓ {question}"}, + {"type": "buttons", "buttons": buttons}, + ] + }], + }) result = await self.send_card(chat_id, card, metadata=metadata) if result.success: self._clarify_state[clarify_id] = session_key return result - return await super().send_clarify( - chat_id, question, choices, clarify_id, session_key, metadata - ) + return await super().send_clarify(chat_id, question, choices, clarify_id, session_key, metadata) - async def edit_message( - self, - chat_id: str, - message_id: str, - content: str, - *, - finalize: bool = False, - ) -> SendResult: - """Edit a previously sent message via ``messages.patch``. + async def edit_message(self, chat_id: str, message_id: str, content: str, *, finalize: bool = False) -> SendResult: + """Edit a sent message via ``messages.patch``. - Required for the gateway tool-progress + token-streaming pipeline: - ``GatewayStreamConsumer`` and ``send_progress_messages`` both gate - on this method being overridden (see gateway/run.py:10199 and - gateway/stream_consumer.py). Without it, Google Chat shows no - tool activity (no "🔍 web_search…", no progressive token edits). - - ``message_id`` is the Google Chat resource name - ``spaces/X/messages/Y``. ``finalize`` is unused here — Google - Chat's patch API has no streaming lifecycle state, so the same - patch closes the stream and any prior edit. - - 404 (message gone) and 403 (perms revoked) are reported as - non-success; the gateway falls back to ``send()`` for the next - edit cycle. + Required by the tool-progress + token-streaming pipeline (``GatewayStreamConsumer`` + gates on this override). ``finalize`` is unused: Chat's patch has no + streaming lifecycle. 404/403 are reported as non-success so the gateway + falls back to ``send()``. """ if not message_id: return SendResult(success=False, error="missing message_id") - # Google Chat caps message text at 4096; we use 4000 elsewhere. if len(content) > _MAX_TEXT_LENGTH: content = content[: _MAX_TEXT_LENGTH - 1] + "…" try: return await self._patch_message(message_id, {"text": content}) except HttpError as exc: - status = getattr(getattr(exc, "resp", None), "status", None) - if status == 429: - self._rate_limit_hits[chat_id] = ( - self._rate_limit_hits.get(chat_id, 0) + 1 - ) - return SendResult( - success=False, error=_redact_sensitive(str(exc)) - ) + if _http_status(exc) == 429: + self._note_rate_limit(chat_id) + return SendResult(success=False, error=_redact_sensitive(str(exc))) except Exception as exc: logger.debug("[GoogleChat] edit_message failed", exc_info=True) return SendResult(success=False, error=str(exc)) async def delete_message(self, chat_id: str, message_id: str) -> bool: - """Delete a message — used sparingly (deletion creates a tombstone). - - The base contract returns False on unsupported. We do support it, - but most internal code should prefer ``edit_message`` to avoid the - "Message deleted by its author" tombstone. Provided so the - gateway's stream-consumer fallback paths (e.g. removing an aborted - partial preview) work correctly when explicit deletion is the - right call. - """ + """Delete a message. Prefer ``edit_message`` internally — deletion leaves a + "Message deleted by its author" tombstone; provided for stream-consumer + fallback paths that genuinely need removal.""" if not message_id: return False def _do_delete() -> None: - ( - self._chat_api.spaces() - .messages() - .delete(name=message_id) - .execute(http=self._new_authed_http()) - ) + self._chat_api.spaces().messages().delete(name=message_id).execute(http=self._new_authed_http()) try: await asyncio.to_thread(_do_delete) return True except HttpError as exc: - status = getattr(getattr(exc, "resp", None), "status", None) - if status in {403, 404}: + if _http_status(exc) in {403, 404}: return False - logger.debug( - "[GoogleChat] delete_message failed: %s", - _redact_sensitive(str(exc)), - ) + logger.debug("[GoogleChat] delete_message failed: %s", _redact_sensitive(str(exc))) return False except Exception: logger.debug("[GoogleChat] delete_message failed", exc_info=True) return False - async def _patch_message( - self, message_name: str, body: Dict[str, Any] - ) -> SendResult: + async def _patch_message(self, message_name: str, body: Dict[str, Any]) -> SendResult: """Update a message's text (and optionally cards) in-place.""" - update_mask_fields = [] - if "text" in body: - update_mask_fields.append("text") - if "cardsV2" in body: - update_mask_fields.append("cardsV2") - update_mask = ",".join(update_mask_fields) or "text" - + update_mask = ",".join(k for k in ("text", "cardsV2") if k in body) or "text" # Patch body cannot carry thread (immutable). patch_body = {k: v for k, v in body.items() if k not in {"thread",}} @@ -2443,7 +1450,7 @@ class GoogleChatAdapter(BasePlatformAdapter): if len(remaining) <= _MAX_TEXT_LENGTH: chunks.append(remaining) break - # Try to split on a newline near the cutoff. + # Split on a newline near the cutoff when one exists past the midpoint. cut = remaining.rfind("\n", 0, _MAX_TEXT_LENGTH) if cut < _MAX_TEXT_LENGTH // 2: cut = _MAX_TEXT_LENGTH @@ -2451,130 +1458,22 @@ class GoogleChatAdapter(BasePlatformAdapter): remaining = remaining[cut:].lstrip() return chunks - # ------------------------------------------------------------------ - # Outbound formatting - # ------------------------------------------------------------------ - # Invisible Unicode codepoints that render as tofu (□) in Google - # Chat's restricted font stack. ZWJ/ZWNJ/ZWS are the glue inside - # composite emoji and bidirectional text; Variation Selectors - # control text-vs-emoji presentation but Chat ignores them and - # often shows a blank box. Pattern lifted from PR #14965. - _INVISIBLE_RE = re.compile( - "[" - "​" # Zero-Width Space - "‌" # Zero-Width Non-Joiner - "‍" # Zero-Width Joiner (ZWJ) - "‎‏" # LTR / RTL marks - "⁠" # Word Joiner - "" # BOM / Zero-Width No-Break Space - "︀-️" # Variation Selectors 1-16 (VS1–VS16) - "\U000e0100-\U000e01ef" # Variation Selectors 17-256 - "]" - ) - @classmethod def format_message(cls, content: str) -> str: - """Convert standard Markdown to Google Chat's formatting dialect. - - Google Chat renders a small subset: ``*bold*``, ``_italic_``, - ``~strikethrough~``, fenced/inline code. Standard Markdown - constructs (``**bold**``, ``# headers``, ``[text](url)``) do - not render and need conversion before they reach Chat. - - Code blocks (fenced AND inline) are protected from transformation - via placeholder substitution so backticks-wrapped content with - literal asterisks or brackets stays intact. Invisible Unicode - codepoints that render as tofu in Chat's restricted font stack - are stripped at the end. Empty/None input passes through. - - Pattern lifted from PR #14965. - """ - if not content: - return content - - text = content - placeholders: Dict[str, str] = {} - counter = [0] - - def _ph(value: str) -> str: - key = f"\x00GC{counter[0]}\x00" - counter[0] += 1 - placeholders[key] = value - return key - - # Protect fenced and inline code blocks from transformation. - # Fenced blocks first (``` ... ```), then inline code (`...`). - text = re.sub( - r"(```(?:[^\n]*\n)?[\s\S]*?```)", - lambda m: _ph(m.group(0)), - text, - ) - text = re.sub(r"(`[^`]+`)", lambda m: _ph(m.group(0)), text) - - # Headers (## Title) → *Title* (Chat has no header support). - text = re.sub( - r"^#{1,6}\s+(.+)$", - lambda m: _ph(f"*{m.group(1).strip()}*"), - text, - flags=re.MULTILINE, - ) - - # Bold+italic: ***text*** → *_text_* - text = re.sub( - r"\*\*\*(.+?)\*\*\*", - lambda m: _ph(f"*_{m.group(1)}_*"), - text, - ) - - # Bold: **text** → *text* (Chat uses single asterisks). - text = re.sub( - r"\*\*(.+?)\*\*", - lambda m: _ph(f"*{m.group(1)}*"), - text, - ) - - # Markdown links [text](url) → (Slack-style angle-bracket). - text = re.sub( - r"\[([^\]]+)\]\(([^)]+)\)", - lambda m: _ph(f"<{m.group(2)}|{m.group(1)}>"), - text, - ) - - # Strip invisible Unicode that renders as tofu. - text = cls._INVISIBLE_RE.sub("", text) - - # Collapse double spaces left over from stripped chars. - text = re.sub(r" +", " ", text) - - # Restore protected regions. - for key, value in placeholders.items(): - text = text.replace(key, value) - - return text + """Convert standard Markdown to Google Chat's dialect (see ``cards.format_message``).""" + return _format_message(content) def _resolve_thread_id( - self, - reply_to: Optional[str], - metadata: Optional[Dict[str, Any]], - chat_id: Optional[str] = None, + self, reply_to: Optional[str], metadata: Optional[Dict[str, Any]], chat_id: Optional[str] = None ) -> Optional[str]: - """Return the Google Chat thread resource name to reply under, or None. + """Return the thread resource name to reply under, or None. - Priority: - 1. ``metadata['thread_id']`` — populated by the gateway's session - plumbing from ``SessionSource.thread_id`` (the inbound - ``thread.name``). Canonical path for groups. - 2. ``metadata['thread_name']`` / ``metadata['thread_ts']`` — Slack - precedent aliases that the broader codebase sometimes passes. - 3. ``reply_to`` if it already looks like a thread resource name - (``spaces/X/threads/Y``). Message names ``spaces/X/messages/Y`` - cannot be converted to threads without an extra API call. - 4. ``self._last_inbound_thread[chat_id]`` — Google Chat DMs spawn - a new thread per top-level user message, and the adapter - intentionally drops thread_id from the source so the session - key stays stable. Without this fallback, DM replies would - land at top-level (a fresh thread separate from the user's), - visually disconnected from the user's question. + Priority: ``metadata['thread_id']`` (gateway session plumbing) → + ``thread_name`` / ``thread_ts`` aliases → ``reply_to`` when it is already a + ``spaces/X/threads/Y`` name → ``_last_inbound_thread[chat_id]`` (DM replies + would otherwise land top-level, disconnected from the user's question). + Cron deliveries (``job_id`` in metadata) skip the last fallback so output + starts a fresh top-level message instead of burying itself in a stale thread. """ if metadata: for key in ("thread_id", "thread_name", "thread_ts"): @@ -2583,13 +1482,6 @@ class GoogleChatAdapter(BasePlatformAdapter): return str(value) if reply_to and "/threads/" in reply_to and "/messages/" not in reply_to: return reply_to - # Cron deliveries (job_id present in metadata) must post as a new - # top-level message unless an explicit thread was requested above. The - # _last_inbound_thread fallback below exists for interactive DMs, where - # Google Chat spawns a fresh thread per top-level user message and the - # adapter drops thread_id to keep the session key stable. Replaying - # that fallback for a cron output would reply inside a stale inbound - # thread instead of starting a new one, burying the delivery. if metadata and metadata.get("job_id"): return None if chat_id: @@ -2599,171 +1491,88 @@ class GoogleChatAdapter(BasePlatformAdapter): return None def _new_authed_http(self) -> Any: - """Return a fresh AuthorizedHttp. - - googleapiclient's discovery client is NOT thread-safe because httplib2 - shares SSL state between calls. Passing a fresh http= to each - ``execute()`` avoids record-layer failures when calls run in - ``asyncio.to_thread`` workers. Cheap (~no network). - """ + """Fresh AuthorizedHttp per call: httplib2 shares SSL state, so the discovery + client is not thread-safe across ``asyncio.to_thread`` workers.""" return AuthorizedHttp(self._credentials, http=httplib2.Http(timeout=30)) - async def _call_with_retry( - self, - sync_fn: Callable[[], Any], - *, - op_name: str = "chat-api-call", - ) -> Any: + async def _call_with_retry(self, sync_fn: Callable[[], Any], *, op_name: str = "chat-api-call") -> Any: """Run ``sync_fn`` in a thread with bounded retry + jittered backoff. - Wraps a sync Chat API call (typically a ``.execute()``) so transient - 429/5xx/timeout failures don't drop user-visible messages. Permanent - failures (auth, client errors, validation) bubble up on the first - attempt — see :func:`_is_retryable_error`. Cancellation propagates - immediately, no extra retries after a CancelledError. - - Pattern lifted from PR #14965. + Only transient failures (see ``_is_retryable_error``) are retried; permanent + ones bubble up on the first attempt. Cancellation propagates immediately. """ delay = _RETRY_BASE_DELAY - last_exc: Optional[BaseException] = None for attempt in range(1, _RETRY_MAX_ATTEMPTS + 1): try: return await asyncio.to_thread(sync_fn) except asyncio.CancelledError: raise except Exception as exc: - last_exc = exc - retryable = _is_retryable_error(exc) - if not retryable or attempt >= _RETRY_MAX_ATTEMPTS: + if not _is_retryable_error(exc) or attempt >= _RETRY_MAX_ATTEMPTS: raise jitter = delay * _RETRY_JITTER * random.random() wait = min(delay + jitter, _RETRY_MAX_DELAY + _RETRY_JITTER) logger.warning( - "[GoogleChat] %s attempt %d/%d failed (%s); " - "retrying in %.2fs", - op_name, attempt, _RETRY_MAX_ATTEMPTS, - _redact_sensitive(str(exc)), wait, + "[GoogleChat] %s attempt %d/%d failed (%s); retrying in %.2fs", + op_name, attempt, _RETRY_MAX_ATTEMPTS, _redact_sensitive(str(exc)), wait, ) - try: - await asyncio.sleep(wait) - except asyncio.CancelledError: - raise + await asyncio.sleep(wait) delay = min(delay * 2, _RETRY_MAX_DELAY) - # Defensive — the loop above always either returns or re-raises. - if last_exc is not None: - raise last_exc - raise RuntimeError(f"{op_name}: retry loop exited without result") - async def _create_message( - self, chat_id: str, body: Dict[str, Any] - ) -> SendResult: - """POST spaces/{space}/messages via REST, returning SendResult. - - When ``body`` carries ``thread.name``, we MUST pass - ``messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD`` — - otherwise Google Chat silently ignores ``thread.name`` and - creates a new thread anyway. From the official docs: - - "Default. Starts a new thread. Using this option ignores - any thread ID or threadKey that's included." - - See https://developers.google.com/workspace/chat/api/reference/rest/v1/spaces.messages/create - """ - kwargs: Dict[str, Any] = {"parent": chat_id, "body": body} - thread_meta = body.get("thread") or {} - if thread_meta.get("name"): - # FALLBACK_TO_NEW_THREAD: try the requested thread; if Chat - # can't route there (e.g. thread no longer exists), create a - # new one rather than erroring. Safer than REPLY_MESSAGE_OR_FAIL - # for a chat-bot context where stale thread names are rare - # but possible. - kwargs["messageReplyOption"] = "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD" - - def _do_create() -> Dict[str, Any]: - return ( - self._chat_api.spaces() - .messages() - .create(**kwargs) - .execute(http=self._new_authed_http()) - ) - - resp = await self._call_with_retry(_do_create, op_name="messages.create") - # Track outbound destination thread in the persistent count store - # so a future user "Reply in thread" on the bot's message resolves - # to a known thread (prev_count >= 1 → side thread). Without - # this, threads created by the bot's own outbound look fresh - # the first time the user engages them, and the heuristic - # incorrectly classifies the engagement as main-flow → bot - # replies at top-level instead of in the thread. + def _track_outbound_thread(self, chat_id: str, resp: Dict[str, Any]) -> None: + """Count the outbound destination thread so a later user "Reply in thread" + on the bot's message resolves as a known side-thread (prev_count >= 1) + instead of looking fresh and being misclassified as main flow.""" resp_thread = (resp.get("thread") or {}).get("name") or "" if chat_id and resp_thread: try: self._thread_count_store.incr(chat_id, resp_thread) except Exception: - logger.debug( - "[GoogleChat] outbound thread-count incr failed", - exc_info=True, - ) + logger.debug("[GoogleChat] outbound thread-count incr failed", exc_info=True) + + async def _create_message(self, chat_id: str, body: Dict[str, Any]) -> SendResult: + """POST spaces/{space}/messages via REST, returning SendResult. + + With ``thread.name`` in the body we MUST pass + ``messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD`` — the default + silently ignores thread.name and starts a new thread. FALLBACK (vs OR_FAIL) + still delivers when a stale thread no longer exists. + """ + kwargs: Dict[str, Any] = {"parent": chat_id, "body": body} + if (body.get("thread") or {}).get("name"): + kwargs["messageReplyOption"] = "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD" + + def _do_create() -> Dict[str, Any]: + return self._chat_api.spaces().messages().create(**kwargs).execute(http=self._new_authed_http()) + + resp = await self._call_with_retry(_do_create, op_name="messages.create") + self._track_outbound_thread(chat_id, resp) return SendResult(success=True, message_id=resp.get("name")) async def send_typing(self, chat_id: str, metadata: Any = None) -> None: - """Post a visible 'Hermes is thinking…' marker message. + """Post a visible 'Hermes is thinking…' marker message (Chat has no typing API). - NOT ephemeral (Google Chat has no ephemeral text messages outside - slash command responses). ``send()`` PATCHes this marker in-place - with the real response (no deletion tombstone). The typing card is - either patched by ``send()`` (success) or by - ``on_processing_complete`` (failure / cancellation). + ``send()`` PATCHes the marker with the real reply; ``on_processing_complete`` + reaps it otherwise. The card must be created in the user's thread because + ``messages.patch`` cannot move a message between threads. - IMPORTANT — must place the typing card in the user's thread: - ``messages.patch`` cannot change a message's ``thread`` (it's - immutable on update). If we create the typing card at top-level - and the user is replying inside thread T, send() will patch the - top-level card in place — leaving the bot's whole response - stranded outside the user's thread. We resolve the thread the - same way send() does. - - IMPORTANT — cancellation safety: - ``base.py``'s ``_keep_typing`` calls this through - ``asyncio.wait_for(send_typing, timeout=1.5)``. When the - create-API call takes longer than 1.5s, ``wait_for`` cancels - ``send_typing`` mid-flight — but the underlying ``asyncio.to_thread`` - keeps running and creates a card in Chat that we have NO way to - track (the storage line never runs). Next ``_keep_typing`` tick - sees an empty slot and creates a SECOND card. Result: one orphan - "Hermes is thinking…" stuck in chat forever, plus one card that - gets patched into the reply. - - Fix: reserve the slot with an in-flight ``Event``, run the - create in a background task, and ``await asyncio.shield`` it. - Cancellation of THIS coroutine no longer cancels the create — - the task runs to completion and the msg_id lands in the slot - regardless. + Cancellation safety: ``_keep_typing`` wraps this in ``wait_for(timeout=1.5)``. + A cancelled ``to_thread`` create would still land a card we never recorded, + and the next tick would create a second one. So the slot is reserved with + an in-flight Event and the create runs in a shielded background task that + records the msg_id regardless of our own cancellation. """ # Already have a card (real msg_id, sentinel, or in-flight) — bail. if chat_id in self._typing_messages: return if chat_id in self._typing_card_inflight: - # Another create is already running for this chat. Wait for - # it to finish so we honor the contract "if called, the card - # is up by the time we return". Bounded wait — if the - # background task is stuck, _keep_typing will retry. - try: - await asyncio.wait_for( - self._typing_card_inflight[chat_id].wait(), - timeout=5.0, - ) - except (asyncio.TimeoutError, KeyError): - pass + # Bounded wait for the running create so "the card is up when we return". + with contextlib.suppress(asyncio.TimeoutError, KeyError): + await asyncio.wait_for(self._typing_card_inflight[chat_id].wait(), timeout=5.0) return - thread_id = self._resolve_thread_id( - reply_to=None, metadata=metadata, chat_id=chat_id, - ) - body: Dict[str, Any] = { - "text": getattr(self.config, "typing_status_text", None) - or "Hermes is thinking…" - } + thread_id = self._resolve_thread_id(reply_to=None, metadata=metadata, chat_id=chat_id) + body: Dict[str, Any] = {"text": getattr(self.config, "typing_status_text", None) or "Hermes is thinking…"} if thread_id: body["thread"] = {"name": thread_id} @@ -2774,68 +1583,31 @@ class GoogleChatAdapter(BasePlatformAdapter): try: result = await self._create_message(chat_id, body) if result.success and result.message_id: - # Only overwrite the slot if nothing else has claimed it - # in the meantime (e.g. send() racing ahead of us). if chat_id not in self._typing_messages: self._typing_messages[chat_id] = result.message_id else: - # Slot already populated — likely send() patched - # something or another create completed first. - # Our card is ORPHANED here, but at least it's a - # known orphan we can clean up at end of turn. - # Track for cleanup by on_processing_complete. - self._orphan_typing_messages.setdefault( - chat_id, [] - ).append(result.message_id) + # send() or another create claimed the slot first: this card + # is an orphan; on_processing_complete cleans it up. + self._orphan_typing_messages.setdefault(chat_id, []).append(result.message_id) except Exception: - logger.debug( - "[GoogleChat] send_typing background create failed", - exc_info=True, - ) + logger.debug("[GoogleChat] send_typing background create failed", exc_info=True) finally: self._typing_card_inflight.pop(chat_id, None) completed.set() task = asyncio.create_task(_create_and_record()) - # Shield the task from cancellation of our awaiter. If - # _keep_typing's wait_for times out, our coroutine is cancelled - # but the task continues in the background — so the msg_id - # eventually lands in the slot even when the API call is slow. + # The shielded task keeps running if our awaiter is cancelled. try: await asyncio.shield(task) except asyncio.CancelledError: - # The shielded task keeps running. Re-raise so the caller's - # cancellation semantics are preserved. raise async def stop_typing(self, chat_id: str) -> None: - """Stop the typing indicator — NO-OP when a live card is tracked. - - Google Chat has no separate typing API: the "Hermes is thinking…" - marker is a real message that ``send()`` patches in-place with the - agent's reply. Deleting the marker creates a "Message deleted by - its author" tombstone, which is visual noise. - - Upstream code (gateway/run.py and gateway/platforms/base.py) calls - ``stop_typing`` at three moments per turn — typically BEFORE - ``send()`` runs (so deleting the slot would leave ``send()`` - nothing to patch, forcing it to create a fresh message and leaving - the original card as a tombstone). To fix this without modifying - upstream contracts, ``stop_typing`` here is intentionally a NO-OP - when the slot holds a real ``message_name``: the card is left in - place so ``send()`` can patch it. - - Three cases: - * Slot empty → nothing to do. - * Slot holds SENTINEL → ``send()`` already patched the card; - pop the sentinel so the next turn starts clean. - * Slot holds a real ``message_name`` → leave it for ``send()`` - to consume. NO-OP. - - Stranded cards on error / cancellation paths (where ``send()`` - never runs) are reaped by ``on_processing_complete`` — see that - hook for the patch-to-final-state cleanup. - """ + """NO-OP when a live card is tracked: the marker is a real message that + ``send()`` patches in place, and upstream calls ``stop_typing`` BEFORE + ``send()`` (deleting would tombstone and force a fresh message). Only the + SENTINEL is popped so the next turn starts clean; stranded cards on + error/cancel paths are reaped by ``on_processing_complete``.""" current = self._typing_messages.get(chat_id) if not current: return @@ -2845,119 +1617,59 @@ class GoogleChatAdapter(BasePlatformAdapter): # Real message_name — leave it for send() to patch. Deliberate no-op. return - async def on_processing_complete( - self, event: MessageEvent, outcome: ProcessingOutcome - ) -> None: - """Reap typing card(s) after the message-handling cycle ends. - - SUCCESS: ``send()`` set the SENTINEL after patching. Pop it. - - FAILURE / CANCELLED: ``send()`` may not have run, leaving a real - ``message_name`` in the slot. Patching the card to a final state - (``"(interrupted)"``) avoids the tombstone that ``messages.delete`` - would create. If ``send()`` did run (e.g. base.py error-send branch - patched it), the slot holds the SENTINEL — pop and exit. - - Orphan cards: when a background ``send_typing`` task creates a - card AFTER ``send()`` already populated the slot (race window - when the API call takes longer than _keep_typing's wait_for - timeout), the orphan id is stashed in ``self._orphan_typing_messages``. - Patch each orphan with an empty-ish marker so the user doesn't - see "Hermes is thinking…" stuck forever. - """ + async def on_processing_complete(self, event: MessageEvent, outcome: ProcessingOutcome) -> None: + """Reap typing card(s) after the turn: pop the SENTINEL on success; on + failure/cancel patch a still-tracked card to a final label (no tombstone); + patch orphan cards (background creates that lost a race with send()) to "·".""" if event.source is None: return chat_id = event.source.chat_id try: current = self._typing_messages.pop(chat_id, None) if current and current != _TYPING_CONSUMED_SENTINEL: - # Real message_name still in slot — send() never ran. Patch - # with a benign final state instead of deleting (no tombstone). - label = ( - "(interrupted)" if outcome == ProcessingOutcome.CANCELLED - else "(no reply)" - ) + label = "(interrupted)" if outcome == ProcessingOutcome.CANCELLED else "(no reply)" try: await self._patch_message(current, {"text": label}) except Exception: - logger.debug( - "[GoogleChat] on_processing_complete patch fallback failed", - exc_info=True, - ) - # Reap orphan typing cards (background creates that lost a - # race with send()). Patch them to a single dot so they - # gracefully retire — the user already saw the real reply - # in another card, this one is just visual noise to clear. - orphans = self._orphan_typing_messages.pop(chat_id, []) - for orphan_id in orphans: + logger.debug("[GoogleChat] on_processing_complete patch fallback failed", exc_info=True) + for orphan_id in self._orphan_typing_messages.pop(chat_id, []): try: await self._patch_message(orphan_id, {"text": "·"}) except Exception: - logger.debug( - "[GoogleChat] orphan typing-card patch failed: %s", - orphan_id, exc_info=True, - ) + logger.debug("[GoogleChat] orphan typing-card patch failed: %s", orphan_id, exc_info=True) except Exception: - logger.debug( - "[GoogleChat] cleanup in on_processing_complete failed", exc_info=True - ) + logger.debug("[GoogleChat] cleanup in on_processing_complete failed", exc_info=True) # ------------------------------------------------------------------ # Attachment send paths # ------------------------------------------------------------------ - async def _consume_typing_card_with_text( - self, chat_id: str, text: str - ) -> Optional[SendResult]: + async def _consume_typing_card_with_text(self, chat_id: str, text: str) -> Optional[SendResult]: """Patch the tracked typing card with ``text`` (no tombstone). - Returns ``None`` if there's no real typing card to patch (caller - should create a new message). Returns the patch result if the - card was successfully patched. Raises on transient HttpErrors so - the caller can decide whether to fall back to ``_create_message``. - - Leaves the SENTINEL in place when present: a previous ``send()`` - already consumed the typing card, and the SENTINEL must stay in - the slot to keep the base class's ``_keep_typing`` loop from - creating a fresh "Hermes is thinking…" card during any subsequent - attachment send (which would later be reaped as "(no reply)"). + Returns None when there is no real card (caller creates a new message) — + the SENTINEL is left in place so ``_keep_typing`` doesn't post a fresh card + during a subsequent attachment send. Raises transient HttpErrors. """ current = self._typing_messages.get(chat_id) if not current or current == _TYPING_CONSUMED_SENTINEL: return None - # Real msg_id — pop and patch. self._typing_messages.pop(chat_id, None) try: result = await self._patch_message(current, {"text": text}) self._typing_messages[chat_id] = _TYPING_CONSUMED_SENTINEL return result except HttpError as exc: - status = getattr(getattr(exc, "resp", None), "status", None) - if status == 404: - # Card disappeared — caller should create a new message. - return None + if _http_status(exc) == 404: + return None # card disappeared — caller creates a new message raise async def send_image( - self, - chat_id: str, - image_url: str, - caption: Optional[str] = None, - reply_to: Optional[str] = None, + self, chat_id: str, image_url: str, caption: Optional[str] = None, reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: - """Send an inline image via attachment URL (no upload). - - If a typing card is tracked for this chat, patch it in-place with - the image (caption + URL) — same anti-tombstone pattern used by - ``send()``. Otherwise create a new message. - """ + """Send an inline image via URL (no upload); patches the typing card when tracked.""" thread_id = self._resolve_thread_id(reply_to, metadata, chat_id=chat_id) - text_parts: List[str] = [] - if caption: - text_parts.append(caption) - text_parts.append(image_url) - text = "\n".join(text_parts) - + text = "\n".join(([caption] if caption else []) + [image_url]) try: patched = await self._consume_typing_card_with_text(chat_id, text) if patched is not None: @@ -2969,122 +1681,59 @@ class GoogleChatAdapter(BasePlatformAdapter): except HttpError as exc: return SendResult(success=False, error=_redact_sensitive(str(exc))) - async def send_image_file( - self, - chat_id: str, - image_path: str, - caption: Optional[str] = None, - reply_to: Optional[str] = None, - **kwargs: Any, + async def _send_file_reply( + self, chat_id: str, path: str, caption: Optional[str], reply_to: Optional[str], kwargs: Dict[str, Any], + mime_hint: Optional[str], override_filename: Optional[str] = None, ) -> SendResult: + thread_id = self._resolve_thread_id(reply_to, kwargs.get("metadata"), chat_id=chat_id) return await self._send_file( - chat_id, image_path, caption, - mime_hint="image/*", - thread_id=self._resolve_thread_id(reply_to, kwargs.get("metadata"), chat_id=chat_id), + chat_id, path, caption, mime_hint=mime_hint, thread_id=thread_id, override_filename=override_filename ) + async def send_image_file( + self, chat_id: str, image_path: str, caption: Optional[str] = None, reply_to: Optional[str] = None, **kwargs: Any + ) -> SendResult: + return await self._send_file_reply(chat_id, image_path, caption, reply_to, kwargs, "image/*") + async def send_document( - self, - chat_id: str, - file_path: str, - caption: Optional[str] = None, - file_name: Optional[str] = None, - reply_to: Optional[str] = None, - **kwargs: Any, + self, chat_id: str, file_path: str, caption: Optional[str] = None, file_name: Optional[str] = None, + reply_to: Optional[str] = None, **kwargs: Any, ) -> SendResult: - return await self._send_file( - chat_id, file_path, caption, - mime_hint=None, - thread_id=self._resolve_thread_id(reply_to, kwargs.get("metadata"), chat_id=chat_id), - override_filename=file_name, - ) + return await self._send_file_reply(chat_id, file_path, caption, reply_to, kwargs, None, file_name) async def send_voice( - self, - chat_id: str, - audio_path: str, - caption: Optional[str] = None, - reply_to: Optional[str] = None, - **kwargs: Any, + self, chat_id: str, audio_path: str, caption: Optional[str] = None, reply_to: Optional[str] = None, **kwargs: Any ) -> SendResult: - return await self._send_file( - chat_id, audio_path, caption, - mime_hint="audio/ogg", - thread_id=self._resolve_thread_id(reply_to, kwargs.get("metadata"), chat_id=chat_id), - ) + return await self._send_file_reply(chat_id, audio_path, caption, reply_to, kwargs, "audio/ogg") async def send_video( - self, - chat_id: str, - video_path: str, - caption: Optional[str] = None, - reply_to: Optional[str] = None, - **kwargs: Any, + self, chat_id: str, video_path: str, caption: Optional[str] = None, reply_to: Optional[str] = None, **kwargs: Any ) -> SendResult: - return await self._send_file( - chat_id, video_path, caption, - mime_hint="video/mp4", - thread_id=self._resolve_thread_id(reply_to, kwargs.get("metadata"), chat_id=chat_id), - ) + return await self._send_file_reply(chat_id, video_path, caption, reply_to, kwargs, "video/mp4") async def send_animation( - self, - chat_id: str, - animation_url: str, - caption: Optional[str] = None, - reply_to: Optional[str] = None, + self, chat_id: str, animation_url: str, caption: Optional[str] = None, reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: """Google Chat has no native animation type; fall back to send_image.""" - return await self.send_image( - chat_id, animation_url, caption=caption, - reply_to=reply_to, metadata=metadata, - ) + return await self.send_image(chat_id, animation_url, caption=caption, reply_to=reply_to, metadata=metadata) # ------------------------------------------------------------------ # Native attachment delivery via user OAuth # - # Google Chat's media.upload endpoint hard-rejects SA authentication - # ("This method doesn't support app authentication with a service - # account"). The bot itself cannot upload files. Instead the user - # grants the bot the chat.messages.create scope ONCE via an in-chat - # OAuth consent flow (``/setup-files``); the resulting refresh token - # lets the bot call media.upload AS the user, producing native Chat - # attachments (file widget, inline preview, click-to-download). - # - # See https://developers.google.com/chat/api/guides/auth/users for - # the upstream limitation that makes user OAuth necessary, and - # ``plugins/platforms/google_chat/oauth.py`` for the helper - # script + library functions backing this path. + # media.upload hard-rejects SA authentication, so the user grants the bot + # chat.messages.create ONCE via ``/setup-files`` and the bot uploads AS the + # user. See https://developers.google.com/chat/api/guides/auth/users and + # ``oauth.py``. # ------------------------------------------------------------------ - @staticmethod - def _is_app_auth_attachment_error(exc: HttpError) -> bool: - """Detect Google Chat's media.upload bot-auth rejection. - - Returns True for the canonical ``"doesn't support app - authentication"`` wording (and the legacy - ``ACCESS_TOKEN_SCOPE_INSUFFICIENT`` variant some older clients - still see). Used to flag a misuse — calling ``media.upload`` - through the SA-authed Chat API client instead of the user-authed - one. With correct routing this error should never fire in the - adapter; it remains as a defensive check. - """ - text = str(exc) or "" - return ( - "doesn't support app authentication" in text - or "ACCESS_TOKEN_SCOPE_INSUFFICIENT" in text - ) - _LEGACY_USER_IDENTITY = "__legacy__" async def _load_per_user_chat_api(self, email: str) -> Optional[Any]: """Get (or build + cache) a user-authed Chat client for ``email``. - Hits ``self._user_chat_api_by_email`` first; on miss, loads the - per-user token from disk, refreshes if needed, builds an API - client, and caches both. Refresh failures evict the slot so the - next request goes back through the disk path (and ultimately the - text-notice fallback if the user has revoked). + Cache hit → refresh creds (eviction on refresh failure so the next request + goes back through disk / the text-notice fallback). Miss → load token from + disk, build the client, cache both. """ from .oauth import ( load_user_credentials as _load, @@ -3098,9 +1747,7 @@ class GoogleChatAdapter(BasePlatformAdapter): try: refreshed = await asyncio.to_thread(_refresh, cached_creds, email) except Exception: - logger.debug( - "[GoogleChat] cached per-user refresh raised", exc_info=True, - ) + logger.debug("[GoogleChat] cached per-user refresh raised", exc_info=True) refreshed = None if refreshed is None: self._user_chat_api_by_email.pop(email, None) @@ -3115,31 +1762,19 @@ class GoogleChatAdapter(BasePlatformAdapter): return None api = await asyncio.to_thread(lambda: _build(creds)) except Exception: - logger.debug( - "[GoogleChat] per-user creds load/build failed for %s", - email, exc_info=True, - ) + logger.debug("[GoogleChat] per-user creds load/build failed for %s", email, exc_info=True) return None self._user_creds_by_email[email] = creds self._user_chat_api_by_email[email] = api return api - async def _acquire_user_chat_api( - self, sender_email: Optional[str] - ) -> Tuple[Optional[Any], Optional[str]]: + async def _acquire_user_chat_api(self, sender_email: Optional[str]) -> Tuple[Optional[Any], Optional[str]]: """Resolve the user-authed Chat client for an outbound attachment. - Lookup order: - 1. Per-user token for ``sender_email`` — the asker's identity. - 2. Legacy single-user fallback (``self._user_chat_api``) for - pre-multi-user installs. - 3. None — caller posts the setup-instructions text notice. - - Returns ``(client, identity_label)`` where ``identity_label`` is - the sanitized email or the literal ``"__legacy__"`` sentinel. - ``_invalidate_user_creds`` uses the label to evict the right slot - on auth failure. + Order: per-user token for ``sender_email`` → legacy single-user fallback → + ``(None, None)`` (caller posts the setup notice). The identity label + (email or ``"__legacy__"``) tells ``_invalidate_user_creds`` which slot to evict. """ if sender_email: api = await self._load_per_user_chat_api(sender_email) @@ -3148,22 +1783,13 @@ class GoogleChatAdapter(BasePlatformAdapter): if self._user_chat_api is not None: try: - from .oauth import ( - refresh_or_none as _refresh, - ) - refreshed = await asyncio.to_thread( - _refresh, self._user_credentials, None, - ) + from .oauth import refresh_or_none as _refresh + refreshed = await asyncio.to_thread(_refresh, self._user_credentials, None) except Exception: - logger.debug( - "[GoogleChat] legacy creds refresh raised", exc_info=True, - ) + logger.debug("[GoogleChat] legacy creds refresh raised", exc_info=True) refreshed = None if refreshed is None: - logger.warning( - "[GoogleChat] legacy user-OAuth refresh returned None — " - "evicting fallback creds" - ) + logger.warning("[GoogleChat] legacy user-OAuth refresh returned None — evicting fallback creds") self._user_credentials = None self._user_chat_api = None return None, None @@ -3173,12 +1799,7 @@ class GoogleChatAdapter(BasePlatformAdapter): return None, None def _invalidate_user_creds(self, identity: Optional[str]) -> None: - """Drop creds for ``identity`` after an auth failure. - - ``identity`` comes from ``_acquire_user_chat_api`` — either the - sender email (per-user slot) or ``__legacy__`` for the fallback - slot. None is a no-op. - """ + """Drop creds for ``identity`` (email or ``__legacy__``) after an auth failure.""" if not identity: return if identity == self._LEGACY_USER_IDENTITY: @@ -3189,81 +1810,39 @@ class GoogleChatAdapter(BasePlatformAdapter): self._user_chat_api_by_email.pop(identity, None) async def _send_file( - self, - chat_id: str, - path: str, - caption: Optional[str], - mime_hint: Optional[str], - thread_id: Optional[str] = None, - override_filename: Optional[str] = None, + self, chat_id: str, path: str, caption: Optional[str], mime_hint: Optional[str], + thread_id: Optional[str] = None, override_filename: Optional[str] = None, ) -> SendResult: - """Native Chat attachment via user-OAuth media.upload. + """Native Chat attachment: user-authed ``media.upload`` then ``messages.create``. - Two-step on the wire: ``media.upload`` then - ``spaces.messages.create`` with the returned ``attachmentDataRef``. - BOTH calls go through a user-authed Chat API client — the - SA-authed client is rejected by ``media.upload`` regardless of - scopes. - - Multi-user routing: the bot looks up the most recent inbound - sender for this ``chat_id`` and uses THAT user's stored OAuth - token. Falls back to a legacy single-user token when present - (for pre-multi-user installs), and to a setup-instructions text - notice when neither is available. - - Google Chat ``messages.patch`` cannot add an attachment to an - existing message, so we cannot transform the typing card directly - into the file message. Instead we patch the typing card with the - caption (or a single space when none) so it retires without a - tombstone, then create the attachment message. + Uses the most recent inbound sender's OAuth token for this chat (legacy + single-user token as fallback; text notice when neither exists). Since + ``messages.patch`` cannot add an attachment, the typing card is patched + with the caption (or a single space) so it retires without a tombstone. """ if not os.path.exists(path): return SendResult(success=False, error=f"file not found: {path}") filename = override_filename or os.path.basename(path) or "upload.bin" mime = mime_hint or "application/octet-stream" - sender_email = self._last_sender_by_chat.get(chat_id) chat_api, identity = await self._acquire_user_chat_api(sender_email) - - # No user OAuth → can't upload natively. Surface clear setup - # instructions in chat instead of silently failing. if chat_api is None: - return await self._post_attachment_fallback( - chat_id=chat_id, - path=path, - filename=filename, - caption=caption, - thread_id=thread_id, - ) + return await self._post_attachment_fallback(chat_id, path, filename, caption, thread_id) - # Pre-patch the typing card with the caption (or single space) so - # it retires without a tombstone before the attachment message is - # posted. try: await self._consume_typing_card_with_text(chat_id, caption or " ") except Exception: - logger.debug( - "[GoogleChat] _send_file pre-patch typing-card failed", - exc_info=True, - ) + logger.debug("[GoogleChat] _send_file pre-patch typing-card failed", exc_info=True) def _upload() -> Dict[str, Any]: media = MediaFileUpload(path, mimetype=mime, resumable=False) - return ( - chat_api.media() - .upload( - parent=chat_id, - body={"filename": filename}, - media_body=media, - ) - .execute() - ) + return chat_api.media().upload(parent=chat_id, body={"filename": filename}, media_body=media).execute() try: upload_resp = await asyncio.to_thread(_upload) except HttpError as exc: - status = getattr(getattr(exc, "resp", None), "status", None) + status = _http_status(exc) if status in {401, 403}: logger.warning( "[GoogleChat] media.upload auth failure for identity=%s " @@ -3271,88 +1850,39 @@ class GoogleChatAdapter(BasePlatformAdapter): "text notice. Status=%s", identity, status, ) self._invalidate_user_creds(identity) - return await self._post_attachment_fallback( - chat_id=chat_id, - path=path, - filename=filename, - caption=caption, - thread_id=thread_id, - ) - return SendResult( - success=False, error=_redact_sensitive(str(exc)) - ) + return await self._post_attachment_fallback(chat_id, path, filename, caption, thread_id) + return SendResult(success=False, error=_redact_sensitive(str(exc))) attachment_ref = upload_resp.get("attachmentDataRef") if not attachment_ref: - return SendResult( - success=False, - error="upload returned no attachmentDataRef", - ) + return SendResult(success=False, error="upload returned no attachmentDataRef") - body: Dict[str, Any] = { - "attachment": [{"attachmentDataRef": attachment_ref}], - } + body: Dict[str, Any] = {"attachment": [{"attachmentDataRef": attachment_ref}]} if caption: body["text"] = caption if thread_id: body["thread"] = {"name": thread_id} - - # The accompanying messages.create that references the attachment - # also needs user auth (the attachmentDataRef is bound to the - # uploading principal). messageReplyOption is required for the - # thread.name in body to actually be honored — see - # _create_message docstring for the API quirk. + # The attachmentDataRef is bound to the uploading principal, so this create + # also needs user auth. messageReplyOption: see _create_message. create_kwargs: Dict[str, Any] = {"parent": chat_id, "body": body} if thread_id: - create_kwargs["messageReplyOption"] = ( - "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD" - ) + create_kwargs["messageReplyOption"] = "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD" def _create_with_attachment() -> Dict[str, Any]: - return ( - chat_api.spaces() - .messages() - .create(**create_kwargs) - .execute() - ) + return chat_api.spaces().messages().create(**create_kwargs).execute() try: resp = await asyncio.to_thread(_create_with_attachment) - # Track outbound destination thread (see _create_message - # comment for why — same reasoning applies to the - # user-OAuth attachment path). - resp_thread = (resp.get("thread") or {}).get("name") or "" - if chat_id and resp_thread: - try: - self._thread_count_store.incr(chat_id, resp_thread) - except Exception: - logger.debug( - "[GoogleChat] outbound thread-count incr failed", - exc_info=True, - ) - return SendResult( - success=True, message_id=resp.get("name"), - ) + self._track_outbound_thread(chat_id, resp) + return SendResult(success=True, message_id=resp.get("name")) except HttpError as exc: - return SendResult( - success=False, error=_redact_sensitive(str(exc)) - ) + return SendResult(success=False, error=_redact_sensitive(str(exc))) async def _post_attachment_fallback( - self, - chat_id: str, - path: str, - filename: str, - caption: Optional[str], - thread_id: Optional[str], + self, chat_id: str, path: str, filename: str, caption: Optional[str], thread_id: Optional[str] ) -> SendResult: - """Post a text notice when native attachment delivery is unavailable. - - Tells the user that file delivery requires a one-time consent - flow (``/setup-files``) and reports the local-host path so the - file isn't lost. Returns ``success=False`` so callers know the - attachment did not land. - """ + """Post the ``/setup-files`` notice (plus host path) when native delivery is + unavailable. Always returns ``success=False``.""" lines = [] if caption: lines.append(caption) @@ -3370,14 +1900,10 @@ class GoogleChatAdapter(BasePlatformAdapter): try: await self._create_message(chat_id, body) except Exception: - logger.debug( - "[GoogleChat] attachment fallback notice send failed", - exc_info=True, - ) + logger.debug("[GoogleChat] attachment fallback notice send failed", exc_info=True) return SendResult( success=False, - error="google_chat: native attachment requires user OAuth — " - "run /setup-files in chat", + error="google_chat: native attachment requires user OAuth — run /setup-files in chat", ) # ------------------------------------------------------------------ @@ -3387,19 +1913,14 @@ class GoogleChatAdapter(BasePlatformAdapter): """Return {name, type, chat_id} for a space.""" try: info = await asyncio.to_thread( - lambda: self._chat_api.spaces() - .get(name=chat_id) - .execute(http=self._new_authed_http()) + lambda: self._chat_api.spaces().get(name=chat_id).execute(http=self._new_authed_http()) ) except HttpError as exc: - logger.debug( - "[GoogleChat] get_chat_info failed: %s", _redact_sensitive(str(exc)) - ) + logger.debug("[GoogleChat] get_chat_info failed: %s", _redact_sensitive(str(exc))) return {"name": chat_id, "type": "group", "chat_id": chat_id} space_type = (info.get("spaceType") or info.get("type") or "").upper() - display = info.get("displayName") or chat_id return { - "name": display, + "name": info.get("displayName") or chat_id, "type": "dm" if space_type in {"DIRECT_MESSAGE", "DM"} else "group", "chat_id": chat_id, } @@ -3413,34 +1934,22 @@ class GoogleChatAdapter(BasePlatformAdapter): def _validate_config(config: PlatformConfig) -> bool: """Plugin-side config gate for HTTP callback or Pub/Sub inbound modes.""" extra = getattr(config, "extra", {}) or {} - return bool( - extra.get("http_events_url") - or (extra.get("project_id") and extra.get("subscription_name")) - ) + return bool(extra.get("http_events_url") or (extra.get("project_id") and extra.get("subscription_name"))) + + +def _env_inbound_settings() -> Tuple[Optional[str], Optional[str], Optional[str]]: + """(project, subscription, http_events_url) from the scoped env, with legacy fallbacks.""" + project = _get_scoped_secret("GOOGLE_CHAT_PROJECT_ID") or _get_scoped_secret("GOOGLE_CLOUD_PROJECT") + subscription = _get_scoped_secret("GOOGLE_CHAT_SUBSCRIPTION_NAME") or _get_scoped_secret("GOOGLE_CHAT_SUBSCRIPTION") + return project, subscription, _get_scoped_secret("GOOGLE_CHAT_HTTP_EVENTS_URL") def _check_for_registry() -> bool: - """``check_fn`` for the platform registry pass — stricter than the - deps-only ``check_google_chat_requirements``. - - The registry pass at ``gateway/config.py:_apply_env_overrides`` adds - the platform to ``cfg.platforms`` whenever ``check_fn`` returns True. - For backward compat with the pre-plugin behavior, we ALSO require - the minimum Pub/Sub env vars so an unconfigured user doesn't - accidentally see ``google_chat`` enabled. This matches the legacy - ``if gc_project and gc_subscription`` gate. - """ + """``check_fn`` for the registry pass: deps installed AND minimum inbound env set, + so an unconfigured user never sees ``google_chat`` auto-enabled.""" if not check_google_chat_requirements(): return False - project = ( - _get_scoped_secret("GOOGLE_CHAT_PROJECT_ID") - or _get_scoped_secret("GOOGLE_CLOUD_PROJECT") - ) - subscription = ( - _get_scoped_secret("GOOGLE_CHAT_SUBSCRIPTION_NAME") - or _get_scoped_secret("GOOGLE_CHAT_SUBSCRIPTION") - ) - http_events_url = _get_scoped_secret("GOOGLE_CHAT_HTTP_EVENTS_URL") + project, subscription, http_events_url = _env_inbound_settings() return bool(http_events_url or (project and subscription)) @@ -3450,83 +1959,60 @@ def _is_connected(config: PlatformConfig) -> bool: def _env_enablement() -> Optional[Dict[str, Any]]: - """Seed ``PlatformConfig.extra`` from env vars during - ``_apply_env_overrides``. + """Seed ``PlatformConfig.extra`` from env vars during ``_apply_env_overrides`` + (before the adapter exists, so ``gateway status`` reflects env-only config). - The registry's env-enablement hook is called BEFORE the adapter is - constructed, so ``gateway status`` and ``get_connected_platforms()`` - reflect env-only configuration without instantiating the Pub/Sub client. - Returns ``None`` when the required Pub/Sub project/subscription aren't - set; the caller then skips auto-enabling the platform. - - 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``. + Returns None when the minimum inbound settings are absent. ``home_channel`` is + turned into a ``HomeChannel`` by the core hook rather than merged into extra. """ - project = ( - _get_scoped_secret("GOOGLE_CHAT_PROJECT_ID") - or _get_scoped_secret("GOOGLE_CLOUD_PROJECT") - ) - subscription = ( - _get_scoped_secret("GOOGLE_CHAT_SUBSCRIPTION_NAME") - or _get_scoped_secret("GOOGLE_CHAT_SUBSCRIPTION") - ) - http_events_url = _get_scoped_secret("GOOGLE_CHAT_HTTP_EVENTS_URL") + project, subscription, http_events_url = _env_inbound_settings() if not (http_events_url or (project and subscription)): return None seed: Dict[str, Any] = {} - if project: - seed["project_id"] = project - if subscription: - seed["subscription_name"] = subscription - if http_events_url: - seed["http_events_url"] = http_events_url - http_events_audience = _get_scoped_secret("GOOGLE_CHAT_HTTP_EVENTS_AUDIENCE") - if http_events_audience: - seed["http_events_audience"] = http_events_audience - http_events_sa_email = _get_scoped_secret("GOOGLE_CHAT_HTTP_EVENTS_SERVICE_ACCOUNT_EMAIL") - if http_events_sa_email: - seed["http_events_service_account_email"] = http_events_sa_email - for env_name, extra_name in ( - ("GOOGLE_CHAT_MAX_MESSAGES", "max_messages"), - ("GOOGLE_CHAT_MAX_BYTES", "max_bytes"), - ("GOOGLE_CHAT_BOOTSTRAP_SPACES", "bootstrap_spaces"), - ("GOOGLE_CHAT_DEBUG_RAW", "debug_raw"), + for extra_name, value in ( + ("project_id", project), + ("subscription_name", subscription), + ("http_events_url", http_events_url), + ("http_events_audience", _get_scoped_secret("GOOGLE_CHAT_HTTP_EVENTS_AUDIENCE")), + ("http_events_service_account_email", _get_scoped_secret("GOOGLE_CHAT_HTTP_EVENTS_SERVICE_ACCOUNT_EMAIL")), + ("max_messages", _get_scoped_secret("GOOGLE_CHAT_MAX_MESSAGES")), + ("max_bytes", _get_scoped_secret("GOOGLE_CHAT_MAX_BYTES")), + ("bootstrap_spaces", _get_scoped_secret("GOOGLE_CHAT_BOOTSTRAP_SPACES")), + ("debug_raw", _get_scoped_secret("GOOGLE_CHAT_DEBUG_RAW")), + ( + "service_account_json", + _get_scoped_secret("GOOGLE_CHAT_SERVICE_ACCOUNT_JSON") or _get_scoped_secret("GOOGLE_APPLICATION_CREDENTIALS"), + ), ): - value = _get_scoped_secret(env_name) if value: seed[extra_name] = value - sa_json = ( - _get_scoped_secret("GOOGLE_CHAT_SERVICE_ACCOUNT_JSON") - or _get_scoped_secret("GOOGLE_APPLICATION_CREDENTIALS") - ) - if sa_json: - seed["service_account_json"] = sa_json home = _get_scoped_secret("GOOGLE_CHAT_HOME_CHANNEL") if home: - seed["home_channel"] = { - "chat_id": home, - "name": _get_scoped_secret("GOOGLE_CHAT_HOME_CHANNEL_NAME", "Home"), - } + seed["home_channel"] = {"chat_id": home, "name": _get_scoped_secret("GOOGLE_CHAT_HOME_CHANNEL_NAME", "Home")} return seed -def interactive_setup() -> None: - """Walk the user through Google Chat configuration via ``hermes setup``. +_SETUP_WALKTHROUGH = """Google Chat needs a GCP project, a Pub/Sub topic + subscription, +and a Service Account with Pub/Sub Subscriber on the subscription. +Walkthrough: + 1. Create or select a GCP project; enable Google Chat API + Cloud Pub/Sub API. + 2. Create a Service Account (no project-level IAM role needed). + 3. Create a Pub/Sub topic (e.g. hermes-chat-events) and a Pull subscription. + 4. On the TOPIC: add chat-api-push@system.gserviceaccount.com as Pub/Sub Publisher. + 5. On the SUBSCRIPTION: grant your Service Account Pub/Sub Subscriber. + 6. Download the Service Account JSON key. + 7. Google Chat API console → Configuration: connection = Cloud Pub/Sub, + point at the topic, enable 1:1 + group, restrict visibility. + 8. Install the bot in a space (fires ADDED_TO_SPACE and resolves its user_id). - The setup wizard at ``hermes_cli/gateway.py`` calls this for plugin - platforms instead of using the in-tree ``_PLATFORMS`` data block. The - flow mirrors the in-tree built-ins: print the GCP setup instructions, - prompt for env vars, persist them to ``~/.hermes/.env`` so the next - gateway restart picks them up. - """ - from hermes_cli.cli_output import ( - print_info, - print_success, - print_warning, - prompt, - prompt_yes_no, - ) +Full guide: website/docs/user-guide/messaging/google_chat.md +""" + + +def interactive_setup() -> None: + """``hermes setup`` wizard for plugin platforms: print GCP instructions, prompt + for env vars, persist them to ``~/.hermes/.env``.""" + from hermes_cli.cli_output import print_info, print_success, print_warning, prompt, prompt_yes_no from hermes_cli.config import get_env_value, save_env_value existing_sub = get_env_value("GOOGLE_CHAT_SUBSCRIPTION_NAME") @@ -3534,54 +2020,24 @@ def interactive_setup() -> None: print_info(f"Google Chat: already configured (subscription: {existing_sub})") if not prompt_yes_no("Reconfigure Google Chat?", False): return + for line in _SETUP_WALKTHROUGH.splitlines(): + print_info(line) - print_info("Google Chat needs a GCP project, a Pub/Sub topic + subscription,") - print_info("and a Service Account with Pub/Sub Subscriber on the subscription.") - print_info("Walkthrough:") - print_info(" 1. Create or select a GCP project; enable Google Chat API + Cloud Pub/Sub API.") - print_info(" 2. Create a Service Account (no project-level IAM role needed).") - print_info(" 3. Create a Pub/Sub topic (e.g. hermes-chat-events) and a Pull subscription.") - print_info(" 4. On the TOPIC: add chat-api-push@system.gserviceaccount.com as Pub/Sub Publisher.") - print_info(" 5. On the SUBSCRIPTION: grant your Service Account Pub/Sub Subscriber.") - print_info(" 6. Download the Service Account JSON key.") - print_info(" 7. Google Chat API console → Configuration: connection = Cloud Pub/Sub,") - print_info(" point at the topic, enable 1:1 + group, restrict visibility.") - print_info(" 8. Install the bot in a space (fires ADDED_TO_SPACE and resolves its user_id).") - print_info("") - print_info("Full guide: website/docs/user-guide/messaging/google_chat.md") - print_info("") - - project = prompt( - "GCP project ID (e.g. my-project)", - default=get_env_value("GOOGLE_CHAT_PROJECT_ID") or "", - ) - if not project: - print_warning("Project ID is required — skipping Google Chat setup") - return - save_env_value("GOOGLE_CHAT_PROJECT_ID", project.strip()) - - subscription = prompt( - "Pub/Sub subscription (projects//subscriptions/)", - default=get_env_value("GOOGLE_CHAT_SUBSCRIPTION_NAME") or "", - ) - if not subscription: - print_warning("Subscription is required — skipping Google Chat setup") - return - save_env_value("GOOGLE_CHAT_SUBSCRIPTION_NAME", subscription.strip()) - - sa_path = prompt( - "Path to Service Account JSON (or inline JSON)", - default=get_env_value("GOOGLE_CHAT_SERVICE_ACCOUNT_JSON") or "", - password=True, - ) - if sa_path: - save_env_value("GOOGLE_CHAT_SERVICE_ACCOUNT_JSON", sa_path.strip()) + for question, env_name, required_label, password in ( + ("GCP project ID (e.g. my-project)", "GOOGLE_CHAT_PROJECT_ID", "Project ID", False), + ("Pub/Sub subscription (projects//subscriptions/)", "GOOGLE_CHAT_SUBSCRIPTION_NAME", "Subscription", False), + ("Path to Service Account JSON (or inline JSON)", "GOOGLE_CHAT_SERVICE_ACCOUNT_JSON", None, True), + ): + value = prompt(question, default=get_env_value(env_name) or "", password=password) + if not value: + if required_label is None: + continue + print_warning(f"{required_label} is required — skipping Google Chat setup") + return + save_env_value(env_name, value.strip()) if prompt_yes_no("Restrict access to specific users? (recommended)", True): - allowed = prompt( - "Allowed user emails (comma-separated)", - default=get_env_value("GOOGLE_CHAT_ALLOWED_USERS") or "", - ) + allowed = prompt("Allowed user emails (comma-separated)", default=get_env_value("GOOGLE_CHAT_ALLOWED_USERS") or "") if allowed: save_env_value("GOOGLE_CHAT_ALLOWED_USERS", allowed.replace(" ", "")) print_success("Allowlist configured") @@ -3603,42 +2059,39 @@ def interactive_setup() -> None: print_info("Restart the gateway: hermes gateway restart") -# Strict resource-name pattern. ``spaces/`` and ``users/`` must -# only contain Google Chat's documented character set; anything else -# means a tampered chat_id trying to break out of the REST URL path -# (path traversal, ``?`` query injection, ``#`` fragment truncation). +# Strict resource-name pattern: anything outside Chat's documented character set +# is a tampered chat_id trying to break out of the REST URL path. _GCHAT_CHAT_ID_RE = re.compile(r"^(?:spaces|users)/[A-Za-z0-9_-]+$") +_STANDALONE_SA_ERRORS = { + "inline_invalid": "Google Chat standalone send: inline SA JSON is invalid: {exc}", + "not_found": "Google Chat standalone send: SA JSON file not found at {path}", + "file_invalid": "Google Chat standalone send: SA JSON file is invalid: {exc}", + "adc_foreign": ( + "Google Chat standalone send: ADC skipped for this profile: service-account credentials are set in " + "the process environment but not in this profile's secret scope" + ), + "adc_no_auth": ( + "Google Chat standalone send: no SA credentials configured and google-auth is not installed for ADC fallback" + ), + "adc_failed": ( + "Google Chat standalone send: no SA credentials configured and Application Default Credentials are " + "unavailable: {exc}" + ), +} + 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, + pconfig, chat_id: str, message: str, *, thread_id: Optional[str] = None, + media_files: Optional[List[str]] = None, force_document: bool = False, ) -> Dict[str, Any]: - """POST a single Google Chat message via the REST API without the SDK. + """POST a single Chat message via REST without the SDK. - Used by ``tools/send_message_tool._send_via_adapter`` when the gateway - runner is not in this process (e.g. ``hermes cron`` running as a - separate process from ``hermes gateway``). Without this hook, - ``deliver=google_chat`` cron jobs fail with ``No live adapter for - platform``. - - Configuration: requires service-account credentials via - ``GOOGLE_CHAT_SERVICE_ACCOUNT_JSON``, ``GOOGLE_APPLICATION_CREDENTIALS``, - or Application Default Credentials, and a space resource name as - ``chat_id`` (e.g. ``spaces/AAAA-BBBB`` or ``users/``). - - Security: ``chat_id`` is validated against the documented Google Chat - resource-name character set before substitution into the REST URL so - a tampered value cannot path-traverse or query-inject. - - ``media_files`` and ``force_document`` are accepted for signature - parity but are not implemented for the standalone path; messages with - attachments send as text-only. The live adapter handles attachments. + Used by ``tools/send_message_tool._send_via_adapter`` when the gateway runner + is not in this process (e.g. ``hermes cron``). Needs SA credentials + (``GOOGLE_CHAT_SERVICE_ACCOUNT_JSON`` / ``GOOGLE_APPLICATION_CREDENTIALS`` / + ADC) and a validated space resource name. ``media_files`` / ``force_document`` + are accepted for signature parity only; attachments send as text. """ if not chat_id: return {"error": "Google Chat standalone send: chat_id (space resource) is required"} @@ -3659,66 +2112,25 @@ async def _standalone_send( or _get_scoped_secret("GOOGLE_CHAT_SERVICE_ACCOUNT_JSON") or _get_scoped_secret("GOOGLE_APPLICATION_CREDENTIALS") ) - if service_account is None: return {"error": "Google Chat standalone send: google-auth not installed"} - try: from google.auth.transport.requests import Request as _GoogleAuthRequest except Exception as e: return {"error": f"Google Chat standalone send: google-auth import failed: {e}"} try: - if sa_value: - stripped = sa_value.lstrip() - if stripped.startswith("{"): - try: - info = json.loads(sa_value) - except json.JSONDecodeError as exc: - return {"error": f"Google Chat standalone send: inline SA JSON is invalid: {exc}"} - creds = service_account.Credentials.from_service_account_info(info, scopes=_CHAT_SCOPES) - else: - if not os.path.exists(sa_value): - return {"error": f"Google Chat standalone send: SA JSON file not found at {sa_value}"} - try: - with open(sa_value, "r", encoding="utf-8") as fh: - info = json.load(fh) - except json.JSONDecodeError as exc: - return {"error": f"Google Chat standalone send: SA JSON file is invalid: {exc}"} - creds = service_account.Credentials.from_service_account_info(info, scopes=_CHAT_SCOPES) - else: - if _adc_would_borrow_foreign_credentials(): - return {"error": ( - "Google Chat standalone send: ADC skipped for this profile: " - "service-account credentials are set in the process environment " - "but not in this profile's secret scope" - )} - try: - import google.auth as _google_auth - except ImportError: - return {"error": ( - "Google Chat standalone send: no SA credentials configured " - "and google-auth is not installed for ADC fallback" - )} - try: - creds, _project = _google_auth.default(scopes=_CHAT_SCOPES) - except Exception as exc: - return {"error": ( - f"Google Chat standalone send: no SA credentials configured " - f"and Application Default Credentials are unavailable: {exc}" - )} + creds = _load_sa_credentials_from(sa_value) + except _SACredentialError as err: + return {"error": _STANDALONE_SA_ERRORS[err.kind].format(exc=err.detail, path=sa_value)} except asyncio.CancelledError: raise except Exception as e: return {"error": f"Google Chat standalone send: credential load failed: {e}"} - # Bound the synchronous urllib3-backed token refresh so a hung Google - # STS endpoint cannot stall the cron scheduler indefinitely. + # Bound the synchronous token refresh so a hung STS endpoint can't stall cron. try: - await asyncio.wait_for( - asyncio.to_thread(creds.refresh, _GoogleAuthRequest()), - timeout=10.0, - ) + await asyncio.wait_for(asyncio.to_thread(creds.refresh, _GoogleAuthRequest()), timeout=10.0) except asyncio.TimeoutError: return {"error": "Google Chat standalone send: token refresh timed out"} except asyncio.CancelledError: @@ -3733,7 +2145,6 @@ async def _standalone_send( body: Dict[str, Any] = {"text": message} if thread_id: body["thread"] = {"name": thread_id} - url = f"https://chat.googleapis.com/v1/{chat_id}/messages" try: import aiohttp as _aiohttp @@ -3745,22 +2156,13 @@ async def _standalone_send( async with session.post( url, json=body, - headers={ - "Authorization": f"Bearer {token}", - "Content-Type": "application/json", - }, + headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"}, ) as resp: if resp.status >= 400: text = await resp.text() - return {"error": ( - f"Google Chat standalone send: API returned " - f"{resp.status}: {text[:300]}" - )} + return {"error": f"Google Chat standalone send: API returned {resp.status}: {text[:300]}"} payload = await resp.json() - return { - "success": True, - "message_id": payload.get("name"), - } + return {"success": True, "message_id": payload.get("name")} except asyncio.CancelledError: raise except Exception as e: @@ -3769,13 +2171,8 @@ async def _standalone_send( def register(ctx) -> None: - """Plugin entry point — called by the Hermes plugin system at startup. - - Registers the Google Chat adapter under the ``google_chat`` name. - The gateway's ``_create_adapter`` consults the platform registry - BEFORE its built-in if/elif chain, so this registration is what - drives adapter creation at runtime. - """ + """Plugin entry point: registers the ``google_chat`` adapter with the platform + registry (consulted by the gateway's ``_create_adapter`` before its built-ins).""" ctx.register_platform( name="google_chat", label="Google Chat", @@ -3783,31 +2180,18 @@ def register(ctx) -> None: check_fn=_check_for_registry, validate_config=_validate_config, is_connected=_is_connected, - required_env=[ - "GOOGLE_CHAT_SERVICE_ACCOUNT_JSON", - ], + required_env=["GOOGLE_CHAT_SERVICE_ACCOUNT_JSON"], install_hint="Run `hermes setup` to install Google Chat support.", setup_fn=interactive_setup, - # Env-driven auto-configuration — the core env-populator hook calls - # this during ``_apply_env_overrides`` and seeds - # ``PlatformConfig.extra`` + home_channel from env vars. Without this - # the adapter would still work on explicit config.yaml entries, but - # env-only setup (GOOGLE_CHAT_PROJECT_ID/_SUBSCRIPTION_NAME/...) would - # not flow through to ``gateway status`` or ``get_connected_platforms``. + # Seeds PlatformConfig.extra + home_channel from env during _apply_env_overrides. env_enablement_fn=_env_enablement, - # Cron home-channel delivery support. Lets ``deliver=google_chat`` - # cron jobs route to the configured home space without editing - # cron/scheduler.py's hardcoded sets. + # Cron home-channel delivery for ``deliver=google_chat`` jobs. cron_deliver_env_var="GOOGLE_CHAT_HOME_CHANNEL", - # Out-of-process cron delivery via the Chat REST API. Without this - # hook, deliver=google_chat cron jobs fail with "No live adapter" - # when cron runs separately from the gateway. + # Out-of-process cron delivery via the Chat REST API. standalone_sender_fn=_standalone_send, - # Auth env vars for _is_user_authorized() integration. allowed_users_env="GOOGLE_CHAT_ALLOWED_USERS", allow_all_env="GOOGLE_CHAT_ALLOW_ALL_USERS", - # Chat caps text messages at 4096 chars; we leave margin to fit - # the "Hermes is thinking..." marker patches and edit overhead. + # Chat caps text at 4096; margin for typing-marker patches and edit overhead. max_message_length=4000, emoji="💬", allow_update_command=True, diff --git a/plugins/platforms/google_chat/cards.py b/plugins/platforms/google_chat/cards.py new file mode 100644 index 0000000000..9305805156 --- /dev/null +++ b/plugins/platforms/google_chat/cards.py @@ -0,0 +1,201 @@ +"""Google Chat outbound text formatting and Cards v2 rendering. + +Extracted from ``adapter.py``; the card dict shapes and key order here are the +wire format and must stay byte-identical. +""" + +from __future__ import annotations + +import re +from typing import Any, Callable, Dict, List + +# Invisible Unicode codepoints that render as tofu (□) in Google Chat's +# restricted font stack: ZWS/ZWNJ/ZWJ, bidi marks, word joiner, BOM and +# Variation Selectors (Chat ignores them and often shows a blank box). +_INVISIBLE_RE = re.compile( + "[" + "\u200b" # Zero-Width Space + "\u200c" # Zero-Width Non-Joiner + "\u200d" # Zero-Width Joiner (ZWJ) + "\u200e\u200f" # LTR / RTL marks + "\u2060" # Word Joiner + "\ufeff" # BOM / Zero-Width No-Break Space + "\ufe00-\ufe0f" # Variation Selectors 1-16 (VS1–VS16) + "\U000e0100-\U000e01ef" # Variation Selectors 17-256 + "]" +) + + +def format_message(content: str) -> str: + """Convert standard Markdown to Google Chat's dialect. + + Chat renders only ``*bold*``, ``_italic_``, ``~strike~`` and code; ``**bold**``, + ``# headers`` and ``[text](url)`` must be converted. Fenced and inline code + are protected via placeholders so literal asterisks/brackets inside them + survive; invisible tofu codepoints are stripped at the end. + """ + if not content: + return content + + text = content + placeholders: Dict[str, str] = {} + counter = [0] + + def _ph(value: str) -> str: + key = f"\x00GC{counter[0]}\x00" + counter[0] += 1 + placeholders[key] = value + return key + + # Protect fenced blocks first, then inline code. + text = re.sub(r"(```(?:[^\n]*\n)?[\s\S]*?```)", lambda m: _ph(m.group(0)), text) + text = re.sub(r"(`[^`]+`)", lambda m: _ph(m.group(0)), text) + # Headers (## Title) → *Title* (Chat has no header support). + text = re.sub(r"^#{1,6}\s+(.+)$", lambda m: _ph(f"*{m.group(1).strip()}*"), text, flags=re.MULTILINE) + # ***text*** → *_text_*, then **text** → *text*. + text = re.sub(r"\*\*\*(.+?)\*\*\*", lambda m: _ph(f"*_{m.group(1)}_*"), text) + text = re.sub(r"\*\*(.+?)\*\*", lambda m: _ph(f"*{m.group(1)}*"), text) + # [text](url) → (Slack-style angle-bracket). + text = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", lambda m: _ph(f"<{m.group(2)}|{m.group(1)}>"), text) + text = _INVISIBLE_RE.sub("", text) + # Collapse double spaces left over from stripped chars. + text = re.sub(r" +", " ", text) + for key, value in placeholders.items(): + text = text.replace(key, value) + return text + + +def _required_str(mapping: Dict[str, Any], key: str, context: str) -> str: + value = mapping.get(key) + if value is None: + raise ValueError(f"{context}.{key} is required") + value = str(value).strip() + if not value: + raise ValueError(f"{context}.{key} is required") + return value + + +def _button_to_chat(button: Dict[str, Any]) -> Dict[str, Any]: + text = _required_str(button, "text", "button") + action = _required_str(button, "action", "button") + raw_params = button.get("parameters") or {} + if not isinstance(raw_params, dict): + raise ValueError("button.parameters must be an object") + parameters = [{"key": str(key), "value": str(value)} for key, value in sorted(raw_params.items())] + return { + "text": text, + "onClick": {"action": {"function": action, "parameters": parameters}}, + } + + +def _text_widget(widget: Dict[str, Any]) -> Dict[str, Any]: + return {"textParagraph": {"text": format_message(_required_str(widget, "text", "widget"))}} + + +def _decorated_text_widget(widget: Dict[str, Any]) -> Dict[str, Any]: + decorated: Dict[str, Any] = { + "text": format_message(_required_str(widget, "text", "widget")), + "wrapText": bool(widget.get("wrap_text", True)), + } + if widget.get("top_label"): + decorated["topLabel"] = str(widget["top_label"]) + if widget.get("bottom_label"): + decorated["bottomLabel"] = str(widget["bottom_label"]) + return {"decoratedText": decorated} + + +def _image_widget(widget: Dict[str, Any]) -> Dict[str, Any]: + image = {"imageUrl": _required_str(widget, "image_url", "widget")} + if widget.get("alt_text"): + image["altText"] = str(widget["alt_text"]) + return {"image": image} + + +def _buttons_widget(widget: Dict[str, Any]) -> Dict[str, Any]: + raw_buttons = widget.get("buttons") or [] + if not isinstance(raw_buttons, list) or not raw_buttons: + raise ValueError("button widgets require at least one button") + return {"buttonList": {"buttons": [_button_to_chat(btn) for btn in raw_buttons]}} + + +def _selection_widget(widget: Dict[str, Any]) -> Dict[str, Any]: + name = _required_str(widget, "name", "widget") + raw_items = widget.get("items") or [] + if not isinstance(raw_items, list) or not raw_items: + raise ValueError("selection widgets require at least one item") + items: List[Dict[str, Any]] = [] + for item in raw_items: + if not isinstance(item, dict): + raise ValueError("selection items must be objects") + items.append({ + "text": _required_str(item, "text", "selection item"), + "value": _required_str(item, "value", "selection item"), + "selected": bool(item.get("selected", False)), + }) + return { + "selectionInput": { + "name": name, + "label": str(widget.get("label") or name), + "type": str(widget.get("selection_type") or "CHECK_BOX"), + "items": items, + } + } + + +_WIDGET_RENDERERS: Dict[str, Callable[[Dict[str, Any]], Dict[str, Any]]] = { + "text": _text_widget, + "text_paragraph": _text_widget, + "decorated_text": _decorated_text_widget, + "buttons": _buttons_widget, + "button_list": _buttons_widget, + "selection": _selection_widget, + "selection_input": _selection_widget, + "image": _image_widget, + "divider": lambda widget: {"divider": {}}, +} + + +def _widget_to_chat(widget: Dict[str, Any]) -> Dict[str, Any]: + if not isinstance(widget, dict): + raise ValueError("card widgets must be objects") + widget_type = str(widget.get("type") or "").strip() + renderer = _WIDGET_RENDERERS.get(widget_type) + if renderer is None: + raise ValueError(f"unsupported widget type: {widget_type or ''}") + return renderer(widget) + + +def card_spec_to_cards_v2(card_spec: Dict[str, Any]) -> Dict[str, Any]: + if not isinstance(card_spec, dict): + raise ValueError("card must be an object") + raw_sections = card_spec.get("sections") or [] + if not isinstance(raw_sections, list) or not raw_sections: + raise ValueError("card.sections must contain at least one section") + + sections: List[Dict[str, Any]] = [] + for section in raw_sections: + if not isinstance(section, dict): + raise ValueError("card sections must be objects") + widgets = section.get("widgets") or [] + if not isinstance(widgets, list) or not widgets: + raise ValueError("card section widgets must contain at least one widget") + rendered: Dict[str, Any] = {"widgets": [_widget_to_chat(w) for w in widgets]} + if section.get("header"): + rendered["header"] = str(section["header"]) + sections.append(rendered) + + card: Dict[str, Any] = {"sections": sections} + header = card_spec.get("header") + if header: + if not isinstance(header, dict): + raise ValueError("card.header must be an object") + rendered_header: Dict[str, Any] = {"title": _required_str(header, "title", "card.header")} + if header.get("subtitle"): + rendered_header["subtitle"] = str(header["subtitle"]) + if header.get("image_url"): + rendered_header["imageUrl"] = str(header["image_url"]) + rendered_header["imageType"] = str(header.get("image_type") or "SQUARE") + if header.get("image_alt_text"): + rendered_header["imageAltText"] = str(header["image_alt_text"]) + card["header"] = rendered_header + return {"cardId": str(card_spec.get("card_id") or "hermes-card"), "card": card} diff --git a/plugins/platforms/google_chat/oauth.py b/plugins/platforms/google_chat/oauth.py index 22bc62113d..4b4891942d 100644 --- a/plugins/platforms/google_chat/oauth.py +++ b/plugins/platforms/google_chat/oauth.py @@ -1,57 +1,26 @@ """User OAuth helper for the Google Chat gateway adapter. -Google Chat's ``media.upload`` REST endpoint hard-rejects service-account -authentication: +Google Chat's ``media.upload`` hard-rejects service-account auth ("This method +doesn't support app authentication with a service account"), so for native +file attachments each user grants the bot ``chat.messages.create`` ONCE in +their own DM. The bot stores per-user refresh tokens and uploads *as the user*. +See https://developers.google.com/chat/api/guides/auth/users. - "This method doesn't support app authentication with a service - account. Authenticate with a user account." +Both a library (imported by the adapter) and a CLI (driven by ``/setup-files``): -(See https://developers.google.com/workspace/chat/api/reference/rest/v1/media/upload -and https://developers.google.com/chat/api/guides/auth/users.) - -For the bot to deliver native file attachments — the same drag-and-drop -file widget the user gets when they upload manually — each user must -grant the bot the ``chat.messages.create`` scope ONCE in their own DM. -The bot stores per-user refresh tokens and calls ``media.upload`` plus -the subsequent ``messages.create`` *as the requesting user* whenever a -file needs sending. - -This module is BOTH a CLI tool (driven by the agent via slash commands or -terminal commands) AND a library imported by ``google_chat.py``: - - Library functions (called from the adapter at runtime): - load_user_credentials(email=None) -> Credentials | None - refresh_or_none(creds, email=None) -> Credentials | None - build_user_chat_service(creds) -> chat_v1.Resource - list_authorized_emails() -> List[str] - - CLI commands (driven by the agent through the /setup-files slash - command, modeled on skills/productivity/google-workspace/scripts/setup.py): - --check Exit 0 if auth is valid, else 1 - --client-secret /path/to.json Persist OAuth client credentials - --auth-url Print the OAuth URL for the user - --auth-code CODE Exchange auth code for token - --revoke Revoke and delete stored token - --install-deps Install Python dependencies - --email EMAIL Scope CLI ops to a specific user - (defaults to legacy single-user - mode when omitted) - -The flow mirrors the existing google-workspace skill exactly so anyone -familiar with that flow can read this without surprises. + Library: load_user_credentials(email=None), refresh_or_none(creds, email=None), + build_user_chat_service(creds), list_authorized_emails() + CLI: --check | --client-secret PATH | --auth-url | --auth-code CODE | + --revoke | --install-deps [--email EMAIL] (legacy single-user + mode when --email is omitted) Token storage layout -------------------- -- Per-user tokens (keyed by sender email): - ``${HERMES_HOME}/google_chat_user_tokens/.json`` -- Legacy single-user token (fallback, untouched for backward compat): - ``${HERMES_HOME}/google_chat_user_token.json`` -- Per-user pending OAuth state during /setup-files start → exchange: - ``${HERMES_HOME}/google_chat_user_oauth_pending/.json`` -- Legacy pending state: - ``${HERMES_HOME}/google_chat_user_oauth_pending.json`` -- OAuth client secret (profile-scoped — each profile registers its own): - ``${HERMES_HOME}/google_chat_user_client_secret.json`` +- Per-user tokens: ``${HERMES_HOME}/google_chat_user_tokens/.json`` +- Legacy single-user: ``${HERMES_HOME}/google_chat_user_token.json`` +- Per-user pending PKCE state: ``${HERMES_HOME}/google_chat_user_oauth_pending/.json`` +- Legacy pending state: ``${HERMES_HOME}/google_chat_user_oauth_pending.json`` +- OAuth client secret (profile-scoped): ``${HERMES_HOME}/google_chat_user_client_secret.json`` """ from __future__ import annotations @@ -63,26 +32,20 @@ import os import re import secrets import stat -import subprocess import sys from importlib.metadata import version as _distribution_version from pathlib import Path -from typing import Any, List, Optional, Tuple +from typing import Any, List, NoReturn, Optional, Tuple from packaging.requirements import Requirement -# Pin the legacy logger name so operator-side log filters keep matching -# after the in-tree → plugin migration. See adapter.py for context. +# Pinned legacy logger name so operator log filters keep matching (see adapter.py). logger = logging.getLogger("gateway.platforms.google_chat_user_oauth") -# Use the project's HERMES_HOME helper so the token follows the user's -# profile (e.g. tests can override via HERMES_HOME=/tmp/...). try: from hermes_constants import display_hermes_home, get_hermes_home except (ModuleNotFoundError, ImportError): - # Fallback for environments where hermes_constants isn't importable - # (mirrors the same fallback used by the google-workspace skill's - # _hermes_home.py shim). + # Mirrors the google-workspace skill's _hermes_home.py shim. def get_hermes_home() -> Path: val = os.environ.get("HERMES_HOME", "").strip() return Path(val) if val else Path.home() / ".hermes" @@ -98,19 +61,12 @@ from utils import atomic_replace def _hermes_home() -> Path: - """Resolve HERMES_HOME at call time (NOT module import). - - Tests and ``HERMES_HOME=...`` env overrides need this to be late- - binding. If we cached the path at import time, switching profiles - or tweaking env vars in tests would silently keep using the old - path.""" + """Resolve HERMES_HOME at call time (late-binding for tests / profile switches).""" return get_hermes_home() -# Filesystem-safe key: lowercase, allow ``[a-z0-9._-@]``, replace anything -# else with ``_``. ``ramon.fernandez@nttdata.com`` stays human-readable -# (``ramon.fernandez@nttdata.com.json``) which makes admin debugging by -# ``ls ~/.hermes/google_chat_user_tokens/`` trivial. +# Filesystem-safe key: lowercase, keep ``[a-z0-9._-@]`` so token files stay +# human-readable under ``ls ~/.hermes/google_chat_user_tokens/``. _EMAIL_FS_RE = re.compile(r"[^a-z0-9._@-]+") @@ -119,27 +75,15 @@ def _sanitize_email(email: str) -> str: return cleaned or "_unknown_" -def _legacy_token_path() -> Path: - return _hermes_home() / "google_chat_user_token.json" - - def _user_tokens_dir() -> Path: return _hermes_home() / "google_chat_user_tokens" -def _legacy_pending_path() -> Path: - return _hermes_home() / "google_chat_user_oauth_pending.json" - - -def _user_pending_dir() -> Path: - return _hermes_home() / "google_chat_user_oauth_pending" - - def _token_path(email: Optional[str] = None) -> Path: - """Return the on-disk token path for ``email`` or the legacy path.""" + """Per-user token path for ``email``, or the legacy single-user path.""" if email: return _user_tokens_dir() / f"{_sanitize_email(email)}.json" - return _legacy_token_path() + return _hermes_home() / "google_chat_user_token.json" def _client_secret_path() -> Path: @@ -148,14 +92,12 @@ def _client_secret_path() -> Path: def _pending_auth_path(email: Optional[str] = None) -> Path: if email: - return _user_pending_dir() / f"{_sanitize_email(email)}.json" - return _legacy_pending_path() + return _hermes_home() / "google_chat_user_oauth_pending" / f"{_sanitize_email(email)}.json" + return _hermes_home() / "google_chat_user_oauth_pending.json" -# Minimum scope for native Chat attachment delivery. -# `chat.messages.create` covers BOTH `media.upload` and the subsequent -# `messages.create` that references the attachmentDataRef. We deliberately -# do NOT request drive.file or other scopes — least privilege. +# Least privilege: chat.messages.create covers BOTH media.upload and the +# subsequent messages.create; no drive.file or other scopes. SCOPES: List[str] = [ "https://www.googleapis.com/auth/chat.messages.create", ] @@ -171,10 +113,8 @@ _REQUIRED_PACKAGES = [ "pyasn1==0.6.4", ] -# Out-of-band redirect: Google deprecated the ``urn:ietf:wg:oauth:2.0:oob`` -# flow, so we use a localhost redirect that's expected to FAIL. The user -# copies the auth code from the failed browser URL bar back into chat. -# Same trick used by skills/productivity/google-workspace/scripts/setup.py. +# Google deprecated the ``oob`` flow: use a localhost redirect that is expected +# to FAIL; the user pastes the code from the failed browser URL back into chat. _REDIRECT_URI = "http://localhost:1" @@ -183,30 +123,37 @@ _REDIRECT_URI = "http://localhost:1" # ============================================================================= +def _refresh_and_persist(creds: Any, token_path: Path, request_cls: Any) -> Optional[Any]: + """Refresh expired creds and write them back; None when unusable or refresh fails.""" + if creds.valid: + return creds + if creds.expired and creds.refresh_token: + try: + creds.refresh(request_cls()) + except Exception as exc: + logger.warning("[google_chat_user_oauth] token refresh failed (user should re-run /setup-files): %s", exc) + return None + _persist_credentials(creds, token_path) + return creds + # Token exists but is unusable (e.g. revoked, no refresh token). + return None + + def load_user_credentials(email: Optional[str] = None) -> Optional[Any]: """Load + validate persisted user OAuth credentials. - ``email`` selects the per-user token file; ``None`` falls back to the - legacy single-user path (left in place for installs that ran the - pre-multi-user flow). Returns a ``google.oauth2.credentials.Credentials`` - instance ready for use, or ``None`` if no token is stored, the token - is corrupt, or refresh fails. Adapter callers should treat ``None`` - as "user has not run /setup-files yet" and surface the setup-instructions - fallback to the user. - - Does NOT raise on the no-token case — that's expected. + ``None`` email → legacy single-user path. Returns ``None`` (never raises) when + no token is stored, the token is corrupt, or refresh fails — callers treat + that as "user has not run /setup-files yet". """ token_path = _token_path(email) if not token_path.exists(): return None - # Same class as slack_tokens.json: hand-provisioned or legacy-written - # token files commonly end up 0o644. Warn so the owner tightens them. + # Hand-provisioned / legacy token files commonly end up 0o644; warn the owner. from utils import warn_if_credential_file_broadly_readable - warn_if_credential_file_broadly_readable( - token_path, label="[google_chat_user_oauth]", log=logger - ) + warn_if_credential_file_broadly_readable(token_path, label="[google_chat_user_oauth]", log=logger) try: from google.oauth2.credentials import Credentials @@ -219,113 +166,58 @@ def load_user_credentials(email: Optional[str] = None) -> Optional[Any]: return None try: - # Don't pass scopes — user may have authorized only a subset, and - # passing scopes makes refresh validate them strictly. Same logic - # as the google-workspace skill. + # No scopes: the user may have authorized a subset, and passing scopes + # makes refresh validate them strictly. creds = Credentials.from_authorized_user_file(str(token_path)) except Exception as exc: - logger.warning( - "[google_chat_user_oauth] token at %s is corrupt: %s", - token_path, exc, - ) + logger.warning("[google_chat_user_oauth] token at %s is corrupt: %s", token_path, exc) return None - - if creds.valid: - return creds - - if creds.expired and creds.refresh_token: - try: - creds.refresh(Request()) - except Exception as exc: - logger.warning( - "[google_chat_user_oauth] token refresh failed (user " - "should re-run /setup-files): %s", exc, - ) - return None - # Persist refreshed token so next start picks up the new access - # token without an unnecessary refresh round-trip. - _persist_credentials(creds, token_path) - return creds - - # Token exists but is unusable (e.g. revoked, no refresh token). - return None + return _refresh_and_persist(creds, token_path, Request) def refresh_or_none(creds: Any, email: Optional[str] = None) -> Optional[Any]: - """Refresh ``creds`` if expired. Returns the credentials or ``None``. - - Used by the adapter just before calling media.upload to ensure the - token is current. Returns ``None`` if refresh fails — caller falls - back to the text-notice path. ``email`` controls where the refreshed - token is written back; ``None`` keeps the legacy single-file path. - """ + """Refresh ``creds`` if expired; ``None`` on failure (caller falls back to the + text-notice path). ``email`` selects where the refreshed token is written.""" if creds is None: return None - if creds.valid: return creds - try: from google.auth.transport.requests import Request except ImportError: return None - if creds.expired and creds.refresh_token: try: creds.refresh(Request()) _persist_credentials(creds, _token_path(email)) return creds except Exception as exc: - logger.warning( - "[google_chat_user_oauth] refresh failed: %s", exc, - ) + logger.warning("[google_chat_user_oauth] refresh failed: %s", exc) return None - return None def build_user_chat_service(creds: Any) -> Any: - """Build a Google Chat API client authenticated as the user. - - Used for media.upload + the subsequent messages.create that - references the attachmentDataRef. The bot's separate SA-authed - client (``self._chat_api`` in the adapter) is for everything else. - """ + """Chat API client authenticated as the user (for media.upload + messages.create).""" from googleapiclient.discovery import build as build_service return build_service("chat", "v1", credentials=creds, cache_discovery=False) def list_authorized_emails() -> List[str]: - """Return the set of user emails that have stored per-user tokens. - - Lists files in the per-user tokens dir; does NOT include the legacy - single-user token (its owner is unknown). Sanitized filenames lose - the ``+suffix`` part of plus-addressed emails — accept that and use - this list only for admin display, not for trust decisions. - """ + """Sanitized emails with stored per-user tokens (admin display only, not trust; + excludes the legacy single-user token whose owner is unknown).""" d = _user_tokens_dir() if not d.exists(): return [] - out: List[str] = [] - for f in d.iterdir(): - if f.is_file() and f.suffix == ".json": - out.append(f.stem) - out.sort() - return out + return sorted(f.stem for f in d.iterdir() if f.is_file() and f.suffix == ".json") def _persist_credentials(creds: Any, token_path: Path) -> None: """Persist refreshed credentials atomically with private permissions.""" try: - _write_private_json( - token_path, - _normalize_authorized_user_payload(json.loads(creds.to_json())), - ) + _write_private_json(token_path, _normalize_authorized_user_payload(json.loads(creds.to_json()))) except Exception: - logger.debug( - "[google_chat_user_oauth] failed to persist credentials at %s", - token_path, exc_info=True, - ) + logger.debug("[google_chat_user_oauth] failed to persist credentials at %s", token_path, exc_info=True) # ============================================================================= @@ -351,11 +243,7 @@ def _write_private_json(path: Path, data: Any) -> None: tmp_path = path.with_suffix(f".tmp.{os.getpid()}.{secrets.token_hex(4)}") try: - fd = os.open( - str(tmp_path), - os.O_WRONLY | os.O_CREAT | os.O_EXCL, - stat.S_IRUSR | stat.S_IWUSR, - ) + fd = os.open(str(tmp_path), os.O_WRONLY | os.O_CREAT | os.O_EXCL, stat.S_IRUSR | stat.S_IWUSR) with os.fdopen(fd, "w", encoding="utf-8") as fh: json.dump(data, fh, indent=2, ensure_ascii=False) fh.flush() @@ -373,6 +261,13 @@ def _write_private_json(path: Path, data: Any) -> None: pass +def _fail(*lines: str) -> NoReturn: + """Print CLI error lines and exit 1.""" + for line in lines: + print(line) + sys.exit(1) + + def _ensure_deps() -> None: """Check exact dependency versions; install if stale; exit on failure.""" if _missing_required_packages() and not install_deps(): @@ -409,9 +304,7 @@ def install_deps() -> bool: raise RuntimeError((result.stderr or "install failed").strip()[:300]) remaining = _missing_required_packages() if remaining: - raise RuntimeError( - "dependencies remain stale after install: " + " ".join(remaining) - ) + raise RuntimeError("dependencies remain stale after install: " + " ".join(remaining)) print("Dependencies installed.") return True except Exception as exc: @@ -421,20 +314,14 @@ def install_deps() -> bool: def check_auth(email: Optional[str] = None) -> bool: - """Print status; return True if creds are usable. - - Per-user when ``email`` given, legacy single-user when omitted. - """ + """Print status; return True if creds are usable.""" token_path = _token_path(email) if not token_path.exists(): print(f"NOT_AUTHENTICATED: No token at {token_path}") return False - - creds = load_user_credentials(email) - if creds is None: + if load_user_credentials(email) is None: print(f"TOKEN_INVALID: Re-run /setup-files (path: {token_path})") return False - print(f"AUTHENTICATED: Token valid at {token_path}") return True @@ -443,35 +330,24 @@ def store_client_secret(path: str) -> None: """Validate and copy the user's OAuth client_secret.json into HERMES_HOME.""" src = Path(path).expanduser().resolve() if not src.exists(): - print(f"ERROR: File not found: {src}") - sys.exit(1) - + _fail(f"ERROR: File not found: {src}") try: data = json.loads(src.read_text(encoding="utf-8")) except json.JSONDecodeError: - print("ERROR: File is not valid JSON.") - sys.exit(1) - + _fail("ERROR: File is not valid JSON.") if "installed" not in data and "web" not in data: - print( - "ERROR: Not a Google OAuth client secret file (missing " - "'installed' or 'web' key)." + _fail( + "ERROR: Not a Google OAuth client secret file (missing 'installed' or 'web' key).", + "Download from: https://console.cloud.google.com/apis/credentials", ) - print( - "Download from: https://console.cloud.google.com/apis/credentials" - ) - sys.exit(1) - target = _client_secret_path() _write_private_json(target, data) print(f"OK: Client secret saved to {target}") -def _save_pending_auth(*, state: str, code_verifier: str, - email: Optional[str] = None) -> None: - pending = _pending_auth_path(email) +def _save_pending_auth(*, state: str, code_verifier: str, email: Optional[str] = None) -> None: _write_private_json( - pending, + _pending_auth_path(email), { "state": state, "code_verifier": code_verifier, @@ -484,18 +360,13 @@ def _save_pending_auth(*, state: str, code_verifier: str, def _load_pending_auth(email: Optional[str] = None) -> dict: pending = _pending_auth_path(email) if not pending.exists(): - print("ERROR: No pending OAuth session found. Run --auth-url first.") - sys.exit(1) + _fail("ERROR: No pending OAuth session found. Run --auth-url first.") try: data = json.loads(pending.read_text(encoding="utf-8")) except Exception as exc: - print(f"ERROR: Could not read pending OAuth session: {exc}") - print("Run --auth-url again to start a fresh session.") - sys.exit(1) + _fail(f"ERROR: Could not read pending OAuth session: {exc}", "Run --auth-url again to start a fresh session.") if not data.get("state") or not data.get("code_verifier"): - print("ERROR: Pending OAuth session is missing PKCE data.") - print("Run --auth-url again.") - sys.exit(1) + _fail("ERROR: Pending OAuth session is missing PKCE data.", "Run --auth-url again.") return data @@ -506,25 +377,21 @@ def _extract_code_and_state(code_or_url: str) -> Tuple[str, Optional[str]]: from urllib.parse import parse_qs, urlparse - parsed = urlparse(code_or_url) - params = parse_qs(parsed.query) + params = parse_qs(urlparse(code_or_url).query) if "code" not in params: - print("ERROR: No 'code' parameter found in URL.") - sys.exit(1) - state = params.get("state", [None])[0] - return params["code"][0], state + _fail("ERROR: No 'code' parameter found in URL.") + return params["code"][0], params.get("state", [None])[0] + + +def _require_client_secret() -> None: + if not _client_secret_path().exists(): + _fail("ERROR: No client secret stored. Run --client-secret first.") def get_auth_url(email: Optional[str] = None) -> None: - """Print the OAuth URL for the user to visit. Persists PKCE state. - - ``email`` namespaces the pending state so two users can be mid-flow - in parallel without trampling each other's PKCE verifier. - """ - if not _client_secret_path().exists(): - print("ERROR: No client secret stored. Run --client-secret first.") - sys.exit(1) - + """Print the OAuth URL for the user to visit; persists PKCE state under ``email`` + so two users can be mid-flow in parallel.""" + _require_client_secret() _ensure_deps() from google_auth_oauthlib.flow import Flow @@ -534,34 +401,20 @@ def get_auth_url(email: Optional[str] = None) -> None: redirect_uri=_REDIRECT_URI, autogenerate_code_verifier=True, ) - auth_url, state = flow.authorization_url( - access_type="offline", - prompt="consent", - ) + auth_url, state = flow.authorization_url(access_type="offline", prompt="consent") _save_pending_auth(state=state, code_verifier=flow.code_verifier, email=email) print(auth_url) def exchange_auth_code(code: str, email: Optional[str] = None) -> None: - """Exchange an auth code (or pasted redirect URL) for a refresh token. - - ``email`` selects the destination token path. ``None`` writes to the - legacy single-user path (kept for the existing CLI entrypoint and for - pre-multi-user installs). - """ - if not _client_secret_path().exists(): - print("ERROR: No client secret stored. Run --client-secret first.") - sys.exit(1) - + """Exchange an auth code (or pasted redirect URL) for a refresh token stored + at the per-user path for ``email`` (legacy single-user path when None).""" + _require_client_secret() pending_auth = _load_pending_auth(email) raw_callback = code code, returned_state = _extract_code_and_state(code) if returned_state and returned_state != pending_auth["state"]: - print( - "ERROR: OAuth state mismatch. Run --auth-url again to start a " - "fresh session." - ) - sys.exit(1) + _fail("ERROR: OAuth state mismatch. Run --auth-url again to start a fresh session.") _ensure_deps() from google_auth_oauthlib.flow import Flow @@ -581,24 +434,16 @@ def exchange_auth_code(code: str, email: Optional[str] = None) -> None: state=pending_auth["state"], code_verifier=pending_auth["code_verifier"], ) - try: # Accept partial scopes — user may deselect items in the consent screen. os.environ["OAUTHLIB_RELAX_TOKEN_SCOPE"] = "1" flow.fetch_token(code=code) except Exception as exc: - print(f"ERROR: Token exchange failed: {exc}") - print("The code may have expired. Run --auth-url to get a fresh URL.") - sys.exit(1) + _fail(f"ERROR: Token exchange failed: {exc}", "The code may have expired. Run --auth-url to get a fresh URL.") creds = flow.credentials token_payload = _normalize_authorized_user_payload(json.loads(creds.to_json())) - - actually_granted = ( - list(creds.granted_scopes or []) - if hasattr(creds, "granted_scopes") and creds.granted_scopes - else [] - ) + actually_granted = list(creds.granted_scopes or []) if hasattr(creds, "granted_scopes") and creds.granted_scopes else [] if actually_granted: token_payload["scopes"] = actually_granted elif granted_scopes != SCOPES: @@ -618,10 +463,7 @@ def exchange_auth_code(code: str, email: Optional[str] = None) -> None: def revoke(email: Optional[str] = None) -> None: - """Revoke the stored token with Google and delete it locally. - - Per-user when ``email`` given, legacy single-user when omitted. - """ + """Revoke the stored token with Google and delete it locally.""" token_path = _token_path(email) if not token_path.exists(): print("No token to revoke.") @@ -659,21 +501,16 @@ def main() -> None: description="Google Chat user-OAuth setup for Hermes (native attachment delivery)" ) group = parser.add_mutually_exclusive_group(required=True) - group.add_argument("--check", action="store_true", - help="Check if auth is valid (exit 0=yes, 1=no)") - group.add_argument("--client-secret", metavar="PATH", - help="Store OAuth client_secret.json") - group.add_argument("--auth-url", action="store_true", - help="Print OAuth URL for user to visit") - group.add_argument("--auth-code", metavar="CODE", - help="Exchange auth code for token") - group.add_argument("--revoke", action="store_true", - help="Revoke and delete stored token") - group.add_argument("--install-deps", action="store_true", - help="Install Python dependencies") - parser.add_argument("--email", metavar="EMAIL", default=None, - help="Scope operation to a specific user's token " - "(default: legacy single-user path)") + group.add_argument("--check", action="store_true", help="Check if auth is valid (exit 0=yes, 1=no)") + group.add_argument("--client-secret", metavar="PATH", help="Store OAuth client_secret.json") + group.add_argument("--auth-url", action="store_true", help="Print OAuth URL for user to visit") + group.add_argument("--auth-code", metavar="CODE", help="Exchange auth code for token") + group.add_argument("--revoke", action="store_true", help="Revoke and delete stored token") + group.add_argument("--install-deps", action="store_true", help="Install Python dependencies") + parser.add_argument( + "--email", metavar="EMAIL", default=None, + help="Scope operation to a specific user's token (default: legacy single-user path)", + ) args = parser.parse_args() email = args.email or None diff --git a/plugins/platforms/google_chat/setup_files.py b/plugins/platforms/google_chat/setup_files.py new file mode 100644 index 0000000000..57d10d3dbc --- /dev/null +++ b/plugins/platforms/google_chat/setup_files.py @@ -0,0 +1,170 @@ +"""``/setup-files`` in-chat OAuth setup flow for native attachment delivery. + +Extracted from ``adapter.py``: ``GoogleChatAdapter._handle_setup_files_command`` +delegates here. Logs under the adapter's pinned logger name. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import io +import logging +from typing import Any, Callable, Dict, Optional + +logger = logging.getLogger("gateway.platforms.google_chat") + +_NOT_CONFIGURED_TEXT = ( + "🔧 Native attachment delivery is **not configured**.\n" + "**Step 1 (one-time, on the host):** create OAuth client credentials at " + "https://console.cloud.google.com/apis/credentials → *Create credentials* → " + "*OAuth client ID* → *Desktop app*. Download the JSON. Then on the host run:\n" + "```\npython -m plugins.platforms.google_chat.oauth --client-secret /path/to/client_secret.json\n```\n" + "**Step 2:** come back here and send `/setup-files start`." +) +_START_INSTRUCTIONS = ( + "1. Open this URL in your browser and authorize:\n{auth_url}\n\n" + "2. After clicking *Allow*, your browser will fail to load " + "`http://localhost:1/?...&code=...`. That's expected.\n\n" + "3. Copy the entire failed URL from the browser's URL bar and paste it back here as: " + "`/setup-files ` (or just the `code=...` value).\n\n" + "Tip: the URL contains your access grant — keep it private." +) + + +async def _run_captured(fn: Callable[..., Any], *args: Any) -> str: + """Run ``fn`` in a thread with stdout captured (the oauth helpers print their output).""" + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + await asyncio.to_thread(fn, *args) + return buf.getvalue() + + +async def handle_setup_files_command( + adapter: Any, + chat_id: str, + thread_id: Optional[str], + raw_text: str, + sender_email: Optional[str] = None, +) -> bool: + """Run the in-chat OAuth setup flow. Returns True when the message was consumed. + + ``sender_email`` is the per-user OAuth key; ``None`` falls back to the legacy + single-user token slot so pre-multi-user installs keep working. + Subcommands: ``/setup-files`` (status), ``start`` (OAuth URL), ``revoke``, + ```` (exchange). Requires client_secret.json on the host. + """ + from . import oauth as oauth_helper + + # Same normalization as the token-path sanitizer so cache lookups stay consistent. + sender_key = sender_email.strip().lower() if sender_email else None + parts = raw_text.split(maxsplit=1) + arg = parts[1].strip() if len(parts) > 1 else "" + + async def _reply(text: str) -> None: + body: Dict[str, Any] = {"text": text} + if thread_id: + body["thread"] = {"name": thread_id} + try: + await adapter._create_message(chat_id, body) + except Exception: + logger.debug("[GoogleChat] /setup-files reply send failed", exc_info=True) + + if not arg: + client_secret_present = oauth_helper._client_secret_path().exists() + token_path = oauth_helper._token_path(sender_key) + creds = oauth_helper.load_user_credentials(sender_key) if token_path.exists() else None + if creds is not None: + who = sender_key or "shared (legacy)" + await _reply( + f"✅ Native attachment delivery is **active** for `{who}`.\n" + f"Token: `{token_path}`\nSend `/setup-files revoke` to disable." + ) + elif not client_secret_present: + await _reply(_NOT_CONFIGURED_TEXT) + else: + await _reply( + "🔧 Client credentials are stored but you haven't authorized yet. " + "Send `/setup-files start` to begin." + ) + return True + + if arg == "start": + if not oauth_helper._client_secret_path().exists(): + await _reply( + "⚠️ No client credentials stored for this profile. Send " + "`/setup-files` (no args) for setup instructions." + ) + return True + try: + output = await _run_captured(oauth_helper.get_auth_url, sender_key) + auth_url = output.strip().splitlines()[-1] + except SystemExit: + await _reply( + "❌ Couldn't generate the OAuth URL. Check the gateway logs and verify " + "the client_secret.json is valid." + ) + return True + except Exception as exc: + logger.warning("[GoogleChat] /setup-files start failed: %s", exc) + await _reply(f"❌ Error: {exc}") + return True + await _reply(_START_INSTRUCTIONS.format(auth_url=auth_url)) + return True + + if arg == "revoke": + try: + output = (await _run_captured(oauth_helper.revoke, sender_key)).strip() or "Revoked." + except SystemExit: + output = "Revoke completed (some steps may have been skipped)." + except Exception as exc: + logger.warning("[GoogleChat] /setup-files revoke failed: %s", exc) + await _reply(f"❌ Error revoking: {exc}") + return True + # Evict only the sender's slot: Bob revoking must not break Alice's + # per-user token nor the shared legacy fallback. + if sender_key: + adapter._user_creds_by_email.pop(sender_key, None) + adapter._user_chat_api_by_email.pop(sender_key, None) + else: + adapter._user_credentials = None + adapter._user_chat_api = None + await _reply(f"✅ Done.\n```\n{output}\n```") + return True + + # Anything else is the auth code or the pasted failed-redirect URL. + try: + output = (await _run_captured(oauth_helper.exchange_auth_code, arg, sender_key)).strip() + except SystemExit: + await _reply( + "❌ Token exchange failed. The code may have expired or the URL is malformed. " + "Send `/setup-files start` to get a fresh OAuth URL." + ) + return True + except Exception as exc: + logger.warning("[GoogleChat] /setup-files exchange failed: %s", exc) + await _reply(f"❌ Error: {exc}") + return True + + # Re-load credentials so the next file send uses them without a gateway restart. + try: + new_creds = await asyncio.to_thread(oauth_helper.load_user_credentials, sender_key) + if new_creds is not None: + new_api = await asyncio.to_thread(lambda: oauth_helper.build_user_chat_service(new_creds)) + if sender_key: + adapter._user_creds_by_email[sender_key] = new_creds + adapter._user_chat_api_by_email[sender_key] = new_api + else: + adapter._user_credentials = new_creds + adapter._user_chat_api = new_api + await _reply("✅ Authorized! Native attachment delivery is now active. Try asking me to send you a PDF.") + return True + except Exception as exc: + logger.warning("[GoogleChat] post-exchange creds load failed: %s", exc) + + await _reply( + "⚠️ Token exchanged but the gateway couldn't load the new credentials in-memory. " + f"Restart the gateway and the token at `{oauth_helper._token_path(sender_key)}` will be picked up.\n" + f"Helper output:\n```\n{output}\n```" + ) + return True diff --git a/plugins/platforms/teams/adapter.py b/plugins/platforms/teams/adapter.py index 172d89d946..111f550247 100644 --- a/plugins/platforms/teams/adapter.py +++ b/plugins/platforms/teams/adapter.py @@ -1,9 +1,6 @@ """ -Microsoft Teams platform adapter for Hermes Agent. - -Uses the microsoft-teams-apps SDK for authentication and activity processing. -Runs an aiohttp webhook server to receive messages from Teams. -Proactive messaging (send, typing) uses the SDK's App.send() method. +Microsoft Teams platform adapter: microsoft-teams-apps SDK for auth/activity +processing, an aiohttp webhook server for inbound, ``App.send()`` for proactive sends. Requires: the ``teams`` extra (auto-installed by the gateway on first start, or @@ -24,23 +21,15 @@ Configuration in config.yaml: from __future__ import annotations import asyncio -import html +import importlib.util import json import logging import os +import re import sys from contextlib import contextmanager from typing import Any, Dict, Iterator, Optional -from urllib.parse import quote - -# httpx is imported lazily — only the ``_write_summary_via_incoming_webhook`` -# code path actually constructs an ``AsyncClient``. Top-level import here -# pulled in the entire httpx + httpcore stack (~37 ms, ~15 MB) on every -# process that triggered plugin discovery, even ones that never instantiate -# the Teams adapter. ``from __future__ import annotations`` above keeps the -# ``httpx.AsyncBaseTransport`` parameter annotation valid as a string at -# runtime; nothing in the codebase calls ``typing.get_type_hints()`` on -# this class so the annotation never has to resolve to a real symbol. +from urllib.parse import urlparse try: from aiohttp import web @@ -50,26 +39,14 @@ except ImportError: AIOHTTP_AVAILABLE = False web = None # type: ignore[assignment] -# microsoft-teams-apps calls ``load_dotenv(find_dotenv(usecwd=True))`` at -# ``microsoft_teams.apps.app`` import time. Importing it during plugin discovery -# / ``TeamsSummaryWriter`` imports would pollute process ``os.environ`` from a -# cwd-discovered ``.env`` (#62935). Detect presence via find_spec only; bind -# symbols in ``check_teams_requirements()`` behind a dotenv no-op. -import importlib.util -import sys as _sys - def _probe_teams_sdk_available() -> bool: """True when ``microsoft_teams.apps`` is on sys.path, without importing it. - Sibling packages (microsoft-teams-api / common / cards) also live under - the ``microsoft_teams`` namespace, so ``find_spec("microsoft_teams")`` - alone can be True while ``App`` is still unbound — connect() then - called None and logged ``'NoneType' object is not callable``. - - Probe the parent first: ``find_spec("microsoft_teams.apps")`` raises - ``ModuleNotFoundError`` on 3.11+ when the parent namespace is absent, - which crashed plugin import and unregistered the Teams platform. + Import is deferred: ``microsoft_teams.apps.app`` loads a cwd ``.env`` at import + (#62935); ``check_teams_requirements()`` binds symbols behind a dotenv no-op. + Sibling packages share the ``microsoft_teams`` namespace, so probe the parent + first — ``find_spec("microsoft_teams.apps")`` raises on 3.11+ if it is absent. """ try: if importlib.util.find_spec("microsoft_teams") is None: @@ -77,28 +54,16 @@ def _probe_teams_sdk_available() -> bool: return importlib.util.find_spec("microsoft_teams.apps") is not None except (ValueError, ModuleNotFoundError, ImportError): # Test stubs may inject a module without ``__spec__``. - return "microsoft_teams.apps" in _sys.modules + return "microsoft_teams.apps" in sys.modules TEAMS_SDK_AVAILABLE = _probe_teams_sdk_available() -ClientOptions = None # type: ignore[assignment,misc] -App = None # type: ignore[assignment,misc] -ActivityContext = None # type: ignore[assignment,misc] -MessageActivity = None # type: ignore[assignment,misc] -ConversationReference = None # type: ignore[assignment,misc] -TypingActivityInput = None # type: ignore[assignment,misc] -AdaptiveCardInvokeActivity = None # type: ignore[assignment,misc] -AdaptiveCardActionCardResponse = None # type: ignore[assignment,misc] -AdaptiveCardActionMessageResponse = None # type: ignore[assignment,misc] -AdaptiveCardInvokeResponse = None # type: ignore[assignment,misc,union-attr] -InvokeResponse = None # type: ignore[assignment,misc] +# SDK symbols stay None until check_teams_requirements() binds them. +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] +HttpRequest = HttpResponse = HttpRouteHandler = AdaptiveCard = ExecuteAction = TextBlock = None # type: ignore[assignment,misc] HttpMethod = str # type: ignore[assignment,misc] -HttpRequest = None # type: ignore[assignment,misc] -HttpResponse = None # type: ignore[assignment,misc] -HttpRouteHandler = None # type: ignore[assignment,misc] -AdaptiveCard = None # type: ignore[assignment,misc] -ExecuteAction = None # type: ignore[assignment,misc] -TextBlock = None # type: ignore[assignment,misc] from gateway.config import Platform, PlatformConfig from gateway.platforms.helpers import MessageDeduplicator @@ -111,306 +76,85 @@ from gateway.platforms.base import ( cache_image_from_url, cache_media_bytes, ) - -from agent.secret_scope import UnscopedSecretError as _UnscopedSecretError -from agent.secret_scope import get_secret as _scoped_get_secret - - -def _get_scoped_secret(name, default=None): - """Scope-aware credential read with the default-profile startup fallback. - - Secondary profiles construct their adapters under a profile secret - scope -- the scope is authoritative and a scoped miss returns ``default`` - (no cross-profile borrow from ``os.environ``, which may hold another - profile's value). The DEFAULT profile's adapter constructs and sends - *unscoped* under multiplexing, where a bare ``get_secret`` would raise - ``UnscopedSecretError`` and crash this path; there ``os.environ`` is that - profile's own value, so fall back to it. Same pattern as the Slack - ``SLACK_APP_TOKEN`` read (#59739) and - ``gateway/platforms/whatsapp_common.py::_get_wsecret``. - """ - try: - val = _scoped_get_secret(name, default) - except _UnscopedSecretError: - val = os.getenv(name) - return val if val is not None else default - +from gateway.platforms._shared import coerce_port, get_scoped_secret as _get_scoped_secret +from plugins.platforms.teams.summary_writer import TeamsSummaryWriter # noqa: F401 — re-exported for teams_pipeline logger = logging.getLogger(__name__) _DEFAULT_PORT = 3978 -# Bot Framework activities are JSON payloads well under 1 MiB; an explicit -# aiohttp client_max_size keeps oversized/chunked request bodies bounded. +# Bot Framework activities are JSON well under 1 MiB; bound oversized/chunked bodies. _MAX_BODY_BYTES = 1_048_576 -# ``None`` → aiohttp/asyncio ``create_server`` binds one listening socket per -# address family (IPv4 + IPv6). The old hardcoded "0.0.0.0" bound IPv4 ONLY -# and was unreachable over IPv6-only private networks (e.g. Fly.io 6PN) — -# same bug as the LINE adapter (NS-603) and gateway/platforms/webhook.py -# (d542894ad). Pin a host via TEAMS_HOST or extra.host. +# ``None`` host → aiohttp binds IPv4 + IPv6 (a hardcoded "0.0.0.0" was unreachable +# on IPv6-only networks such as Fly.io 6PN). Pin via TEAMS_HOST or extra.host. _DEFAULT_HOST = None _WEBHOOK_PATH = "/api/messages" +# Bot Framework default service URL. Regional/government tenants may need a +# different host (e.g. ``https://smba.infra.gov.teams.microsoft.us/``) via +# ``TEAMS_SERVICE_URL`` or ``extra['service_url']``. +_DEFAULT_TEAMS_SERVICE_URL = "https://smba.trafficmanager.net/teams/" -def _parse_bool(value: Any, *, default: bool = False) -> bool: - if isinstance(value, bool): - return value - if isinstance(value, str): - normalized = value.strip().lower() - if normalized in {"1", "true", "yes", "on"}: - return True - if normalized in {"0", "false", "no", "off"}: - return False - return default +# Bot Framework hosts that may receive a freshly minted bearer token (blocks SSRF / +# token exfiltration via a tampered env var). Exact match only: any Azure customer can +# register ``.trafficmanager.net``. New regions are allowlist additions. +_ALLOWED_TEAMS_SERVICE_HOSTS = frozenset({"smba.trafficmanager.net", "smba.infra.gov.teams.microsoft.us"}) + +# Conservative Bot Framework conversation-ID charset (digits, colons, hyphens, +# dots, '@', ``thread.skype`` / ``thread.tacv2`` suffixes) so a hostile value +# cannot path-traverse out of ``/v3/conversations//activities``. +_TEAMS_CONV_ID_RE = re.compile(r"^[A-Za-z0-9:@\-_.]+$") + +_BF_TOKEN_SCOPE = "https://api.botframework.com/.default" -def _coerce_port(value: Any, *, default: int = _DEFAULT_PORT) -> int: +def _bf_token_request(tenant_id: str, client_id: str, client_secret: str) -> tuple[str, dict]: + """(token URL, client-credentials form) for a Bot Framework bearer token.""" + return ( + f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token", + { + "grant_type": "client_credentials", + "client_id": client_id, + "client_secret": client_secret, + "scope": _BF_TOKEN_SCOPE, + }, + ) + + +def _is_allowed_https_host(url: str, *, check_port: bool = False) -> bool: + """https + host in ``_ALLOWED_TEAMS_SERVICE_HOSTS`` (+ default port when asked).""" try: - return int(value) - except (TypeError, ValueError): - return default + parsed = urlparse(url) + if parsed.scheme != "https": + return False + if check_port and parsed.port not in (None, 443): + return False + return parsed.hostname in _ALLOWED_TEAMS_SERVICE_HOSTS + except Exception: + return False -class _StaticAccessTokenProvider: - """Minimal token-provider shim so outbound Graph delivery can reuse the shared client.""" +def _is_botframework_attachment_url(url: str) -> bool: + """True if ``url`` is a Bot Framework connector attachment host (may carry the bot token).""" + return _is_allowed_https_host(url, check_port=True) - def __init__(self, access_token: str): - self._access_token = str(access_token or "").strip() - async def get_access_token(self, *, force_refresh: bool = False) -> str: - del force_refresh - if not self._access_token: - raise ValueError("TEAMS_GRAPH_ACCESS_TOKEN is required for graph delivery mode.") - return self._access_token - - def clear_cache(self) -> None: +def _validate_teams_service_url(raw: str) -> Optional[str]: + """Normalized (trailing-slash) service URL, or ``None`` if not on the allowlist.""" + if not raw or not _is_allowed_https_host(raw): return None - - -class TeamsSummaryWriter: - """Pipeline-facing Teams outbound delivery surface. - - This stays inside the existing Teams platform plugin so the meeting-pipeline - PR can reuse one Teams integration surface instead of introducing a second - adapter elsewhere in the gateway core. - """ - - def __init__( - self, - platform_config: PlatformConfig | None = None, - *, - graph_client: Any | None = None, - transport: httpx.AsyncBaseTransport | None = None, - ) -> None: - self._platform_config = platform_config - self._graph_client = graph_client - self._transport = transport - - async def write_summary( - self, - payload: Any, - config: dict[str, Any] | None, - existing_record: Optional[dict[str, Any]] = None, - ) -> dict[str, Any]: - merged = self._resolve_delivery_config(config) - if existing_record and not _parse_bool(merged.get("force_resend"), default=False): - return dict(existing_record) - - mode = str(merged.get("delivery_mode") or merged.get("mode") or "").strip().lower() - if not mode: - if merged.get("incoming_webhook_url"): - mode = "incoming_webhook" - elif merged.get("chat_id") or ( - merged.get("team_id") and merged.get("channel_id") - ): - mode = "graph" - if mode == "incoming_webhook": - return await self._write_summary_via_incoming_webhook(payload, merged) - if mode == "graph": - return await self._write_summary_via_graph(payload, merged) - raise ValueError( - "Teams delivery_mode must be 'incoming_webhook' or 'graph'." - ) - - def _resolve_delivery_config(self, config: dict[str, Any] | None) -> dict[str, Any]: - merged: dict[str, Any] = {} - platform_cfg = self._platform_config - if platform_cfg is not None: - merged.update(dict(platform_cfg.extra or {})) - if platform_cfg.token and "access_token" not in merged: - merged["access_token"] = platform_cfg.token - if platform_cfg.home_channel: - merged.setdefault("channel_id", platform_cfg.home_channel.chat_id) - merged.update(dict(config or {})) - - env_defaults = { - "delivery_mode": os.getenv("TEAMS_DELIVERY_MODE", ""), - "incoming_webhook_url": os.getenv("TEAMS_INCOMING_WEBHOOK_URL", ""), - "access_token": _get_scoped_secret("TEAMS_GRAPH_ACCESS_TOKEN", ""), - "team_id": os.getenv("TEAMS_TEAM_ID", ""), - "channel_id": os.getenv("TEAMS_CHANNEL_ID", ""), - "chat_id": os.getenv("TEAMS_CHAT_ID", ""), - } - for key, value in env_defaults.items(): - if value and not merged.get(key): - merged[key] = value - return merged - - async def _write_summary_via_incoming_webhook( - self, - payload: Any, - config: dict[str, Any], - ) -> dict[str, Any]: - # Lazy import — see module-level note. The teams plugin loads on - # every CLI invocation as a side effect of plugin discovery, but - # 99% of those processes never reach this method. - import httpx - webhook_url = str(config.get("incoming_webhook_url") or "").strip() - if not webhook_url: - raise ValueError("TEAMS_INCOMING_WEBHOOK_URL is required for incoming_webhook mode.") - body = {"text": self._render_summary_markdown(payload)} - async with httpx.AsyncClient(timeout=20.0, transport=self._transport) as client: - response = await client.post(webhook_url, json=body) - response.raise_for_status() - return { - "delivery_mode": "incoming_webhook", - "webhook_url": webhook_url, - "status_code": response.status_code, - "delivered": True, - } - - async def _write_summary_via_graph( - self, - payload: Any, - config: dict[str, Any], - ) -> dict[str, Any]: - graph_client = self._build_graph_client(config) - chat_id = str(config.get("chat_id") or "").strip() - if chat_id: - path = f"/chats/{quote(chat_id, safe='')}/messages" - response = await graph_client.post_json( - path, - json_body={"body": {"contentType": "html", "content": self._render_summary_html(payload)}}, - ) - return { - "delivery_mode": "graph", - "target_type": "chat", - "chat_id": chat_id, - "message_id": (response or {}).get("id"), - "web_url": (response or {}).get("webUrl"), - } - - team_id = str(config.get("team_id") or "").strip() - channel_id = str(config.get("channel_id") or "").strip() - if not team_id or not channel_id: - raise ValueError( - "Graph delivery mode requires chat_id, or both team_id and channel_id." - ) - path = ( - f"/teams/{quote(team_id, safe='')}/channels/" - f"{quote(channel_id, safe='')}/messages" - ) - response = await graph_client.post_json( - path, - json_body={"body": {"contentType": "html", "content": self._render_summary_html(payload)}}, - ) - return { - "delivery_mode": "graph", - "target_type": "channel", - "team_id": team_id, - "channel_id": channel_id, - "message_id": (response or {}).get("id"), - "web_url": (response or {}).get("webUrl"), - } - - def _build_graph_client(self, config: dict[str, Any]) -> Any: - if self._graph_client is not None: - return self._graph_client - - from tools.microsoft_graph_auth import MicrosoftGraphTokenProvider - from tools.microsoft_graph_client import MicrosoftGraphClient - - access_token = str(config.get("access_token") or "").strip() - if access_token: - return MicrosoftGraphClient( - _StaticAccessTokenProvider(access_token), - transport=self._transport, - ) - return MicrosoftGraphClient( - MicrosoftGraphTokenProvider.from_env(), - transport=self._transport, - ) - - def _render_summary_markdown(self, payload: Any) -> str: - lines = [ - f"**{self._title(payload)}**", - "", - f"Summary: {self._text(getattr(payload, 'summary', None), 'No summary available.')}", - "", - "Key decisions:", - *self._bullet_lines(getattr(payload, "key_decisions", None)), - "", - "Action items:", - *self._bullet_lines(getattr(payload, "action_items", None)), - "", - "Risks:", - *self._bullet_lines(getattr(payload, "risks", None)), - ] - return "\n".join(lines) - - def _render_summary_html(self, payload: Any) -> str: - sections = [ - ("Summary", [self._text(getattr(payload, "summary", None), "No summary available.")]), - ("Key decisions", list(getattr(payload, "key_decisions", None) or [])), - ("Action items", list(getattr(payload, "action_items", None) or [])), - ("Risks", list(getattr(payload, "risks", None) or [])), - ] - blocks = [f"

{html.escape(self._title(payload))}

"] - for heading, items in sections: - blocks.append(f"

{html.escape(heading)}

") - if len(items) == 1 and heading == "Summary": - blocks.append(f"

{html.escape(str(items[0]))}

") - continue - if items: - rendered = "".join(f"
  • {html.escape(str(item))}
  • " for item in items if str(item).strip()) - blocks.append(rendered and f"
      {rendered}
    " or "

    None

    ") - else: - blocks.append("

    None

    ") - return "".join(blocks) - - @staticmethod - def _title(payload: Any) -> str: - title = getattr(payload, "title", None) - if title: - return str(title) - meeting_ref = getattr(payload, "meeting_ref", None) - meeting_id = getattr(meeting_ref, "meeting_id", None) if meeting_ref else None - return f"Meeting {meeting_id or 'summary'}" - - @staticmethod - def _text(value: Any, default: str) -> str: - text = str(value or "").strip() - return text or default - - @classmethod - def _bullet_lines(cls, values: Any) -> list[str]: - items = [str(item).strip() for item in (values or []) if str(item).strip()] - return [f"- {item}" for item in items] or ["- None"] + return raw if raw.endswith("/") else raw + "/" class _AiohttpBridgeAdapter: - """HttpServerAdapter that bridges the Teams SDK into an aiohttp server. + """HttpServerAdapter bridging SDK route registrations into our aiohttp app. - Without a custom adapter, ``App()`` unconditionally imports fastapi/uvicorn - and allocates a ``FastAPI()`` instance. This bridge captures the SDK's - route registrations and wires them into our own aiohttp ``Application``. + Without it ``App()`` unconditionally imports fastapi/uvicorn and allocates a ``FastAPI()``. """ def __init__(self, aiohttp_app: "web.Application"): self._aiohttp_app = aiohttp_app def register_route(self, method: "HttpMethod", path: str, handler: "HttpRouteHandler") -> None: - """Register an SDK route handler as an aiohttp route.""" - async def _aiohttp_handler(request: "web.Request") -> "web.Response": body = await request.json() headers = dict(request.headers) @@ -418,11 +162,7 @@ class _AiohttpBridgeAdapter: status = result.get("status", 200) resp_body = result.get("body") if resp_body is not None: - return web.Response( - status=status, - body=json.dumps(resp_body), - content_type="application/json", - ) + return web.Response(status=status, body=json.dumps(resp_body), content_type="application/json") return web.Response(status=status) self._aiohttp_app.router.add_route(method, path, _aiohttp_handler) @@ -438,22 +178,23 @@ class _AiohttpBridgeAdapter: def check_requirements() -> bool: - """PASSIVE probe: are the Teams SDK and aiohttp importable right now? - - Never installs anything — credentials are gated separately via - ``is_connected``/``validate_config``. The ACTIVE lazy-installer is - ``check_teams_requirements`` (registered as ``ensure_deps_fn``). - """ + """PASSIVE probe (registry ``check_fn``): SDK + aiohttp importable? Never installs.""" return TEAMS_SDK_AVAILABLE and AIOHTTP_AVAILABLE -def validate_config(config) -> bool: - """Return True when the config has the minimum required credentials.""" +def _credentials(config) -> tuple[str, str, str]: + """(client_id, client_secret, tenant_id): env first, then ``config.extra``.""" extra = getattr(config, "extra", {}) or {} - client_id = os.getenv("TEAMS_CLIENT_ID") or extra.get("client_id", "") - client_secret = _get_scoped_secret("TEAMS_CLIENT_SECRET") or extra.get("client_secret", "") - tenant_id = os.getenv("TEAMS_TENANT_ID") or extra.get("tenant_id", "") - return bool(client_id and client_secret and tenant_id) + return ( + os.getenv("TEAMS_CLIENT_ID") or extra.get("client_id", ""), + _get_scoped_secret("TEAMS_CLIENT_SECRET") or extra.get("client_secret", ""), + os.getenv("TEAMS_TENANT_ID") or extra.get("tenant_id", ""), + ) + + +def validate_config(config) -> bool: + """True when the config has the minimum required credentials.""" + return bool(all(_credentials(config))) def is_connected(config) -> bool: @@ -462,167 +203,50 @@ def is_connected(config) -> bool: def _env_enablement() -> dict | None: - """Seed ``PlatformConfig.extra`` from env vars during gateway config load. + """Seed ``PlatformConfig.extra`` from env vars before adapter construction. - 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 Teams SDK. - Returns ``None`` when Teams isn't minimally configured. - - The special ``home_channel`` key in the returned dict becomes a proper - ``HomeChannel`` dataclass on the ``PlatformConfig`` via the core hook. + Lets ``gateway status`` reflect env-only setups without the SDK. ``None`` when + not minimally configured; ``home_channel`` becomes a ``HomeChannel`` via the core hook. """ client_id = os.getenv("TEAMS_CLIENT_ID", "").strip() client_secret = _get_scoped_secret("TEAMS_CLIENT_SECRET", "").strip() tenant_id = os.getenv("TEAMS_TENANT_ID", "").strip() if not (client_id and client_secret and tenant_id): return None - seed: dict = { - "client_id": client_id, - "client_secret": client_secret, - "tenant_id": tenant_id, - } - port = os.getenv("TEAMS_PORT", "").strip() - if port: - try: - seed["port"] = int(port) - except ValueError: - pass + seed: dict = {"client_id": client_id, "client_secret": client_secret, "tenant_id": tenant_id} + port = coerce_port(os.getenv("TEAMS_PORT", "").strip(), None) + if port is not None: + seed["port"] = port service_url = os.getenv("TEAMS_SERVICE_URL", "").strip() if service_url: seed["service_url"] = service_url home = os.getenv("TEAMS_HOME_CHANNEL", "").strip() if home: - seed["home_channel"] = { - "chat_id": home, - "name": os.getenv("TEAMS_HOME_CHANNEL_NAME", "Home"), - } + seed["home_channel"] = {"chat_id": home, "name": os.getenv("TEAMS_HOME_CHANNEL_NAME", "Home")} return seed -# Bot Framework default service URL for the global Teams endpoint. Some -# regional/government tenants need a different host (e.g. -# ``https://smba.infra.gov.teams.microsoft.us/``) which can be supplied via -# ``TEAMS_SERVICE_URL`` or ``extra['service_url']``. -_DEFAULT_TEAMS_SERVICE_URL = "https://smba.trafficmanager.net/teams/" - -# Allowlist of Bot Framework service hosts that may receive a freshly -# minted bearer token. Operator-supplied URLs are matched against this -# allowlist to block SSRF / token-exfiltration via a tampered env var. -_ALLOWED_TEAMS_SERVICE_HOSTS = frozenset({ - "smba.trafficmanager.net", - "smba.infra.gov.teams.microsoft.us", -}) - - -def _is_botframework_attachment_url(url: str) -> bool: - """True if ``url`` points at a Bot Framework connector attachment host. - - Exact-match against ``_ALLOWED_TEAMS_SERVICE_HOSTS`` — the same allowlist - that gates where outbound sends may carry a freshly minted bearer token — - plus scheme/port sanity: only https on the default port qualifies. A - lookalike host must never receive the bot's bearer token: note that any - Azure customer can register ``.trafficmanager.net`` Traffic Manager - profiles, so a suffix match would not be safe either. New Bot Framework - regions are allowlist additions, not predicate changes. - """ - try: - from urllib.parse import urlparse - - parsed = urlparse(url) - if parsed.scheme != "https": - return False - if parsed.port not in (None, 443): - return False - return parsed.hostname in _ALLOWED_TEAMS_SERVICE_HOSTS - except Exception: - return False - -# Conservative pattern for Bot Framework conversation IDs. Real values -# combine digits, colons, hyphens, dots, '@', and the ``thread.skype`` / -# ``thread.tacv2`` suffixes; reject anything outside this set so a hostile -# value cannot path-traverse out of ``/v3/conversations//activities``. -import re as _re_teams -_TEAMS_CONV_ID_RE = _re_teams.compile(r"^[A-Za-z0-9:@\-_.]+$") - - -def _validate_teams_service_url(raw: str) -> Optional[str]: - """Return a normalized service URL or ``None`` if it is not allowed. - - Requires ``https://`` and a host in ``_ALLOWED_TEAMS_SERVICE_HOSTS``. - The trailing slash is added if absent so callers can append - ``v3/conversations/...`` without double slashes. - """ - if not raw: - return None - try: - from urllib.parse import urlparse - - parsed = urlparse(raw) - except Exception: - return None - if parsed.scheme != "https": - return None - if parsed.hostname not in _ALLOWED_TEAMS_SERVICE_HOSTS: - return None - normalized = raw if raw.endswith("/") else raw + "/" - return normalized - - async def _standalone_send( - pconfig, - chat_id: str, - message: str, - *, - thread_id: Optional[str] = None, - media_files: Optional[list] = None, - force_document: bool = False, + pconfig, chat_id: str, message: str, *, + thread_id: Optional[str] = None, media_files: Optional[list] = None, force_document: bool = False, ) -> Dict[str, Any]: """Acquire a Bot Framework bearer token and POST a single message activity. - Used by ``tools/send_message_tool._send_via_adapter`` when the gateway - runner is not in this process (e.g. ``hermes cron`` running as a - separate process from ``hermes gateway``). Without this hook, - ``deliver=teams`` cron jobs fail with ``No live adapter for platform``. - - Configuration: requires ``TEAMS_CLIENT_ID``, ``TEAMS_CLIENT_SECRET``, - ``TEAMS_TENANT_ID``, ``TEAMS_HOME_CHANNEL`` (the conversation ID), and - optionally ``TEAMS_SERVICE_URL`` (Bot Framework service host; must be - a known Bot Framework endpoint, see ``_ALLOWED_TEAMS_SERVICE_HOSTS``). - - Security: ``service_url`` is validated against an allowlist of known - Bot Framework hosts to block SSRF / token-exfiltration via a tampered - env var. ``chat_id`` is validated to match the documented Bot - Framework ID character set so it cannot escape the URL path. - - ``media_files`` and ``force_document`` are accepted for signature - parity but not implemented for the standalone path; messages with - attachments will send as text-only. The live adapter handles - attachments via the SDK. + Used by ``send_message_tool._send_via_adapter`` when the gateway runner is + not in this process (``hermes cron``). ``TEAMS_SERVICE_URL`` is allowlisted + and ``chat_id`` charset-checked (SSRF/path traversal). ``media_files`` / + ``force_document`` are signature parity only — this path is text-only. """ extra = getattr(pconfig, "extra", {}) or {} - client_id = os.getenv("TEAMS_CLIENT_ID") or extra.get("client_id", "") - client_secret = _get_scoped_secret("TEAMS_CLIENT_SECRET") or extra.get("client_secret", "") - tenant_id = os.getenv("TEAMS_TENANT_ID") or extra.get("tenant_id", "") + client_id, client_secret, tenant_id = _credentials(pconfig) if not (client_id and client_secret and tenant_id): 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 - ) + 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 " - f"Bot Framework allowlist; expected one of " - f"{sorted(_ALLOWED_TEAMS_SERVICE_HOSTS)}" - )} - - # Bot Framework conversation IDs are restricted to a known character - # set; anything else means a tampered chat_id trying to break out of - # the URL path. + 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): @@ -630,7 +254,7 @@ async def _standalone_send( if not _TEAMS_CONV_ID_RE.match(tenant_id): return {"error": "Teams standalone send: TEAMS_TENANT_ID contains characters outside the expected set"} - token_url = f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token" + 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: @@ -639,18 +263,12 @@ async def _standalone_send( try: import aiohttp as _aiohttp - # Per-request timeouts so a slow STS endpoint cannot starve the - # subsequent activity POST of its budget. + # 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: async with session.post( token_url, - data={ - "grant_type": "client_credentials", - "client_id": client_id, - "client_secret": client_secret, - "scope": "https://api.botframework.com/.default", - }, + data=token_form, headers={"Content-Type": "application/x-www-form-urlencoded"}, timeout=per_request_timeout, ) as token_resp: @@ -662,28 +280,18 @@ async def _standalone_send( if not access_token: return {"error": "Teams standalone send: token response missing access_token"} - activity = { - "type": "message", - "text": message, - "textFormat": "markdown", - } + activity = {"type": "message", "text": message, "textFormat": "markdown"} async with session.post( activities_url, json=activity, - headers={ - "Authorization": f"Bearer {access_token}", - "Content-Type": "application/json", - }, + headers={"Authorization": f"Bearer {access_token}", "Content-Type": "application/json"}, timeout=per_request_timeout, ) as send_resp: if send_resp.status >= 400: body = await send_resp.text() return {"error": f"Teams standalone send: activity post failed ({send_resp.status}): {body[:300]}"} send_payload = await send_resp.json() - return { - "success": True, - "message_id": send_payload.get("id"), - } + return {"success": True, "message_id": send_payload.get("id")} except asyncio.CancelledError: raise except Exception as e: @@ -691,27 +299,31 @@ async def _standalone_send( return {"error": f"Teams standalone send failed: {e}"} -# Keep the old name as an alias so existing test imports don't break. -# NOTE: ``check_requirements`` is the PASSIVE probe (registry ``check_fn``, -# status / unit tests) — it must never trigger a pip install. -# ``check_teams_requirements`` is the ACTIVE lazy-installer, registered as -# ``ensure_deps_fn``: the registry's ``create_adapter()`` runs it when the -# passive probe fails, right before the gateway connects Teams (#79812). -# ``connect()`` re-checks defensively. +# SDK module → names rebound into this module's globals by check_teams_requirements(). +_SDK_IMPORTS = { + "microsoft_teams.apps": ("App", "ActivityContext"), + "microsoft_teams.common.http.client": ("ClientOptions",), + "microsoft_teams.api": ("MessageActivity", "ConversationReference"), + "microsoft_teams.api.activities.typing": ("TypingActivityInput",), + "microsoft_teams.api.activities.invoke.adaptive_card": ("AdaptiveCardInvokeActivity",), + "microsoft_teams.api.models.adaptive_card": ("AdaptiveCardActionCardResponse", "AdaptiveCardActionMessageResponse"), + "microsoft_teams.api.models.invoke_response": ("InvokeResponse", "AdaptiveCardInvokeResponse"), + "microsoft_teams.apps.http.adapter": ("HttpMethod", "HttpRequest", "HttpResponse", "HttpRouteHandler"), + "microsoft_teams.cards": ("AdaptiveCard", "ExecuteAction", "TextBlock"), +} + + @contextmanager def _suppress_third_party_dotenv() -> Iterator[None]: """No-op ``dotenv.load_dotenv`` while importing the Teams SDK (#62935). - ``microsoft_teams.apps.app`` calls ``load_dotenv(find_dotenv(usecwd=True))`` - at module import time. That mutates process-global ``os.environ`` from - whatever ``.env`` sits above cwd — typically a root profile's secrets. - Hermes owns dotenv loading; third-party import side effects must not. + ``microsoft_teams.apps.app`` loads a cwd-discovered ``.env`` at import, + mutating process-global ``os.environ``. Hermes owns dotenv loading. """ try: import dotenv as _dotenv except ImportError: - yield - return + _dotenv = None original = getattr(_dotenv, "load_dotenv", None) if original is None: yield @@ -724,15 +336,11 @@ def _suppress_third_party_dotenv() -> Iterator[None]: def check_teams_requirements() -> bool: - """Ensure the Teams SDK is importable, lazy-installing it on first use. + """ACTIVE lazy-installer (registry ``ensure_deps_fn``): install the SDK on first use. - Lazy-installs ``microsoft-teams-apps`` via - ``tools.lazy_deps.ensure("platform.teams")`` if not present, then rebinds - all module-level SDK globals on success. Returns True once the SDK (and - aiohttp) are importable, False if they couldn't be installed/imported. - - ``App is not None`` means symbols are already bound — ``TEAMS_SDK_AVAILABLE`` - alone can be True from ``find_spec`` without an import having run yet. + Rebinds all module-level SDK globals on success; True once the SDK and + aiohttp are importable. Gate on ``App is not None`` — ``TEAMS_SDK_AVAILABLE`` + is only a find_spec probe and can be True before any import ran. """ if App is not None and AIOHTTP_AVAILABLE: return True @@ -740,59 +348,36 @@ def check_teams_requirements() -> bool: def _import() -> dict: from aiohttp import web as _web + bindings: dict = {"web": _web, "AIOHTTP_AVAILABLE": True} with _suppress_third_party_dotenv(): - from microsoft_teams.apps import App, ActivityContext - from microsoft_teams.common.http.client import ClientOptions - from microsoft_teams.api import MessageActivity, ConversationReference - from microsoft_teams.api.activities.typing import TypingActivityInput - from microsoft_teams.api.activities.invoke.adaptive_card import ( - AdaptiveCardInvokeActivity, - ) - from microsoft_teams.api.models.adaptive_card import ( - AdaptiveCardActionCardResponse, - AdaptiveCardActionMessageResponse, - ) - from microsoft_teams.api.models.invoke_response import ( - InvokeResponse, - AdaptiveCardInvokeResponse, - ) - from microsoft_teams.apps.http.adapter import ( - HttpMethod, - HttpRequest, - HttpResponse, - HttpRouteHandler, - ) - from microsoft_teams.cards import AdaptiveCard, ExecuteAction, TextBlock - - return { - "web": _web, - "AIOHTTP_AVAILABLE": True, - "App": App, - "ActivityContext": ActivityContext, - "ClientOptions": ClientOptions, - "MessageActivity": MessageActivity, - "ConversationReference": ConversationReference, - "TypingActivityInput": TypingActivityInput, - "AdaptiveCardInvokeActivity": AdaptiveCardInvokeActivity, - "AdaptiveCardActionCardResponse": AdaptiveCardActionCardResponse, - "AdaptiveCardActionMessageResponse": AdaptiveCardActionMessageResponse, - "InvokeResponse": InvokeResponse, - "AdaptiveCardInvokeResponse": AdaptiveCardInvokeResponse, - "HttpMethod": HttpMethod, - "HttpRequest": HttpRequest, - "HttpResponse": HttpResponse, - "HttpRouteHandler": HttpRouteHandler, - "AdaptiveCard": AdaptiveCard, - "ExecuteAction": ExecuteAction, - "TextBlock": TextBlock, - "TEAMS_SDK_AVAILABLE": True, - } + for module_name, names in _SDK_IMPORTS.items(): + module = importlib.import_module(module_name) + for name in names: + try: + bindings[name] = getattr(module, name) + except AttributeError as exc: # same failure class as ``from X import Y`` + raise ImportError(f"cannot import name {name!r} from {module_name!r}") from exc + bindings["TEAMS_SDK_AVAILABLE"] = True + return bindings from tools.lazy_deps import ensure_and_bind return ensure_and_bind("platform.teams", _import, globals(), prompt=False) +_CHAT_TYPES = {"personal": "dm", "groupChat": "group", "channel": "channel"} +# DOCUMENT wins over PHOTO/VIDEO/AUDIO for mixed attachments: document-context +# injection gates strictly on MessageType.DOCUMENT (same precedence as Email/Signal). +_MEDIA_KIND_PRECEDENCE = ( + ("document", MessageType.DOCUMENT), ("image", MessageType.PHOTO), + ("video", MessageType.VIDEO), ("audio", MessageType.AUDIO), +) +_APPROVAL_CHOICES = {"approve_once": "once", "approve_session": "session", "approve_always": "always", "deny": "deny"} +_APPROVAL_LABELS = { + "once": "✅ Allowed (once)", "session": "✅ Allowed (session)", "always": "✅ Always allowed", "deny": "❌ Denied", +} + + class TeamsAdapter(BasePlatformAdapter): """Microsoft Teams adapter using the microsoft-teams-apps SDK.""" @@ -805,65 +390,39 @@ class TeamsAdapter(BasePlatformAdapter): self._client_id = extra.get("client_id") or os.getenv("TEAMS_CLIENT_ID", "") self._client_secret = extra.get("client_secret") or _get_scoped_secret("TEAMS_CLIENT_SECRET", "") self._tenant_id = extra.get("tenant_id") or os.getenv("TEAMS_TENANT_ID", "") - # (token, expiry monotonic ts) for Bot Framework connector attachment - # auth; refreshed under _bf_token_lock so concurrent attachments - # can't stampede the token endpoint. + # (token, expiry monotonic ts) for connector attachment auth; refreshed + # under _bf_token_lock so concurrent attachments can't stampede the STS. self._bf_token_cache: Optional[tuple] = None self._bf_token_lock: Optional[asyncio.Lock] = None - self._port = _coerce_port( - extra.get("port") or os.getenv("TEAMS_PORT", str(_DEFAULT_PORT)) - ) + self._port = coerce_port(extra.get("port") or os.getenv("TEAMS_PORT", str(_DEFAULT_PORT)), _DEFAULT_PORT) # Falsy host (unset/"") collapses to the dual-stack default (None). _raw_host = extra.get("host") or os.getenv("TEAMS_HOST", "") or _DEFAULT_HOST self._host: Optional[str] = str(_raw_host) if _raw_host else None self._app: Optional["App"] = None self._runner: Optional["web.AppRunner"] = None self._dedup = MessageDeduplicator(max_size=1000) - # Maps chat_id → ConversationReference captured from incoming messages. - # Used to send cards with the correct conversation type (personal/group/channel). + # chat_id → ConversationReference from incoming messages, so proactive + # cards use the right conversation type (personal/group/channel). self._conv_refs: Dict[str, Any] = {} async def connect(self, *, is_reconnect: bool = False) -> bool: - # Defensive re-check: create_adapter() already ran the installer - # (ensure_deps_fn) if deps were missing, but connect() can also be - # reached via reconnect paths — re-run to bind SDK globals. - # - # Gate on App, not TEAMS_SDK_AVAILABLE. The latter is a find_spec - # probe and can be True from the microsoft_teams namespace without - # symbols ever being bound (check_teams_requirements returning - # False is ignored if we only inspect the flag). + # create_adapter() already ran the installer, but reconnect paths reach + # here too — re-run to bind SDK globals. Gate on App, not the find_spec flag. check_teams_requirements() - if App is None or ClientOptions is None: - self._set_fatal_error( - "MISSING_SDK", - "microsoft-teams-apps could not be installed. " - f"Run: {sys.executable} -m pip install microsoft-teams-apps", - retryable=False, - ) - return False - - if not AIOHTTP_AVAILABLE: - self._set_fatal_error( - "MISSING_SDK", - f"aiohttp not installed. Run: {sys.executable} -m pip install aiohttp", - retryable=False, - ) - return False - - if not self._client_id or not self._client_secret or not self._tenant_id: - self._set_fatal_error( - "MISSING_CREDENTIALS", - "TEAMS_CLIENT_ID, TEAMS_CLIENT_SECRET, and TEAMS_TENANT_ID are all required", - retryable=False, - ) - return False + pip = f"{sys.executable} -m pip install" + for failed, code, message in ( + (App is None or ClientOptions is None, "MISSING_SDK", + f"microsoft-teams-apps could not be installed. Run: {pip} microsoft-teams-apps"), + (not AIOHTTP_AVAILABLE, "MISSING_SDK", f"aiohttp not installed. Run: {pip} aiohttp"), + (not self._client_id or not self._client_secret or not self._tenant_id, "MISSING_CREDENTIALS", + "TEAMS_CLIENT_ID, TEAMS_CLIENT_SECRET, and TEAMS_TENANT_ID are all required"), + ): + if failed: + self._set_fatal_error(code, message, retryable=False) + return False try: - # Set up aiohttp app first — the bridge adapter wires SDK routes into it. - # client_max_size: Bot Framework activities are JSON (caps out well - # under 1 MiB); an explicit cap keeps oversized/chunked bodies from - # being buffered unbounded on a 0.0.0.0 bind (same pattern as - # webhook.py / raft, #58536/#58902). + # aiohttp app first — the bridge adapter wires SDK routes into it. aiohttp_app = web.Application(client_max_size=_MAX_BODY_BYTES) aiohttp_app.router.add_get("/health", lambda _: web.Response(text="ok")) @@ -875,7 +434,9 @@ class TeamsAdapter(BasePlatformAdapter): client=ClientOptions(headers={"User-Agent": "Hermes"}), ) - # Register message handler before initialize() + # Handlers (ours, then plugin-registered on_* decorators) must be + # wired before initialize(), which registers POST /api/messages + # on aiohttp_app via the bridge's register_route(). @self._app.on_message async def _handle_message(ctx: ActivityContext[MessageActivity]): await self._on_message(ctx) @@ -886,13 +447,7 @@ class TeamsAdapter(BasePlatformAdapter): ) -> InvokeResponse[AdaptiveCardActionMessageResponse]: return await self._on_card_action(ctx) - # Plugin-registered native handlers (Teams App — on_message / - # on_card_action / on_* decorators). Wired before initialize() - # so plugin routes register alongside ours. self._wire_plugin_handlers(self._app) - - # initialize() calls register_route() on the bridge, which adds - # POST /api/messages to aiohttp_app automatically await self._app.initialize() self._runner = web.AppRunner(aiohttp_app) @@ -909,13 +464,8 @@ class TeamsAdapter(BasePlatformAdapter): _WEBHOOK_PATH, ) return True - except Exception as e: - self._set_fatal_error( - "CONNECT_FAILED", - f"Teams connection failed: {e}", - retryable=True, - ) + self._set_fatal_error("CONNECT_FAILED", f"Teams connection failed: {e}", retryable=True) logger.error("[teams] Failed to connect: %s", e, exc_info=True) return False @@ -929,45 +479,25 @@ class TeamsAdapter(BasePlatformAdapter): logger.info("[teams] Disconnected") async def _get_botframework_token(self) -> str: - """Acquire a Bot Framework bearer token via client credentials. + """Bot Framework bearer token (client credentials), cached until ~5 min before expiry. - Needed to download connector attachments (smba.trafficmanager.net - /v3/attachments/...), which -- unlike SharePoint file downloadUrls -- - are NOT pre-authenticated and return 401 without the bot's own - token. Token is cached until ~5 minutes before expiry. The refresh - is serialized by an asyncio lock (lazily created on first use — - ``asyncio.Lock()`` at __init__ time would bind to the wrong event - loop on Python < 3.10) so concurrent attachments share one POST. + Connector attachments are NOT pre-authenticated, unlike SharePoint downloadUrls. + Lock is created lazily: ``asyncio.Lock()`` in __init__ may bind the wrong loop. """ import time import httpx - # The gateway may run adapters on a loop created after __init__; - # bind the lock on first use instead of at construction. - lock = self._bf_token_lock - if lock is None: - lock = self._bf_token_lock = asyncio.Lock() - async with lock: + if self._bf_token_lock is None: + self._bf_token_lock = asyncio.Lock() + async with self._bf_token_lock: cached = self._bf_token_cache if cached and cached[1] > time.monotonic() + 300: return cached[0] - - client_id = self._client_id - client_secret = self._client_secret - tenant_id = self._tenant_id - if not (client_id and client_secret and tenant_id): + if not (self._client_id and self._client_secret and self._tenant_id): raise ValueError("Missing TEAMS_CLIENT_ID/SECRET/TENANT_ID for attachment auth") - + token_url, token_form = _bf_token_request(self._tenant_id, self._client_id, self._client_secret) async with httpx.AsyncClient(timeout=15.0) as client: - resp = await client.post( - f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token", - data={ - "grant_type": "client_credentials", - "client_id": client_id, - "client_secret": client_secret, - "scope": "https://api.botframework.com/.default", - }, - ) + resp = await client.post(token_url, data=token_form) resp.raise_for_status() payload = resp.json() token = payload["access_token"] @@ -978,114 +508,83 @@ class TeamsAdapter(BasePlatformAdapter): async def _fetch_attachment_bytes(self, url: str, timeout: float = 30.0) -> bytes: """Download attachment bytes with SSRF protection. - Teams file attachments carry pre-authenticated SharePoint download - URLs (no extra auth header needed). Bot Framework connector - attachment URLs (pasted/inline images on _ALLOWED_TEAMS_SERVICE_HOSTS - hosts) require the bot's bearer token -- detected below and fetched - with auth. Validates the URL against the SSRF guard, streams the - body through the shared inbound media cap, and follows redirects - through the shared redirect guard, matching the cache_*_from_url - helpers in gateway.platforms.base. + Bot Framework connector URLs get the bot's bearer token; redirects and body + size go through the shared guards (as the cache_*_from_url helpers). """ from tools.url_safety import create_ssrf_safe_async_client, is_safe_url from gateway.platforms.base import _ssrf_redirect_guard, _read_httpx_body_with_limit if not is_safe_url(url): raise ValueError("Blocked unsafe attachment URL (SSRF protection)") - headers = {"User-Agent": "Mozilla/5.0 (compatible; HermesAgent/1.0)"} if _is_botframework_attachment_url(url): try: headers["Authorization"] = f"Bearer {await self._get_botframework_token()}" except Exception as e: logger.warning("[teams] Could not acquire Bot Framework token for attachment: %s", e) - async with create_ssrf_safe_async_client( - timeout=timeout, - follow_redirects=True, - event_hooks={"response": [_ssrf_redirect_guard]}, + timeout=timeout, follow_redirects=True, event_hooks={"response": [_ssrf_redirect_guard]} ) as client: async with client.stream("GET", url, headers=headers) as response: response.raise_for_status() - # Stream through the shared inbound media cap (matches - # cache_image_from_url) instead of buffering .content — a - # lying Content-Length must not OOM the gateway. + # Never buffer .content — a lying Content-Length must not OOM the gateway. return await _read_httpx_body_with_limit(response, media_type="attachment") async def _on_message(self, ctx: ActivityContext[MessageActivity]) -> None: """Process an incoming Teams message and dispatch to the gateway.""" activity = ctx.activity - - # Self-message filter bot_id = self._app.id if self._app else None if bot_id and getattr(activity.from_, "id", None) == bot_id: return - - # Deduplication msg_id = getattr(activity, "id", None) if msg_id and self._dedup.is_duplicate(msg_id): return - # Cache the conversation reference for proactive sends (approval cards, etc.) conv_id = getattr(activity.conversation, "id", None) if conv_id: self._conv_refs[conv_id] = ctx.conversation_ref - # Extract text — strip bot @mentions - text = "" - if hasattr(activity, "text") and activity.text: - text = activity.text - # Strip BotName HTML tags that Teams prepends for @mentions - if "" in text: - import re + text = activity.text if hasattr(activity, "text") and activity.text else "" + if "" in text: # strip the BotName tags Teams prepends for @mentions text = re.sub(r"[^<]*\s*", "", text).strip() - # Determine chat type from conversation conv = activity.conversation - conv_type = getattr(conv, "conversation_type", None) or "" - if conv_type == "personal": - chat_type = "dm" - elif conv_type == "groupChat": - chat_type = "group" - elif conv_type == "channel": - chat_type = "channel" - else: - chat_type = "dm" - - # Build source from_account = activity.from_ user_id = getattr(from_account, "aad_object_id", None) or getattr(from_account, "id", "") - user_name = getattr(from_account, "name", None) or "" - source = self.build_source( chat_id=conv.id, chat_name=getattr(conv, "name", None) or "", - chat_type=chat_type, + chat_type=_CHAT_TYPES.get(getattr(conv, "conversation_type", None) or "", "dm"), user_id=str(user_id), - user_name=user_name, + user_name=getattr(from_account, "name", None) or "", guild_id=getattr(conv, "tenant_id", None) or self._tenant_id, ) - # Handle attachments (images, documents, video, audio) - media_urls = [] - media_types = [] - media_kinds = [] + media_urls: list = [] + media_types: list = [] + media_kinds: list = [] + + def _add_cached(cached: Any, kind: Optional[str] = None) -> bool: + if not cached: + return False + media_urls.append(cached.path) + media_types.append(cached.media_type) + media_kinds.append(kind or cached.kind) + return True + for att in getattr(activity, "attachments", None) or []: content_url = getattr(att, "content_url", None) content_type = (getattr(att, "content_type", None) or "").lower() att_name = getattr(att, "name", None) or "" - # Skip non-file payloads: Teams mirrors the message body as a - # text/html attachment on every message, and adaptive/hero cards - # arrive as application/vnd.microsoft.card.* attachments. + # text/html attachment, and cards arrive as application/vnd.microsoft.card.* if content_type in ("text/html", "text/plain") and not content_url: continue if content_type.startswith("application/vnd.microsoft.card"): continue if content_type == "application/vnd.microsoft.teams.file.download.info": - # File consent-free download: content carries a pre-authed - # SharePoint downloadUrl plus the real file type. + # Consent-free download: content carries a pre-authed SharePoint downloadUrl + file type. content = getattr(att, "content", None) if not isinstance(content, dict): content = getattr(content, "__dict__", None) or {} @@ -1096,16 +595,8 @@ class TeamsAdapter(BasePlatformAdapter): filename = att_name or (f"document.{file_type}" if file_type else "document") try: data = await self._fetch_attachment_bytes(download_url) - cached = cache_media_bytes(data, filename=filename, mime_type="") - if cached: - media_urls.append(cached.path) - media_types.append(cached.media_type) - media_kinds.append(cached.kind) - else: - logger.warning( - "[teams] Unsupported document type for attachment '%s', skipping", - filename, - ) + if not _add_cached(cache_media_bytes(data, filename=filename, mime_type="")): + logger.warning("[teams] Unsupported document type for attachment '%s', skipping", filename) except Exception as e: logger.warning("[teams] Failed to cache file attachment '%s': %s", filename, e) continue @@ -1113,20 +604,11 @@ class TeamsAdapter(BasePlatformAdapter): if content_url and content_type.startswith("image/"): try: if _is_botframework_attachment_url(content_url): - # Bot Framework connector URL: needs the bot's own - # bearer token; the generic cache helper sends none. + # Connector URL needs the bot's bearer token; the generic cache helper sends none. data = await self._fetch_attachment_bytes(content_url) ext = content_type.split("/")[-1].split(";")[0] or "png" - cached_m = cache_media_bytes( - data, - filename=att_name or f"image.{ext}", - mime_type=content_type, - ) - if cached_m: - media_urls.append(cached_m.path) - media_types.append(cached_m.media_type) - media_kinds.append("image") - else: + cached_m = cache_media_bytes(data, filename=att_name or f"image.{ext}", mime_type=content_type) + if not _add_cached(cached_m, kind="image"): logger.warning( "[teams] Bot Framework attachment '%s' returned data that failed image validation, skipping", att_name or content_url, @@ -1141,60 +623,47 @@ class TeamsAdapter(BasePlatformAdapter): logger.warning("[teams] Failed to cache image attachment: %s", e) continue - if content_url: - # Direct-URL non-image attachment (video/audio/document). + if content_url: # direct-URL non-image attachment (video/audio/document) try: data = await self._fetch_attachment_bytes(content_url) - cached = cache_media_bytes( - data, filename=att_name, mime_type=content_type - ) - if cached: - media_urls.append(cached.path) - media_types.append(cached.media_type) - media_kinds.append(cached.kind) + _add_cached(cache_media_bytes(data, filename=att_name, mime_type=content_type)) except Exception as e: logger.warning( - "[teams] Failed to cache attachment '%s' (%s): %s", - att_name or content_url, content_type, e, + "[teams] Failed to cache attachment '%s' (%s): %s", att_name or content_url, content_type, e ) - # Classification: DOCUMENT wins over PHOTO/VIDEO/AUDIO for mixed - # attachments — run.py's image handling keys off the per-path image/* - # mime types regardless of message_type, but document-context - # injection gates strictly on MessageType.DOCUMENT (same precedence - # as Email/Signal, PR #44695). - if "document" in media_kinds: - msg_type = MessageType.DOCUMENT - elif "image" in media_kinds: - msg_type = MessageType.PHOTO - elif "video" in media_kinds: - msg_type = MessageType.VIDEO - elif "audio" in media_kinds: - msg_type = MessageType.AUDIO - else: - msg_type = MessageType.TEXT + msg_type = next((t for kind, t in _MEDIA_KIND_PRECEDENCE if kind in media_kinds), MessageType.TEXT) - event = MessageEvent( - text=text, - source=source, - message_type=msg_type, - media_urls=media_urls, - media_types=media_types, - message_id=msg_id, - ) - await self.handle_message(event) + await self.handle_message(MessageEvent( + text=text, source=source, message_type=msg_type, + media_urls=media_urls, media_types=media_types, message_id=msg_id, + )) async def _send_card(self, chat_id: str, card: "AdaptiveCard") -> "Any": """Send an AdaptiveCard, using a stored ConversationReference when available.""" from microsoft_teams.api import MessageActivityInput + if not self._app: + return None + return await self._send_via_conv_ref(chat_id, MessageActivityInput().add_card(card), card) + + async def _send_via_conv_ref(self, chat_id: str, activity: Any, fallback: Any) -> Any: + """Send ``activity`` through the cached ConversationReference, else ``App.send(fallback)``.""" conv_ref = self._conv_refs.get(chat_id) - if conv_ref and self._app: - activity = MessageActivityInput().add_card(card) + if conv_ref: return await self._app.activity_sender.send(activity, conv_ref) - elif self._app: - return await self._app.send(chat_id, card) - return None + return await self._app.send(chat_id, fallback) + + @staticmethod + def _invoke_message(text: str) -> "InvokeResponse[AdaptiveCardActionMessageResponse]": + return InvokeResponse(status=200, body=AdaptiveCardActionMessageResponse(value=text)) + + @staticmethod + def _invoke_card(body: list) -> "InvokeResponse[AdaptiveCardActionMessageResponse]": + return InvokeResponse( + status=200, + body=AdaptiveCardActionCardResponse(value=AdaptiveCard().with_version("1.4").with_body(body)), + ) async def _on_card_action( self, ctx: "ActivityContext[AdaptiveCardInvokeActivity]" @@ -1206,74 +675,36 @@ class TeamsAdapter(BasePlatformAdapter): data = action.data or {} hermes_action = data.get("hermes_action", "") session_key = data.get("session_key", "") - if not hermes_action or not session_key: - return InvokeResponse( - status=200, - body=AdaptiveCardActionMessageResponse(value="Unknown action."), - ) + return self._invoke_message("Unknown action.") - # Only authorized users may click approval buttons. - # Default-deny: require either TEAMS_ALLOWED_USERS or an explicit - # TEAMS_ALLOW_ALL_USERS=true opt-in. Without one of these set, the - # bot silently treated every clicker as authorized — meaning any - # Teams user who could message the bot could approve dangerous commands. + # Default-deny: approval clicks require TEAMS_ALLOWED_USERS or an explicit + # TEAMS_ALLOW_ALL_USERS=true opt-in, else anyone who can message the bot + # could approve dangerous commands. allowed_csv = os.getenv("TEAMS_ALLOWED_USERS", "").strip() allow_all = os.getenv("TEAMS_ALLOW_ALL_USERS", "").strip().lower() in {"1", "true", "yes"} - if not allow_all: if not allowed_csv: logger.warning( "[teams] card action rejected: TEAMS_ALLOWED_USERS not configured " "and TEAMS_ALLOW_ALL_USERS not set — default deny" ) - return InvokeResponse( - status=200, - body=AdaptiveCardActionMessageResponse( - value="⛔ Approval buttons require TEAMS_ALLOWED_USERS to be configured." - ), - ) + return self._invoke_message("⛔ Approval buttons require TEAMS_ALLOWED_USERS to be configured.") from_account = ctx.activity.from_ clicker_id = getattr(from_account, "aad_object_id", None) or getattr(from_account, "id", "") allowed_ids = {uid.strip() for uid in allowed_csv.split(",") if uid.strip()} if "*" not in allowed_ids and clicker_id not in allowed_ids: logger.warning("[teams] Unauthorized card action by %s — ignoring", clicker_id) - return InvokeResponse( - status=200, - body=AdaptiveCardActionMessageResponse(value="⛔ Not authorized."), - ) + return self._invoke_message("⛔ Not authorized.") - choice_map = { - "approve_once": "once", - "approve_session": "session", - "approve_always": "always", - "deny": "deny", - } - choice = choice_map.get(hermes_action) + choice = _APPROVAL_CHOICES.get(hermes_action) if not choice: - return InvokeResponse( - status=200, - body=AdaptiveCardActionMessageResponse(value="Unknown action."), - ) - + return self._invoke_message("Unknown action.") if not has_blocking_approval(session_key): - return InvokeResponse( - status=200, - body=AdaptiveCardActionCardResponse( - value=AdaptiveCard() - .with_version("1.4") - .with_body([TextBlock(text="⚠️ Approval already resolved or expired.", wrap=True)]) - ), - ) + return self._invoke_card([TextBlock(text="⚠️ Approval already resolved or expired.", wrap=True)]) resolve_gateway_approval(session_key, choice) - label_map = { - "once": "✅ Allowed (once)", - "session": "✅ Allowed (session)", - "always": "✅ Always allowed", - "deny": "❌ Denied", - } cmd = data.get("cmd", "") desc = data.get("desc", "") body = [] @@ -1282,24 +713,12 @@ class TeamsAdapter(BasePlatformAdapter): body.append(TextBlock(text=f"```\n{cmd}\n```", wrap=True)) if desc: body.append(TextBlock(text=f"Reason: {desc}", wrap=True, isSubtle=True)) - body.append(TextBlock(text=label_map[choice], wrap=True, weight="Bolder")) - - return InvokeResponse( - status=200, - body=AdaptiveCardActionCardResponse( - value=AdaptiveCard().with_version("1.4").with_body(body) - ), - ) + body.append(TextBlock(text=_APPROVAL_LABELS[choice], wrap=True, weight="Bolder")) + return self._invoke_card(body) async def send_exec_approval( - self, - chat_id: str, - command: str, - session_key: str, - description: str = "dangerous command", - metadata: Optional[Dict[str, Any]] = None, - allow_permanent: bool = True, - allow_session: bool = True, + self, chat_id: str, command: str, session_key: str, description: str = "dangerous command", + metadata: Optional[Dict[str, Any]] = None, allow_permanent: bool = True, allow_session: bool = True, smart_denied: bool = False, ) -> SendResult: """Send an Adaptive Card approval prompt with Allow/Deny buttons.""" @@ -1307,40 +726,31 @@ class TeamsAdapter(BasePlatformAdapter): return SendResult(success=False, error="Teams app not initialized") cmd_preview = command[:2000] + "..." if len(command) > 2000 else command - # Truncated for button data payload — just enough to reconstruct the card body. + # Button data carries a truncated cmd — just enough to reconstruct the card body. btn_data_base = { "session_key": session_key, "cmd": command[:200] + "..." if len(command) > 200 else command, "desc": description, } - actions = [ExecuteAction( - title="Allow Once", verb="hermes_approve", - data={**btn_data_base, "hermes_action": "approve_once"}, style="positive", - )] + def _action(title: str, hermes_action: str, **kw) -> "ExecuteAction": + return ExecuteAction( + title=title, verb="hermes_approve", data={**btn_data_base, "hermes_action": hermes_action}, **kw + ) + + actions = [_action("Allow Once", "approve_once", style="positive")] if not smart_denied and allow_session: - actions.append(ExecuteAction( - title="Allow Session", verb="hermes_approve", - data={**btn_data_base, "hermes_action": "approve_session"}, - )) + actions.append(_action("Allow Session", "approve_session")) if allow_permanent: - actions.append(ExecuteAction( - title="Always Allow", verb="hermes_approve", - data={**btn_data_base, "hermes_action": "approve_always"}, - )) - actions.append(ExecuteAction( - title="Deny", verb="hermes_approve", - data={**btn_data_base, "hermes_action": "deny"}, style="destructive", - )) + actions.append(_action("Always Allow", "approve_always")) + actions.append(_action("Deny", "deny", style="destructive")) body = [ TextBlock(text="⚠️ Command Approval Required", wrap=True, weight="Bolder"), TextBlock(text=f"```\n{cmd_preview}\n```", wrap=True), TextBlock(text=f"Reason: {description}", wrap=True, isSubtle=True), ] if smart_denied: - body.append(TextBlock( - text="Smart DENY: owner override applies to this one operation only.", wrap=True - )) + body.append(TextBlock(text="Smart DENY: owner override applies to this one operation only.", wrap=True)) card = AdaptiveCard().with_version("1.4").with_body(body).with_actions(actions) try: @@ -1352,39 +762,27 @@ class TeamsAdapter(BasePlatformAdapter): return SendResult(success=False, error=str(e), retryable=True) async def send( - self, - chat_id: str, - content: str, - reply_to: Optional[str] = None, - metadata: Optional[Dict[str, Any]] = None, + self, chat_id: str, content: str, reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None ) -> SendResult: if not self._app: return SendResult(success=False, error="Teams app not initialized") - formatted = self.format_message(content) - chunks = self.truncate_message(formatted) last_message_id = None - - for chunk in chunks: + for chunk in self.truncate_message(self.format_message(content)): try: if reply_to and reply_to.isdigit() and reply_to != "0": try: result = await self._app.reply(chat_id, reply_to, chunk) except Exception as reply_err: - # Group chats 400 on threaded sends; the Teams SDK - # doesn't expose typed HTTP errors, so fall back on - # any exception and log for diagnostics. - logger.debug( - "Teams reply() failed, falling back to flat send: %s", - reply_err, - ) + # Group chats 400 on threaded sends; the SDK has no typed + # HTTP errors, so fall back on any exception. + logger.debug("Teams reply() failed, falling back to flat send: %s", reply_err) result = await self._app.send(chat_id, chunk) else: result = await self._app.send(chat_id, chunk) last_message_id = getattr(result, "id", None) except Exception as e: return SendResult(success=False, error=str(e), retryable=True) - return SendResult(success=True, message_id=last_message_id) async def send_typing(self, chat_id: str, metadata: Optional[Dict[str, Any]] = None) -> None: @@ -1396,24 +794,16 @@ class TeamsAdapter(BasePlatformAdapter): pass async def _send_media_attachment( - self, - chat_id: str, - source: str, - default_mime: str, - caption: Optional[str] = None, - media_label: str = "media", + self, chat_id: str, source: str, default_mime: str, caption: Optional[str] = None, media_label: str = "media" ) -> SendResult: - """Send any media file/URL as a Teams attachment. + """Send any media file/URL as a Teams attachment (shared by send_image/video/voice/document). Remote ``http(s)://`` URLs are attached by reference; local paths - (with optional ``file://`` prefix) are base64-encoded into a data - URI. MIME type is guessed from the path/extension, falling back to - ``default_mime``. Shared by send_image / send_video / send_voice / - send_document so every media kind uses the same Attachment path. + (optional ``file://`` prefix) are base64-encoded into a data URI. + MIME type is guessed from the path, falling back to ``default_mime``. """ if not self._app: return SendResult(success=False, error="Teams app not initialized") - try: import base64 import mimetypes @@ -1423,109 +813,49 @@ class TeamsAdapter(BasePlatformAdapter): content_url = source mime_type = mimetypes.guess_type(source.split("?")[0])[0] or default_mime else: - # Local path — encode as base64 data URI path = source.removeprefix("file://") mime_type = mimetypes.guess_type(path)[0] or default_mime with open(path, "rb") as f: content_url = f"data:{mime_type};base64,{base64.b64encode(f.read()).decode()}" - attachment = Attachment(content_type=mime_type, content_url=content_url) - activity = MessageActivityInput().add_attachments(attachment) + activity = MessageActivityInput().add_attachments(Attachment(content_type=mime_type, content_url=content_url)) if caption: activity = activity.add_text(caption) - - conv_ref = self._conv_refs.get(chat_id) - if conv_ref: - result = await self._app.activity_sender.send(activity, conv_ref) - else: - result = await self._app.send(chat_id, activity) - + result = await self._send_via_conv_ref(chat_id, activity, activity) return SendResult(success=True, message_id=getattr(result, "id", None)) except Exception as e: logger.error("[teams] send_%s failed: %s", media_label, e, exc_info=True) return SendResult(success=False, error=str(e), retryable=True) async def send_image( - self, - chat_id: str, - image_url: str, - caption: Optional[str] = None, - reply_to: Optional[str] = None, + self, chat_id: str, image_url: str, caption: Optional[str] = None, reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: - return await self._send_media_attachment( - chat_id=chat_id, - source=image_url, - default_mime="image/png", - caption=caption, - media_label="image", - ) + return await self._send_media_attachment(chat_id, image_url, "image/png", caption=caption, media_label="image") async def send_image_file( - self, - chat_id: str, - image_path: str, - caption: Optional[str] = None, - reply_to: Optional[str] = None, - **kwargs, + self, chat_id: str, image_path: str, caption: Optional[str] = None, reply_to: Optional[str] = None, **kwargs ) -> SendResult: - return await self.send_image( - chat_id=chat_id, - image_url=image_path, - caption=caption, - reply_to=reply_to, - ) + return await self.send_image(chat_id=chat_id, image_url=image_path, caption=caption, reply_to=reply_to) async def send_video( - self, - chat_id: str, - video_path: str, - caption: Optional[str] = None, - reply_to: Optional[str] = None, - metadata: Optional[Dict[str, Any]] = None, - **kwargs, + self, chat_id: str, video_path: str, caption: Optional[str] = None, reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, **kwargs, ) -> SendResult: - return await self._send_media_attachment( - chat_id=chat_id, - source=video_path, - default_mime="video/mp4", - caption=caption, - media_label="video", - ) + return await self._send_media_attachment(chat_id, video_path, "video/mp4", caption=caption, media_label="video") async def send_voice( - self, - chat_id: str, - audio_path: str, - caption: Optional[str] = None, - reply_to: Optional[str] = None, - metadata: Optional[Dict[str, Any]] = None, - **kwargs, + self, chat_id: str, audio_path: str, caption: Optional[str] = None, reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, **kwargs, ) -> SendResult: - return await self._send_media_attachment( - chat_id=chat_id, - source=audio_path, - default_mime="audio/mpeg", - caption=caption, - media_label="voice", - ) + return await self._send_media_attachment(chat_id, audio_path, "audio/mpeg", caption=caption, media_label="voice") async def send_document( - self, - chat_id: str, - file_path: str, - caption: Optional[str] = None, - file_name: Optional[str] = None, - reply_to: Optional[str] = None, - metadata: Optional[Dict[str, Any]] = None, - **kwargs, + self, chat_id: str, file_path: str, caption: Optional[str] = None, file_name: Optional[str] = None, + reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, **kwargs, ) -> SendResult: return await self._send_media_attachment( - chat_id=chat_id, - source=file_path, - default_mime="application/octet-stream", - caption=caption, - media_label="document", + chat_id, file_path, "application/octet-stream", caption=caption, media_label="document" ) async def get_chat_info(self, chat_id: str) -> dict: @@ -1534,19 +864,17 @@ class TeamsAdapter(BasePlatformAdapter): # ── Interactive setup ───────────────────────────────────────────────────────── +_SETUP_CREDENTIALS = ( + ("Client ID", "TEAMS_CLIENT_ID", {}), + ("Client secret", "TEAMS_CLIENT_SECRET", {"password": True}), + ("Tenant ID", "TEAMS_TENANT_ID", {}), +) + + def interactive_setup() -> None: """Guide the user through Teams setup using the Teams CLI.""" - from hermes_cli.config import ( - get_env_value, - save_env_value, - ) - from hermes_cli.cli_output import ( - prompt, - prompt_yes_no, - print_info, - print_success, - print_warning, - ) + from hermes_cli.config import get_env_value, save_env_value + from hermes_cli.cli_output import prompt, prompt_yes_no, print_info, print_success, print_warning existing_id = get_env_value("TEAMS_CLIENT_ID") if existing_id: @@ -1565,31 +893,17 @@ def interactive_setup() -> None: print_info("The CLI will print CLIENT_ID, CLIENT_SECRET, and TENANT_ID. Paste them below.") print() - client_id = prompt("Client ID", default=existing_id or "") - if not client_id: - print_warning("Client ID is required — skipping Teams setup") - return - save_env_value("TEAMS_CLIENT_ID", client_id.strip()) - - client_secret = prompt("Client secret", default=get_env_value("TEAMS_CLIENT_SECRET") or "", password=True) - if not client_secret: - print_warning("Client secret is required — skipping Teams setup") - return - save_env_value("TEAMS_CLIENT_SECRET", client_secret.strip()) - - tenant_id = prompt("Tenant ID", default=get_env_value("TEAMS_TENANT_ID") or "") - if not tenant_id: - print_warning("Tenant ID is required — skipping Teams setup") - return - save_env_value("TEAMS_TENANT_ID", tenant_id.strip()) + for label, env_key, prompt_kwargs in _SETUP_CREDENTIALS: + value = prompt(label, default=get_env_value(env_key) or "", **prompt_kwargs) + if not value: + 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): - allowed = prompt( - "Allowed AAD object IDs (comma-separated)", - default=get_env_value("TEAMS_ALLOWED_USERS") or "", - ) + allowed = prompt("Allowed AAD object IDs (comma-separated)", default=get_env_value("TEAMS_ALLOWED_USERS") or "") if allowed: save_env_value("TEAMS_ALLOWED_USERS", allowed.replace(" ", "")) print_success("Allowlist configured") @@ -1608,13 +922,9 @@ def interactive_setup() -> None: # ── Plugin entry point ──────────────────────────────────────────────────────── def _install_hint() -> str: - """Build the Teams install hint from the canonical LAZY_DEPS pins. + """Install hint derived from the LAZY_DEPS pins (aiohttp is CVE-pinned, so bumps happen). - Derived (not hardcoded) so a pin bump in ``tools/lazy_deps.py`` — aiohttp - is CVE-pinned, so bumps happen — never leaves this string stale. - ``feature_install_command(venv_pip=True)`` targets the actual Hermes - venv in every layout and sidesteps Ubuntu 24.04's PEP 668 failure that - a bare ``pip install`` hint invites. + ``venv_pip=True`` targets the real Hermes venv, sidestepping PEP 668 on Ubuntu 24.04. """ try: from tools.lazy_deps import feature_install_command @@ -1632,38 +942,21 @@ def register(ctx) -> None: name="teams", label="Microsoft Teams", adapter_factory=lambda cfg: TeamsAdapter(cfg), - # PASSIVE probe — deps importable right now? Never installs, so - # status displays / config loading can call it freely. - check_fn=check_requirements, - # ACTIVE lazy-installer — create_adapter() calls this when check_fn - # is False, right before the gateway connects Teams (#79812). - ensure_deps_fn=check_teams_requirements, + 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, required_env=["TEAMS_CLIENT_ID", "TEAMS_CLIENT_SECRET", "TEAMS_TENANT_ID"], install_hint=_install_hint(), setup_fn=interactive_setup, - # Env-driven auto-configuration — seeds PlatformConfig.extra with - # client_id/secret/tenant + port + home_channel so env-only setups - # show up in gateway status without instantiating the Teams SDK. - env_enablement_fn=_env_enablement, - # Cron home-channel delivery support. Lets deliver=teams cron - # jobs route to the configured Teams chat/channel without editing - # cron/scheduler.py's hardcoded sets. - cron_deliver_env_var="TEAMS_HOME_CHANNEL", - # Out-of-process cron delivery via Bot Framework REST. Without - # this hook, deliver=teams 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 + 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", - # Teams supports up to ~28 KB per message - max_message_length=28000, - # Display + max_message_length=28000, # Teams supports up to ~28 KB per message emoji="💼", allow_update_command=True, - # LLM guidance platform_hint=( "You are chatting via Microsoft Teams. Teams renders a subset of " "markdown — bold (**text**), italic (*text*), and inline code " diff --git a/plugins/platforms/teams/summary_writer.py b/plugins/platforms/teams/summary_writer.py new file mode 100644 index 0000000000..0de2f22d63 --- /dev/null +++ b/plugins/platforms/teams/summary_writer.py @@ -0,0 +1,195 @@ +"""Pipeline-facing Teams outbound delivery (meeting-summary writer). + +Lives inside the Teams platform plugin so the meeting pipeline reuses one Teams +integration surface. httpx is imported lazily: plugin discovery imports this +module on every CLI start, but only ``incoming_webhook`` delivery needs it. +""" + +from __future__ import annotations + +import html +import os +from typing import Any, Optional +from urllib.parse import quote + +from gateway.config import PlatformConfig +from gateway.platforms._shared import get_scoped_secret as _get_scoped_secret + + +def _parse_bool(value: Any, *, default: bool = False) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {"1", "true", "yes", "on"}: + return True + if normalized in {"0", "false", "no", "off"}: + return False + return default + + +_LIST_SECTIONS = (("Key decisions", "key_decisions"), ("Action items", "action_items"), ("Risks", "risks")) + + +class _StaticAccessTokenProvider: + """Minimal token-provider shim so outbound Graph delivery can reuse the shared client.""" + + def __init__(self, access_token: str): + self._access_token = str(access_token or "").strip() + + async def get_access_token(self, *, force_refresh: bool = False) -> str: + del force_refresh + if not self._access_token: + raise ValueError("TEAMS_GRAPH_ACCESS_TOKEN is required for graph delivery mode.") + return self._access_token + + def clear_cache(self) -> None: + return None + + +class TeamsSummaryWriter: + """Deliver a meeting summary to Teams via incoming webhook or Graph.""" + + def __init__( + self, platform_config: PlatformConfig | None = None, *, + graph_client: Any | None = None, transport: httpx.AsyncBaseTransport | None = None, + ) -> None: + self._platform_config = platform_config + self._graph_client = graph_client + self._transport = transport + + async def write_summary( + self, payload: Any, config: dict[str, Any] | None, existing_record: Optional[dict[str, Any]] = None + ) -> dict[str, Any]: + merged = self._resolve_delivery_config(config) + if existing_record and not _parse_bool(merged.get("force_resend"), default=False): + return dict(existing_record) + mode = str(merged.get("delivery_mode") or merged.get("mode") or "").strip().lower() + if not mode: + if merged.get("incoming_webhook_url"): + mode = "incoming_webhook" + elif merged.get("chat_id") or (merged.get("team_id") and merged.get("channel_id")): + mode = "graph" + if mode == "incoming_webhook": + return await self._write_summary_via_incoming_webhook(payload, merged) + if mode == "graph": + return await self._write_summary_via_graph(payload, merged) + raise ValueError("Teams delivery_mode must be 'incoming_webhook' or 'graph'.") + + def _resolve_delivery_config(self, config: dict[str, Any] | None) -> dict[str, Any]: + merged: dict[str, Any] = {} + platform_cfg = self._platform_config + if platform_cfg is not None: + merged.update(dict(platform_cfg.extra or {})) + if platform_cfg.token and "access_token" not in merged: + merged["access_token"] = platform_cfg.token + if platform_cfg.home_channel: + merged.setdefault("channel_id", platform_cfg.home_channel.chat_id) + merged.update(dict(config or {})) + env_defaults = { + "delivery_mode": os.getenv("TEAMS_DELIVERY_MODE", ""), + "incoming_webhook_url": os.getenv("TEAMS_INCOMING_WEBHOOK_URL", ""), + "access_token": _get_scoped_secret("TEAMS_GRAPH_ACCESS_TOKEN", ""), + "team_id": os.getenv("TEAMS_TEAM_ID", ""), + "channel_id": os.getenv("TEAMS_CHANNEL_ID", ""), + "chat_id": os.getenv("TEAMS_CHAT_ID", ""), + } + for key, value in env_defaults.items(): + if value and not merged.get(key): + merged[key] = value + return merged + + async def _write_summary_via_incoming_webhook(self, payload: Any, config: dict[str, Any]) -> dict[str, Any]: + import httpx # lazy — see module docstring + + webhook_url = str(config.get("incoming_webhook_url") or "").strip() + if not webhook_url: + raise ValueError("TEAMS_INCOMING_WEBHOOK_URL is required for incoming_webhook mode.") + body = {"text": self._render_summary_markdown(payload)} + async with httpx.AsyncClient(timeout=20.0, transport=self._transport) as client: + response = await client.post(webhook_url, json=body) + response.raise_for_status() + return { + "delivery_mode": "incoming_webhook", "webhook_url": webhook_url, + "status_code": response.status_code, "delivered": True, + } + + async def _write_summary_via_graph(self, payload: Any, config: dict[str, Any]) -> dict[str, Any]: + graph_client = self._build_graph_client(config) + chat_id = str(config.get("chat_id") or "").strip() + if chat_id: + path = f"/chats/{quote(chat_id, safe='')}/messages" + target = {"target_type": "chat", "chat_id": chat_id} + else: + team_id = str(config.get("team_id") or "").strip() + channel_id = str(config.get("channel_id") or "").strip() + if not team_id or not channel_id: + raise ValueError("Graph delivery mode requires chat_id, or both team_id and channel_id.") + path = f"/teams/{quote(team_id, safe='')}/channels/{quote(channel_id, safe='')}/messages" + target = {"target_type": "channel", "team_id": team_id, "channel_id": channel_id} + response = await graph_client.post_json( + path, + json_body={"body": {"contentType": "html", "content": self._render_summary_html(payload)}}, + ) + return { + "delivery_mode": "graph", **target, + "message_id": (response or {}).get("id"), "web_url": (response or {}).get("webUrl"), + } + + def _build_graph_client(self, config: dict[str, Any]) -> Any: + if self._graph_client is not None: + return self._graph_client + from tools.microsoft_graph_auth import MicrosoftGraphTokenProvider + from tools.microsoft_graph_client import MicrosoftGraphClient + + access_token = str(config.get("access_token") or "").strip() + if access_token: + return MicrosoftGraphClient(_StaticAccessTokenProvider(access_token), transport=self._transport) + return MicrosoftGraphClient(MicrosoftGraphTokenProvider.from_env(), transport=self._transport) + + def _render_summary_markdown(self, payload: Any) -> str: + lines = [ + f"**{self._title(payload)}**", + "", + f"Summary: {self._text(getattr(payload, 'summary', None), 'No summary available.')}", + ] + for heading, attr in _LIST_SECTIONS: + lines += ["", f"{heading}:", *self._bullet_lines(getattr(payload, attr, None))] + return "\n".join(lines) + + def _render_summary_html(self, payload: Any) -> str: + sections = [ + ("Summary", [self._text(getattr(payload, "summary", None), "No summary available.")]), + *((heading, list(getattr(payload, attr, None) or [])) for heading, attr in _LIST_SECTIONS), + ] + blocks = [f"

    {html.escape(self._title(payload))}

    "] + for heading, items in sections: + blocks.append(f"

    {html.escape(heading)}

    ") + if len(items) == 1 and heading == "Summary": + blocks.append(f"

    {html.escape(str(items[0]))}

    ") + continue + if items: + rendered = "".join(f"
  • {html.escape(str(item))}
  • " for item in items if str(item).strip()) + blocks.append(rendered and f"
      {rendered}
    " or "

    None

    ") + else: + blocks.append("

    None

    ") + return "".join(blocks) + + @staticmethod + def _title(payload: Any) -> str: + title = getattr(payload, "title", None) + if title: + return str(title) + meeting_ref = getattr(payload, "meeting_ref", None) + meeting_id = getattr(meeting_ref, "meeting_id", None) if meeting_ref else None + return f"Meeting {meeting_id or 'summary'}" + + @staticmethod + def _text(value: Any, default: str) -> str: + text = str(value or "").strip() + return text or default + + @classmethod + def _bullet_lines(cls, values: Any) -> list[str]: + items = [str(item).strip() for item in (values or []) if str(item).strip()] + return [f"- {item}" for item in items] or ["- None"] diff --git a/plugins/platforms/wecom/adapter.py b/plugins/platforms/wecom/adapter.py index 27fbf52e2b..02385d2955 100644 --- a/plugins/platforms/wecom/adapter.py +++ b/plugins/platforms/wecom/adapter.py @@ -1,14 +1,11 @@ """ WeCom (Enterprise WeChat) platform adapter. -Uses the WeCom AI Bot WebSocket gateway for inbound and outbound messages. -The adapter focuses on the core gateway path: - -- authenticate via ``aibot_subscribe`` -- receive inbound ``aibot_msg_callback`` events -- send outbound markdown messages via ``aibot_send_msg`` -- upload outbound media via ``aibot_upload_media_*`` and send native attachments -- best-effort download of inbound image/file attachments for agent context +Uses the WeCom AI Bot WebSocket gateway: authenticate via ``aibot_subscribe``, +receive ``aibot_msg_callback`` events, send markdown via ``aibot_send_msg`` / +``aibot_respond_msg``, upload media via ``aibot_upload_media_*``. Native +streaming lives in ``streaming.py``, media in ``media.py``, the per-chat +send queue in ``send_queue.py``. Configuration in config.yaml: platforms: @@ -30,21 +27,13 @@ Configuration in config.yaml: from __future__ import annotations import asyncio -import base64 -import hashlib import json import logging -import mimetypes -import os import re import time import uuid -from collections import deque -from dataclasses import dataclass from datetime import datetime, timezone -from pathlib import Path from typing import Any, Dict, List, Optional, Tuple -from urllib.parse import unquote, urlparse try: import aiohttp @@ -62,39 +51,17 @@ except ImportError: from gateway.config import Platform, PlatformConfig from gateway.platforms.helpers import MessageDeduplicator -from gateway.platforms.base import ( - gateway_trust_env, - BasePlatformAdapter, - MessageEvent, - MessageType, - SendResult, - cache_document_from_bytes, - cache_image_from_bytes, -) +from gateway.platforms.base import gateway_trust_env, BasePlatformAdapter, MessageEvent, MessageType, SendResult from utils import env_float -from agent.secret_scope import UnscopedSecretError as _UnscopedSecretError -from agent.secret_scope import get_secret as _scoped_get_secret - - -def _get_scoped_secret(name, default=None): - """Scope-aware credential read with the default-profile startup fallback. - - Secondary profiles construct their adapters under a profile secret - scope -- the scope is authoritative and a scoped miss returns ``default`` - (no cross-profile borrow from ``os.environ``, which may hold another - profile's value). The DEFAULT profile's adapter constructs and sends - *unscoped* under multiplexing, where a bare ``get_secret`` would raise - ``UnscopedSecretError`` and crash this path; there ``os.environ`` is that - profile's own value, so fall back to it. Same pattern as the Slack - ``SLACK_APP_TOKEN`` read (#59739) and - ``gateway/platforms/whatsapp_common.py::_get_wsecret``. - """ - try: - val = _scoped_get_secret(name, default) - except _UnscopedSecretError: - val = os.getenv(name) - return val if val is not None else default +from gateway.platforms._shared import get_scoped_secret as _get_scoped_secret +from plugins.platforms.wecom.send_queue import ChatSendQueueMixin +from plugins.platforms.wecom.media import WeComMediaMixin, APP_CMD_SEND +from plugins.platforms.wecom.streaming import ( # noqa: F401 — re-exported for tests/stream_consumer + WeComStreamMixin, WeComStreamExpiredError, ReplyQueue, StreamTurn, APP_CMD_RESPONSE, + STREAM_EXPIRED_ERRCODE, STREAM_NOT_SUBSCRIBED_ERRCODE, MAX_STREAM_CONTENT_LENGTH, MAX_INTERMEDIATE_FRAMES, + STREAM_SAFE_DURATION_SECONDS, STREAM_KEEPALIVE_INTERVAL_SECONDS, STREAM_KEEPALIVE_ENABLED_DEFAULT, +) logger = logging.getLogger(__name__) @@ -105,12 +72,7 @@ APP_CMD_SUBSCRIBE = "aibot_subscribe" APP_CMD_CALLBACK = "aibot_msg_callback" APP_CMD_LEGACY_CALLBACK = "aibot_callback" APP_CMD_EVENT_CALLBACK = "aibot_event_callback" -APP_CMD_SEND = "aibot_send_msg" -APP_CMD_RESPONSE = "aibot_respond_msg" APP_CMD_PING = "ping" -APP_CMD_UPLOAD_MEDIA_INIT = "aibot_upload_media_init" -APP_CMD_UPLOAD_MEDIA_CHUNK = "aibot_upload_media_chunk" -APP_CMD_UPLOAD_MEDIA_FINISH = "aibot_upload_media_finish" CALLBACK_COMMANDS = {APP_CMD_CALLBACK, APP_CMD_LEGACY_CALLBACK} NON_RESPONSE_COMMANDS = CALLBACK_COMMANDS | {APP_CMD_EVENT_CALLBACK} @@ -123,69 +85,6 @@ RECONNECT_BACKOFF = [2, 5, 10, 30, 60] DEDUP_MAX_SIZE = 1000 -# Native streaming (msgtype: stream) constants — modeled on WeCom's official -# OpenClaw plugin behavior. WeCom's AI Bot supports cumulative stream frames -# via aibot_respond_msg; the first frame sends a placeholder -# (matching the plugin's THINKING_MESSAGE) to signal a reasoning turn, -# subsequent frames push cumulative content, and a final frame with -# finish=true closes the stream. -STREAM_EXPIRED_ERRCODE = 846608 # >6 min without update — stream is dead -STREAM_REQUEST_EXPIRED_ERRCODE = 846604 # passive-reply request (req_id) itself -# expired — "websocket request expired, response is invalid". Sibling of 846608: -# 846608 is the stream update window, 846604 is the req_id reply channel window. -# Both mean the reply flow is dead and further finish=true / finish=false frames -# on it will be rejected — so keep-alive treats either as "stream expired". -STREAM_NOT_SUBSCRIBED_ERRCODE = 846609 # ws connection lost the subscription -STREAM_VERSION_CONFLICT_ERRCODE = 6000 # finalize raced a newer frame on the -# same stream_id — the bubble was ALREADY replaced by that newer version, so -# for a finalize frame this is benign (idempotent re-finalize hitting an -# already-updated bubble), NOT a delivery failure. See _send_stream_reply. -MAX_STREAM_CONTENT_LENGTH = 20480 # WeCom server-enforced byte limit per frame -# Per-turn cap on intermediate frames. WeCom SDK has an internal 100-frame -# per-reqId queue limit; we cap at 85 (matching openclaw plugin) to guarantee -# room for the finalize frame. Once hit, all further intermediate frames are -# silently dropped — finalize still sends unconditionally. -MAX_INTERMEDIATE_FRAMES = 85 - -# ── Stream-level keep-alive (aligned with wecom-openclaw-plugin PR #90) ────── -# WeCom binds a ~6-minute lifetime timer to each *reply stream* (stream_id + -# req_id). The connection-level ping (_heartbeat_loop) does NOT refresh it — -# it only keeps the WS socket open. Long turns (e.g. the daily-report cron, -# which spends minutes fetching data / rendering charts with no LLM tokens to -# push) let that window elapse, so the finalize frame lands on a dead stream -# and comes back 846604 / 846608 — a real delivery risk in group chats, which -# cannot fall back to a proactive send. See docs/wecom-stream-keepalive-*.md. -# -# Two independent defences, both defaulting to the SAFE (non-aggressive) side: -# -# Layer 2 — clock fallback (always on, zero new uplink frames): -# finalize computes stream age from StreamTurn.start_time; past -# STREAM_SAFE_DURATION_SECONDS it declines the finish=true frame (which -# would almost certainly hit 846604/846608) and returns False so the -# gateway consumer's existing fallback send() path delivers the content. -# -# Layer 1 — keep-alive heartbeat (OFF by default; opt-in via config): -# every STREAM_KEEPALIVE_INTERVAL_SECONDS re-send the already-accumulated -# text as a finish=false frame to refresh the server window. Never sends a -# placeholder (that would pollute last_sent_content and could strand the -# user on "still working…"); when there is no accumulated text yet the tick -# is simply skipped. Guarded off by default because a heartbeat frame is an -# extra intermediate frame sharing the finalize's req_id, which widens the -# ack race the double-send coordination depends on (see ANALYSIS §4.2). -STREAM_SAFE_DURATION_SECONDS = 330.0 # 5.5 min — Layer 2 clock fallback -STREAM_KEEPALIVE_INTERVAL_SECONDS = 120.0 # 2 min — Layer 1 heartbeat cadence -STREAM_KEEPALIVE_ENABLED_DEFAULT = False # Layer 1 off unless config opts in - -IMAGE_MAX_BYTES = 10 * 1024 * 1024 -VIDEO_MAX_BYTES = 10 * 1024 * 1024 -VOICE_MAX_BYTES = 2 * 1024 * 1024 -FILE_MAX_BYTES = 20 * 1024 * 1024 -ABSOLUTE_MAX_BYTES = FILE_MAX_BYTES -UPLOAD_CHUNK_SIZE = 512 * 1024 -MAX_UPLOAD_CHUNKS = 100 -VOICE_SUPPORTED_MIMES = {"audio/amr"} - - def check_wecom_requirements() -> bool: """Check if WeCom runtime dependencies are available.""" return AIOHTTP_AVAILABLE and HTTPX_AVAILABLE @@ -204,139 +103,71 @@ def _coerce_list(value: Any) -> List[str]: def _normalize_entry(raw: str) -> str: """Normalize allowlist entries such as ``wecom:user:foo``.""" - value = str(raw).strip() - value = re.sub(r"^wecom:", "", value, flags=re.IGNORECASE) - value = re.sub(r"^(user|group):", "", value, flags=re.IGNORECASE) - return value.strip() + value = re.sub(r"^wecom:", "", str(raw).strip(), flags=re.IGNORECASE) + return re.sub(r"^(user|group):", "", value, flags=re.IGNORECASE).strip() def _entry_matches(entries: List[str], target: str) -> bool: """Case-insensitive allowlist match with ``*`` support.""" normalized_target = str(target).strip().lower() - for entry in entries: - normalized = _normalize_entry(entry).lower() - if normalized == "*" or normalized == normalized_target: - return True - return False + return any(_normalize_entry(e).lower() in ("*", normalized_target) for e in entries) -class WeComStreamExpiredError(RuntimeError): - """Raised when WeCom returns errcode 846608 or 846604 (stream/req expired). - - WeCom's stream protocol caps a stream session at ~6 minutes from the - first frame. After that window the server refuses further updates with - 846608 (stream update window) or 846604 (req_id reply-request window) and - the reply flow is dead — callers must fall back to a proactive - ``aibot_send_msg`` to deliver the remaining content. - """ - - def __init__(self, errcode: int = STREAM_EXPIRED_ERRCODE, errmsg: str = ""): - super().__init__( - f"WeCom stream expired (errcode={errcode}): {errmsg or 'no detail'}" - ) - self.errcode = errcode - self.errmsg = errmsg +def _dict_or_empty(container: Dict[str, Any], key: str) -> Dict[str, Any]: + value = container.get(key) + return value if isinstance(value, dict) else {} -@dataclass -class ReplyFrame: - """A queued reply frame waiting to be sent via aibot_respond_msg. - - Used for ack tracking and FIFO ordering per req_id, aligning with - the official WeCom SDK's replyStreamNonBlocking semantics. - """ - body: Dict[str, Any] - future: asyncio.Future - is_final: bool = False - sent_at: Optional[float] = None +def _content_of(container: Dict[str, Any], key: str) -> str: + return str(_dict_or_empty(container, key).get("content") or "").strip() -class ReplyQueue: - """Per-req_id pending ack tracker. - - Ensures: - - Intermediate frames skip if a previous frame's ack is pending - - Final frames wait for pending ack before sending - - Aligned with official SDK's replyStreamNonBlocking + 5s ack timeout. - """ - def __init__(self, req_id: str): - self.req_id = req_id - self.pending_ack: Optional[ReplyFrame] = None +def _bounded_put(store: Dict[str, str], key: str, value: str) -> bool: + """Insert into an insertion-ordered dict bounded at DEDUP_MAX_SIZE; False if key/value empty.""" + key = str(key or "").strip() + value = str(value or "").strip() + if not key or not value: + return False + store[key] = value + while len(store) > DEDUP_MAX_SIZE: + store.pop(next(iter(store))) + return True - -class StreamTurn: - """Per-turn stream state to avoid global state conflicts. - - Each inbound message creates its own StreamTurn, ensuring concurrent - messages don't interfere with each other's stream state. - """ - def __init__(self, chat_id: str, req_id: str): - self.chat_id = chat_id - self.req_id = req_id - self.stream_id = f"stream_{uuid.uuid4().hex[:12]}" - self.accumulated_text = "" - self.finalized = False - self.seeded = False # True after seed frame sent (prevents double seed) - self.start_time = time.monotonic() - self.expired = False - # Track the last content that was ACTUALLY sent to WeCom (not skipped). - # Used by finalize to detect duplicate content and avoid silent ack drops. - self.last_sent_content: str = "" - # Per-turn intermediate-frame counter (count-based cap at - # MAX_INTERMEDIATE_FRAMES to leave room for the finalize frame). - self._last_frame_sent_at: float = 0.0 - self._intermediate_frames_sent: int = 0 - # Idle flush handle — retained for _cancel_idle_flush() compatibility - # (called in finalize/boundary paths; always None in fire-and-forget). - self.idle_flush_handle: Optional[asyncio.TimerHandle] = None - # Keep-alive handle (Layer 1) — set when the stream-level keep-alive - # timer is armed. Structurally identical to idle_flush_handle: a - # per-turn asyncio TimerHandle that MUST be cancelled on every turn - # exit path (finalize / expired / error / cleanup) to avoid a leaked - # timer firing on a dead turn. None when keep-alive is disabled or - # the turn has no armed timer. - self.keepalive_handle: Optional[asyncio.TimerHandle] = None - - -class WeComAdapter(BasePlatformAdapter): +class WeComAdapter(WeComStreamMixin, WeComMediaMixin, ChatSendQueueMixin, BasePlatformAdapter): """WeCom AI Bot adapter backed by a persistent WebSocket connection.""" MAX_MESSAGE_LENGTH = MAX_MESSAGE_LENGTH SUPPORTS_MESSAGE_EDITING = False - # WeCom AI Bot supports msgtype: "stream" via aibot_respond_msg, which - # the gateway streaming consumer treats as a transport that bypasses the - # edit-based path. See ``send_stream_frame`` and ``supports_native_streaming``. + # msgtype "stream" via aibot_respond_msg bypasses the edit-based streaming path. SUPPORTS_NATIVE_STREAMING = True MAX_STREAM_CONTENT_LENGTH = MAX_STREAM_CONTENT_LENGTH - # Threshold for detecting WeCom client-side message splits. - # When a chunk is near the 4000-char limit, a continuation is almost certain. + # Chunks near the 4000-char WeCom client split are almost certainly continued. _SPLIT_THRESHOLD = 3900 def __init__(self, config: PlatformConfig): super().__init__(config, Platform.WECOM) extra = config.extra or {} + + def _extra_float(key: str, default: float) -> float: + try: + return float(extra.get(key, default)) + except (TypeError, ValueError): + return default + self._bot_id = str(extra.get("bot_id") or _get_scoped_secret("WECOM_BOT_ID", "")).strip() self._secret = str(extra.get("secret") or _get_scoped_secret("WECOM_SECRET", "")).strip() self._ws_url = str( - extra.get("websocket_url") - or extra.get("websocketUrl") - or _get_scoped_secret("WECOM_WEBSOCKET_URL", DEFAULT_WS_URL) + extra.get("websocket_url") or extra.get("websocketUrl") or _get_scoped_secret("WECOM_WEBSOCKET_URL", DEFAULT_WS_URL) ).strip() or DEFAULT_WS_URL self._dm_policy = str(extra.get("dm_policy") or _get_scoped_secret("WECOM_DM_POLICY", "pairing")).strip().lower() - # dm_policy already honors WECOM_DM_POLICY, so the allowlist must honor - # WECOM_ALLOWED_USERS too. Without the env fallback an env-only setup - # (dm_policy=allowlist via env, no config extra) runs with an empty - # allowlist and drops every authorized DM at intake. + # Env-only setups (dm_policy=allowlist via env) need the WECOM_ALLOWED_USERS + # fallback or every authorized DM is dropped at intake. self._allow_from = _coerce_list( - extra.get("allow_from") - or extra.get("allowFrom") - or _get_scoped_secret("WECOM_ALLOWED_USERS", "") + extra.get("allow_from") or extra.get("allowFrom") or _get_scoped_secret("WECOM_ALLOWED_USERS", "") ) - self._group_policy = str(extra.get("group_policy") or _get_scoped_secret("WECOM_GROUP_POLICY", "pairing")).strip().lower() self._group_allow_from = _coerce_list(extra.get("group_allow_from") or extra.get("groupAllowFrom")) self._groups = extra.get("groups") if isinstance(extra.get("groups"), dict) else {} @@ -347,281 +178,58 @@ class WeComAdapter(BasePlatformAdapter): self._listen_task: Optional[asyncio.Task] = None self._heartbeat_task: Optional[asyncio.Task] = None self._pending_responses: Dict[str, asyncio.Future] = {} - # Per-req_id reply queue with ack tracking — aligns with official - # SDK's replyStreamNonBlocking (skip if pending, wait before final). self._reply_queues: Dict[str, ReplyQueue] = {} self._dedup = MessageDeduplicator(max_size=DEDUP_MAX_SIZE) self._reply_req_ids: Dict[str, str] = {} - # Text batching: merge rapid successive messages (Telegram-style). - # WeCom clients split long messages around 4000 chars. + # Text batching: WeCom clients split long messages around 4000 chars. self._text_batch_delay_seconds = env_float("HERMES_WECOM_TEXT_BATCH_DELAY_SECONDS", 0.6) self._text_batch_split_delay_seconds = env_float("HERMES_WECOM_TEXT_BATCH_SPLIT_DELAY_SECONDS", 2.0) - # Attachment/text merge window: WeCom clients send "image + text" as two - # separate inbound callbacks (one attachment-only, one text) a few - # hundred ms apart. Holding an attachment-only message for this window - # lets the following text merge into the SAME event, so it dispatches - # as one turn instead of the attachment spawning a run that the text - # then "interrupts" (mirrors the official plugin's - # ATTACHMENT_TEXT_MERGE_WINDOW_MS = 800). Behavioral config lives in - # config.extra, not an env var. - try: - self._attachment_text_merge_delay_seconds = float( - extra.get("attachment_text_merge_delay_seconds", 0.8) - ) - except (TypeError, ValueError): - self._attachment_text_merge_delay_seconds = 0.8 + # WeCom sends "image + text" as two callbacks a few hundred ms apart; hold an + # attachment-only message this long so the trailing text merges into ONE + # event (official plugin: ATTACHMENT_TEXT_MERGE_WINDOW_MS = 800). + self._attachment_text_merge_delay_seconds = _extra_float("attachment_text_merge_delay_seconds", 0.8) self._pending_text_batches: Dict[str, MessageEvent] = {} self._pending_text_batch_tasks: Dict[str, asyncio.Task] = {} - # ── Stream-level keep-alive config (config.extra, not env) ────────── - # Behavioral config lives in config.extra, matching - # attachment_text_merge_delay_seconds above. See the module-level - # STREAM_* constants and docs/wecom-stream-keepalive-ANALYSIS.md. - def _extra_float(key: str, default: float) -> float: - try: - return float(extra.get(key, default)) - except (TypeError, ValueError): - return default - - # Layer 2 clock fallback: decline the finalize frame once the stream is - # older than this, so we don't hand the server a doomed finish=true. - self._stream_safe_duration_seconds = _extra_float( - "stream_safe_duration_seconds", STREAM_SAFE_DURATION_SECONDS - ) - # Layer 1 heartbeat: off unless config opts in (see ANALYSIS §4.2/§5). - self._stream_keepalive_enabled = bool( - extra.get("stream_keepalive_enabled", STREAM_KEEPALIVE_ENABLED_DEFAULT) - ) + # Stream keep-alive config (see streaming.py STREAM_* constants). + self._stream_safe_duration_seconds = _extra_float("stream_safe_duration_seconds", STREAM_SAFE_DURATION_SECONDS) + self._stream_keepalive_enabled = bool(extra.get("stream_keepalive_enabled", STREAM_KEEPALIVE_ENABLED_DEFAULT)) self._stream_keepalive_interval_seconds = _extra_float( "stream_keepalive_interval_seconds", STREAM_KEEPALIVE_INTERVAL_SECONDS ) self._device_id = uuid.uuid4().hex self._last_chat_req_ids: Dict[str, str] = {} - - # Per-turn stream state: keyed by (chat_id, req_id) to support concurrent messages. - # Replaces global _active_stream_id to avoid conflicts when multiple messages - # are processed simultaneously (e.g., approval during streaming). - self._stream_turns: Dict[str, StreamTurn] = {} # key = f"{chat_id}:{req_id}" - - # Chats whose stream session has been retired (846608 / 846609 / no - # req_id). Cleared whenever a fresh inbound callback for the chat - # arrives — a new inbound message gives us a new req_id and the - # stream channel becomes usable again. + # Per-turn stream state keyed f"{chat_id}:{req_id|turn_id}" so concurrent + # messages (e.g. approval during streaming) never share a stream. + self._stream_turns: Dict[str, StreamTurn] = {} + # Chats whose stream session was retired (846608 / 846609 / no req_id); + # cleared when a fresh inbound callback gives the chat a new req_id. self._stream_expired_chats: set[str] = set() - - # Track which chat_ids are group chats. Populated in _on_message - # when chattype=="group". Used by _send_inner to avoid APP_CMD_SEND - # for groups (WeCom AI Bots cannot initiate proactive sends in groups). + # Group chats can't receive proactive APP_CMD_SEND (populated in _on_message). self._group_chat_ids: set[str] = set() - # Per-chat FIFO send queue with token-bucket rate limiting. - # Mirrors OpenClaw's chat-queue.ts (serial per chat) plus a - # token bucket to stay within WeCom's 30 msgs/min/chat limit. + # Per-chat FIFO send queues (normal + control lanes) with token-bucket + # rate limiting — see send_queue.py. self._chat_queues: Dict[str, asyncio.Queue] = {} self._chat_workers: Dict[str, asyncio.Task] = {} - - # Control lane: high-priority queue for approval prompts, finalize frames, - # and error notifications. These bypass normal queue to prevent blocking. self._control_queues: Dict[str, asyncio.Queue] = {} self._control_workers: Dict[str, asyncio.Task] = {} - - # Token bucket with reserved tokens for control messages. - # Per-chat usage tracking: {chat_id: {"normal": used, "reserved": used, "last_reset": ts}} self._chat_token_usage: Dict[str, Dict[str, float]] = {} - # Token bucket parameters: 30 tokens max per minute, split between normal and reserved. - _BUCKET_MAX_TOKENS = 30 - _BUCKET_NORMAL_TOKENS = 24 # For normal messages - _BUCKET_RESERVED_TOKENS = 6 # Reserved for control lane (approval, finalize, errors) - - def _get_token_usage(self, chat_id: str) -> Dict[str, float]: - """Get or create token usage tracking for a chat.""" - key = str(chat_id or "").strip() - if key not in self._chat_token_usage: - self._chat_token_usage[key] = { - "normal": 0.0, - "reserved": 0.0, - "last_reset": time.monotonic(), - } - return self._chat_token_usage[key] - - def _bucket_try_consume(self, chat_id: str) -> float: - """Try to consume a normal token. Returns 0 if available, or seconds to wait.""" - usage = self._get_token_usage(chat_id) - now = time.monotonic() - - # Reset counters every minute - if now - usage["last_reset"] > 60.0: - usage["normal"] = 0.0 - usage["reserved"] = 0.0 - usage["last_reset"] = now - - # Normal messages can only use normal quota - if usage["normal"] < self._BUCKET_NORMAL_TOKENS: - usage["normal"] += 1.0 - return 0.0 # token available, no wait - else: - # Wait until next minute - return 60.0 - (now - usage["last_reset"]) - - def _bucket_try_consume_control(self, chat_id: str) -> float: - """Try to consume a control token. Can use normal remaining + reserved pool.""" - usage = self._get_token_usage(chat_id) - now = time.monotonic() - - # Reset counters every minute - if now - usage["last_reset"] > 60.0: - usage["normal"] = 0.0 - usage["reserved"] = 0.0 - usage["last_reset"] = now - - # Control messages prefer normal quota first (don't waste reserved) - normal_available = self._BUCKET_NORMAL_TOKENS - usage["normal"] - if normal_available > 0: - usage["normal"] += 1.0 - return 0.0 - - # Normal exhausted, use reserved pool - reserved_available = self._BUCKET_RESERVED_TOKENS - usage["reserved"] - if reserved_available > 0: - usage["reserved"] += 1.0 - return 0.0 - - # Both exhausted, wait until next minute - return 60.0 - (now - usage["last_reset"]) - - async def _enqueue_chat_send(self, chat_id: str, coro_factory, is_control: bool = False): - """Enqueue a send task for a chat and await its result. - - FIFO per chat, parallel across chats. Two lanes: - - Control lane: approval prompts, finalize frames, errors (uses reserved tokens) - - Normal lane: regular messages (uses normal tokens only) - - Control messages bypass normal queue to prevent approval prompt blocking. - """ - key = str(chat_id or "").strip() - - if is_control: - # Control lane: high priority, reserved token pool - if key not in self._control_queues: - logger.debug( - "[%s] Creating control queue + worker for chat %s", - self.name, key, - ) - self._control_queues[key] = asyncio.Queue() - self._control_workers[key] = asyncio.create_task( - self._control_send_worker(key) - ) - queue = self._control_queues[key] - else: - # Normal lane - if key not in self._chat_queues: - logger.debug( - "[%s] Creating normal queue + worker for chat %s", - self.name, key, - ) - self._chat_queues[key] = asyncio.Queue() - self._chat_workers[key] = asyncio.create_task( - self._chat_send_worker(key) - ) - queue = self._chat_queues[key] - - logger.debug( - "[%s] Enqueuing send for chat %s (lane=%s, qsize=%d)", - self.name, key, "control" if is_control else "normal", queue.qsize(), - ) - future = asyncio.get_running_loop().create_future() - await queue.put((coro_factory, future)) - return await future - - async def _chat_send_worker(self, chat_key: str) -> None: - """Per-chat worker: drain normal queue with token-bucket rate limiting.""" - queue = self._chat_queues[chat_key] - logger.debug("[%s] Normal send worker started for chat %s", self.name, chat_key) - try: - while True: - coro_factory, future = await queue.get() - try: - # Token bucket: wait only if bucket is empty - wait = self._bucket_try_consume(chat_key) - if wait > 0: - logger.debug( - "[%s] Normal worker rate-limited for chat %s, waiting %.1fs", - self.name, chat_key, wait, - ) - await asyncio.sleep(wait) - # Re-consume after wait - self._bucket_try_consume(chat_key) - - result = await coro_factory() - if not future.done(): - future.set_result(result) - except Exception as exc: - if not future.done(): - future.set_exception(exc) - finally: - queue.task_done() - except asyncio.CancelledError: - while not queue.empty(): - try: - _, future = queue.get_nowait() - if not future.done(): - future.set_exception( - RuntimeError("WeCom adapter shutting down") - ) - except asyncio.QueueEmpty: - break - - async def _control_send_worker(self, chat_key: str) -> None: - """Control lane worker: drain control queue with reserved token pool.""" - queue = self._control_queues[chat_key] - try: - while True: - coro_factory, future = await queue.get() - try: - # Control messages use reserved + normal remaining tokens - wait = self._bucket_try_consume_control(chat_key) - if wait > 0: - await asyncio.sleep(wait) - self._bucket_try_consume_control(chat_key) - - result = await coro_factory() - if not future.done(): - future.set_result(result) - except Exception as exc: - if not future.done(): - future.set_exception(exc) - finally: - queue.task_done() - except asyncio.CancelledError: - while not queue.empty(): - try: - _, future = queue.get_nowait() - if not future.done(): - future.set_exception( - RuntimeError("WeCom adapter shutting down") - ) - except asyncio.QueueEmpty: - break - # ------------------------------------------------------------------ # Connection lifecycle # ------------------------------------------------------------------ async def connect(self, *, is_reconnect: bool = False) -> bool: """Connect to the WeCom AI Bot gateway.""" - if not AIOHTTP_AVAILABLE: - message = "WeCom startup failed: aiohttp not installed" - self._set_fatal_error("wecom_missing_dependency", message, retryable=True) - logger.warning("[%s] %s. Run: pip install aiohttp", self.name, message) - return False - if not HTTPX_AVAILABLE: - message = "WeCom startup failed: httpx not installed" - self._set_fatal_error("wecom_missing_dependency", message, retryable=True) - logger.warning("[%s] %s. Run: pip install httpx", self.name, message) - return False + for available, dep in ((AIOHTTP_AVAILABLE, "aiohttp"), (HTTPX_AVAILABLE, "httpx")): + if not available: + message = f"WeCom startup failed: {dep} not installed" + self._set_fatal_error("wecom_missing_dependency", message, retryable=True) + logger.warning("[%s] %s. Run: pip install %s", self.name, message, dep) + return False if not self._bot_id or not self._secret: message = "WeCom startup failed: WECOM_BOT_ID and WECOM_SECRET are required" self._set_fatal_error("wecom_missing_credentials", message, retryable=True) @@ -629,7 +237,7 @@ class WeComAdapter(BasePlatformAdapter): return False try: - # Tighter keepalive so idle CLOSE_WAIT drains promptly (#18451). + # Tighter keepalive so idle CLOSE_WAIT drains promptly. from gateway.platforms._http_client_limits import platform_httpx_limits from gateway.platforms.base import _ssrf_redirect_guard from tools.url_safety import create_ssrf_safe_async_client @@ -645,59 +253,44 @@ class WeComAdapter(BasePlatformAdapter): self._listen_task = asyncio.create_task(self._listen_loop()) self._heartbeat_task = asyncio.create_task(self._heartbeat_loop()) logger.info("[%s] Connected to %s", self.name, self._ws_url) - # Plugin-registered native handlers (ctx.register_platform_handler). - self._wire_plugin_handlers(None) + self._wire_plugin_handlers(None) # ctx.register_platform_handler hooks return True except Exception as exc: message = f"WeCom startup failed: {exc}" self._set_fatal_error("wecom_connect_error", message, retryable=True) logger.error("[%s] Failed to connect: %s", self.name, exc, exc_info=True) await self._cleanup_ws() - if self._http_client: - await self._http_client.aclose() - self._http_client = None + await self._close_http_client() return False + async def _close_http_client(self) -> None: + if self._http_client: + await self._http_client.aclose() + self._http_client = None + async def disconnect(self) -> None: """Disconnect from WeCom.""" self._running = False self._mark_disconnected() - # Force-close any lingering stream so the WeCom client doesn't show - # a permanent typing bubble after the gateway goes down. - self._reset_native_stream_state() - - # Cancel per-chat send workers (normal + control lanes) so queued tasks get cleaned up. for task in list(self._chat_workers.values()) + list(self._control_workers.values()): task.cancel() - self._chat_workers.clear() - self._control_workers.clear() - self._chat_queues.clear() - self._control_queues.clear() + for registry in (self._chat_workers, self._control_workers, self._chat_queues, self._control_queues): + registry.clear() - if self._listen_task: - self._listen_task.cancel() - try: - await self._listen_task - except asyncio.CancelledError: - pass - self._listen_task = None - - if self._heartbeat_task: - self._heartbeat_task.cancel() - try: - await self._heartbeat_task - except asyncio.CancelledError: - pass - self._heartbeat_task = None + for attr in ("_listen_task", "_heartbeat_task"): + task = getattr(self, attr) + if task: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + setattr(self, attr, None) self._fail_pending_responses(RuntimeError("WeCom adapter disconnected")) self._fail_reply_queues(RuntimeError("WeCom adapter disconnected")) await self._cleanup_ws() - - if self._http_client: - await self._http_client.aclose() - self._http_client = None - + await self._close_http_client() self._dedup.clear() logger.info("[%s] Disconnected", self.name) @@ -706,7 +299,6 @@ class WeComAdapter(BasePlatformAdapter): if self._ws and not self._ws.closed: await self._ws.close() self._ws = None - if self._session and not self._session.closed: await self._session.close() self._session = None @@ -714,36 +306,25 @@ class WeComAdapter(BasePlatformAdapter): async def _open_connection(self) -> None: """Open and authenticate a websocket connection.""" await self._cleanup_ws() - # Use certifi's CA bundle so aiohttp trusts the same roots as - # urllib/requests — avoids SSL_CERTIFICATE_VERIFY_FAILED on macOS - # where the OpenSSL default path may be empty or stale. + # certifi's CA bundle so aiohttp trusts the same roots as urllib/requests + # (avoids SSL_CERTIFICATE_VERIFY_FAILED on macOS with a stale OpenSSL path). import ssl as _ssl try: import certifi _ssl_ctx = _ssl.create_default_context(cafile=certifi.where()) except ImportError: _ssl_ctx = _ssl.create_default_context() - _connector = aiohttp.TCPConnector(ssl=_ssl_ctx) - self._session = aiohttp.ClientSession(trust_env=gateway_trust_env(), connector=_connector) + self._session = aiohttp.ClientSession(trust_env=gateway_trust_env(), connector=aiohttp.TCPConnector(ssl=_ssl_ctx)) self._ws = await self._session.ws_connect( - self._ws_url, - heartbeat=HEARTBEAT_INTERVAL_SECONDS * 2, - timeout=CONNECT_TIMEOUT_SECONDS, + self._ws_url, heartbeat=HEARTBEAT_INTERVAL_SECONDS * 2, timeout=CONNECT_TIMEOUT_SECONDS, ) req_id = self._new_req_id("subscribe") - await self._send_json( - { - "cmd": APP_CMD_SUBSCRIBE, - "headers": {"req_id": req_id}, - "body": { - "bot_id": self._bot_id, - "secret": self._secret, - "device_id": self._device_id, - }, - } - ) - + await self._send_json({ + "cmd": APP_CMD_SUBSCRIBE, + "headers": {"req_id": req_id}, + "body": {"bot_id": self._bot_id, "secret": self._secret, "device_id": self._device_id}, + }) auth_payload = await self._wait_for_handshake(req_id) errcode = auth_payload.get("errcode", 0) if errcode not in {0, None}: @@ -754,19 +335,15 @@ class WeComAdapter(BasePlatformAdapter): """Wait for the subscribe acknowledgement.""" if not self._ws: raise RuntimeError("WebSocket not initialized") - deadline = asyncio.get_running_loop().time() + CONNECT_TIMEOUT_SECONDS while True: remaining = deadline - asyncio.get_running_loop().time() if remaining <= 0: raise TimeoutError("Timed out waiting for WeCom subscribe acknowledgement") - msg = await asyncio.wait_for(self._ws.receive(), timeout=remaining) if msg.type == aiohttp.WSMsgType.TEXT: payload = self._parse_json(msg.data) - if not payload: - continue - if payload.get("cmd") == APP_CMD_PING: + if not payload or payload.get("cmd") == APP_CMD_PING: continue if self._payload_req_id(payload) == req_id: return payload @@ -789,11 +366,9 @@ class WeComAdapter(BasePlatformAdapter): logger.warning("[%s] WebSocket error: %s", self.name, exc) self._fail_pending_responses(RuntimeError("WeCom connection interrupted")) self._fail_reply_queues(RuntimeError("WeCom connection interrupted")) - delay = RECONNECT_BACKOFF[min(backoff_idx, len(RECONNECT_BACKOFF) - 1)] backoff_idx += 1 await asyncio.sleep(delay) - try: await self._open_connection() backoff_idx = 0 @@ -806,43 +381,31 @@ class WeComAdapter(BasePlatformAdapter): """Read websocket frames until the connection closes.""" if not self._ws: raise RuntimeError("WebSocket not connected") - while self._running and self._ws and not self._ws.closed: msg = await self._ws.receive() - if msg.type == aiohttp.WSMsgType.TEXT: - payload = self._parse_json(msg.data) - if payload: - await self._dispatch_payload(payload) - else: - # Parse returned None (JSON decode failed or non-dict - # payload). _parse_json already logs the failure detail; - # this makes the DROP itself visible at INFO so we can - # correlate a missing inbound message with a bad frame. + if msg.type in (aiohttp.WSMsgType.TEXT, aiohttp.WSMsgType.BINARY): + is_binary = msg.type == aiohttp.WSMsgType.BINARY + data_len = len(msg.data) if isinstance(msg.data, (str, bytes, bytearray)) else -1 + if is_binary: + # WeCom is expected to send TEXT; log a decoded preview so an + # unhandled transport for group messages isn't silently discarded. + try: + decoded = msg.data.decode("utf-8", errors="replace") + except Exception: + decoded = "" logger.info( - "[%s] Inbound TEXT frame dropped (unparseable/non-dict) len=%d", - self.name, - len(msg.data) if isinstance(msg.data, (str, bytes)) else -1, + "[%s] Inbound BINARY frame received (len=%d) head=%r — attempting JSON parse", + self.name, data_len, decoded[:200], ) - elif msg.type == aiohttp.WSMsgType.BINARY: - # WeCom is expected to send TEXT frames; a BINARY frame is - # unexpected. Log at INFO with a decoded preview so we can - # tell whether group messages are arriving in an unhandled - # transport instead of being silently discarded. - try: - decoded = msg.data.decode("utf-8", errors="replace") - except Exception: - decoded = "" - logger.info( - "[%s] Inbound BINARY frame received (len=%d) head=%r — attempting JSON parse", - self.name, - len(msg.data) if isinstance(msg.data, (bytes, bytearray)) else -1, - decoded[:200], - ) payload = self._parse_json(msg.data) if payload: await self._dispatch_payload(payload) - else: + elif is_binary: logger.info("[%s] BINARY frame not parseable as JSON — dropped", self.name) + else: + # _parse_json logged the detail; make the DROP itself visible at + # INFO so a missing inbound message can be correlated to a bad frame. + logger.info("[%s] Inbound TEXT frame dropped (unparseable/non-dict) len=%d", self.name, data_len) elif msg.type in {aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR, aiohttp.WSMsgType.CLOSING}: raise RuntimeError("WeCom websocket closed") else: @@ -856,13 +419,7 @@ class WeComAdapter(BasePlatformAdapter): if not self._ws or self._ws.closed: continue try: - await self._send_json( - { - "cmd": APP_CMD_PING, - "headers": {"req_id": self._new_req_id("ping")}, - "body": {}, - } - ) + await self._send_json({"cmd": APP_CMD_PING, "headers": {"req_id": self._new_req_id("ping")}, "body": {}}) except Exception as exc: logger.debug("[%s] Heartbeat send failed: %s", self.name, exc) except asyncio.CancelledError: @@ -872,45 +429,37 @@ class WeComAdapter(BasePlatformAdapter): """Route inbound websocket payloads.""" req_id = self._payload_req_id(payload) cmd = str(payload.get("cmd") or "") + body_dict = payload.get("body") if isinstance(payload.get("body"), dict) else None - # --- Diagnostic: log ALL non-ping inbound payloads when any reply queue - # is active, to detect whether WeCom acks arrive at all. + # Diagnostics for ack-timeout analysis: do WeCom acks arrive at all, and + # under which cmd? if self._reply_queues and cmd != APP_CMD_PING: logger.debug( "[%s] _dispatch_payload[ALL]: req_id=%s cmd=%r active_queues=%s", self.name, req_id or "(none)", cmd or "(empty)", list(self._reply_queues.keys()), ) - - # --- Diagnostic: log all payloads that carry a req_id matching an - # active reply queue, regardless of whether they get routed there. - # This helps diagnose ack timeout issues (e.g., ack arriving with - # unexpected cmd that gets filtered out). if req_id and self._reply_queues.get(req_id): - queue = self._reply_queues[req_id] - has_pending = queue.pending_ack is not None logger.debug( "[%s] _dispatch_payload: req_id=%s cmd=%r has_pending_ack=%s " "errcode=%s in_NON_RESPONSE=%s payload_keys=%s", - self.name, req_id, cmd, has_pending, - payload.get("body", {}).get("errcode", "N/A") if isinstance(payload.get("body"), dict) else "N/A", + self.name, req_id, cmd, self._reply_queues[req_id].pending_ack is not None, + body_dict.get("errcode", "N/A") if body_dict is not None else "N/A", cmd in NON_RESPONSE_COMMANDS, list(payload.keys()), ) - # Check reply queue ack first — aibot_respond_msg acks arrive with - # the original inbound req_id and no cmd (or non-callback cmd). - # This must be checked before _pending_responses to avoid the old - # _send_reply_request path stealing acks meant for the queue. + # aibot_respond_msg acks arrive with the inbound req_id and no/other cmd. + # Reply-queue acks MUST be checked before _pending_responses so the + # _send_reply_request path can't steal them. if req_id and cmd not in NON_RESPONSE_COMMANDS: if self._resolve_reply_ack(req_id, payload): return - - if req_id and req_id in self._pending_responses and cmd not in NON_RESPONSE_COMMANDS: - future = self._pending_responses.get(req_id) - if future and not future.done(): - future.set_result(payload) - return + if req_id in self._pending_responses: + future = self._pending_responses.get(req_id) + if future and not future.done(): + future.set_result(payload) + return if cmd in CALLBACK_COMMANDS: await self._on_message(payload) @@ -918,29 +467,25 @@ class WeComAdapter(BasePlatformAdapter): if cmd == APP_CMD_PING: return if cmd == APP_CMD_EVENT_CALLBACK: - # Check for "kicked by server" event — WeCom sends this when a new - # connection is established elsewhere (another instance). Mirror the - # official OpenClaw SDK: suppress reconnect to avoid mutual kicking. + # "Kicked by server": another connection was established elsewhere. + # Mirror the official SDK — suppress reconnect to avoid mutual kicking. body = payload.get("body") or {} - event_type = str(body.get("event_type") or "") - if event_type == "disconnected_event": + if str(body.get("event_type") or "") == "disconnected_event": logger.warning( "[%s] Kicked by server (another WS connection established). " "Suppressing reconnect to avoid mutual kicking. " "Check for duplicate gateway instances.", self.name, ) - self._running = False # stop _listen_loop from reconnecting + self._running = False return - # Unrouted payload — did not match reply-queue, pending-response, - # callback, ping, or event. If WeCom delivers group messages under a - # cmd not in CALLBACK_COMMANDS, they land here and are dropped. Log at - # INFO with cmd + body keys so we can spot an unhandled callback cmd. - body_keys = list(payload.get("body", {}).keys()) if isinstance(payload.get("body"), dict) else None + # Unrouted: if WeCom delivered group messages under an unknown cmd they + # would land here, so log cmd + body keys at INFO. logger.info( "[%s] Unrouted websocket payload dropped: cmd=%r req_id=%s body_keys=%s", - self.name, cmd or "(empty)", req_id or "(none)", body_keys, + self.name, cmd or "(empty)", req_id or "(none)", + list(body_dict.keys()) if body_dict is not None else None, ) def _fail_pending_responses(self, exc: Exception) -> None: @@ -956,262 +501,31 @@ class WeComAdapter(BasePlatformAdapter): raise RuntimeError("WeCom websocket is not connected") await self._ws.send_json(payload) - async def _send_request(self, cmd: str, body: Dict[str, Any], timeout: float = REQUEST_TIMEOUT_SECONDS) -> Dict[str, Any]: - """Send a JSON request and await the correlated response.""" - if not self._ws or self._ws.closed: - raise RuntimeError("WeCom websocket is not connected") - - req_id = self._new_req_id(cmd) + async def _request(self, cmd: str, req_id: str, body: Dict[str, Any], timeout: float) -> Dict[str, Any]: future = asyncio.get_running_loop().create_future() self._pending_responses[req_id] = future try: await self._send_json({"cmd": cmd, "headers": {"req_id": req_id}, "body": body}) - response = await asyncio.wait_for(future, timeout=timeout) - return response + return await asyncio.wait_for(future, timeout=timeout) finally: self._pending_responses.pop(req_id, None) + async def _send_request(self, cmd: str, body: Dict[str, Any], timeout: float = REQUEST_TIMEOUT_SECONDS) -> Dict[str, Any]: + """Send a JSON request and await the correlated response.""" + if not self._ws or self._ws.closed: + raise RuntimeError("WeCom websocket is not connected") + return await self._request(cmd, self._new_req_id(cmd), body, timeout) + async def _send_reply_request( - self, - reply_req_id: str, - body: Dict[str, Any], - cmd: str = APP_CMD_RESPONSE, - timeout: float = REQUEST_TIMEOUT_SECONDS, + self, reply_req_id: str, body: Dict[str, Any], cmd: str = APP_CMD_RESPONSE, timeout: float = REQUEST_TIMEOUT_SECONDS, ) -> Dict[str, Any]: """Send a reply frame correlated to an inbound callback req_id.""" if not self._ws or self._ws.closed: raise RuntimeError("WeCom websocket is not connected") - normalized_req_id = str(reply_req_id or "").strip() if not normalized_req_id: raise ValueError("reply_req_id is required") - - future = asyncio.get_running_loop().create_future() - self._pending_responses[normalized_req_id] = future - try: - await self._send_json( - {"cmd": cmd, "headers": {"req_id": normalized_req_id}, "body": body} - ) - response = await asyncio.wait_for(future, timeout=timeout) - return response - finally: - self._pending_responses.pop(normalized_req_id, None) - - # ── Per-req_id Reply Queue (ack tracking) ──────────────────────────── - # Aligns with official SDK replyStreamNonBlocking: - # - intermediate frame: skip if pending ack on this req_id - # - final frame: wait for pending ack to drain before sending - # - ack timeout: 15 seconds - # - # Matches the official @wecom/wecom-openclaw-plugin's REPLY_SEND_TIMEOUT_MS - # = 15_000. The prior 5s value was too aggressive for the bilibili WeCom - # environment where ack > 5s is not rare on long replies (server-side queue - # lag, WS jitter, concurrent replies on the same WS). A short window widens - # the race where the final-frame ack is still in flight while the gateway's - # normal final-send fires, producing duplicate messages - # (see docs/rca-wecom-stream-final-ack-timeout-duplicate.md). - - _REPLY_ACK_TIMEOUT = 15.0 - - async def _send_reply_queued( - self, - reply_req_id: str, - body: Dict[str, Any], - *, - is_final: bool = False, - skip_if_pending: bool = False, - ) -> Dict[str, Any]: - """Send a reply via aibot_respond_msg with per-req_id ack tracking. - - Args: - reply_req_id: The inbound callback req_id to reply to. - body: Reply body (msgtype: stream/markdown/...). - is_final: If True, wait for any pending ack before sending. - skip_if_pending: If True and a previous frame's ack is pending, - return immediately with {"skipped": True}. - - Returns: - Response dict from WeCom, or {"skipped": True} if skipped. - """ - if not self._ws or self._ws.closed: - raise RuntimeError("WeCom websocket is not connected") - - normalized = str(reply_req_id or "").strip() - if not normalized: - raise ValueError("reply_req_id is required") - - queue = self._reply_queues.get(normalized) - if queue is None: - queue = ReplyQueue(normalized) - self._reply_queues[normalized] = queue - - # NonBlocking semantics: skip if a prior frame ack is pending - if skip_if_pending and queue.pending_ack is not None: - return {"skipped": True, "errcode": 0, "errmsg": "pending_ack"} - - # Final frame: wait for pending ack to drain first - if is_final and queue.pending_ack is not None: - pending_frame = queue.pending_ack - _pending_stream = pending_frame.body.get("stream", {}) if isinstance(pending_frame.body.get("stream"), dict) else {} - logger.debug( - "[%s] _send_reply_queued: final waiting for pending ack drain — " - "req_id=%s pending_stream_id=%s pending_finish=%s pending_sent_at=%.1fs_ago", - self.name, normalized, - _pending_stream.get("id", "N/A"), - _pending_stream.get("finish", "N/A"), - time.monotonic() - (pending_frame.sent_at or time.monotonic()), - ) - try: - await asyncio.wait_for( - asyncio.shield(pending_frame.future), - timeout=self._REPLY_ACK_TIMEOUT, - ) - except asyncio.TimeoutError: - logger.warning( - "[%s] Reply ack timeout waiting for pending (req_id=%s) — " - "pending_stream_id=%s pending_finish=%s elapsed=%.1fs. " - "Possible causes: ack cmd filtered, ack req_id mismatch, or WeCom did not ack.", - self.name, normalized, - _pending_stream.get("id", "N/A"), - _pending_stream.get("finish", "N/A"), - time.monotonic() - (pending_frame.sent_at or time.monotonic()), - ) - except Exception: - pass - # Clear pending regardless — either resolved or timed out - queue.pending_ack = None - - # Create future for THIS frame's ack - future: asyncio.Future = asyncio.get_running_loop().create_future() - frame = ReplyFrame(body=body, future=future, is_final=is_final) - frame.sent_at = time.monotonic() - - # Register as pending BEFORE sending to avoid race: - # If WeCom acks during _send_json await, _dispatch_payload needs - # to find the pending frame to resolve it. Registering after would - # miss the ack and timeout. - # - # Fix (orphan-queue race): re-attach `queue` to the dict before - # registering pending_ack. A final frame shares the inbound req_id - # with the intermediate frames; while it awaits the pending - # intermediate ack to drain (the `is_final` branch above yields at - # `await`), that intermediate ack can arrive and _resolve_reply_ack - # pops the WHOLE queue out of self._reply_queues (the "cleanup empty - # queue" pop). The local `queue` reference captured at the top is then - # an ORPHAN — detached from the dict — so registering pending_ack on it - # is invisible to _dispatch_payload, and the final frame's own ack - # lands in Unrouted → 15s timeout. Writing the reference back here - # closes that window: the final frame's ack can always be routed. - self._reply_queues[normalized] = queue - queue.pending_ack = frame - - # Diagnostic: log every frame send for ack tracking analysis - _stream_info = body.get("stream", {}) if isinstance(body.get("stream"), dict) else {} - logger.debug( - "[%s] _send_reply_queued: req_id=%s is_final=%s skip_if_pending=%s " - "stream_id=%s finish=%s content_len=%d", - self.name, normalized, is_final, skip_if_pending, - _stream_info.get("id", "N/A"), - _stream_info.get("finish", "N/A"), - len(_stream_info.get("content", "") or ""), - ) - - # Send the frame - try: - await self._send_json( - {"cmd": APP_CMD_RESPONSE, "headers": {"req_id": normalized}, "body": body} - ) - except Exception as e: - # Send failed — clear pending and reject future. The future has - # no awaiter on the send-failure branch (we re-raise immediately), - # so cancel it instead of setting an exception that would otherwise - # be logged as "Future exception was never retrieved". - if queue.pending_ack is frame: - queue.pending_ack = None - if not self._reply_queues.get(normalized) or queue.pending_ack is None: - self._reply_queues.pop(normalized, None) - if not future.done(): - future.cancel() - raise - - # For final frames: await the ack (blocking) - if is_final: - try: - response = await asyncio.wait_for(future, timeout=self._REPLY_ACK_TIMEOUT) - return response - except asyncio.TimeoutError: - # Final-frame ack timeout: WeCom received the frame (we wrote - # the bytes successfully — _send_json above did not raise) but - # the ack didn't return within the window. In practice the - # server has already rendered the message to the client; the - # ack is delayed for unrelated reasons (server-side queue lag, - # WS jitter, concurrent reply on the same WS). - # - # The official wecom-openclaw-plugin treats this case as an - # error and surfaces it to its caller, which then *does not* - # resend. Hermes' prior behaviour — raising RuntimeError so - # the upper layer falls back to a normal markdown send — - # produced duplicate messages whenever WeCom *had* rendered - # the streamed frame (see docs/rca-wecom-stream-final-ack- - # timeout-duplicate.md). - # - # Aligning with the official plugin: log a warning and - # synthesise a success-shaped response so the caller treats - # the message as delivered. The thinking-bubble is already - # closed on the client side by the finish=true frame; if WeCom - # never queued it (rare), the user sees no answer — same - # outcome as the official plugin. - logger.warning( - "[%s] Final frame ack timeout (req_id=%s) — treating as " - "delivered (matches official wecom-openclaw-plugin " - "behaviour). No fallback send.", - self.name, normalized, - ) - return { - "errcode": 0, - "errmsg": "ack_timeout_assumed_delivered", - "ack_pending": True, - } - finally: - if queue.pending_ack is frame: - queue.pending_ack = None - # Cleanup empty queue - if queue.pending_ack is None: - self._reply_queues.pop(normalized, None) - else: - # Intermediate frame: fire-and-forget (don't await ack) - # But the pending_ack stays registered so subsequent frames can - # check and skip. The ack will be resolved by _dispatch_payload. - return {"errcode": 0, "errmsg": "sent_nonblocking"} - - def _resolve_reply_ack(self, req_id: str, payload: Dict[str, Any]) -> bool: - """Resolve a pending reply ack. Returns True if handled.""" - queue = self._reply_queues.get(req_id) - if queue is None or queue.pending_ack is None: - return False - frame = queue.pending_ack - if not frame.future.done(): - _body = payload.get("body", {}) if isinstance(payload.get("body"), dict) else {} - logger.debug( - "[%s] _resolve_reply_ack: resolved req_id=%s is_final=%s " - "elapsed=%.2fs errcode=%s", - self.name, req_id, frame.is_final, - time.monotonic() - (frame.sent_at or time.monotonic()), - _body.get("errcode", "N/A"), - ) - frame.future.set_result(payload) - queue.pending_ack = None - # Cleanup empty queue - if queue.pending_ack is None: - self._reply_queues.pop(req_id, None) - return True - - def _fail_reply_queues(self, error: Exception) -> None: - """Fail all pending reply acks (called on disconnect/error).""" - for queue in list(self._reply_queues.values()): - if queue.pending_ack and not queue.pending_ack.future.done(): - queue.pending_ack.future.set_exception(error) - self._reply_queues.clear() + return await self._request(cmd, normalized_req_id, body, timeout) @staticmethod def _new_req_id(prefix: str) -> str: @@ -1220,39 +534,30 @@ class WeComAdapter(BasePlatformAdapter): @staticmethod def _payload_req_id(payload: Dict[str, Any]) -> str: headers = payload.get("headers") - if isinstance(headers, dict): - return str(headers.get("req_id") or "") - return "" + return str(headers.get("req_id") or "") if isinstance(headers, dict) else "" @staticmethod def _parse_json(raw: Any) -> Optional[Dict[str, Any]]: + raw_len = len(raw) if isinstance(raw, (str, bytes)) else -1 try: payload = json.loads(raw) except json.JSONDecodeError: - # WeCom sometimes sends unescaped control characters (e.g. raw - # newlines) inside JSON string values. Retry with strict=False - # which accepts control chars in strings per the JSON decoder. + # WeCom sometimes sends unescaped control chars (raw newlines) inside + # JSON strings; strict=False accepts them. try: - decoder = json.JSONDecoder(strict=False) - payload = decoder.decode(raw if isinstance(raw, str) else raw.decode("utf-8", errors="replace")) - logger.info( - "WeCom payload required strict=False fallback (len=%d)", - len(raw) if isinstance(raw, (str, bytes)) else -1, - ) + text = raw if isinstance(raw, str) else raw.decode("utf-8", errors="replace") + payload = json.JSONDecoder(strict=False).decode(text) + logger.info("WeCom payload required strict=False fallback (len=%d)", raw_len) except Exception as exc2: logger.warning( "Failed to parse WeCom payload (strict=False also failed): " "error=%s len=%d tail=%r", - exc2, - len(raw) if isinstance(raw, (str, bytes)) else -1, + exc2, raw_len, raw[-100:] if isinstance(raw, (str, bytes)) and len(raw) > 100 else raw, ) return None except Exception as exc: - logger.warning( - "Failed to parse WeCom payload: error=%s len=%d", - exc, len(raw) if isinstance(raw, (str, bytes)) else -1, - ) + logger.warning("Failed to parse WeCom payload: error=%s len=%d", exc, raw_len) return None return payload if isinstance(payload, dict) else None @@ -1265,45 +570,28 @@ class WeComAdapter(BasePlatformAdapter): body = payload.get("body") if not isinstance(body, dict): return - - msg_id = str(body.get("msgid") or self._payload_req_id(payload) or uuid.uuid4().hex) - if self._dedup.is_duplicate(msg_id): - # Promoted from debug to INFO: dedup misfire (#62860 timing bug — - # is_duplicate marks at check time, so a msgid redelivered ~5s after - # a processing exception is dropped for the 300s TTL) is a top - # suspect for Coral's intermittent group non-reply. At debug level it - # left zero trace at INFO, so a dropped message looked like it - # vanished after the FULL-payload dump. Log req_id/sender too so this - # line correlates with the inbound FULL payload above. - logger.info( - "[%s] Duplicate message %s ignored (dedup drop) req_id=%s sender=%r chattype=%r", - self.name, - msg_id, - self._payload_req_id(payload), - (body.get("from") or {}).get("userid") if isinstance(body.get("from"), dict) else None, - body.get("chattype"), - ) - return - self._remember_reply_req_id(msg_id, self._payload_req_id(payload)) - + req_id = self._payload_req_id(payload) + msg_id = str(body.get("msgid") or req_id or uuid.uuid4().hex) sender = body.get("from") if isinstance(body.get("from"), dict) else {} sender_id = str(sender.get("userid") or "").strip() - chat_id = str(body.get("chatid") or sender_id).strip() + if self._dedup.is_duplicate(msg_id): + # INFO (not debug): is_duplicate marks at check time, so a msgid + # redelivered after a processing exception is dropped for the TTL — + # a top suspect for intermittent group non-replies. + logger.info( + "[%s] Duplicate message %s ignored (dedup drop) req_id=%s sender=%r chattype=%r", + self.name, msg_id, req_id, sender.get("userid") if sender else None, body.get("chattype"), + ) + return + _bounded_put(self._reply_req_ids, msg_id, req_id) - # Diagnostic: log the shape of every inbound callback at INFO so we can - # see what WeCom actually sends for group messages (chattype value, - # presence of chatid, msgtype). Group frames may arrive with a - # chattype other than the literal "group" we test for below. + chat_id = str(body.get("chatid") or sender_id).strip() + # Shape of every inbound callback at INFO: group frames may arrive with + # a chattype other than the literal "group". logger.info( "[%s] Inbound callback: chattype=%r chatid=%r sender=%r msgtype=%r has_chatid=%s", - self.name, - body.get("chattype"), - body.get("chatid"), - sender_id, - body.get("msgtype"), - bool(body.get("chatid")), + self.name, body.get("chattype"), body.get("chatid"), sender_id, body.get("msgtype"), bool(body.get("chatid")), ) - if not chat_id: logger.info("[%s] Missing chat id, skipping message; body_keys=%s", self.name, list(body.keys())) return @@ -1322,24 +610,19 @@ class WeComAdapter(BasePlatformAdapter): logger.info("[%s] DM sender %s blocked by policy", self.name, sender_id) return - # Cache the inbound req_id after policy checks so proactive sends to - # this chat can fall back to APP_CMD_RESPONSE (required for groups — - # WeCom AI Bots cannot initiate APP_CMD_SEND in group chats). - self._remember_chat_req_id(chat_id, self._payload_req_id(payload)) + # After policy checks: cache the req_id so proactive sends can fall back to + # APP_CMD_RESPONSE (required for groups, where APP_CMD_SEND is blocked). + self._remember_chat_req_id(chat_id, req_id) text, reply_text = self._extract_text(body) - # Strip leading @mention in group chats so slash commands like - # "@BotName /approve" are correctly recognized as "/approve". - # Mirrors what the Telegram adapter does (re.sub @botname). if is_group and text: + # Strip leading @mention so "@Bot /approve" is recognized as "/approve". text = re.sub(r"^@\S+\s*", "", text).strip() media_urls, media_types = await self._extract_media(body) message_type = self._derive_message_type(body, text, media_types) has_reply_context = bool(reply_text and (text or media_urls)) - if not text and reply_text and not media_urls: text = reply_text - if not text and not media_urls: logger.info( "[%s] Empty WeCom message skipped: is_group=%s chat=%s msgtype=%r", @@ -1348,46 +631,23 @@ class WeComAdapter(BasePlatformAdapter): return source = self.build_source( - chat_id=chat_id, - chat_type="group" if is_group else "dm", - user_id=sender_id or None, - user_name=sender_id or None, + chat_id=chat_id, chat_type="group" if is_group else "dm", user_id=sender_id or None, user_name=sender_id or None, ) - event = MessageEvent( - text=text, - message_type=message_type, - source=source, - raw_message=payload, - message_id=msg_id, - media_urls=media_urls, - media_types=media_types, + text=text, message_type=message_type, source=source, raw_message=payload, message_id=msg_id, + media_urls=media_urls, media_types=media_types, reply_to_message_id=f"quote:{msg_id}" if has_reply_context else None, reply_to_text=reply_text if has_reply_context else None, timestamp=datetime.now(tz=timezone.utc), ) - # Only batch plain text messages — commands, media, etc. dispatch - # immediately since they won't be split by the WeCom client. - # - # Exception: an attachment-ONLY message (media, no text) is held for - # a short merge window. WeCom clients send "image + text" as TWO - # separate inbound callbacks — an attachment-only frame followed a few - # hundred ms later by the text frame. Dispatching the attachment - # immediately spawns an agent run that the trailing text then - # "interrupts" (junk "⚡ Interrupting" + "✅" acks). Instead we buffer - # the attachment on the SAME pending-batch machinery so the following - # text merges into one event (see _flush_text_batch for the window). - batch_key = self._text_batch_key(event) - has_pending_batch = batch_key in self._pending_text_batches + # Only plain text is batched (commands/media aren't split by the client), + # EXCEPT an attachment-only message, which is held for the merge window so + # the trailing text callback merges into the same event instead of + # "interrupting" a run the attachment already spawned. + has_pending_batch = self._text_batch_key(event) in self._pending_text_batches is_attachment_only = bool(media_urls) and not (text or "").strip() - - if message_type == MessageType.TEXT and ( - self._text_batch_delay_seconds > 0 or has_pending_batch - ): - # Route text through the buffer whenever batching is on OR an - # attachment is already held for this session, so the trailing - # text always merges instead of dispatching on its own. + if message_type == MessageType.TEXT and (self._text_batch_delay_seconds > 0 or has_pending_batch): self._enqueue_text_event(event) elif is_attachment_only and self._attachment_text_merge_delay_seconds > 0: self._enqueue_text_event(event) @@ -1398,29 +658,12 @@ class WeComAdapter(BasePlatformAdapter): # Text message aggregation (handles WeCom client-side splits) # ------------------------------------------------------------------ - def _text_batch_key(self, event: MessageEvent) -> str: - """Session-scoped key for text message batching.""" - from gateway.session import build_session_key - return build_session_key( - event.source, - group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True), - thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False), - profile=self._session_key_profile(event.source), - ) - def _enqueue_text_event(self, event: MessageEvent) -> None: """Buffer an event and reset the flush timer. - Two cases share this buffer: - - * WeCom splits a long user message at 4000 chars — the chunks arrive - within a few hundred milliseconds and are merged into one event. - * WeCom sends "image + text" as two callbacks (an attachment-only - frame, then a text frame). The attachment-only frame is buffered - here first (message_type PHOTO/DOCUMENT/VOICE); when the text frame - arrives it merges into the same event and the type is promoted to - TEXT so it dispatches as a single text-with-media turn — matching - the shape WeCom uses when text+media arrive in one callback. + Merges both 4000-char client splits and the "attachment-only frame, then + text frame" pair: once real text joins a buffered attachment the type is + promoted to TEXT (and it inherits the text frame's quote context). """ key = self._text_batch_key(event) existing = self._pending_text_batches.get(key) @@ -1432,62 +675,37 @@ class WeComAdapter(BasePlatformAdapter): if event.text: existing.text = f"{existing.text}\n{event.text}" if existing.text else event.text existing._last_chunk_len = chunk_len # type: ignore[attr-defined] - # Merge any media that might be attached if event.media_urls: existing.media_urls.extend(event.media_urls) existing.media_types.extend(event.media_types) - # Once real text joins a buffered attachment-only event, the merged - # event is a text-with-media turn. Promote the type so downstream - # dispatch treats it like a normal text message (and inherits the - # trailing text frame's reply/quote context if the first frame had - # none). if event.text and (event.text or "").strip(): existing.message_type = MessageType.TEXT if event.reply_to_text and not existing.reply_to_text: existing.reply_to_text = event.reply_to_text existing.reply_to_message_id = event.reply_to_message_id - # Cancel any pending flush and restart the timer prior_task = self._pending_text_batch_tasks.get(key) if prior_task and not prior_task.done(): prior_task.cancel() - self._pending_text_batch_tasks[key] = asyncio.create_task( - self._flush_text_batch(key) - ) + self._pending_text_batch_tasks[key] = asyncio.create_task(self._flush_text_batch(key)) async def _flush_text_batch(self, key: str) -> None: - """Wait for the quiet period then dispatch the aggregated text. - - Uses a longer delay when the latest chunk is near WeCom's 4000-char - split point, since a continuation chunk is almost certain. - """ + """Wait for the quiet period then dispatch the aggregated text.""" current_task = asyncio.current_task() try: pending = self._pending_text_batches.get(key) last_len = getattr(pending, "_last_chunk_len", 0) if pending else 0 - # An attachment-only buffered event (no text yet) waits the - # attachment/text merge window for a trailing text frame. A text - # buffered event uses the normal (or split-continuation) delay. - is_attachment_only = bool( - pending and pending.media_urls and not (pending.text or "").strip() - ) - if is_attachment_only: - delay = self._attachment_text_merge_delay_seconds + if pending and pending.media_urls and not (pending.text or "").strip(): + delay = self._attachment_text_merge_delay_seconds # attachment-only: wait for text elif last_len >= self._SPLIT_THRESHOLD: - delay = self._text_batch_split_delay_seconds + delay = self._text_batch_split_delay_seconds # continuation almost certain else: delay = self._text_batch_delay_seconds await asyncio.sleep(delay) - # Guard against the cancel-delivery race: when the sleep timer - # fires just before cancel() is called, CPython sets - # Task._must_cancel but cannot cancel the already-done sleep - # future, so CancelledError is delivered at the *next* await - # (handle_message) rather than here. By that point this task - # has already popped the merged event, so the superseding task - # sees an empty batch and silently drops the message. - # This check is synchronous — no await between the sleep and - # the pop — so no other coroutine can modify the task registry - # in between. + # Cancel-delivery race: if the sleep timer fired just before cancel(), + # CancelledError is delivered at the NEXT await, after we'd have popped + # the merged event — so the superseding task would find nothing. + # This check is synchronous (no await between it and the pop). if self._pending_text_batch_tasks.get(key) is not current_task: return event = self._pending_text_batches.pop(key, None) @@ -1506,194 +724,26 @@ class WeComAdapter(BasePlatformAdapter): def _extract_text(body: Dict[str, Any]) -> Tuple[str, Optional[str]]: """Extract plain text and quoted text from a callback payload.""" text_parts: List[str] = [] - reply_text: Optional[str] = None msgtype = str(body.get("msgtype") or "").lower() - if msgtype == "mixed": - _raw_mixed = body.get("mixed") - mixed = _raw_mixed if isinstance(_raw_mixed, dict) else {} - _raw_items = mixed.get("msg_item") - items = _raw_items if isinstance(_raw_items, list) else [] - for item in items: - if not isinstance(item, dict): - continue - if str(item.get("msgtype") or "").lower() == "text": - _raw_text = item.get("text") - text_block = _raw_text if isinstance(_raw_text, dict) else {} - content = str(text_block.get("content") or "").strip() + items = _dict_or_empty(body, "mixed").get("msg_item") + for item in items if isinstance(items, list) else []: + if isinstance(item, dict) and str(item.get("msgtype") or "").lower() == "text": + content = _content_of(item, "text") if content: text_parts.append(content) else: - text_block = body.get("text") if isinstance(body.get("text"), dict) else {} - content = str(text_block.get("content") or "").strip() - if content: - text_parts.append(content) - + text_parts.append(_content_of(body, "text")) if msgtype == "voice": - voice_block = body.get("voice") if isinstance(body.get("voice"), dict) else {} - voice_text = str(voice_block.get("content") or "").strip() - if voice_text: - text_parts.append(voice_text) + text_parts.append(_content_of(body, "voice")) + if msgtype == "appmsg": # attachment title (filename) + text_parts.append(str(_dict_or_empty(body, "appmsg").get("title") or "").strip()) - # Extract appmsg title (filename) for WeCom AI Bot attachments - if msgtype == "appmsg": - appmsg = body.get("appmsg") if isinstance(body.get("appmsg"), dict) else {} - title = str(appmsg.get("title") or "").strip() - if title: - text_parts.append(title) - - quote = body.get("quote") if isinstance(body.get("quote"), dict) else {} + quote = _dict_or_empty(body, "quote") quote_type = str(quote.get("msgtype") or "").lower() - if quote_type == "text": - quote_text = quote.get("text") if isinstance(quote.get("text"), dict) else {} - reply_text = str(quote_text.get("content") or "").strip() or None - elif quote_type == "voice": - quote_voice = quote.get("voice") if isinstance(quote.get("voice"), dict) else {} - reply_text = str(quote_voice.get("content") or "").strip() or None - + reply_text = _content_of(quote, quote_type) or None if quote_type in ("text", "voice") else None return "\n".join(part for part in text_parts if part).strip(), reply_text - async def _extract_media(self, body: Dict[str, Any]) -> Tuple[List[str], List[str]]: - """Best-effort extraction of inbound media to local cache paths.""" - media_paths: List[str] = [] - media_types: List[str] = [] - refs: List[Tuple[str, Dict[str, Any]]] = [] - msgtype = str(body.get("msgtype") or "").lower() - - if msgtype == "mixed": - _raw_mixed = body.get("mixed") - mixed = _raw_mixed if isinstance(_raw_mixed, dict) else {} - _raw_items = mixed.get("msg_item") - items = _raw_items if isinstance(_raw_items, list) else [] - for item in items: - if not isinstance(item, dict): - continue - item_type = str(item.get("msgtype") or "").lower() - if item_type == "image" and isinstance(item.get("image"), dict): - refs.append(("image", item["image"])) - else: - if isinstance(body.get("image"), dict): - refs.append(("image", body["image"])) - if msgtype == "file" and isinstance(body.get("file"), dict): - refs.append(("file", body["file"])) - # Handle appmsg (WeCom AI Bot attachments with PDF/Word/Excel) - if msgtype == "appmsg" and isinstance(body.get("appmsg"), dict): - appmsg = body["appmsg"] - if isinstance(appmsg.get("file"), dict): - refs.append(("file", appmsg["file"])) - elif isinstance(appmsg.get("image"), dict): - refs.append(("image", appmsg["image"])) - - quote = body.get("quote") if isinstance(body.get("quote"), dict) else {} - quote_type = str(quote.get("msgtype") or "").lower() - if quote_type == "image" and isinstance(quote.get("image"), dict): - refs.append(("image", quote["image"])) - elif quote_type == "file" and isinstance(quote.get("file"), dict): - refs.append(("file", quote["file"])) - - for kind, ref in refs: - cached = await self._cache_media(kind, ref) - if cached: - path, content_type = cached - media_paths.append(path) - media_types.append(content_type) - - return media_paths, media_types - - async def _cache_media(self, kind: str, media: Dict[str, Any]) -> Optional[Tuple[str, str]]: - """Cache an inbound image/file/media reference to local storage.""" - if "base64" in media and media.get("base64"): - try: - raw = self._decode_base64(media["base64"]) - except Exception as exc: - logger.debug("[%s] Failed to decode %s base64 media: %s", self.name, kind, exc) - return None - - if kind == "image": - ext = self._detect_image_ext(raw) - try: - return cache_image_from_bytes(raw, ext), self._mime_for_ext(ext, fallback="image/jpeg") - except ValueError as exc: - logger.warning("[%s] Rejected non-image bytes: %s", self.name, exc) - return None - - filename = str(media.get("filename") or media.get("name") or "wecom_file") - return cache_document_from_bytes(raw, filename), mimetypes.guess_type(filename)[0] or "application/octet-stream" - - url = str(media.get("url") or "").strip() - if not url: - return None - - try: - raw, headers = await self._download_remote_bytes(url, max_bytes=ABSOLUTE_MAX_BYTES) - except Exception as exc: - logger.debug("[%s] Failed to download %s from %s: %s", self.name, kind, url, exc) - return None - - aes_key = str(media.get("aeskey") or "").strip() - if aes_key: - try: - raw = self._decrypt_file_bytes(raw, aes_key) - except Exception as exc: - logger.debug("[%s] Failed to decrypt %s from %s: %s", self.name, kind, url, exc) - return None - - content_type = str(headers.get("content-type") or "").split(";", 1)[0].strip() or "application/octet-stream" - if kind == "image": - ext = self._guess_extension(url, content_type, fallback=self._detect_image_ext(raw)) - try: - return cache_image_from_bytes(raw, ext), content_type or self._mime_for_ext(ext, fallback="image/jpeg") - except ValueError as exc: - logger.warning("[%s] Rejected non-image bytes from %s: %s", self.name, url, exc) - return None - - filename = self._guess_filename(url, headers.get("content-disposition"), content_type) - return cache_document_from_bytes(raw, filename), content_type - - @staticmethod - def _decode_base64(data: str) -> bytes: - payload = data.split(",", 1)[-1].strip() - return base64.b64decode(payload) - - @staticmethod - def _detect_image_ext(data: bytes) -> str: - if data.startswith(b"\x89PNG\r\n\x1a\n"): - return ".png" - if data.startswith(b"\xff\xd8\xff"): - return ".jpg" - if data.startswith((b"GIF87a", b"GIF89a")): - return ".gif" - if data.startswith(b"RIFF") and data[8:12] == b"WEBP": - return ".webp" - return ".jpg" - - @staticmethod - def _mime_for_ext(ext: str, fallback: str = "application/octet-stream") -> str: - return mimetypes.types_map.get(ext.lower(), fallback) - - @staticmethod - def _guess_extension(url: str, content_type: str, fallback: str) -> str: - ext = mimetypes.guess_extension(content_type) if content_type else None - if ext: - return ext - path_ext = Path(urlparse(url).path).suffix - if path_ext: - return path_ext - return fallback - - @staticmethod - def _guess_filename(url: str, content_disposition: Optional[str], content_type: str) -> str: - if content_disposition: - match = re.search(r'filename="?([^";]+)"?', content_disposition) - if match: - return match.group(1) - - name = Path(urlparse(url).path).name or "document" - if "." not in name: - ext = mimetypes.guess_extension(content_type) or ".bin" - name = f"{name}{ext}" - return name - @staticmethod def _derive_message_type(body: Dict[str, Any], text: str, media_types: List[str]) -> MessageType: """Choose the normalized inbound message type.""" @@ -1715,15 +765,14 @@ class WeComAdapter(BasePlatformAdapter): return True def _open_dm_opted_in(self) -> bool: - # Scoped reads (#93522): the default profile's allow-all flag must - # not leak into a multiplexed secondary profile's admission gate. - if (_get_scoped_secret("GATEWAY_ALLOW_ALL_USERS", "") or "").lower() in {"true", "1", "yes"}: - return True - return (_get_scoped_secret("WECOM_ALLOW_ALL_USERS", "") or "").lower() in {"true", "1", "yes"} + # Scoped reads: the default profile's allow-all flag must not leak into a + # multiplexed secondary profile's admission gate. + return any( + (_get_scoped_secret(var, "") or "").lower() in {"true", "1", "yes"} + for var in ("GATEWAY_ALLOW_ALL_USERS", "WECOM_ALLOW_ALL_USERS") + ) def _is_dm_allowed(self, sender_id: str) -> bool: - if self._dm_policy == "disabled": - return False if self._dm_policy == "allowlist": return _entry_matches(self._allow_from, sender_id) if self._dm_policy == "open": @@ -1734,34 +783,23 @@ class WeComAdapter(BasePlatformAdapter): principal = str(sender_id or "").strip() if not principal: return False - if self._dm_policy == "disabled": - return False - if self._dm_policy == "allowlist": - return _entry_matches(self._allow_from, principal) if self._dm_policy == "pairing": return True - if self._dm_policy == "open": - return self._open_dm_opted_in() - return False + return self._is_dm_allowed(principal) def _is_group_allowed(self, chat_id: str, sender_id: str) -> bool: - if self._group_policy == "disabled": - return False - if self._group_policy == "pairing": + if self._group_policy in ("disabled", "pairing"): return False if self._group_policy == "allowlist" and not _entry_matches(self._group_allow_from, chat_id): return False - group_cfg = self._resolve_group_cfg(chat_id) sender_allow = _coerce_list(group_cfg.get("allow_from") or group_cfg.get("allowFrom")) - if sender_allow: - return _entry_matches(sender_allow, sender_id) - return True + return _entry_matches(sender_allow, sender_id) if sender_allow else True def _resolve_group_cfg(self, chat_id: str) -> Dict[str, Any]: if not isinstance(self._groups, dict): return {} - if chat_id in self._groups and isinstance(self._groups[chat_id], dict): + if isinstance(self._groups.get(chat_id), dict): return self._groups[chat_id] lowered = chat_id.lower() for key, value in self._groups.items(): @@ -1770,247 +808,14 @@ class WeComAdapter(BasePlatformAdapter): wildcard = self._groups.get("*") return wildcard if isinstance(wildcard, dict) else {} - def _remember_reply_req_id(self, message_id: str, req_id: str) -> None: - normalized_message_id = str(message_id or "").strip() - normalized_req_id = str(req_id or "").strip() - if not normalized_message_id or not normalized_req_id: - return - self._reply_req_ids[normalized_message_id] = normalized_req_id - while len(self._reply_req_ids) > DEDUP_MAX_SIZE: - self._reply_req_ids.pop(next(iter(self._reply_req_ids))) - def _remember_chat_req_id(self, chat_id: str, req_id: str) -> None: - """Cache the most recent inbound req_id per chat. + """Cache the most recent inbound req_id per chat (bounded like _reply_req_ids). - Used as a fallback reply target when we need to send into a group - without an explicit ``reply_to`` — WeCom AI Bots are blocked from - APP_CMD_SEND in groups and must use APP_CMD_RESPONSE bound to some - prior req_id. Bounded like _reply_req_ids so long-running gateways - don't leak memory across many chats. + Fallback reply target for group sends (APP_CMD_SEND is blocked in groups). + A fresh req_id also resurrects the chat's stream channel. """ - normalized_chat_id = str(chat_id or "").strip() - normalized_req_id = str(req_id or "").strip() - if not normalized_chat_id or not normalized_req_id: - return - self._last_chat_req_ids[normalized_chat_id] = normalized_req_id - while len(self._last_chat_req_ids) > DEDUP_MAX_SIZE: - self._last_chat_req_ids.pop(next(iter(self._last_chat_req_ids))) - # A fresh inbound req_id resurrects the stream channel — drop any - # stale "stream is dead" marker from prior 846608 responses so the - # next outbound turn can attempt native streaming again. - self._stream_expired_chats.discard(normalized_chat_id) - # A new inbound message starts a new "turn" — allow send_typing to - # open a fresh stream again (the previous turn's delivery guard is - # no longer relevant). - - def _resolve_stream_req_id( - self, chat_id: str, reply_to: Optional[str] - ) -> Optional[str]: - """Pick a req_id for a stream reply. - - Precedence: explicit ``reply_to`` (a prior message id we cached) → - last inbound req_id for this chat → ``None`` (stream impossible). - """ - req_id = self._reply_req_id_for_message(reply_to) - if req_id: - return req_id - return self._last_chat_req_ids.get(str(chat_id or "").strip()) or None - - def _get_or_create_stream_turn(self, chat_id: str, req_id: str) -> StreamTurn: - """Get or create a StreamTurn for the given chat and req_id.""" - key = f"{chat_id}:{req_id}" - if key not in self._stream_turns: - self._stream_turns[key] = StreamTurn(chat_id, req_id) - return self._stream_turns[key] - - def _cleanup_stream_turn(self, chat_id: str, req_id: str) -> None: - """Clean up a StreamTurn after finalization or error.""" - key = f"{chat_id}:{req_id}" - turn = self._stream_turns.pop(key, None) - if turn is not None: - self._cancel_idle_flush(turn) - self._cancel_keepalive(turn) - - def _cancel_idle_flush(self, turn: StreamTurn) -> None: - """Cancel a pending idle-flush timer on the turn (no-op if unarmed).""" - handle = turn.idle_flush_handle - if handle is not None: - try: - handle.cancel() - except Exception: - pass - turn.idle_flush_handle = None - - # ── Stream-level keep-alive (Layer 1) ───────────────────────────────── - # Structurally mirrors the idle-flush timer: a per-turn asyncio TimerHandle - # stored on the StreamTurn, cancelled on every turn-exit path. The only - # difference from idle-flush is cadence (minutes vs 250ms) and intent - # (refresh the server's 6-min stream window vs ship a partial buffer). - - def _cancel_keepalive(self, turn: StreamTurn) -> None: - """Cancel a pending keep-alive timer on the turn (no-op if unarmed).""" - handle = turn.keepalive_handle - if handle is not None: - try: - handle.cancel() - except Exception: - pass - turn.keepalive_handle = None - - def _arm_keepalive( - self, - turn: StreamTurn, - *, - turn_id: Optional[str], - ) -> None: - """Arm the keep-alive timer (Layer 1) if enabled and not already armed. - - Idempotent — re-arming while one is pending no-ops; a fresh timer is - only scheduled after the previous one fired or was cancelled. Skips - entirely when keep-alive is disabled by config (the default). - """ - if not self._stream_keepalive_enabled: - return - if turn.finalized or turn.expired: - return - if turn.keepalive_handle is not None: - return # already armed - try: - loop = asyncio.get_running_loop() - except RuntimeError: - return # not inside a loop (defensive) - handle = loop.call_later( - self._stream_keepalive_interval_seconds, - self._on_keepalive_fire, - turn, - turn_id, - ) - turn.keepalive_handle = handle - - def _on_keepalive_fire( - self, - turn: StreamTurn, - turn_id: Optional[str], - ) -> None: - """Loop callback — dispatch an async keep-alive send without blocking.""" - turn.keepalive_handle = None - if turn.finalized or turn.expired: - return - try: - asyncio.ensure_future(self._keepalive_send(turn, turn_id)) - except RuntimeError: - pass - - async def _keepalive_send( - self, - turn: StreamTurn, - turn_id: Optional[str], - ) -> None: - """Re-send the accumulated text as a finish=false frame to refresh the - WeCom server's stream window, then re-arm for the next interval. - - Deliberately conservative (see ANALYSIS §4.2/§5): - - * **Never sends a placeholder.** When there is no accumulated text yet - (e.g. a cron turn still fetching data), the tick is skipped and the - timer is re-armed — we'd rather let Layer 2's clock fallback handle a - content-less turn than pollute ``last_sent_content`` with a filler - frame or strand the user on a "still working…" bubble. - * Reuses ``_send_stream_reply(finish=False)`` with the queue's - ``skip_if_pending`` semantics, so a heartbeat that races an in-flight - ack is dropped rather than piling onto the ack queue. - * On 846604/846608 marks the turn expired and retires it so finalize - takes the Layer 2 fallback; stops the timer (no re-arm). - """ - if turn.finalized or turn.expired: - return - if turn._intermediate_frames_sent >= MAX_INTERMEDIATE_FRAMES: - # No room left for intermediate frames; stop keeping alive and let - # finalize (or Layer 2) run. Do not re-arm. - return - content = turn.accumulated_text or "" - if not content.strip(): - # Nothing to refresh with yet — skip this tick, re-arm for later. - self._arm_keepalive(turn, turn_id=turn_id) - return - try: - await self._send_stream_reply( - turn.req_id, - turn.stream_id, - content, - finish=False, - ) - except WeComStreamExpiredError: - turn.expired = True - self._retire_turn(turn, turn_id) - self._stream_expired_chats.add(turn.chat_id) - return - except Exception as exc: - logger.debug( - "[%s] keep-alive send failed (chat=%s, turn=%s): %s", - self.name, turn.chat_id, turn.stream_id, exc, - ) - # Transient failure — re-arm and try again next interval. - self._arm_keepalive(turn, turn_id=turn_id) - return - turn._last_frame_sent_at = time.monotonic() - turn.last_sent_content = content - # Re-arm for the next interval (guarded internally against - # finalized/expired). - self._arm_keepalive(turn, turn_id=turn_id) - - def _retire_turn(self, turn: StreamTurn, turn_id: Optional[str]) -> None: - """Remove a turn from the registry and cancel BOTH of its timers. - - Single choke point for the "turn is dead" cleanup shared by the - expired/error paths. Cancels idle-flush and keep-alive timers before - popping so neither can fire on a retired turn. - """ - self._cancel_idle_flush(turn) - self._cancel_keepalive(turn) - if turn_id: - self._stream_turns.pop(f"{turn.chat_id}:{turn_id}", None) - else: - self._cleanup_stream_turn(turn.chat_id, turn.req_id) - - def _find_active_turn_for_chat(self, chat_id: str) -> Optional[StreamTurn]: - """Find the most recent active (non-finalized) turn for a chat.""" - for turn in self._stream_turns.values(): - if turn.chat_id == chat_id and not turn.finalized: - return turn - return None - - def _reset_native_stream_state(self) -> None: - """Legacy method for compatibility. Now a no-op since state is per-turn.""" - # No-op: stream state is now per-turn, not global. - # Kept for compatibility with existing code that calls this. - pass - - async def _force_reconnect_on_stale_subscription(self, errcode: int) -> None: - """Force-close the WS when server rejects our subscription (846609). - - WeCom errcode 846609 means the server no longer considers this WS - session subscribed — all sends will fail until we reconnect. Rather - than waiting for the WS to close naturally (can take 2+ minutes of - timeouts), we proactively close it to trigger _listen_loop's - reconnect cycle immediately. - """ - if errcode != STREAM_NOT_SUBSCRIBED_ERRCODE: - return - logger.warning( - "[%s] Got errcode %d (subscription lost) — clearing stale state", - self.name, errcode, - ) - # Only invalidate cached req_ids (bound to the dead session). - # Do NOT close the WS — closing triggers _listen_loop to reconnect, - # which opens a second WS connection. WeCom only allows one long-lived - # connection per bot; the server kicks the second one and invalidates - # the first's session, creating an infinite kick-reconnect loop. - # The WS will be closed by the server side naturally; _listen_loop - # handles the reconnect when that happens. - self._last_chat_req_ids.clear() - self._reply_req_ids.clear() - self._reset_native_stream_state() + if _bounded_put(self._last_chat_req_ids, chat_id, req_id): + self._stream_expired_chats.discard(str(chat_id).strip()) def _reply_req_id_for_message(self, reply_to: Optional[str]) -> Optional[str]: normalized = str(reply_to or "").strip() @@ -2018,111 +823,29 @@ class WeComAdapter(BasePlatformAdapter): return None return self._reply_req_ids.get(normalized) + async def _force_reconnect_on_stale_subscription(self, errcode: int) -> None: + """On 846609 (subscription lost) invalidate cached req_ids bound to the dead session. + + Do NOT close the WS: that makes _listen_loop open a second connection, + WeCom kicks it and invalidates the first — an infinite kick-reconnect + loop. The server closes the WS itself and _listen_loop reconnects then. + """ + if errcode != STREAM_NOT_SUBSCRIBED_ERRCODE: + return + logger.warning("[%s] Got errcode %d (subscription lost) — clearing stale state", self.name, errcode) + self._last_chat_req_ids.clear() + self._reply_req_ids.clear() + # ------------------------------------------------------------------ # Outbound messaging # ------------------------------------------------------------------ - @staticmethod - def _guess_mime_type(filename: str) -> str: - mime_type = mimetypes.guess_type(filename)[0] - if mime_type: - return mime_type - if Path(filename).suffix.lower() == ".amr": - return "audio/amr" - return "application/octet-stream" - - @staticmethod - def _normalize_content_type(content_type: str, filename: str) -> str: - normalized = str(content_type or "").split(";", 1)[0].strip().lower() - guessed = WeComAdapter._guess_mime_type(filename) - if not normalized: - return guessed - if normalized in {"application/octet-stream", "text/plain"}: - return guessed - return normalized - - @staticmethod - def _detect_wecom_media_type(content_type: str) -> str: - mime_type = str(content_type or "").strip().lower() - if mime_type.startswith("image/"): - return "image" - if mime_type.startswith("video/"): - return "video" - if mime_type.startswith("audio/") or mime_type == "application/ogg": - return "voice" - return "file" - - @staticmethod - def _apply_file_size_limits(file_size: int, detected_type: str, content_type: Optional[str] = None) -> Dict[str, Any]: - file_size_mb = file_size / (1024 * 1024) - normalized_type = str(detected_type or "file").lower() - normalized_content_type = str(content_type or "").strip().lower() - - if file_size > ABSOLUTE_MAX_BYTES: - return { - "final_type": normalized_type, - "rejected": True, - "reject_reason": ( - f"文件大小 {file_size_mb:.2f}MB 超过了企业微信允许的最大限制 20MB,无法发送。" - "请尝试压缩文件或减小文件大小。" - ), - "downgraded": False, - "downgrade_note": None, - } - - if normalized_type == "image" and file_size > IMAGE_MAX_BYTES: - return { - "final_type": "file", - "rejected": False, - "reject_reason": None, - "downgraded": True, - "downgrade_note": f"图片大小 {file_size_mb:.2f}MB 超过 10MB 限制,已转为文件格式发送", - } - - if normalized_type == "video" and file_size > VIDEO_MAX_BYTES: - return { - "final_type": "file", - "rejected": False, - "reject_reason": None, - "downgraded": True, - "downgrade_note": f"视频大小 {file_size_mb:.2f}MB 超过 10MB 限制,已转为文件格式发送", - } - - if normalized_type == "voice": - if normalized_content_type and normalized_content_type not in VOICE_SUPPORTED_MIMES: - return { - "final_type": "file", - "rejected": False, - "reject_reason": None, - "downgraded": True, - "downgrade_note": ( - f"语音格式 {normalized_content_type} 不支持,企微仅支持 AMR 格式,已转为文件格式发送" - ), - } - if file_size > VOICE_MAX_BYTES: - return { - "final_type": "file", - "rejected": False, - "reject_reason": None, - "downgraded": True, - "downgrade_note": f"语音大小 {file_size_mb:.2f}MB 超过 2MB 限制,已转为文件格式发送", - } - - return { - "final_type": normalized_type, - "rejected": False, - "reject_reason": None, - "downgraded": False, - "downgrade_note": None, - } - @staticmethod def _response_error(response: Dict[str, Any]) -> Optional[str]: errcode = response.get("errcode", 0) if errcode in {0, None}: return None - errmsg = str(response.get("errmsg") or "unknown error") - return f"WeCom errcode {errcode}: {errmsg}" + return f"WeCom errcode {errcode}: {response.get('errmsg') or 'unknown error'}" @classmethod def _raise_for_wecom_error(cls, response: Dict[str, Any], operation: str) -> None: @@ -2130,480 +853,37 @@ class WeComAdapter(BasePlatformAdapter): if error: raise RuntimeError(f"{operation} failed: {error}") - @staticmethod - def _decrypt_file_bytes(encrypted_data: bytes, aes_key: str) -> bytes: - if not encrypted_data: - raise ValueError("encrypted_data is empty") - if not aes_key: - raise ValueError("aes_key is required") - - # WeCom doesn't pad base64 keys; add padding if needed - aes_key = aes_key + '=' * ((4 - len(aes_key) % 4) % 4) - key = base64.b64decode(aes_key) - if len(key) != 32: - raise ValueError(f"Invalid WeCom AES key length: expected 32 bytes, got {len(key)}") - - try: - from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes - except ImportError as exc: # pragma: no cover - dependency is environment-specific - raise RuntimeError("cryptography is required for WeCom media decryption") from exc - - cipher = Cipher(algorithms.AES(key), modes.CBC(key[:16])) - decryptor = cipher.decryptor() - decrypted = decryptor.update(encrypted_data) + decryptor.finalize() - - pad_len = decrypted[-1] - if pad_len < 1 or pad_len > 32 or pad_len > len(decrypted): - raise ValueError(f"Invalid PKCS#7 padding value: {pad_len}") - if any(byte != pad_len for byte in decrypted[-pad_len:]): - raise ValueError("Invalid PKCS#7 padding: padding bytes mismatch") - - return decrypted[:-pad_len] - - async def _download_remote_bytes( - self, - url: str, - max_bytes: int, - ) -> Tuple[bytes, Dict[str, str]]: - from gateway.platforms.base import _ssrf_redirect_guard - from tools.url_safety import create_ssrf_safe_async_client, is_safe_url - - if not is_safe_url(url): - raise ValueError(f"Blocked unsafe URL (SSRF protection): {url[:80]}") - - if not HTTPX_AVAILABLE: - raise RuntimeError("httpx is required for WeCom media download") - - client = self._http_client or create_ssrf_safe_async_client( - timeout=30.0, - follow_redirects=True, - event_hooks={"response": [_ssrf_redirect_guard]}, - ) - created_client = client is not self._http_client - try: - async with client.stream( - "GET", - url, - headers={ - "User-Agent": "HermesAgent/1.0", - "Accept": "*/*", - }, - ) as response: - response.raise_for_status() - headers = {key.lower(): value for key, value in response.headers.items()} - content_length = headers.get("content-length") - if content_length and content_length.isdigit() and int(content_length) > max_bytes: - raise ValueError( - f"Remote media exceeds WeCom limit: {int(content_length)} bytes > {max_bytes} bytes" - ) - - data = bytearray() - async for chunk in response.aiter_bytes(): - data.extend(chunk) - if len(data) > max_bytes: - raise ValueError( - f"Remote media exceeds WeCom limit while downloading: {len(data)} bytes > {max_bytes} bytes" - ) - - return bytes(data), headers - finally: - if created_client: - await client.aclose() - - @staticmethod - def _looks_like_url(media_source: str) -> bool: - parsed = urlparse(str(media_source or "")) - return parsed.scheme in {"http", "https"} - - async def _load_outbound_media( - self, - media_source: str, - file_name: Optional[str] = None, - ) -> Tuple[bytes, str, str]: - source = str(media_source or "").strip() - if not source: - raise ValueError("media source is required") - if re.fullmatch(r"<[^>\n]+>", source): - raise ValueError(f"Media placeholder was not replaced with a real file path: {source}") - - parsed = urlparse(source) - if parsed.scheme in {"http", "https"}: - data, headers = await self._download_remote_bytes(source, max_bytes=ABSOLUTE_MAX_BYTES) - content_disposition = headers.get("content-disposition") - resolved_name = file_name or self._guess_filename(source, content_disposition, headers.get("content-type", "")) - content_type = self._normalize_content_type(headers.get("content-type", ""), resolved_name) - return data, content_type, resolved_name - - if parsed.scheme == "file": - local_path = Path(unquote(parsed.path)).expanduser() - else: - local_path = Path(source).expanduser() - - if not local_path.is_absolute(): - local_path = (Path.cwd() / local_path).resolve() - - if not local_path.exists() or not local_path.is_file(): - raise FileNotFoundError(f"Media file not found: {local_path}") - - data = local_path.read_bytes() - resolved_name = file_name or local_path.name - content_type = self._normalize_content_type("", resolved_name) - return data, content_type, resolved_name - - async def _prepare_outbound_media( - self, - media_source: str, - file_name: Optional[str] = None, - ) -> Dict[str, Any]: - data, content_type, resolved_name = await self._load_outbound_media(media_source, file_name=file_name) - detected_type = self._detect_wecom_media_type(content_type) - size_check = self._apply_file_size_limits(len(data), detected_type, content_type) - return { - "data": data, - "content_type": content_type, - "file_name": resolved_name, - "detected_type": detected_type, - **size_check, - } - - async def _upload_media_bytes(self, data: bytes, media_type: str, filename: str) -> Dict[str, Any]: - if not data: - raise ValueError("Cannot upload empty media") - - total_size = len(data) - total_chunks = (total_size + UPLOAD_CHUNK_SIZE - 1) // UPLOAD_CHUNK_SIZE - if total_chunks > MAX_UPLOAD_CHUNKS: - raise ValueError( - f"File too large: {total_chunks} chunks exceeds maximum of {MAX_UPLOAD_CHUNKS} chunks" - ) - - init_response = await self._send_request( - APP_CMD_UPLOAD_MEDIA_INIT, - { - "type": media_type, - "filename": filename, - "total_size": total_size, - "total_chunks": total_chunks, - "md5": hashlib.md5(data).hexdigest(), - }, - ) - self._raise_for_wecom_error(init_response, "media upload init") - - init_body = init_response.get("body") if isinstance(init_response.get("body"), dict) else {} - upload_id = str(init_body.get("upload_id") or "").strip() - if not upload_id: - raise RuntimeError(f"media upload init failed: missing upload_id in response {init_response}") - - for chunk_index, start in enumerate(range(0, total_size, UPLOAD_CHUNK_SIZE)): - chunk = data[start : start + UPLOAD_CHUNK_SIZE] - chunk_response = await self._send_request( - APP_CMD_UPLOAD_MEDIA_CHUNK, - { - "upload_id": upload_id, - # Match the official SDK implementation, which currently uses 0-based chunk indexes. - "chunk_index": chunk_index, - "base64_data": base64.b64encode(chunk).decode("ascii"), - }, - ) - self._raise_for_wecom_error(chunk_response, f"media upload chunk {chunk_index}") - - finish_response = await self._send_request( - APP_CMD_UPLOAD_MEDIA_FINISH, - {"upload_id": upload_id}, - ) - self._raise_for_wecom_error(finish_response, "media upload finish") - - finish_body = finish_response.get("body") if isinstance(finish_response.get("body"), dict) else {} - media_id = str(finish_body.get("media_id") or "").strip() - if not media_id: - raise RuntimeError(f"media upload finish failed: missing media_id in response {finish_response}") - - return { - "type": str(finish_body.get("type") or media_type), - "media_id": media_id, - "created_at": finish_body.get("created_at"), - } - - async def _send_media_message(self, chat_id: str, media_type: str, media_id: str) -> Dict[str, Any]: - response = await self._send_request( - APP_CMD_SEND, - { - "chatid": chat_id, - "msgtype": media_type, - media_type: {"media_id": media_id}, - }, - ) - self._raise_for_wecom_error(response, "send media message") - return response - async def _send_reply_markdown(self, reply_req_id: str, content: str) -> Dict[str, Any]: response = await self._send_reply_request( - reply_req_id, - { - "msgtype": "markdown", - "markdown": {"content": content[:self.MAX_MESSAGE_LENGTH]}, - }, + reply_req_id, {"msgtype": "markdown", "markdown": {"content": content[:self.MAX_MESSAGE_LENGTH]}}, ) self._raise_for_wecom_error(response, "send reply markdown") return response - @staticmethod - def _truncate_stream_content(content: str, limit: int) -> str: - """Truncate ``content`` to fit within ``limit`` UTF-8 bytes. - - WeCom enforces a byte-length cap on stream frames; truncating by - codepoints would still let multi-byte runs blow past the limit. - """ - encoded = content.encode("utf-8") - if len(encoded) <= limit: - return content - return encoded[:limit].decode("utf-8", errors="ignore") - - async def _send_stream_reply( - self, - reply_req_id: str, - stream_id: str, - content: str, - finish: bool = False, - ) -> Dict[str, Any]: - """Send a single ``msgtype: "stream"`` frame via aibot_respond_msg. - - Uses the per-req_id reply queue with ack tracking, aligned with the - official WeCom SDK's replyStreamNonBlocking semantics: - - * **Intermediate frames** (finish=False): sent non-blocking via - ``_send_reply_queued(skip_if_pending=True)``. If a prior frame's - ack is still pending, the frame is skipped (cumulative text means - no information is lost — the next frame carries all content). - * **Final frame** (finish=True): waits for any pending ack to drain - before sending, then awaits its own ack. This prevents version - conflicts (errcode 6000) between the finalize and a concurrent - intermediate frame. - - Raises :class:`WeComStreamExpiredError` on errcode 846608 so the - caller can fall back to a proactive markdown send. - """ - truncated = self._truncate_stream_content( - content or "", self.MAX_STREAM_CONTENT_LENGTH, - ) - if len(content or "") != len(truncated): - logger.warning( - "[%s] Stream content truncated for stream_id=%s", - self.name, stream_id, - ) - body: Dict[str, Any] = { - "msgtype": "stream", - "stream": { - "id": stream_id, - "finish": bool(finish), - "content": truncated, - }, - } - - if not finish: - # Intermediate frame: non-blocking with pending-skip semantics. - # If a previous frame's ack is still pending on this req_id, - # skip this frame entirely (cumulative text guarantees no loss). - response = await self._send_reply_queued( - reply_req_id, body, is_final=False, skip_if_pending=True, - ) - return response - - # Final frame: wait for any pending intermediate ack, then send - # with ack tracking so we reliably detect 846608/6000. - response = await self._send_reply_queued( - reply_req_id, body, is_final=True, skip_if_pending=False, - ) - errcode = response.get("errcode", 0) - if errcode in (STREAM_EXPIRED_ERRCODE, STREAM_REQUEST_EXPIRED_ERRCODE): - # 846608 (stream update window) and 846604 (req_id reply-request - # window) both mean the reply flow is dead — raise the same - # expired error so the caller falls back to a proactive send. - raise WeComStreamExpiredError( - errcode=errcode, errmsg=str(response.get("errmsg") or ""), - ) - if errcode == STREAM_VERSION_CONFLICT_ERRCODE: - # 6000 = version conflict: a newer frame on this stream_id already - # replaced the bubble. For a finalize frame this means the content - # is ALREADY on screen (idempotent re-finalize losing the race to a - # newer version), so treat it as delivered rather than raising — - # raising here would pop the turn and drop us into a duplicate - # standalone send(). This is what makes idempotent finalize retry - # safe: retrying a finalize that already landed returns 6000, which - # we now absorb instead of turning into a second message. - logger.info( - "[%s] finalize hit errcode 6000 (version conflict) — bubble " - "already replaced by a newer frame; treating as delivered.", - self.name, - ) - return response - self._raise_for_wecom_error(response, "send stream reply") - return response - - async def _send_reply_media_message( - self, - reply_req_id: str, - media_type: str, - media_id: str, - ) -> Dict[str, Any]: - response = await self._send_reply_request( - reply_req_id, - { - "msgtype": media_type, - media_type: {"media_id": media_id}, - }, - ) - self._raise_for_wecom_error(response, "send reply media message") - return response - - async def _send_followup_markdown( - self, - chat_id: str, - content: str, - reply_to: Optional[str] = None, - ) -> Optional[SendResult]: - if not content: - return None - result = await self.send(chat_id=chat_id, content=content, reply_to=reply_to) - if not result.success: - logger.warning("[%s] Follow-up markdown send failed: %s", self.name, result.error) - return result - - async def _send_media_source( - self, - chat_id: str, - media_source: str, - caption: Optional[str] = None, - file_name: Optional[str] = None, - reply_to: Optional[str] = None, - ) -> SendResult: - if not chat_id: - return SendResult(success=False, error="chat_id is required") - - try: - prepared = await self._prepare_outbound_media(media_source, file_name=file_name) - except FileNotFoundError as exc: - return SendResult(success=False, error=str(exc)) - except Exception as exc: - logger.error("[%s] Failed to prepare outbound media %s: %s", self.name, media_source, exc) - return SendResult(success=False, error=str(exc)) - - if prepared["rejected"]: - await self._send_followup_markdown( - chat_id, - f"⚠️ {prepared['reject_reason']}", - reply_to=reply_to, - ) - return SendResult(success=False, error=prepared["reject_reason"]) - - reply_req_id = self._reply_req_id_for_message(reply_to) - if not reply_req_id and chat_id in self._last_chat_req_ids: - reply_req_id = self._last_chat_req_ids[chat_id] - - # When native streaming was/is active for this chat, media MUST go - # through the proactive send path (aibot_send_msg), NOT passive reply - # (aibot_respond_msg). This mirrors the official OpenClaw plugin: - # "replyMedia(被动回复)无法覆盖 replyStream 发出的 thinking 流式消息, - # 因此所有媒体统一走 aibot_send_msg 主动发送。" - # The reply_req_id is "owned" by the stream — using it for media - # causes the server to either ignore it or never ack. - active_turn = self._find_active_turn_for_chat(chat_id) - if active_turn or chat_id in self._stream_expired_chats: - reply_req_id = None # force proactive send - - try: - upload_result = await self._upload_media_bytes( - prepared["data"], - prepared["final_type"], - prepared["file_name"], - ) - logger.info("[%s] upload_media_bytes OK: media_id=%s type=%s", self.name, upload_result.get("media_id"), prepared["final_type"]) - if reply_req_id: - media_response = await self._send_reply_media_message( - reply_req_id, - prepared["final_type"], - upload_result["media_id"], - ) - logger.info("[%s] send_reply_media OK: %s", self.name, media_response) - else: - media_response = await self._send_media_message( - chat_id, - prepared["final_type"], - upload_result["media_id"], - ) - logger.info("[%s] send_media_message OK: %s", self.name, media_response) - except asyncio.TimeoutError: - logger.error("[%s] TIMEOUT in _send_media_source for %s", self.name, media_source) - return SendResult(success=False, error="Timeout sending media to WeCom") - except Exception as exc: - logger.error("[%s] Failed to send media %s: %s", self.name, media_source, exc) - return SendResult(success=False, error=str(exc)) - - caption_result = None - downgrade_result = None - if caption: - caption_result = await self._send_followup_markdown( - chat_id, - caption, - reply_to=reply_to, - ) - if prepared["downgraded"] and prepared["downgrade_note"]: - downgrade_result = await self._send_followup_markdown( - chat_id, - f"ℹ️ {prepared['downgrade_note']}", - reply_to=reply_to, - ) - - return SendResult( - success=True, - message_id=self._payload_req_id(media_response) or uuid.uuid4().hex[:12], - raw_response={ - "upload": upload_result, - "media": media_response, - "caption": caption_result.raw_response if caption_result else None, - "caption_error": caption_result.error if caption_result and not caption_result.success else None, - "downgrade": downgrade_result.raw_response if downgrade_result else None, - "downgrade_error": downgrade_result.error if downgrade_result and not downgrade_result.success else None, - }, + async def _send_proactive_markdown(self, chat_id: str, content: str) -> Dict[str, Any]: + return await self._send_request( + APP_CMD_SEND, + {"chatid": chat_id, "msgtype": "markdown", "markdown": {"content": content[:self.MAX_MESSAGE_LENGTH]}}, ) async def send( - self, - chat_id: str, - content: str, - reply_to: Optional[str] = None, - metadata: Optional[Dict[str, Any]] = None, + self, chat_id: str, content: str, reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: - """Send markdown to a WeCom chat. + """Send markdown to a WeCom chat as a standalone message (never touches active streams). - Sends content as a standalone message without interfering with any - active streams. Streams are managed by their creators (typically - GatewayStreamConsumer) who call send_stream_frame(finalize=True) - when ready. - - All sends are serialized per chat_id to avoid exceeding WeCom's - 30 msgs/min/chat rate limit (errcode 846607). - - If metadata contains "is_approval_prompt": True, the message is routed - through the control lane for immediate delivery. + Serialized per chat to stay under 30 msgs/min/chat (errcode 846607). + ``metadata["is_approval_prompt"]`` routes through the control lane. """ if not chat_id: return SendResult(success=False, error="chat_id is required") - - # Check if this is an approval prompt (should use control lane) is_control = False force_proactive = False if metadata: is_control = metadata.pop("is_approval_prompt", False) - # Explicit opt-in for proactive send: used by approval - # *confirmation* messages (post-/approve) that must not consume - # the req_id the stream consumer needs for resumed output. - # Distinct from is_approval_prompt which only routes to the - # control lane — the initial approval *request* prompt still - # uses passive reply (required for groups where APP_CMD_SEND - # is blocked). + # Approval *confirmations* must not consume the req_id the stream + # consumer needs for resumed output. The initial approval *prompt* + # still uses passive reply (required in groups). force_proactive = bool(metadata.pop("force_proactive_send", False)) - return await self._enqueue_chat_send( chat_id, lambda: self._send_inner(chat_id, content, reply_to, force_proactive=force_proactive), @@ -2611,37 +891,17 @@ class WeComAdapter(BasePlatformAdapter): ) async def _send_inner( - self, - chat_id: str, - content: str, - reply_to: Optional[str] = None, - *, - force_proactive: bool = False, + self, chat_id: str, content: str, reply_to: Optional[str] = None, *, force_proactive: bool = False, ) -> SendResult: - """Actual send logic, called under the per-chat lock. + """Actual send logic, run under the per-chat queue. - Sends content as a standalone message. Does NOT close any active - streams — streams are managed by their creators (GatewayStreamConsumer) - who call send_stream_frame(finalize=True) when ready. - - This aligns with the official wecom-openclaw-plugin model where - send() and streaming are independent operations. - - Args: - force_proactive: When True, always use APP_CMD_SEND instead of - passive reply. Used for approval confirmations to avoid - consuming the req_id needed by the post-approval stream. + force_proactive: always use APP_CMD_SEND instead of passive reply + (except in groups, where APP_CMD_SEND is blocked). """ try: - # Directly send the message without touching any active streams. - # GatewayStreamConsumer manages its own stream lifecycle via - # send_stream_frame() with turn_id, so send() shouldn't interfere. - reply_req_id = self._reply_req_id_for_message(reply_to) - if not reply_req_id and chat_id in self._last_chat_req_ids: reply_req_id = self._last_chat_req_ids[chat_id] - if force_proactive and chat_id not in self._group_chat_ids: reply_req_id = None @@ -2649,554 +909,47 @@ class WeComAdapter(BasePlatformAdapter): try: response = await self._send_reply_markdown(reply_req_id, content) except (asyncio.TimeoutError, RuntimeError) as passive_err: - # Passive reply failed (req_id may be stale after WS reconnect). - # Fall back to proactive aibot_send_msg which doesn't depend - # on any prior req_id. + # req_id may be stale after a WS reconnect — proactive send + # doesn't depend on any prior req_id. logger.warning( "[%s] Passive reply failed (%s), falling back to proactive send", self.name, passive_err, ) - response = await self._send_request( - APP_CMD_SEND, - { - "chatid": chat_id, - "msgtype": "markdown", - "markdown": {"content": content[:self.MAX_MESSAGE_LENGTH]}, - }, - ) + response = await self._send_proactive_markdown(chat_id, content) else: - # No req_id available — must use proactive APP_CMD_SEND. - # Group chats cannot use APP_CMD_SEND (WeCom blocks it), - # so fail early with a clear error instead of making a - # doomed network request. if chat_id in self._group_chat_ids: logger.warning( "[%s] No cached req_id for group chat %s — " "cannot send (groups require passive reply via req_id)", self.name, chat_id, ) - return SendResult( - success=False, - error="No req_id available for group chat (passive reply required)", - ) - response = await self._send_request( - APP_CMD_SEND, - { - "chatid": chat_id, - "msgtype": "markdown", - "markdown": {"content": content[:self.MAX_MESSAGE_LENGTH]}, - }, - ) + return SendResult(success=False, error="No req_id available for group chat (passive reply required)") + response = await self._send_proactive_markdown(chat_id, content) except asyncio.TimeoutError: return SendResult(success=False, error="Timeout sending message to WeCom") except Exception as exc: logger.error("[%s] Send failed: %s", self.name, exc) - # Detect 846609 (subscription lost) and trigger reconnect so - # subsequent messages don't fail for 2+ minutes while the dead - # WS connection lingers. - exc_str = str(exc) - if str(STREAM_NOT_SUBSCRIBED_ERRCODE) in exc_str: - asyncio.ensure_future( - self._force_reconnect_on_stale_subscription(STREAM_NOT_SUBSCRIBED_ERRCODE) - ) + # 846609 (subscription lost): clear stale req_ids so later sends don't + # fail for minutes while the dead WS lingers. + if str(STREAM_NOT_SUBSCRIBED_ERRCODE) in str(exc): + asyncio.ensure_future(self._force_reconnect_on_stale_subscription(STREAM_NOT_SUBSCRIBED_ERRCODE)) return SendResult(success=False, error=str(exc)) error = self._response_error(response) if error: - # Also check the response-level errcode for 846609. errcode = response.get("errcode", 0) if errcode == STREAM_NOT_SUBSCRIBED_ERRCODE: - asyncio.ensure_future( - self._force_reconnect_on_stale_subscription(errcode) - ) + asyncio.ensure_future(self._force_reconnect_on_stale_subscription(errcode)) return SendResult(success=False, error=error) - - # Mark delivered so _keep_typing cannot open an orphan stream after - # this turn's reply already landed (regardless of which path was taken). return SendResult( success=True, message_id=self._payload_req_id(response) or uuid.uuid4().hex[:12], raw_response=response, ) - async def send_image( - self, - chat_id: str, - image_url: str, - caption: Optional[str] = None, - reply_to: Optional[str] = None, - metadata: Optional[Dict[str, Any]] = None, - ) -> SendResult: - del metadata - - result = await self._send_media_source( - chat_id=chat_id, - media_source=image_url, - caption=caption, - reply_to=reply_to, - ) - if result.success or not self._looks_like_url(image_url): - return result - - logger.warning("[%s] Falling back to text send for image URL %s: %s", self.name, image_url, result.error) - fallback_text = f"{caption}\n{image_url}" if caption else image_url - return await self.send(chat_id=chat_id, content=fallback_text, reply_to=reply_to) - - async def send_image_file( - self, - chat_id: str, - image_path: str, - caption: Optional[str] = None, - reply_to: Optional[str] = None, - **kwargs, - ) -> SendResult: - del kwargs - return await self._send_media_source( - chat_id=chat_id, - media_source=image_path, - caption=caption, - reply_to=reply_to, - ) - - async def send_document( - self, - chat_id: str, - file_path: str, - caption: Optional[str] = None, - file_name: Optional[str] = None, - reply_to: Optional[str] = None, - **kwargs, - ) -> SendResult: - del kwargs - logger.info("[%s] send_document called: chat=%s file=%s", self.name, chat_id, file_path) - return await self._send_media_source( - chat_id=chat_id, - media_source=file_path, - caption=caption, - file_name=file_name, - reply_to=reply_to, - ) - - async def send_voice( - self, - chat_id: str, - audio_path: str, - caption: Optional[str] = None, - reply_to: Optional[str] = None, - **kwargs, - ) -> SendResult: - del kwargs - return await self._send_media_source( - chat_id=chat_id, - media_source=audio_path, - caption=caption, - reply_to=reply_to, - ) - - async def send_video( - self, - chat_id: str, - video_path: str, - caption: Optional[str] = None, - reply_to: Optional[str] = None, - **kwargs, - ) -> SendResult: - del kwargs - return await self._send_media_source( - chat_id=chat_id, - media_source=video_path, - caption=caption, - reply_to=reply_to, - ) - - async def send_stream_frame( - self, - text: str, - *, - finalize: bool = False, - chat_id: Optional[str] = None, - reply_to: Optional[str] = None, - **kwargs, - ) -> bool: - """Public entry-point for the gateway streaming consumer. - - Native streaming lifecycle (per-turn): - * **First call** for a turn: resolve req_id, create StreamTurn, - and send an empty seed frame to trigger WeCom typing animation. - * **Subsequent calls**: reuse the same StreamTurn's stream_id - - Args: - **kwargs: Additional platform-specific parameters. Currently supports: - - turn_id (str): Optional unique identifier for this turn. When - provided, the StreamTurn is keyed by (chat_id, turn_id) instead - of (chat_id, req_id), preventing concurrent consumers (e.g., - /background, parallel subagents) from interfering with each - other. Mirrors official wecom-openclaw-plugin's per-message - streamId model. - and push cumulative text (not deltas) for in-place updates. - * **finalize=True**: send closing frame and clean up turn state. - - Each turn (chat_id + req_id) maintains independent state, allowing - concurrent messages without interference (e.g., approval during streaming). - - Returns ``True`` when the frame landed; ``False`` when the - stream is unavailable (no req_id, expired session, transport - error). On ``False`` the caller should fall back to - :meth:`send` to deliver the remaining content as a one-shot - markdown reply. - """ - chat = (chat_id or "").strip() - if not chat: - logger.warning( - "[%s] send_stream_frame: chat_id required", - self.name, - ) - return False - - # Extract turn_id early to decide whether to check chat-level expired - turn_id = kwargs.get("turn_id") - - # Chat-level stream expiry only blocks NEW turn creation. - # Existing turns (identified by turn_id) can continue to finalize - # even after another turn in the same chat triggered WeComStreamExpiredError. - # This prevents cross-turn interference in concurrent scenarios. - if not turn_id and chat in self._stream_expired_chats: - # No turn_id provided, and chat is expired → block new turn creation - return False - - if finalize: - # Finalize frame counts toward 30/min — go through the control queue - # (high priority) to prevent blocking by normal messages or other streams. - turn_id = kwargs.get("turn_id") - return await self._enqueue_chat_send( - chat, - lambda: self._send_stream_frame_inner(text, chat=chat, reply_to=reply_to, finalize=True, turn_id=turn_id), - is_control=True, - ) - else: - # Intermediate frames: fire-and-forget, no queue, no rate limit. - # WeCom does NOT count them toward the 30/min quota. - turn_id = kwargs.get("turn_id") - return await self._send_stream_frame_inner(text, chat=chat, reply_to=reply_to, finalize=False, turn_id=turn_id) - - async def _send_stream_frame_inner( - self, - text: str, - *, - chat: str, - reply_to: Optional[str] = None, - finalize: bool = False, - turn_id: Optional[str] = None, - ) -> bool: - """Actual stream frame logic with per-turn state. - - Each turn (identified by chat_id + turn_id OR chat_id + req_id) - maintains its own stream state. This prevents concurrent messages - from interfering with each other. - - When turn_id is provided (from GatewayStreamConsumer), the turn is - keyed by (chat, turn_id) instead of (chat, req_id). This ensures - concurrent consumers (e.g., /background, parallel subagents) maintain - independent streams. - - IMPORTANT: Once a turn is created, it locks to its req_id. Even if - _last_chat_req_ids[chat] changes (e.g., user sends /approve), the - existing turn continues with its original req_id. This prevents the - stream from switching to a new req_id mid-turn. - """ - try: - # If turn_id is provided, use it to find/create the turn. - # This is the true per-turn model that prevents concurrent - # consumers from interfering. - if turn_id: - turn_key = f"{chat}:{turn_id}" - turn = self._stream_turns.get(turn_key) - if not turn: - # finalize=True should NOT create a new turn. - # If the turn was already cleaned up (e.g., due to errcode 6000), - # the caller should fallback to proactive send() instead of - # creating a fresh turn just to finalize it (which would send - # another seed + finish, potentially triggering more conflicts). - if finalize: - logger.debug( - "[%s] send_stream_frame: cannot finalize non-existent turn (turn_id=%s, chat=%s)", - self.name, turn_id, chat, - ) - return False - - # First frame for this turn: need to create it. - # Check if chat is expired (blocks NEW turn creation). - if chat in self._stream_expired_chats: - logger.debug( - "[%s] send_stream_frame: chat %s is expired, cannot create new turn (turn_id=%s)", - self.name, chat, turn_id, - ) - return False - - # First frame for this turn: resolve req_id and create turn - req_id = self._resolve_stream_req_id(chat, reply_to) - if not req_id: - logger.debug( - "[%s] send_stream_frame: no req_id available for chat %s (turn_id=%s)", - self.name, chat, turn_id, - ) - return False - turn = StreamTurn(chat, req_id) - self._stream_turns[turn_key] = turn - logger.debug( - "[%s] send_stream_frame: created new turn %s (turn_id=%s, req_id=%s) for chat %s", - self.name, turn.stream_id, turn_id, req_id, chat, - ) - else: - # Fallback: no turn_id provided (backward compatibility or direct calls). - # Check if we already have an active turn for this chat. - # If yes, reuse it (don't resolve req_id again). - existing_turn = self._find_active_turn_for_chat(chat) - if existing_turn and not existing_turn.finalized: - turn = existing_turn - logger.debug( - "[%s] send_stream_frame: reusing existing turn %s for chat %s", - self.name, turn.stream_id, chat, - ) - else: - # No active turn, need to create a new one. - # Check if chat is expired at the chat level (blocks NEW turn creation). - if chat in self._stream_expired_chats: - logger.debug( - "[%s] send_stream_frame: chat %s is expired, cannot create new turn", - self.name, chat, - ) - return False - - req_id = self._resolve_stream_req_id(chat, reply_to) - if not req_id: - logger.debug( - "[%s] send_stream_frame: no req_id available for chat %s", - self.name, chat, - ) - return False - turn = self._get_or_create_stream_turn(chat, req_id) - logger.debug( - "[%s] send_stream_frame: created new turn %s (req_id=%s) for chat %s", - self.name, turn.stream_id, req_id, chat, - ) - - # Check if this turn has expired - if turn.expired: - return False - - # First frame for this turn: send seed ONLY if not already seeded. - # The GatewayStreamConsumer sends the initial empty seed frame itself - # (stream_consumer.py:461), so we must not duplicate it here. - # The seeded flag prevents double-seed which causes WeCom errcode 6000 - # (data version conflict). - if not turn.seeded and not turn.finalized: - # Seed frame with closed empty — matches the - # official OpenClaw plugin's THINKING_MESSAGE constant. This - # tells the WeCom client that a reasoning turn is starting; - # subsequent frames replace it with cumulative content. - await self._send_stream_reply( - turn.req_id, turn.stream_id, - "", finish=False, - ) - turn.seeded = True - # Stream is now open on the server — arm the keep-alive timer - # (Layer 1) so long, content-sparse turns refresh the 6-min - # window. No-op when keep-alive is disabled by config. - self._arm_keepalive(turn, turn_id=turn_id) - # If caller sent empty text (consumer's explicit seed call), - # we're done — don't send another empty frame below. - if not text and not finalize: - return True - - # Send the frame - if finalize: - # ── Layer 2 clock fallback ─────────────────────────────── - # If the stream is older than the safe duration, the finish - # frame would almost certainly hit 846604/846608. Decline it - # up front: mark the turn expired, retire it (cancels both - # timers), and return False so the gateway consumer's existing - # fallback send() path delivers the content exactly once — the - # same contract the WeComStreamExpiredError path already uses - # (stream_consumer.py rolls back _final_content_delivered on a - # False finalize). Zero new uplink frames; safe for groups in - # the sense that it does not make delivery any worse than the - # current 846608 fallback. - # - # SKIP entirely when Layer 1 keep-alive is enabled: the - # heartbeat has been refreshing the stream window every - # STREAM_KEEPALIVE_INTERVAL_SECONDS, so an old stream_age does - # NOT mean the stream is dead. Declining a still-live stream on - # a blind clock read would force the consumer's send() fallback - # to re-deliver content the intermediate frames already put on - # screen — the exact duplicate-bubble bug this guards against. - # If the stream truly HAS expired, the _send_stream_reply( - # finish=True) below will hit 846604/846608 and raise - # WeComStreamExpiredError, which the except block turns into the - # real (finalize-only) fallback. - if not self._stream_keepalive_enabled: - stream_age = time.monotonic() - turn.start_time - if stream_age >= self._stream_safe_duration_seconds: - logger.info( - "[%s] Stream age %.0fs >= safe duration %.0fs for chat " - "%s — declining finalize frame, falling back to " - "proactive send (Layer 2 clock fallback).", - self.name, stream_age, - self._stream_safe_duration_seconds, chat, - ) - turn.expired = True - self._retire_turn(turn, turn_id) - self._stream_expired_chats.add(chat) - return False - - self._cancel_idle_flush(turn) - self._cancel_keepalive(turn) - - # WeCom may silently drop (no ack) a final frame whose content - # is identical to the preceding intermediate frame — it treats - # the frame as a duplicate despite the finish flag change. - # Append a zero-width space to ensure the content differs when - # the text matches the last ACTUALLY SENT intermediate content. - final_text = text - if text and text == turn.last_sent_content: - final_text = text + "​" # zero-width space - await self._send_stream_reply( - turn.req_id, - turn.stream_id, - final_text, - finish=True, - ) - turn.finalized = True - # Clean up this turn's state - # If turn_id was provided, the key is chat:turn_id, otherwise chat:req_id - if turn_id: - turn_key = f"{chat}:{turn_id}" - self._stream_turns.pop(turn_key, None) - else: - self._cleanup_stream_turn(chat, turn.req_id) - else: - # Fire-and-forget: gateway already decides when to push - # (pure identity-dedup in stream_consumer.py). No adapter- - # side buffering — send immediately when content differs - # from the last pushed frame. This removes the _BlockChunker - # sentence-alignment layer whose "only grow" guard in - # update() silently dropped frames whenever gateway-side - # _accumulated was reset (commentary, boundary) and the new - # cumulative text was shorter than the chunker's high-water - # mark — the root cause of the "Cla"/"ude" split-bubble bug. - turn.accumulated_text = text - - if turn._intermediate_frames_sent >= MAX_INTERMEDIATE_FRAMES: - # Frame cap reached — drop intermediates, keep accumulating. - # The finalize path will drain whatever is left. - return True - - # Pure dedup: skip if content is identical to last sent frame. - if text == turn.last_sent_content: - return True - - self._cancel_idle_flush(turn) - - await self._send_stream_reply( - turn.req_id, - turn.stream_id, - text, - finish=False, - ) - turn._last_frame_sent_at = time.monotonic() - turn._intermediate_frames_sent += 1 - turn.last_sent_content = text - - return True - - except WeComStreamExpiredError: - # Intermediate frames (finalize=False) are fire-and-forget: a later - # cumulative frame — or the finalize frame — carries the full text - # and overwrites whatever this one would have shown. A transient - # failure here must NOT flip the turn expired or trip the consumer's - # send() fallback; doing so re-delivers content the stream will - # replace anyway (duplicate bubble). The stream is still alive - # (keep-alive is refreshing it), so leave the turn intact and report - # success so the consumer keeps streaming. Only a FINAL frame's - # expiry means the screen is genuinely missing this content and the - # consumer must fall back. - if not finalize: - logger.info( - "[%s] Intermediate stream frame expired (errcode=%d) for " - "chat %s — dropping frame, stream stays live", - self.name, STREAM_EXPIRED_ERRCODE, chat, - ) - return True - - logger.info( - "[%s] Stream expired (errcode=%d) for chat %s — switching to proactive send", - self.name, STREAM_EXPIRED_ERRCODE, chat, - ) - # Mark this specific turn as expired and clean it up - if 'turn' in locals(): - turn.expired = True - self._retire_turn(turn, turn_id) - - # Mark the chat as stream-expired to prevent new stream attempts. - # Other concurrent turns may continue if they're already active. - self._stream_expired_chats.add(chat) - return False - except Exception as exc: - # Same intermediate/final split as the expired path above: a single - # intermediate frame failing is transient and self-healing (the next - # cumulative frame overwrites it), so swallow it and keep the turn - # (and its keep-alive) alive. A final-frame failure genuinely leaves - # the screen short of the answer, so retire the turn and let the - # consumer's send() fallback deliver it. - if not finalize: - logger.info( - "[%s] Intermediate stream frame failed (chat=%s): %s — " - "dropping frame, stream stays live", - self.name, chat, exc, - ) - return True - - logger.warning( - "[%s] Stream frame failed (chat=%s): %s", - self.name, chat, exc, - ) - # Clean up this turn on error - if 'turn' in locals(): - self._retire_turn(turn, turn_id) - return False - - def supports_native_streaming( - self, - chat_type: Optional[str] = None, - metadata: Optional[Dict[str, Any]] = None, - ) -> bool: - """Probed by ``GatewayStreamConsumer`` to gate native streaming. - - WeCom AI Bot supports stream frames in both DMs and groups; group - chats just need a cached inbound ``req_id`` (every group message - the bot receives populates ``_last_chat_req_ids``, so this is - effectively always satisfied for actively-used groups). - """ - del chat_type, metadata - return True - - async def send_typing(self, chat_id: str, metadata=None) -> None: - """No-op: WeCom typing is handled by the stream consumer seed frame. - - The stream consumer sends an empty seed frame at the start of run(), - which is what triggers WeCom's typing animation. _keep_typing loops - are designed for platforms where typing expires (Telegram 5s) — WeCom - streams stay open indefinitely, so repeated send_typing calls cause - orphan streams. Delegating entirely to the consumer avoids the race. - """ - del chat_id, metadata - async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: """Return minimal chat info.""" - return { - "name": chat_id, - "type": "group" if chat_id and chat_id.lower().startswith("group") else "dm", - } + return {"name": chat_id, "type": "group" if chat_id and chat_id.lower().startswith("group") else "dm"} # ------------------------------------------------------------------ @@ -3210,55 +963,37 @@ _QR_POLL_INTERVAL = 3 # seconds _QR_POLL_TIMEOUT = 300 # 5 minutes -def qr_scan_for_bot_info( - *, - timeout_seconds: int = _QR_POLL_TIMEOUT, -) -> Optional[Dict[str, str]]: - """Run the WeCom QR scan flow to obtain bot_id and secret. +def qr_scan_for_bot_info(*, timeout_seconds: int = _QR_POLL_TIMEOUT) -> Optional[Dict[str, str]]: + """Fetch a WeCom QR code, render it in the terminal, poll until scanned or timeout. - Fetches a QR code from WeCom, renders it in the terminal, and polls - until the user scans it or the timeout expires. - - Returns ``{"bot_id": ..., "secret": ...}`` on success, ``None`` on - failure or timeout. - - Note: the ``work.weixin.qq.com/ai/qc/{generate,query_result}`` endpoints - used here are not part of WeCom's public developer API — they back the - admin-console web UI's bot-creation flow and may change without notice. - The same pattern is used by the feishu/dingtalk QR setup wizards. + Returns ``{"bot_id", "secret"}`` or None. The ``ai/qc/{generate,query_result}`` + endpoints back the admin-console bot-creation UI, not the public API, and + may change without notice. """ - try: - import urllib.request - import urllib.parse - except ImportError: # pragma: no cover - logger.error("urllib is required for WeCom QR scan") - return None + import urllib.request + import urllib.parse - generate_url = f"{_QR_GENERATE_URL}?source=hermes" + def _get_json(url: str, timeout: int) -> Dict[str, Any]: + req = urllib.request.Request(url, headers={"User-Agent": "HermesAgent/1.0"}) + with urllib.request.urlopen(req, timeout=timeout) as resp: + return json.loads(resp.read().decode("utf-8")) - # ── Step 1: Fetch QR code ── print(" Connecting to WeCom...", end="", flush=True) try: - req = urllib.request.Request(generate_url, headers={"User-Agent": "HermesAgent/1.0"}) - with urllib.request.urlopen(req, timeout=15) as resp: - raw = json.loads(resp.read().decode("utf-8")) + raw = _get_json(f"{_QR_GENERATE_URL}?source=hermes", 15) except Exception as exc: logger.error("WeCom QR: failed to fetch QR code: %s", exc) print(f" failed: {exc}") return None - data = raw.get("data") or {} scode = str(data.get("scode") or "").strip() auth_url = str(data.get("auth_url") or "").strip() - if not scode or not auth_url: logger.error("WeCom QR: unexpected response format: %s", raw) print(" failed: unexpected response format") return None - print(" done.") - # ── Step 2: Render QR code in terminal ── print() qr_rendered = False try: @@ -3268,11 +1003,8 @@ def qr_scan_for_bot_info( qr.make(fit=True) qr.print_ascii(invert=True) qr_rendered = True - except ImportError: - pass except Exception: pass - page_url = f"{_QR_CODE_PAGE}{urllib.parse.quote(scode)}" if qr_rendered: print(f"\n Scan the QR code above, or open this URL directly:\n {page_url}") @@ -3282,91 +1014,63 @@ def qr_scan_for_bot_info( print() print(" Fetching configuration results...", end="", flush=True) - # ── Step 3: Poll for result ── deadline = time.monotonic() + timeout_seconds query_url = f"{_QR_QUERY_URL}?scode={urllib.parse.quote(scode)}" - poll_count = 0 - while time.monotonic() < deadline: try: - req = urllib.request.Request(query_url, headers={"User-Agent": "HermesAgent/1.0"}) - with urllib.request.urlopen(req, timeout=10) as resp: - result = json.loads(resp.read().decode("utf-8")) + result = _get_json(query_url, 10) except Exception as exc: logger.debug("WeCom QR poll error: %s", exc) time.sleep(_QR_POLL_INTERVAL) continue - - poll_count += 1 - # Print a dot on every poll so progress is visible within 3s. - print(".", end="", flush=True) - + print(".", end="", flush=True) # progress dot on every poll result_data = result.get("data") or {} - status = str(result_data.get("status") or "").lower() - - if status == "success": - print() # newline after "Fetching configuration results..." dots + if str(result_data.get("status") or "").lower() == "success": + print() bot_info = result_data.get("bot_info") or {} bot_id = str(bot_info.get("botid") or bot_info.get("bot_id") or "").strip() secret = str(bot_info.get("secret") or "").strip() if bot_id and secret: return {"bot_id": bot_id, "secret": secret} - logger.warning( - "WeCom QR: scan reported success but bot_info missing or incomplete: %s", - result_data, - ) + logger.warning("WeCom QR: scan reported success but bot_info missing or incomplete: %s", result_data) print( " QR scan reported success but no bot credentials were returned.\n" " This usually means the bot was not actually created on the WeCom side.\n" " Falling back to manual credential entry." ) return None - time.sleep(_QR_POLL_INTERVAL) - print() # newline after dots + print() print(f" QR scan timed out ({timeout_seconds // 60} minutes). Please try again.") return None # ────────────────────────────────────────────────────────────────────────── -# Plugin migration glue (#41112 / #3823) -# -# Added when the WeCom adapters (wecom + wecom_callback, sharing the -# wecom_crypto satellite) moved from gateway/platforms/ into this bundled -# plugin. register() exposes BOTH platforms via the registry, replacing the -# Platform.WECOM / Platform.WECOM_CALLBACK elifs in gateway/run.py, the -# _PLATFORM_CONNECTED_CHECKERS entries in gateway/config.py, the _setup_wecom -# wizard + _PLATFORMS["wecom"] static dict in hermes_cli/gateway.py, and the -# _send_wecom dispatch in tools/send_message_tool.py. Env→PlatformConfig -# seeding stays in core, same as prior migrations. +# Plugin glue: register() exposes both WeCom platforms (wecom + wecom_callback) +# via the registry; env→PlatformConfig seeding stays in core. # ────────────────────────────────────────────────────────────────────────── -async def _standalone_send( - pconfig, - chat_id, - message, - *, - thread_id=None, - media_files=None, - force_document=False, -): - """WeCom delivery via live gateway adapter or ephemeral connection. +async def _send_via(adapter, chat_id, message, *, live: bool): + try: + result = await adapter.send(chat_id, message) + except Exception as e: + return {"error": f"WeCom live adapter send failed: {e}" if live else f"WeCom send failed: {e}"} + if not result.success: + return {"error": f"WeCom send failed: {result.error}"} + return {"success": True, "platform": "wecom", "chat_id": chat_id, "message_id": result.message_id} - Implements the standalone_sender_fn contract. WeCom only allows ONE - WebSocket connection per bot — opening a second kicks the first. So - when the gateway is running in-process, we reuse the live adapter. - Only when running out-of-process (cron separate from gateway) do we - open an ephemeral connection. - """ - # Prefer the live gateway adapter to avoid kicking the main connection. + +async def _standalone_send(pconfig, chat_id, message, *, thread_id=None, media_files=None, force_document=False): + """standalone_sender_fn: reuse the live gateway adapter when in-process, else + open an ephemeral connection. WeCom allows ONE WebSocket per bot — a second + connection kicks the first.""" try: from gateway.run import _gateway_runner_ref runner = _gateway_runner_ref() except Exception: runner = None - if runner is not None: from gateway.platforms.base import Platform adapter = None @@ -3375,37 +1079,16 @@ async def _standalone_send( except Exception: pass if adapter is not None: - try: - result = await adapter.send(chat_id, message) - if not result.success: - return {"error": f"WeCom send failed: {result.error}"} - return { - "success": True, - "platform": "wecom", - "chat_id": chat_id, - "message_id": result.message_id, - } - except Exception as e: - return {"error": f"WeCom live adapter send failed: {e}"} + return await _send_via(adapter, chat_id, message, live=True) - # Fallback: out-of-process — open ephemeral connection. if not check_wecom_requirements(): return {"error": "WeCom requirements not met. Need aiohttp + WECOM_BOT_ID/SECRET."} try: adapter = WeComAdapter(pconfig) - connected = await adapter.connect() - if not connected: + if not await adapter.connect(): return {"error": f"WeCom: failed to connect - {getattr(adapter, 'fatal_error_message', None) or 'unknown error'}"} try: - result = await adapter.send(chat_id, message) - if not result.success: - return {"error": f"WeCom send failed: {result.error}"} - return { - "success": True, - "platform": "wecom", - "chat_id": chat_id, - "message_id": result.message_id, - } + return await _send_via(adapter, chat_id, message, live=False) finally: await adapter.disconnect() except Exception as e: @@ -3413,21 +1096,11 @@ async def _standalone_send( def interactive_setup() -> None: - """Interactive setup for WeCom — QR scan or manual credential input. - - Replaces hermes_cli/gateway.py::_setup_wecom and the static - _PLATFORMS["wecom"] dict. CLI helpers are lazy-imported. - """ + """Interactive setup for WeCom — QR scan or manual credential input.""" from hermes_cli.config import get_env_value, remove_env_value, save_env_value from hermes_cli.setup import prompt_choice from hermes_cli.cli_output import ( - prompt, - prompt_yes_no, - print_header, - print_info, - print_success, - print_warning, - print_error, + prompt, prompt_yes_no, print_header, print_info, print_success, print_warning, ) print_header("WeCom (Enterprise WeChat)") @@ -3440,16 +1113,11 @@ def interactive_setup() -> None: method_idx = prompt_choice( "How would you like to set up WeCom?", - [ - "Scan QR code to obtain Bot ID and Secret automatically (recommended)", - "Enter existing Bot ID and Secret manually", - ], + ["Scan QR code to obtain Bot ID and Secret automatically (recommended)", "Enter existing Bot ID and Secret manually"], 0, ) - bot_id = None secret = None - if method_idx == 0: try: credentials = qr_scan_for_bot_info() @@ -3520,35 +1188,29 @@ def interactive_setup() -> None: if home: save_env_value("WECOM_HOME_CHANNEL", home) print_success(f"Home channel set to {home}") - else: - if remove_env_value("WECOM_HOME_CHANNEL"): - print_info("Home channel cleared.") + elif remove_env_value("WECOM_HOME_CHANNEL"): + print_info("Home channel cleared.") print_success("💬 WeCom configured!") def _is_connected(config) -> bool: - """WeCom (Smart Robot) is connected when a bot_id is configured. Mirrors the - legacy _PLATFORM_CONNECTED_CHECKERS[Platform.WECOM] entry.""" + """Connected when a bot_id is configured.""" extra = getattr(config, "extra", {}) or {} return bool(extra.get("bot_id")) def _callback_is_connected(config) -> bool: - """WeCom callback mode is connected when corp_id (or a multi-app `apps` - block) is configured. Mirrors the legacy - _PLATFORM_CONNECTED_CHECKERS[Platform.WECOM_CALLBACK] entry.""" + """Callback mode is connected when corp_id (or a multi-app `apps` block) is configured.""" extra = getattr(config, "extra", {}) or {} return bool(extra.get("corp_id") or extra.get("apps")) def _build_adapter(config): - """Factory wrapper that constructs WeComAdapter from a PlatformConfig.""" return WeComAdapter(config) def _build_callback_adapter(config): - """Factory wrapper that constructs WecomCallbackAdapter from a PlatformConfig.""" from plugins.platforms.wecom.callback_adapter import WecomCallbackAdapter return WecomCallbackAdapter(config) diff --git a/plugins/platforms/wecom/callback_adapter.py b/plugins/platforms/wecom/callback_adapter.py index dc2d682960..5b69ad47fd 100644 --- a/plugins/platforms/wecom/callback_adapter.py +++ b/plugins/platforms/wecom/callback_adapter.py @@ -1,12 +1,8 @@ """WeCom callback-mode adapter for self-built enterprise applications. -Unlike the bot/websocket adapter in ``wecom.py``, this handles the standard -WeCom callback flow: WeCom POSTs encrypted XML to an HTTP endpoint, the -adapter decrypts it, queues the message for the agent, and immediately -acknowledges. The agent's reply is delivered later via the proactive -``message/send`` API using an access-token. - -Supports multiple self-built apps under one gateway instance, scoped by +WeCom POSTs encrypted XML to an HTTP endpoint; we decrypt, queue for the agent +and ack immediately. Replies go out later via the proactive ``message/send`` +API with an access-token. Multiple apps per gateway are scoped by ``corp_id:user_id`` to avoid cross-corp collisions. """ @@ -17,10 +13,8 @@ import logging import socket as _socket import time from typing import Any, Dict, List, Optional -# Security: parse untrusted, pre-auth request bodies (WeCom callbacks) with -# defusedxml to block billion-laughs / entity-expansion (and XXE) DoS. The -# parsing API (fromstring) is a drop-in for the stdlib calls used below; -# response-building XML lives in wecom_crypto.py and is not parsed here. + +# Untrusted pre-auth bodies are parsed with defusedxml (billion-laughs / XXE). try: import defusedxml.ElementTree as ET @@ -51,43 +45,26 @@ from plugins.platforms.wecom.wecom_crypto import WXBizMsgCrypt, WeComCryptoError logger = logging.getLogger(__name__) -# ``None`` → aiohttp/asyncio ``create_server`` binds one listening socket per -# address family (IPv4 + IPv6). The old "0.0.0.0" default bound IPv4 ONLY and -# was unreachable over IPv6-only private networks (e.g. Fly.io 6PN) — same -# bug as the LINE adapter (NS-603) and gateway/platforms/webhook.py -# (d542894ad). Pin a host via WECOM_CALLBACK_HOST or extra.host. +# None → aiohttp binds one socket per address family (IPv4 + IPv6); "0.0.0.0" +# was unreachable on IPv6-only networks. Pin via WECOM_CALLBACK_HOST / extra.host. DEFAULT_HOST = None DEFAULT_PORT = 8645 DEFAULT_PATH = "/wecom/callback" -# Cap pre-auth request bodies. WeCom callbacks are small encrypted XML -# envelopes (media is delivered out-of-band via MediaId, never inline), so -# 64 KB is ample for any legitimate message while bounding the work an -# unauthenticated POST can force before signature verification. +# Pre-auth body cap: callbacks are small encrypted XML envelopes (media is +# out-of-band via MediaId), so 64 KB bounds unauthenticated work. _MAX_BODY = 65_536 ACCESS_TOKEN_TTL_SECONDS = 7200 MESSAGE_DEDUP_TTL_SECONDS = 300 def check_wecom_callback_requirements() -> bool: - """PASSIVE probe: are aiohttp/httpx/defusedxml importable right now? - - Registry ``check_fn`` — must never install anything. The ACTIVE - lazy-installer is ``ensure_wecom_callback_requirements`` below. - """ + """PASSIVE probe (registry ``check_fn``) — must never install anything.""" return AIOHTTP_AVAILABLE and HTTPX_AVAILABLE and DEFUSEDXML_AVAILABLE def ensure_wecom_callback_requirements() -> bool: - """ACTIVE lazy-installer for the ``platform.wecom_callback`` feature. - - Registered as ``ensure_deps_fn``: the registry's ``create_adapter()`` - runs it when the passive probe fails, right before the gateway connects - the platform (#79812). Installs ``defusedxml`` (the only non-core dep; - aiohttp/httpx ship with every messaging install) and rebinds the module - globals. Before this hook existed, the passive ``check_fn`` returned - False forever on installs without the ``wecom`` extra and the - ``platform.wecom_callback`` LAZY_DEPS entry was never exercised. - """ + """ACTIVE lazy-installer (``ensure_deps_fn``): installs ``defusedxml`` — the + only non-core dep — when the passive probe fails, and rebinds module globals.""" if check_wecom_callback_requirements(): return True @@ -109,7 +86,6 @@ class WecomCallbackAdapter(BasePlatformAdapter): def __init__(self, config: PlatformConfig): super().__init__(config, Platform.WECOM_CALLBACK) extra = config.extra or {} - # Falsy host (None/"") collapses to the dual-stack default. _raw_host = extra.get("host") or DEFAULT_HOST self._host = str(_raw_host) if _raw_host else None self._port = int(extra.get("port") or DEFAULT_PORT) @@ -125,10 +101,6 @@ class WecomCallbackAdapter(BasePlatformAdapter): self._user_app_map: Dict[str, str] = {} self._access_tokens: Dict[str, Dict[str, Any]] = {} - # ------------------------------------------------------------------ - # App normalisation - # ------------------------------------------------------------------ - @staticmethod def _user_app_key(corp_id: str, user_id: str) -> str: return f"{corp_id}:{user_id}" if corp_id else user_id @@ -139,29 +111,18 @@ class WecomCallbackAdapter(BasePlatformAdapter): if isinstance(apps, list) and apps: return [dict(app) for app in apps if isinstance(app, dict)] if extra.get("corp_id"): - return [ - { - "name": extra.get("name") or "default", - "corp_id": extra.get("corp_id", ""), - "corp_secret": extra.get("corp_secret", ""), - "agent_id": str(extra.get("agent_id", "")), - "token": extra.get("token", ""), - "encoding_aes_key": extra.get("encoding_aes_key", ""), - } - ] + return [{ + "name": extra.get("name") or "default", + "corp_id": extra.get("corp_id", ""), + "corp_secret": extra.get("corp_secret", ""), + "agent_id": str(extra.get("agent_id", "")), + "token": extra.get("token", ""), + "encoding_aes_key": extra.get("encoding_aes_key", ""), + }] return [] - # ------------------------------------------------------------------ - # Lifecycle - # ------------------------------------------------------------------ - async def connect(self, *, is_reconnect: bool = False) -> bool: - # ``is_reconnect`` is forwarded by GatewayRunner on every retry per - # the BasePlatformAdapter.connect contract. Callback adapters have - # no server-side queue to preserve, so the flag is accepted-and- - # ignored — but the kwarg MUST be present or the reconnect watcher - # dies with TypeError and the platform silently stays offline. - del is_reconnect + del is_reconnect # kwarg MUST exist (GatewayRunner passes it) even though unused if not self._apps: logger.warning("[WecomCallback] No callback apps configured") return False @@ -169,8 +130,7 @@ class WecomCallbackAdapter(BasePlatformAdapter): logger.warning("[WecomCallback] aiohttp/httpx not installed") return False - # Quick port-in-use check. - try: + try: # quick port-in-use check with _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM) as sock: sock.settimeout(1) sock.connect(("127.0.0.1", self._port)) @@ -180,11 +140,9 @@ class WecomCallbackAdapter(BasePlatformAdapter): pass try: - # Tighter keepalive so idle CLOSE_WAIT drains promptly (#18451). from gateway.platforms._http_client_limits import platform_httpx_limits self._http_client = httpx.AsyncClient(timeout=20.0, limits=platform_httpx_limits()) - # client_max_size rejects oversized bodies at the aiohttp layer - # (413) before our handler — and before any signature work — runs. + # client_max_size → 413 before our handler / any signature work runs. self._app = web.Application(client_max_size=_MAX_BODY) self._app.router.add_get("/health", self._handle_health) self._app.router.add_get(self._path, self._handle_verify) @@ -195,10 +153,7 @@ class WecomCallbackAdapter(BasePlatformAdapter): await self._site.start() self._poll_task = asyncio.create_task(self._poll_loop()) self._mark_connected() - logger.info( - "[WecomCallback] HTTP server listening on %s:%s%s", - self._host, self._port, self._path, - ) + logger.info("[WecomCallback] HTTP server listening on %s:%s%s", self._host, self._port, self._path) for app in self._apps: try: await self._refresh_access_token(app) @@ -236,10 +191,6 @@ class WecomCallbackAdapter(BasePlatformAdapter): await self._http_client.aclose() self._http_client = None - # ------------------------------------------------------------------ - # Outbound: proactive send via access-token API - # ------------------------------------------------------------------ - async def send( self, chat_id: str, @@ -266,8 +217,7 @@ class WecomCallbackAdapter(BasePlatformAdapter): data = resp.json() errcode = data.get("errcode") if errcode in {40001, 42001} and _attempt == 0: - # WeCom rejected the token — evict the cached entry so - # the next _get_access_token call forces a fresh fetch. + # Token rejected — evict so the next call fetches a fresh one. logger.warning( "[WecomCallback] Token rejected for app '%s' (errcode=%s), refreshing", app.get("name", "default"), errcode, @@ -276,11 +226,7 @@ class WecomCallbackAdapter(BasePlatformAdapter): continue if errcode != 0: return SendResult(success=False, error=str(data)) - return SendResult( - success=True, - message_id=str(data.get("msgid", "")), - raw_response=data, - ) + return SendResult(success=True, message_id=str(data.get("msgid", "")), raw_response=data) return SendResult(success=False, error="send failed after token refresh") except Exception as exc: return SendResult(success=False, error=str(exc)) @@ -288,8 +234,7 @@ class WecomCallbackAdapter(BasePlatformAdapter): def _resolve_app_for_chat(self, chat_id: str) -> Dict[str, Any]: """Pick the app associated with *chat_id*, falling back sensibly.""" app_name = self._user_app_map.get(chat_id) - if not app_name and ":" not in chat_id: - # Legacy bare user_id — try to find a unique match. + if not app_name and ":" not in chat_id: # legacy bare user_id — unique match only matching = [k for k in self._user_app_map if k.endswith(f":{chat_id}")] if len(matching) == 1: app_name = self._user_app_map.get(matching[0]) @@ -299,23 +244,16 @@ class WecomCallbackAdapter(BasePlatformAdapter): async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: return {"name": chat_id, "type": "dm"} - # ------------------------------------------------------------------ - # Inbound: HTTP callback handlers - # ------------------------------------------------------------------ - async def _handle_health(self, request: web.Request) -> web.Response: return web.json_response({"status": "ok", "platform": "wecom_callback"}) async def _handle_verify(self, request: web.Request) -> web.Response: """GET endpoint — WeCom URL verification handshake.""" - msg_signature = request.query.get("msg_signature", "") - timestamp = request.query.get("timestamp", "") - nonce = request.query.get("nonce", "") + msg_signature, timestamp, nonce = self._signature_params(request) echostr = request.query.get("echostr", "") for app in self._apps: try: - crypt = self._crypt_for_app(app) - plain = crypt.verify_url(msg_signature, timestamp, nonce, echostr) + plain = self._crypt_for_app(app).verify_url(msg_signature, timestamp, nonce, echostr) return web.Response(text=plain, content_type="text/plain") except Exception: continue @@ -323,11 +261,8 @@ class WecomCallbackAdapter(BasePlatformAdapter): async def _handle_callback(self, request: web.Request) -> web.Response: """POST endpoint — receive an encrypted message callback.""" - msg_signature = request.query.get("msg_signature", "") - timestamp = request.query.get("timestamp", "") - nonce = request.query.get("nonce", "") - # Explicit guard in addition to client_max_size: rejects oversized - # payloads before any XML parse / signature check (DoS, zip bombs). + msg_signature, timestamp, nonce = self._signature_params(request) + # Explicit guard in addition to client_max_size (DoS / zip bombs). body_bytes = await request.read() if len(body_bytes) > _MAX_BODY: logger.warning("[WecomCallback] Payload too large (%d bytes) — rejected", len(body_bytes)) @@ -336,34 +271,18 @@ class WecomCallbackAdapter(BasePlatformAdapter): for app in self._apps: try: - decrypted = self._decrypt_request( - app, body, msg_signature, timestamp, nonce, - ) + decrypted = self._decrypt_request(app, body, msg_signature, timestamp, nonce) event = self._build_event(app, decrypted) if event is not None: - # Deduplicate: WeCom retries callbacks on timeout, - # producing duplicate inbound messages (#10305). - if event.message_id: - now = time.time() - if event.message_id in self._seen_messages: - if now - self._seen_messages[event.message_id] < MESSAGE_DEDUP_TTL_SECONDS: - logger.debug("[WecomCallback] Duplicate MsgId %s, skipping", event.message_id) - return web.Response(text="success", content_type="text/plain") - del self._seen_messages[event.message_id] - self._seen_messages[event.message_id] = now - # Prune expired entries when cache grows large - if len(self._seen_messages) > 2000: - cutoff = now - MESSAGE_DEDUP_TTL_SECONDS - self._seen_messages = {k: v for k, v in self._seen_messages.items() if v > cutoff} - # Record which app this user belongs to. + # WeCom retries callbacks on timeout → duplicate inbound messages. + if event.message_id and self._is_duplicate(event.message_id): + logger.debug("[WecomCallback] Duplicate MsgId %s, skipping", event.message_id) + return web.Response(text="success", content_type="text/plain") if event.source and event.source.user_id: - map_key = self._user_app_key( - str(app.get("corp_id") or ""), event.source.user_id, - ) + map_key = self._user_app_key(str(app.get("corp_id") or ""), event.source.user_id) self._user_app_map[map_key] = app["name"] await self._message_queue.put(event) - # Immediately acknowledge — the agent's reply will arrive - # later via the proactive message/send API. + # Ack immediately — the reply arrives later via proactive message/send. return web.Response(text="success", content_type="text/plain") except WeComCryptoError: continue @@ -372,6 +291,23 @@ class WecomCallbackAdapter(BasePlatformAdapter): break return web.Response(status=400, text="invalid callback payload") + @staticmethod + def _signature_params(request: web.Request): + q = request.query + return q.get("msg_signature", ""), q.get("timestamp", ""), q.get("nonce", "") + + def _is_duplicate(self, message_id: str) -> bool: + now = time.time() + if message_id in self._seen_messages: + if now - self._seen_messages[message_id] < MESSAGE_DEDUP_TTL_SECONDS: + return True + del self._seen_messages[message_id] + self._seen_messages[message_id] = now + if len(self._seen_messages) > 2000: # prune expired entries + cutoff = now - MESSAGE_DEDUP_TTL_SECONDS + self._seen_messages = {k: v for k, v in self._seen_messages.items() if v > cutoff} + return False + async def _poll_loop(self) -> None: """Drain the message queue and dispatch to the gateway runner.""" while True: @@ -383,27 +319,16 @@ class WecomCallbackAdapter(BasePlatformAdapter): except Exception: logger.exception("[WecomCallback] Failed to enqueue event") - # ------------------------------------------------------------------ - # XML / crypto helpers - # ------------------------------------------------------------------ - - def _decrypt_request( - self, app: Dict[str, Any], body: str, - msg_signature: str, timestamp: str, nonce: str, - ) -> str: - root = ET.fromstring(body) - encrypt = root.findtext("Encrypt", default="") - crypt = self._crypt_for_app(app) - return crypt.decrypt(msg_signature, timestamp, nonce, encrypt).decode("utf-8") + def _decrypt_request(self, app: Dict[str, Any], body: str, msg_signature: str, timestamp: str, nonce: str) -> str: + encrypt = ET.fromstring(body).findtext("Encrypt", default="") + return self._crypt_for_app(app).decrypt(msg_signature, timestamp, nonce, encrypt).decode("utf-8") def _build_event(self, app: Dict[str, Any], xml_text: str) -> Optional[MessageEvent]: root = ET.fromstring(xml_text) msg_type = (root.findtext("MsgType") or "").lower() - # Silently acknowledge lifecycle events. - if msg_type == "event": - event_name = (root.findtext("Event") or "").lower() - if event_name in {"enter_agent", "subscribe"}: - return None + # Lifecycle events are silently acknowledged. + if msg_type == "event" and (root.findtext("Event") or "").lower() in {"enter_agent", "subscribe"}: + return None if msg_type not in {"text", "event"}: return None @@ -413,24 +338,9 @@ class WecomCallbackAdapter(BasePlatformAdapter): content = root.findtext("Content", default="").strip() if not content and msg_type == "event": content = "/start" - msg_id = ( - root.findtext("MsgId") - or f"{user_id}:{root.findtext('CreateTime', default='0')}" - ) - source = self.build_source( - chat_id=scoped_chat_id, - chat_name=user_id, - chat_type="dm", - user_id=user_id, - user_name=user_id, - ) - return MessageEvent( - text=content, - message_type=MessageType.TEXT, - source=source, - raw_message=xml_text, - message_id=msg_id, - ) + msg_id = root.findtext("MsgId") or f"{user_id}:{root.findtext('CreateTime', default='0')}" + source = self.build_source(chat_id=scoped_chat_id, chat_name=user_id, chat_type="dm", user_id=user_id, user_name=user_id) + return MessageEvent(text=content, message_type=MessageType.TEXT, source=source, raw_message=xml_text, message_id=msg_id) def _crypt_for_app(self, app: Dict[str, Any]) -> WXBizMsgCrypt: return WXBizMsgCrypt( @@ -440,16 +350,7 @@ class WecomCallbackAdapter(BasePlatformAdapter): ) def _get_app_by_name(self, name: Optional[str]) -> Optional[Dict[str, Any]]: - if not name: - return None - for app in self._apps: - if app.get("name") == name: - return app - return None - - # ------------------------------------------------------------------ - # Access-token management - # ------------------------------------------------------------------ + return next((app for app in self._apps if app.get("name") == name), None) if name else None async def _get_access_token(self, app: Dict[str, Any]) -> str: cached = self._access_tokens.get(app["name"]) @@ -461,24 +362,16 @@ class WecomCallbackAdapter(BasePlatformAdapter): async def _refresh_access_token(self, app: Dict[str, Any]) -> str: resp = await self._http_client.get( "https://qyapi.weixin.qq.com/cgi-bin/gettoken", - params={ - "corpid": app.get("corp_id"), - "corpsecret": app.get("corp_secret"), - }, + params={"corpid": app.get("corp_id"), "corpsecret": app.get("corp_secret")}, ) data = resp.json() if data.get("errcode") != 0: raise RuntimeError(f"WeCom token refresh failed: {data}") token = data["access_token"] expires_in = int(data.get("expires_in", ACCESS_TOKEN_TTL_SECONDS)) - self._access_tokens[app["name"]] = { - "token": token, - "expires_at": time.time() + expires_in, - } + self._access_tokens[app["name"]] = {"token": token, "expires_at": time.time() + expires_in} logger.info( "[WecomCallback] Token refreshed for app '%s' (corp=%s), expires in %ss", - app.get("name", "default"), - app.get("corp_id", ""), - expires_in, + app.get("name", "default"), app.get("corp_id", ""), expires_in, ) return token diff --git a/plugins/platforms/wecom/media.py b/plugins/platforms/wecom/media.py new file mode 100644 index 0000000000..f31b45c085 --- /dev/null +++ b/plugins/platforms/wecom/media.py @@ -0,0 +1,471 @@ +"""WeCom media: inbound attachment caching and outbound upload/send. + +Mixed into :class:`WeComAdapter`. Outbound media goes through the chunked +``aibot_upload_media_*`` flow and is then sent natively (image/video/voice/file). +""" + +from __future__ import annotations + +import asyncio +import base64 +import hashlib +import logging +import mimetypes +import re +import uuid +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple +from urllib.parse import unquote, urlparse + +from gateway.platforms.base import SendResult, cache_document_from_bytes, cache_image_from_bytes + +logger = logging.getLogger("plugins.platforms.wecom.adapter") + +APP_CMD_SEND = "aibot_send_msg" +APP_CMD_UPLOAD_MEDIA_INIT = "aibot_upload_media_init" +APP_CMD_UPLOAD_MEDIA_CHUNK = "aibot_upload_media_chunk" +APP_CMD_UPLOAD_MEDIA_FINISH = "aibot_upload_media_finish" + +IMAGE_MAX_BYTES = 10 * 1024 * 1024 +VIDEO_MAX_BYTES = 10 * 1024 * 1024 +VOICE_MAX_BYTES = 2 * 1024 * 1024 +FILE_MAX_BYTES = 20 * 1024 * 1024 +ABSOLUTE_MAX_BYTES = FILE_MAX_BYTES +UPLOAD_CHUNK_SIZE = 512 * 1024 +MAX_UPLOAD_CHUNKS = 100 +VOICE_SUPPORTED_MIMES = {"audio/amr"} + + +def _size_verdict(final_type: str, *, reject: Optional[str] = None, downgrade: Optional[str] = None) -> Dict[str, Any]: + return { + "final_type": final_type, + "rejected": reject is not None, + "reject_reason": reject, + "downgraded": downgrade is not None, + "downgrade_note": downgrade, + } + + +class WeComMediaMixin: + """Media helpers for WeComAdapter (expects ``_http_client``, ``_send_request``, + ``_send_reply_request``, ``send``, ``_reply_req_id_for_message``, + ``_last_chat_req_ids``, ``_stream_expired_chats``, ``_find_active_turn_for_chat``).""" + + # ── Inbound ────────────────────────────────────────────────────────── + + async def _extract_media(self, body: Dict[str, Any]) -> Tuple[List[str], List[str]]: + """Best-effort extraction of inbound media to local cache paths.""" + refs: List[Tuple[str, Dict[str, Any]]] = [] + msgtype = str(body.get("msgtype") or "").lower() + + def _ref(kind: str, container: Dict[str, Any]) -> bool: + if isinstance(container.get(kind), dict): + refs.append((kind, container[kind])) + return True + return False + + if msgtype == "mixed": + mixed = body.get("mixed") if isinstance(body.get("mixed"), dict) else {} + items = mixed.get("msg_item") if isinstance(mixed.get("msg_item"), list) else [] + for item in items: + if isinstance(item, dict) and str(item.get("msgtype") or "").lower() == "image": + _ref("image", item) + else: + _ref("image", body) + if msgtype == "file": + _ref("file", body) + # appmsg = WeCom AI Bot attachments (PDF/Word/Excel) + if msgtype == "appmsg" and isinstance(body.get("appmsg"), dict): + _ref("file", body["appmsg"]) or _ref("image", body["appmsg"]) + + quote = body.get("quote") if isinstance(body.get("quote"), dict) else {} + quote_type = str(quote.get("msgtype") or "").lower() + if quote_type in ("image", "file"): + _ref(quote_type, quote) + + media_paths: List[str] = [] + media_types: List[str] = [] + for kind, ref in refs: + cached = await self._cache_media(kind, ref) + if cached: + media_paths.append(cached[0]) + media_types.append(cached[1]) + return media_paths, media_types + + async def _cache_media(self, kind: str, media: Dict[str, Any]) -> Optional[Tuple[str, str]]: + """Cache an inbound image/file reference (inline base64 or URL) to local storage.""" + if media.get("base64"): + try: + raw = self._decode_base64(media["base64"]) + except Exception as exc: + logger.debug("[%s] Failed to decode %s base64 media: %s", self.name, kind, exc) + return None + if kind == "image": + ext = self._detect_image_ext(raw) + return self._cache_image(raw, ext, self._mime_for_ext(ext, fallback="image/jpeg"), "") + filename = str(media.get("filename") or media.get("name") or "wecom_file") + return cache_document_from_bytes(raw, filename), mimetypes.guess_type(filename)[0] or "application/octet-stream" + + url = str(media.get("url") or "").strip() + if not url: + return None + try: + raw, headers = await self._download_remote_bytes(url, max_bytes=ABSOLUTE_MAX_BYTES) + except Exception as exc: + logger.debug("[%s] Failed to download %s from %s: %s", self.name, kind, url, exc) + return None + aes_key = str(media.get("aeskey") or "").strip() + if aes_key: + try: + raw = self._decrypt_file_bytes(raw, aes_key) + except Exception as exc: + logger.debug("[%s] Failed to decrypt %s from %s: %s", self.name, kind, url, exc) + return None + content_type = str(headers.get("content-type") or "").split(";", 1)[0].strip() or "application/octet-stream" + if kind == "image": + ext = self._guess_extension(url, content_type, fallback=self._detect_image_ext(raw)) + return self._cache_image(raw, ext, content_type or self._mime_for_ext(ext, fallback="image/jpeg"), f" from {url}") + filename = self._guess_filename(url, headers.get("content-disposition"), content_type) + return cache_document_from_bytes(raw, filename), content_type + + def _cache_image(self, raw: bytes, ext: str, mime: str, origin: str) -> Optional[Tuple[str, str]]: + try: + return cache_image_from_bytes(raw, ext), mime + except ValueError as exc: + logger.warning("[%s] Rejected non-image bytes%s: %s", self.name, origin, exc) + return None + + @staticmethod + def _decode_base64(data: str) -> bytes: + return base64.b64decode(data.split(",", 1)[-1].strip()) + + @staticmethod + def _detect_image_ext(data: bytes) -> str: + if data.startswith(b"\x89PNG\r\n\x1a\n"): + return ".png" + if data.startswith(b"\xff\xd8\xff"): + return ".jpg" + if data.startswith((b"GIF87a", b"GIF89a")): + return ".gif" + if data.startswith(b"RIFF") and data[8:12] == b"WEBP": + return ".webp" + return ".jpg" + + @staticmethod + def _mime_for_ext(ext: str, fallback: str = "application/octet-stream") -> str: + return mimetypes.types_map.get(ext.lower(), fallback) + + @staticmethod + def _guess_extension(url: str, content_type: str, fallback: str) -> str: + ext = mimetypes.guess_extension(content_type) if content_type else None + return ext or Path(urlparse(url).path).suffix or fallback + + @staticmethod + def _guess_filename(url: str, content_disposition: Optional[str], content_type: str) -> str: + if content_disposition: + match = re.search(r'filename="?([^";]+)"?', content_disposition) + if match: + return match.group(1) + name = Path(urlparse(url).path).name or "document" + if "." not in name: + name = f"{name}{mimetypes.guess_extension(content_type) or '.bin'}" + return name + + @staticmethod + def _decrypt_file_bytes(encrypted_data: bytes, aes_key: str) -> bytes: + if not encrypted_data: + raise ValueError("encrypted_data is empty") + if not aes_key: + raise ValueError("aes_key is required") + # WeCom doesn't pad base64 keys; add padding if needed + aes_key = aes_key + '=' * ((4 - len(aes_key) % 4) % 4) + key = base64.b64decode(aes_key) + if len(key) != 32: + raise ValueError(f"Invalid WeCom AES key length: expected 32 bytes, got {len(key)}") + try: + from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + except ImportError as exc: # pragma: no cover - dependency is environment-specific + raise RuntimeError("cryptography is required for WeCom media decryption") from exc + decryptor = Cipher(algorithms.AES(key), modes.CBC(key[:16])).decryptor() + decrypted = decryptor.update(encrypted_data) + decryptor.finalize() + pad_len = decrypted[-1] + if pad_len < 1 or pad_len > 32 or pad_len > len(decrypted): + raise ValueError(f"Invalid PKCS#7 padding value: {pad_len}") + if any(byte != pad_len for byte in decrypted[-pad_len:]): + raise ValueError("Invalid PKCS#7 padding: padding bytes mismatch") + return decrypted[:-pad_len] + + async def _download_remote_bytes(self, url: str, max_bytes: int) -> Tuple[bytes, Dict[str, str]]: + from gateway.platforms.base import _ssrf_redirect_guard + from tools.url_safety import create_ssrf_safe_async_client, is_safe_url + from plugins.platforms.wecom import adapter as _adapter_mod + + if not is_safe_url(url): + raise ValueError(f"Blocked unsafe URL (SSRF protection): {url[:80]}") + if not _adapter_mod.HTTPX_AVAILABLE: + raise RuntimeError("httpx is required for WeCom media download") + + client = self._http_client or create_ssrf_safe_async_client( + timeout=30.0, follow_redirects=True, event_hooks={"response": [_ssrf_redirect_guard]}, + ) + created_client = client is not self._http_client + try: + async with client.stream( + "GET", url, headers={"User-Agent": "HermesAgent/1.0", "Accept": "*/*"}, + ) as response: + response.raise_for_status() + headers = {key.lower(): value for key, value in response.headers.items()} + content_length = headers.get("content-length") + if content_length and content_length.isdigit() and int(content_length) > max_bytes: + raise ValueError( + f"Remote media exceeds WeCom limit: {int(content_length)} bytes > {max_bytes} bytes" + ) + data = bytearray() + async for chunk in response.aiter_bytes(): + data.extend(chunk) + if len(data) > max_bytes: + raise ValueError( + f"Remote media exceeds WeCom limit while downloading: {len(data)} bytes > {max_bytes} bytes" + ) + return bytes(data), headers + finally: + if created_client: + await client.aclose() + + # ── Outbound classification ────────────────────────────────────────── + + @staticmethod + def _guess_mime_type(filename: str) -> str: + mime_type = mimetypes.guess_type(filename)[0] + if mime_type: + return mime_type + if Path(filename).suffix.lower() == ".amr": + return "audio/amr" + return "application/octet-stream" + + @staticmethod + def _normalize_content_type(content_type: str, filename: str) -> str: + normalized = str(content_type or "").split(";", 1)[0].strip().lower() + if not normalized or normalized in {"application/octet-stream", "text/plain"}: + return WeComMediaMixin._guess_mime_type(filename) + return normalized + + @staticmethod + def _detect_wecom_media_type(content_type: str) -> str: + mime_type = str(content_type or "").strip().lower() + if mime_type.startswith("image/"): + return "image" + if mime_type.startswith("video/"): + return "video" + if mime_type.startswith("audio/") or mime_type == "application/ogg": + return "voice" + return "file" + + @staticmethod + def _apply_file_size_limits(file_size: int, detected_type: str, content_type: Optional[str] = None) -> Dict[str, Any]: + file_size_mb = file_size / (1024 * 1024) + normalized_type = str(detected_type or "file").lower() + normalized_content_type = str(content_type or "").strip().lower() + if file_size > ABSOLUTE_MAX_BYTES: + return _size_verdict(normalized_type, reject=( + f"文件大小 {file_size_mb:.2f}MB 超过了企业微信允许的最大限制 20MB,无法发送。" + "请尝试压缩文件或减小文件大小。" + )) + if normalized_type == "image" and file_size > IMAGE_MAX_BYTES: + return _size_verdict("file", downgrade=f"图片大小 {file_size_mb:.2f}MB 超过 10MB 限制,已转为文件格式发送") + if normalized_type == "video" and file_size > VIDEO_MAX_BYTES: + return _size_verdict("file", downgrade=f"视频大小 {file_size_mb:.2f}MB 超过 10MB 限制,已转为文件格式发送") + if normalized_type == "voice": + if normalized_content_type and normalized_content_type not in VOICE_SUPPORTED_MIMES: + return _size_verdict("file", downgrade=( + f"语音格式 {normalized_content_type} 不支持,企微仅支持 AMR 格式,已转为文件格式发送" + )) + if file_size > VOICE_MAX_BYTES: + return _size_verdict("file", downgrade=f"语音大小 {file_size_mb:.2f}MB 超过 2MB 限制,已转为文件格式发送") + return _size_verdict(normalized_type) + + @staticmethod + def _looks_like_url(media_source: str) -> bool: + return urlparse(str(media_source or "")).scheme in {"http", "https"} + + async def _load_outbound_media(self, media_source: str, file_name: Optional[str] = None) -> Tuple[bytes, str, str]: + source = str(media_source or "").strip() + if not source: + raise ValueError("media source is required") + if re.fullmatch(r"<[^>\n]+>", source): + raise ValueError(f"Media placeholder was not replaced with a real file path: {source}") + + parsed = urlparse(source) + if parsed.scheme in {"http", "https"}: + data, headers = await self._download_remote_bytes(source, max_bytes=ABSOLUTE_MAX_BYTES) + content_disposition = headers.get("content-disposition") + resolved_name = file_name or self._guess_filename(source, content_disposition, headers.get("content-type", "")) + content_type = self._normalize_content_type(headers.get("content-type", ""), resolved_name) + return data, content_type, resolved_name + + local_path = Path(unquote(parsed.path) if parsed.scheme == "file" else source).expanduser() + if not local_path.is_absolute(): + local_path = (Path.cwd() / local_path).resolve() + if not local_path.exists() or not local_path.is_file(): + raise FileNotFoundError(f"Media file not found: {local_path}") + data = local_path.read_bytes() + resolved_name = file_name or local_path.name + return data, self._normalize_content_type("", resolved_name), resolved_name + + async def _prepare_outbound_media(self, media_source: str, file_name: Optional[str] = None) -> Dict[str, Any]: + data, content_type, resolved_name = await self._load_outbound_media(media_source, file_name=file_name) + detected_type = self._detect_wecom_media_type(content_type) + size_check = self._apply_file_size_limits(len(data), detected_type, content_type) + return {"data": data, "content_type": content_type, "file_name": resolved_name, "detected_type": detected_type, **size_check} + + # ── Outbound upload + send ─────────────────────────────────────────── + + async def _upload_media_bytes(self, data: bytes, media_type: str, filename: str) -> Dict[str, Any]: + if not data: + raise ValueError("Cannot upload empty media") + total_size = len(data) + total_chunks = (total_size + UPLOAD_CHUNK_SIZE - 1) // UPLOAD_CHUNK_SIZE + if total_chunks > MAX_UPLOAD_CHUNKS: + raise ValueError(f"File too large: {total_chunks} chunks exceeds maximum of {MAX_UPLOAD_CHUNKS} chunks") + + init_response = await self._send_request(APP_CMD_UPLOAD_MEDIA_INIT, { + "type": media_type, "filename": filename, "total_size": total_size, "total_chunks": total_chunks, + "md5": hashlib.md5(data).hexdigest(), + }) + self._raise_for_wecom_error(init_response, "media upload init") + init_body = init_response.get("body") if isinstance(init_response.get("body"), dict) else {} + upload_id = str(init_body.get("upload_id") or "").strip() + if not upload_id: + raise RuntimeError(f"media upload init failed: missing upload_id in response {init_response}") + + for chunk_index, start in enumerate(range(0, total_size, UPLOAD_CHUNK_SIZE)): + chunk_response = await self._send_request(APP_CMD_UPLOAD_MEDIA_CHUNK, { + "upload_id": upload_id, + "chunk_index": chunk_index, # official SDK uses 0-based chunk indexes + "base64_data": base64.b64encode(data[start : start + UPLOAD_CHUNK_SIZE]).decode("ascii"), + }) + self._raise_for_wecom_error(chunk_response, f"media upload chunk {chunk_index}") + + finish_response = await self._send_request(APP_CMD_UPLOAD_MEDIA_FINISH, {"upload_id": upload_id}) + self._raise_for_wecom_error(finish_response, "media upload finish") + finish_body = finish_response.get("body") if isinstance(finish_response.get("body"), dict) else {} + media_id = str(finish_body.get("media_id") or "").strip() + if not media_id: + raise RuntimeError(f"media upload finish failed: missing media_id in response {finish_response}") + return {"type": str(finish_body.get("type") or media_type), "media_id": media_id, "created_at": finish_body.get("created_at")} + + async def _send_media_message(self, chat_id: str, media_type: str, media_id: str) -> Dict[str, Any]: + response = await self._send_request( + APP_CMD_SEND, {"chatid": chat_id, "msgtype": media_type, media_type: {"media_id": media_id}}, + ) + self._raise_for_wecom_error(response, "send media message") + return response + + async def _send_reply_media_message(self, reply_req_id: str, media_type: str, media_id: str) -> Dict[str, Any]: + response = await self._send_reply_request(reply_req_id, {"msgtype": media_type, media_type: {"media_id": media_id}}) + self._raise_for_wecom_error(response, "send reply media message") + return response + + async def _send_followup_markdown(self, chat_id: str, content: str, reply_to: Optional[str] = None) -> Optional[SendResult]: + if not content: + return None + result = await self.send(chat_id=chat_id, content=content, reply_to=reply_to) + if not result.success: + logger.warning("[%s] Follow-up markdown send failed: %s", self.name, result.error) + return result + + async def _send_media_source( + self, chat_id: str, media_source: str, caption: Optional[str] = None, file_name: Optional[str] = None, + reply_to: Optional[str] = None, + ) -> SendResult: + if not chat_id: + return SendResult(success=False, error="chat_id is required") + try: + prepared = await self._prepare_outbound_media(media_source, file_name=file_name) + except FileNotFoundError as exc: + return SendResult(success=False, error=str(exc)) + except Exception as exc: + logger.error("[%s] Failed to prepare outbound media %s: %s", self.name, media_source, exc) + return SendResult(success=False, error=str(exc)) + + if prepared["rejected"]: + await self._send_followup_markdown(chat_id, f"⚠️ {prepared['reject_reason']}", reply_to=reply_to) + return SendResult(success=False, error=prepared["reject_reason"]) + + reply_req_id = self._reply_req_id_for_message(reply_to) + if not reply_req_id and chat_id in self._last_chat_req_ids: + reply_req_id = self._last_chat_req_ids[chat_id] + # Media MUST use the proactive path when a stream was/is active for the + # chat: passive replyMedia cannot overwrite a replyStream thinking bubble + # and the stream "owns" the req_id (server ignores or never acks). + if self._find_active_turn_for_chat(chat_id) or chat_id in self._stream_expired_chats: + reply_req_id = None + + try: + upload_result = await self._upload_media_bytes(prepared["data"], prepared["final_type"], prepared["file_name"]) + logger.info("[%s] upload_media_bytes OK: media_id=%s type=%s", self.name, upload_result.get("media_id"), prepared["final_type"]) + if reply_req_id: + media_response = await self._send_reply_media_message(reply_req_id, prepared["final_type"], upload_result["media_id"]) + logger.info("[%s] send_reply_media OK: %s", self.name, media_response) + else: + media_response = await self._send_media_message(chat_id, prepared["final_type"], upload_result["media_id"]) + logger.info("[%s] send_media_message OK: %s", self.name, media_response) + except asyncio.TimeoutError: + logger.error("[%s] TIMEOUT in _send_media_source for %s", self.name, media_source) + return SendResult(success=False, error="Timeout sending media to WeCom") + except Exception as exc: + logger.error("[%s] Failed to send media %s: %s", self.name, media_source, exc) + return SendResult(success=False, error=str(exc)) + + caption_result = downgrade_result = None + if caption: + caption_result = await self._send_followup_markdown(chat_id, caption, reply_to=reply_to) + if prepared["downgraded"] and prepared["downgrade_note"]: + downgrade_result = await self._send_followup_markdown(chat_id, f"ℹ️ {prepared['downgrade_note']}", reply_to=reply_to) + + return SendResult( + success=True, + message_id=self._payload_req_id(media_response) or uuid.uuid4().hex[:12], + raw_response={ + "upload": upload_result, + "media": media_response, + "caption": caption_result.raw_response if caption_result else None, + "caption_error": caption_result.error if caption_result and not caption_result.success else None, + "downgrade": downgrade_result.raw_response if downgrade_result else None, + "downgrade_error": downgrade_result.error if downgrade_result and not downgrade_result.success else None, + }, + ) + + async def send_image( + self, chat_id: str, image_url: str, caption: Optional[str] = None, reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + del metadata + result = await self._send_media_source(chat_id=chat_id, media_source=image_url, caption=caption, reply_to=reply_to) + if result.success or not self._looks_like_url(image_url): + return result + logger.warning("[%s] Falling back to text send for image URL %s: %s", self.name, image_url, result.error) + fallback_text = f"{caption}\n{image_url}" if caption else image_url + return await self.send(chat_id=chat_id, content=fallback_text, reply_to=reply_to) + + async def send_image_file(self, chat_id: str, image_path: str, caption: Optional[str] = None, reply_to: Optional[str] = None, **kwargs) -> SendResult: + del kwargs + return await self._send_media_source(chat_id=chat_id, media_source=image_path, caption=caption, reply_to=reply_to) + + async def send_document( + self, chat_id: str, file_path: str, caption: Optional[str] = None, file_name: Optional[str] = None, + reply_to: Optional[str] = None, **kwargs, + ) -> SendResult: + del kwargs + logger.info("[%s] send_document called: chat=%s file=%s", self.name, chat_id, file_path) + return await self._send_media_source( + chat_id=chat_id, media_source=file_path, caption=caption, file_name=file_name, reply_to=reply_to, + ) + + async def send_voice(self, chat_id: str, audio_path: str, caption: Optional[str] = None, reply_to: Optional[str] = None, **kwargs) -> SendResult: + del kwargs + return await self._send_media_source(chat_id=chat_id, media_source=audio_path, caption=caption, reply_to=reply_to) + + async def send_video(self, chat_id: str, video_path: str, caption: Optional[str] = None, reply_to: Optional[str] = None, **kwargs) -> SendResult: + del kwargs + return await self._send_media_source(chat_id=chat_id, media_source=video_path, caption=caption, reply_to=reply_to) diff --git a/plugins/platforms/wecom/send_queue.py b/plugins/platforms/wecom/send_queue.py new file mode 100644 index 0000000000..435da40bc3 --- /dev/null +++ b/plugins/platforms/wecom/send_queue.py @@ -0,0 +1,108 @@ +"""Per-chat FIFO send queues with token-bucket rate limiting for WeCom. + +Mirrors OpenClaw's chat-queue.ts (serial per chat) plus a token bucket that +keeps each chat under WeCom's 30 msgs/min/chat limit (errcode 846607). Two +lanes per chat: a normal lane and a high-priority control lane (approval +prompts, finalize frames, error notices) backed by a reserved token pool. +""" + +from __future__ import annotations + +import asyncio +import logging +import time +from typing import Dict + +logger = logging.getLogger("plugins.platforms.wecom.adapter") + + +class ChatSendQueueMixin: + """Expects ``_chat_queues/_chat_workers/_control_queues/_control_workers/_chat_token_usage`` dicts.""" + + # Token bucket: 30 tokens/min per chat, split between normal and reserved (control) quota. + _BUCKET_MAX_TOKENS = 30 + _BUCKET_NORMAL_TOKENS = 24 + _BUCKET_RESERVED_TOKENS = 6 + + def _get_token_usage(self, chat_id: str) -> Dict[str, float]: + """Get or create token usage tracking for a chat.""" + key = str(chat_id or "").strip() + if key not in self._chat_token_usage: + self._chat_token_usage[key] = {"normal": 0.0, "reserved": 0.0, "last_reset": time.monotonic()} + return self._chat_token_usage[key] + + def _bucket_try_consume(self, chat_id: str, is_control: bool = False) -> float: + """Consume one token. Returns 0 if available, else seconds until the next minute window. + + Normal messages only use the normal quota; control messages use normal + quota first (don't waste reserved), then the reserved pool. + """ + usage = self._get_token_usage(chat_id) + now = time.monotonic() + if now - usage["last_reset"] > 60.0: # reset counters every minute + usage["normal"] = 0.0 + usage["reserved"] = 0.0 + usage["last_reset"] = now + if usage["normal"] < self._BUCKET_NORMAL_TOKENS: + usage["normal"] += 1.0 + return 0.0 + if is_control and usage["reserved"] < self._BUCKET_RESERVED_TOKENS: + usage["reserved"] += 1.0 + return 0.0 + return 60.0 - (now - usage["last_reset"]) + + async def _enqueue_chat_send(self, chat_id: str, coro_factory, is_control: bool = False): + """Enqueue a send task for a chat and await its result (FIFO per chat, parallel across chats). + + Control-lane sends bypass the normal queue so approval prompts are never blocked. + """ + key = str(chat_id or "").strip() + lane = "control" if is_control else "normal" + queues = self._control_queues if is_control else self._chat_queues + if key not in queues: + logger.debug("[%s] Creating %s queue + worker for chat %s", self.name, lane, key) + queues[key] = asyncio.Queue() + workers = self._control_workers if is_control else self._chat_workers + workers[key] = asyncio.create_task(self._send_worker(key, is_control)) + queue = queues[key] + logger.debug("[%s] Enqueuing send for chat %s (lane=%s, qsize=%d)", self.name, key, lane, queue.qsize()) + future = asyncio.get_running_loop().create_future() + await queue.put((coro_factory, future)) + return await future + + async def _send_worker(self, chat_key: str, is_control: bool) -> None: + """Per-chat worker: drain one lane's queue under the token bucket.""" + if is_control: + queue = self._control_queues[chat_key] + else: + queue = self._chat_queues[chat_key] + logger.debug("[%s] Normal send worker started for chat %s", self.name, chat_key) + try: + while True: + coro_factory, future = await queue.get() + try: + wait = self._bucket_try_consume(chat_key, is_control) + if wait > 0: + if not is_control: + logger.debug( + "[%s] Normal worker rate-limited for chat %s, waiting %.1fs", + self.name, chat_key, wait, + ) + await asyncio.sleep(wait) + self._bucket_try_consume(chat_key, is_control) # re-consume after wait + result = await coro_factory() + if not future.done(): + future.set_result(result) + except Exception as exc: + if not future.done(): + future.set_exception(exc) + finally: + queue.task_done() + except asyncio.CancelledError: + while not queue.empty(): + try: + _, future = queue.get_nowait() + if not future.done(): + future.set_exception(RuntimeError("WeCom adapter shutting down")) + except asyncio.QueueEmpty: + break diff --git a/plugins/platforms/wecom/streaming.py b/plugins/platforms/wecom/streaming.py new file mode 100644 index 0000000000..6c06d2a6c5 --- /dev/null +++ b/plugins/platforms/wecom/streaming.py @@ -0,0 +1,562 @@ +"""WeCom native streaming (``msgtype: stream`` via aibot_respond_msg). + +Per-turn stream state, per-req_id ack tracking (official SDK's +replyStreamNonBlocking semantics), the stream-level keep-alive heartbeat and +the finalize clock fallback. Mixed into :class:`WeComAdapter`. +""" + +from __future__ import annotations + +import asyncio +import logging +import time +import uuid +from dataclasses import dataclass +from typing import Any, Dict, Optional + +logger = logging.getLogger("plugins.platforms.wecom.adapter") + +APP_CMD_RESPONSE = "aibot_respond_msg" + +# WeCom binds a ~6-minute lifetime to each reply stream (stream_id + req_id); +# the connection-level ping does NOT refresh it. Past that window updates come +# back 846608 (stream update window) / 846604 (req_id reply-request window) — +# both mean the reply flow is dead and further frames will be rejected. +STREAM_EXPIRED_ERRCODE = 846608 +STREAM_REQUEST_EXPIRED_ERRCODE = 846604 +STREAM_NOT_SUBSCRIBED_ERRCODE = 846609 # ws connection lost the subscription +# 6000 = finalize raced a newer frame on the same stream_id: the bubble was +# ALREADY replaced, so for a finalize frame this is benign, not a failure. +STREAM_VERSION_CONFLICT_ERRCODE = 6000 +MAX_STREAM_CONTENT_LENGTH = 20480 # WeCom server-enforced byte limit per frame +# WeCom SDK has a 100-frame per-reqId queue; cap intermediates at 85 (matches +# the openclaw plugin) so the finalize frame always has room. Past the cap +# intermediates are silently dropped — finalize still sends unconditionally. +MAX_INTERMEDIATE_FRAMES = 85 + +# Two independent defences against the 6-min stream window, both defaulting +# to the safe side (see docs/wecom-stream-keepalive-*.md): +# Layer 2 — clock fallback (always on): finalize declines the finish=true +# frame once the stream is older than STREAM_SAFE_DURATION_SECONDS and +# returns False so the consumer's send() fallback delivers the content. +# Layer 1 — keep-alive heartbeat (OFF by default): every +# STREAM_KEEPALIVE_INTERVAL_SECONDS re-send the accumulated text as a +# finish=false frame. Never sends a placeholder. Off by default because an +# extra intermediate frame widens the ack race the double-send +# coordination depends on. +STREAM_SAFE_DURATION_SECONDS = 330.0 +STREAM_KEEPALIVE_INTERVAL_SECONDS = 120.0 +STREAM_KEEPALIVE_ENABLED_DEFAULT = False + + +class WeComStreamExpiredError(RuntimeError): + """Raised on errcode 846608/846604: the stream/req_id reply flow is dead. + + Callers must fall back to a proactive ``aibot_send_msg``. + """ + + def __init__(self, errcode: int = STREAM_EXPIRED_ERRCODE, errmsg: str = ""): + super().__init__(f"WeCom stream expired (errcode={errcode}): {errmsg or 'no detail'}") + self.errcode = errcode + self.errmsg = errmsg + + +@dataclass +class ReplyFrame: + """A reply frame awaiting its aibot_respond_msg ack (FIFO per req_id).""" + body: Dict[str, Any] + future: asyncio.Future + is_final: bool = False + sent_at: Optional[float] = None + + +class ReplyQueue: + """Per-req_id pending-ack tracker: intermediates skip while an ack is pending, finals wait.""" + def __init__(self, req_id: str): + self.req_id = req_id + self.pending_ack: Optional[ReplyFrame] = None + + +class StreamTurn: + """Per-turn stream state so concurrent messages never share a stream.""" + def __init__(self, chat_id: str, req_id: str): + self.chat_id = chat_id + self.req_id = req_id + self.stream_id = f"stream_{uuid.uuid4().hex[:12]}" + self.accumulated_text = "" + self.finalized = False + self.seeded = False # seed frame sent (prevents double seed → errcode 6000) + self.start_time = time.monotonic() + self.expired = False + # Last content ACTUALLY sent (not skipped) — finalize uses it to avoid a + # duplicate-content final frame that WeCom silently drops. + self.last_sent_content: str = "" + self._intermediate_frames_sent: int = 0 + # Keep-alive TimerHandle; MUST be cancelled on every turn-exit path + # (finalize / expired / error / cleanup) so it never fires on a dead turn. + self.keepalive_handle: Optional[asyncio.TimerHandle] = None + + +def _stream_of(body: Dict[str, Any]) -> Dict[str, Any]: + return body.get("stream", {}) if isinstance(body.get("stream"), dict) else {} + + +class WeComStreamMixin: + """Native streaming for WeComAdapter (expects ``_ws``, ``_send_json``, ``_reply_queues``, + ``_stream_turns``, ``_stream_expired_chats``, ``_last_chat_req_ids`` and the + ``_stream_*`` config attributes set in ``__init__``).""" + + MAX_STREAM_CONTENT_LENGTH = MAX_STREAM_CONTENT_LENGTH + + # Ack timeout matches the official plugin's REPLY_SEND_TIMEOUT_MS = 15_000; a + # shorter window widened the race where the final-frame ack is still in + # flight while the gateway's normal final send fires → duplicate messages. + _REPLY_ACK_TIMEOUT = 15.0 + + # ── Per-req_id reply queue (ack tracking) ──────────────────────────── + + async def _send_reply_queued( + self, reply_req_id: str, body: Dict[str, Any], *, is_final: bool = False, skip_if_pending: bool = False, + ) -> Dict[str, Any]: + """Send a reply via aibot_respond_msg with per-req_id ack tracking. + + is_final: wait for any pending ack before sending, then await our own ack. + skip_if_pending: return ``{"skipped": True}`` if a prior frame's ack is pending. + """ + if not self._ws or self._ws.closed: + raise RuntimeError("WeCom websocket is not connected") + normalized = str(reply_req_id or "").strip() + if not normalized: + raise ValueError("reply_req_id is required") + + queue = self._reply_queues.get(normalized) + if queue is None: + queue = ReplyQueue(normalized) + self._reply_queues[normalized] = queue + + if skip_if_pending and queue.pending_ack is not None: + return {"skipped": True, "errcode": 0, "errmsg": "pending_ack"} + + if is_final and queue.pending_ack is not None: + pending_frame = queue.pending_ack + _pending_stream = _stream_of(pending_frame.body) + pending_desc = (self.name, normalized, _pending_stream.get("id", "N/A"), _pending_stream.get("finish", "N/A")) + logger.debug( + "[%s] _send_reply_queued: final waiting for pending ack drain — " + "req_id=%s pending_stream_id=%s pending_finish=%s pending_sent_at=%.1fs_ago", + *pending_desc, time.monotonic() - (pending_frame.sent_at or time.monotonic()), + ) + try: + await asyncio.wait_for(asyncio.shield(pending_frame.future), timeout=self._REPLY_ACK_TIMEOUT) + except asyncio.TimeoutError: + logger.warning( + "[%s] Reply ack timeout waiting for pending (req_id=%s) — " + "pending_stream_id=%s pending_finish=%s elapsed=%.1fs. " + "Possible causes: ack cmd filtered, ack req_id mismatch, or WeCom did not ack.", + *pending_desc, time.monotonic() - (pending_frame.sent_at or time.monotonic()), + ) + except Exception: + pass + queue.pending_ack = None # resolved or timed out either way + + future: asyncio.Future = asyncio.get_running_loop().create_future() + frame = ReplyFrame(body=body, future=future, is_final=is_final) + frame.sent_at = time.monotonic() + # Register pending BEFORE sending so an ack arriving mid-send is routed. + # Re-attach `queue` too: while the final frame awaited the drain above, the + # intermediate ack may have popped the whole queue out of _reply_queues, + # leaving our local reference orphaned (its ack would then be Unrouted → + # 15s timeout). + self._reply_queues[normalized] = queue + queue.pending_ack = frame + + _stream_info = _stream_of(body) + logger.debug( + "[%s] _send_reply_queued: req_id=%s is_final=%s skip_if_pending=%s stream_id=%s finish=%s content_len=%d", + self.name, normalized, is_final, skip_if_pending, + _stream_info.get("id", "N/A"), _stream_info.get("finish", "N/A"), len(_stream_info.get("content", "") or ""), + ) + + try: + await self._send_json({"cmd": APP_CMD_RESPONSE, "headers": {"req_id": normalized}, "body": body}) + except Exception: + # Nobody awaits the future on this branch — cancel it rather than + # leave a "Future exception was never retrieved" log. + if queue.pending_ack is frame: + queue.pending_ack = None + self._reply_queues.pop(normalized, None) + if not future.done(): + future.cancel() + raise + + if not is_final: + # Fire-and-forget; pending_ack stays registered so later frames can skip. + return {"errcode": 0, "errmsg": "sent_nonblocking"} + try: + return await asyncio.wait_for(future, timeout=self._REPLY_ACK_TIMEOUT) + except asyncio.TimeoutError: + # The bytes went out (send did not raise) but the ack is late — in + # practice WeCom has already rendered the message. Raising here made + # the upper layer fall back to a markdown send and produced duplicates; + # match the official plugin: warn and treat as delivered. + logger.warning( + "[%s] Final frame ack timeout (req_id=%s) — treating as " + "delivered (matches official wecom-openclaw-plugin " + "behaviour). No fallback send.", + self.name, normalized, + ) + return {"errcode": 0, "errmsg": "ack_timeout_assumed_delivered", "ack_pending": True} + finally: + self._release_pending(queue, normalized, frame) + + def _release_pending(self, queue: ReplyQueue, req_id: str, frame: ReplyFrame) -> None: + """Clear ``frame`` if it is still the pending ack; drop the queue once empty.""" + if queue.pending_ack is frame: + queue.pending_ack = None + if queue.pending_ack is None: + self._reply_queues.pop(req_id, None) + + def _resolve_reply_ack(self, req_id: str, payload: Dict[str, Any]) -> bool: + """Resolve a pending reply ack. Returns True if handled.""" + queue = self._reply_queues.get(req_id) + if queue is None or queue.pending_ack is None: + return False + frame = queue.pending_ack + if not frame.future.done(): + _body = payload.get("body", {}) if isinstance(payload.get("body"), dict) else {} + logger.debug( + "[%s] _resolve_reply_ack: resolved req_id=%s is_final=%s " + "elapsed=%.2fs errcode=%s", + self.name, req_id, frame.is_final, + time.monotonic() - (frame.sent_at or time.monotonic()), + _body.get("errcode", "N/A"), + ) + frame.future.set_result(payload) + self._release_pending(queue, req_id, frame) + return True + + def _fail_reply_queues(self, error: Exception) -> None: + """Fail all pending reply acks (disconnect/error).""" + for queue in list(self._reply_queues.values()): + if queue.pending_ack and not queue.pending_ack.future.done(): + queue.pending_ack.future.set_exception(error) + self._reply_queues.clear() + + # ── Turn registry ──────────────────────────────────────────────────── + + def _resolve_stream_req_id(self, chat_id: str, reply_to: Optional[str]) -> Optional[str]: + """Explicit ``reply_to`` (cached message id) → last inbound req_id for the chat → None.""" + req_id = self._reply_req_id_for_message(reply_to) + if req_id: + return req_id + return self._last_chat_req_ids.get(str(chat_id or "").strip()) or None + + @staticmethod + def _cancel_keepalive(turn: StreamTurn) -> None: + if turn.keepalive_handle is not None: + try: + turn.keepalive_handle.cancel() + except Exception: + pass + turn.keepalive_handle = None + + def _retire_turn(self, turn: StreamTurn, turn_id: Optional[str]) -> None: + """Single choke point for "turn is dead": cancel the timer, then drop it from the registry.""" + self._cancel_keepalive(turn) + self._stream_turns.pop(f"{turn.chat_id}:{turn_id or turn.req_id}", None) + + def _expire_turn(self, turn: StreamTurn, turn_id: Optional[str]) -> None: + turn.expired = True + self._retire_turn(turn, turn_id) + self._stream_expired_chats.add(turn.chat_id) + + def _find_active_turn_for_chat(self, chat_id: str) -> Optional[StreamTurn]: + for turn in self._stream_turns.values(): + if turn.chat_id == chat_id and not turn.finalized: + return turn + return None + + # ── Stream-level keep-alive (Layer 1) ──────────────────────────────── + + def _arm_keepalive(self, turn: StreamTurn, *, turn_id: Optional[str]) -> None: + """Arm the keep-alive timer if enabled and not already armed (idempotent).""" + if not self._stream_keepalive_enabled or turn.finalized or turn.expired: + return + if turn.keepalive_handle is not None: + return + try: + loop = asyncio.get_running_loop() + except RuntimeError: + return + turn.keepalive_handle = loop.call_later( + self._stream_keepalive_interval_seconds, self._on_keepalive_fire, turn, turn_id, + ) + + def _on_keepalive_fire(self, turn: StreamTurn, turn_id: Optional[str]) -> None: + turn.keepalive_handle = None + if turn.finalized or turn.expired: + return + try: + asyncio.ensure_future(self._keepalive_send(turn, turn_id)) + except RuntimeError: + pass + + async def _keepalive_send(self, turn: StreamTurn, turn_id: Optional[str]) -> None: + """Re-send the accumulated text as finish=false to refresh the server window, then re-arm. + + Never sends a placeholder: with no accumulated text the tick is skipped + (Layer 2 handles content-less turns). On 846604/846608 the turn is + retired so finalize takes the Layer 2 fallback; no re-arm. + """ + if turn.finalized or turn.expired: + return + if turn._intermediate_frames_sent >= MAX_INTERMEDIATE_FRAMES: + return # no room left for intermediates; let finalize / Layer 2 run + content = turn.accumulated_text or "" + if not content.strip(): + self._arm_keepalive(turn, turn_id=turn_id) + return + try: + await self._send_stream_reply(turn.req_id, turn.stream_id, content, finish=False) + except WeComStreamExpiredError: + self._expire_turn(turn, turn_id) + return + except Exception as exc: + logger.debug( + "[%s] keep-alive send failed (chat=%s, turn=%s): %s", + self.name, turn.chat_id, turn.stream_id, exc, + ) + self._arm_keepalive(turn, turn_id=turn_id) # transient — retry next interval + return + turn.last_sent_content = content + self._arm_keepalive(turn, turn_id=turn_id) + + # ── Frame sending ──────────────────────────────────────────────────── + + @staticmethod + def _truncate_stream_content(content: str, limit: int) -> str: + """Truncate to ``limit`` UTF-8 bytes (WeCom caps frames by bytes, not codepoints).""" + encoded = content.encode("utf-8") + if len(encoded) <= limit: + return content + return encoded[:limit].decode("utf-8", errors="ignore") + + async def _send_stream_reply( + self, reply_req_id: str, stream_id: str, content: str, finish: bool = False, + ) -> Dict[str, Any]: + """Send one ``msgtype: "stream"`` frame. + + Intermediate frames are non-blocking with skip-if-pending (cumulative + text means nothing is lost). The final frame drains any pending ack + first, then awaits its own ack so 846608/6000 are detected reliably. + Raises WeComStreamExpiredError on 846608/846604. + """ + truncated = self._truncate_stream_content(content or "", self.MAX_STREAM_CONTENT_LENGTH) + if len(content or "") != len(truncated): + logger.warning("[%s] Stream content truncated for stream_id=%s", self.name, stream_id) + body: Dict[str, Any] = {"msgtype": "stream", "stream": {"id": stream_id, "finish": bool(finish), "content": truncated}} + if not finish: + return await self._send_reply_queued(reply_req_id, body, is_final=False, skip_if_pending=True) + + response = await self._send_reply_queued(reply_req_id, body, is_final=True, skip_if_pending=False) + errcode = response.get("errcode", 0) + if errcode in (STREAM_EXPIRED_ERRCODE, STREAM_REQUEST_EXPIRED_ERRCODE): + raise WeComStreamExpiredError(errcode=errcode, errmsg=str(response.get("errmsg") or "")) + if errcode == STREAM_VERSION_CONFLICT_ERRCODE: + # Content is already on screen; raising would pop the turn and cause + # a duplicate standalone send(). Absorbing makes finalize retry safe. + logger.info( + "[%s] finalize hit errcode 6000 (version conflict) — bubble " + "already replaced by a newer frame; treating as delivered.", + self.name, + ) + return response + self._raise_for_wecom_error(response, "send stream reply") + return response + + async def send_stream_frame( + self, text: str, *, finalize: bool = False, chat_id: Optional[str] = None, reply_to: Optional[str] = None, **kwargs, + ) -> bool: + """Entry point for the gateway streaming consumer. + + First call for a turn resolves the req_id, creates the StreamTurn and + seeds the typing bubble; later calls push cumulative text (not deltas); + ``finalize=True`` closes the stream and drops turn state. ``turn_id`` + (kwarg) keys the turn by (chat, turn_id) so concurrent consumers + (/background, subagents) never share a stream. + + Returns False when the stream is unavailable (no req_id, expired, + transport error) — the caller should fall back to :meth:`send`. + """ + chat = (chat_id or "").strip() + if not chat: + logger.warning("[%s] send_stream_frame: chat_id required", self.name) + return False + turn_id = kwargs.get("turn_id") + # Chat-level expiry only blocks NEW turn creation; a known turn_id may + # still finalize after another turn in the chat expired. + if not turn_id and chat in self._stream_expired_chats: + return False + if finalize: + # Finalize counts toward 30/min — control lane so it is never blocked. + return await self._enqueue_chat_send( + chat, + lambda: self._send_stream_frame_inner(text, chat=chat, reply_to=reply_to, finalize=True, turn_id=turn_id), + is_control=True, + ) + # Intermediate frames don't count toward the quota: no queue, no rate limit. + return await self._send_stream_frame_inner(text, chat=chat, reply_to=reply_to, finalize=False, turn_id=turn_id) + + def _locate_turn( + self, chat: str, reply_to: Optional[str], finalize: bool, turn_id: Optional[str], + ) -> Optional[StreamTurn]: + """Find or create the StreamTurn for a frame; None means "stream unavailable". + + A turn locks to its req_id at creation even if ``_last_chat_req_ids`` + changes mid-turn (e.g. the user sends /approve). + """ + if turn_id: + turn = self._stream_turns.get(f"{chat}:{turn_id}") + if turn: + return turn + # finalize must NOT create a turn: if it was cleaned up (e.g. 6000) the + # caller should fall back rather than send a fresh seed + finish. + if finalize: + logger.debug( + "[%s] send_stream_frame: cannot finalize non-existent turn (turn_id=%s, chat=%s)", + self.name, turn_id, chat, + ) + return None + else: + # No turn_id (direct callers): reuse the chat's active turn if any. + existing_turn = self._find_active_turn_for_chat(chat) + if existing_turn and not existing_turn.finalized: + logger.debug( + "[%s] send_stream_frame: reusing existing turn %s for chat %s", + self.name, existing_turn.stream_id, chat, + ) + return existing_turn + + suffix = f" (turn_id={turn_id})" if turn_id else "" + if chat in self._stream_expired_chats: + logger.debug("[%s] send_stream_frame: chat %s is expired, cannot create new turn%s", self.name, chat, suffix) + return None + req_id = self._resolve_stream_req_id(chat, reply_to) + if not req_id: + logger.debug("[%s] send_stream_frame: no req_id available for chat %s%s", self.name, chat, suffix) + return None + key = f"{chat}:{turn_id or req_id}" + turn = (None if turn_id else self._stream_turns.get(key)) or StreamTurn(chat, req_id) + self._stream_turns[key] = turn + logger.debug( + "[%s] send_stream_frame: created new turn %s (%s) for chat %s", + self.name, turn.stream_id, f"turn_id={turn_id}, req_id={req_id}" if turn_id else f"req_id={req_id}", chat, + ) + return turn + + async def _send_stream_frame_inner( + self, text: str, *, chat: str, reply_to: Optional[str] = None, finalize: bool = False, turn_id: Optional[str] = None, + ) -> bool: + """Stream frame logic with per-turn state (see ``send_stream_frame``).""" + turn: Optional[StreamTurn] = None + try: + turn = self._locate_turn(chat, reply_to, finalize, turn_id) + if turn is None or turn.expired: + return False + + if not turn.seeded and not turn.finalized: + # Seed with the official plugin's THINKING_MESSAGE () + # so the client shows a reasoning turn; the seeded flag prevents a + # double seed (errcode 6000) since the consumer seeds too. + await self._send_stream_reply(turn.req_id, turn.stream_id, "", finish=False) + turn.seeded = True + self._arm_keepalive(turn, turn_id=turn_id) + if not text and not finalize: + return True # consumer's explicit seed call — nothing more to send + + if finalize: + # Layer 2 clock fallback: an old stream would almost certainly hit + # 846604/846608 on finish=true, so decline up front and let the + # consumer's send() fallback deliver exactly once. SKIPPED when + # Layer 1 keep-alive is on — the heartbeat has been refreshing the + # window, so age alone does not mean dead, and declining a live + # stream would re-deliver content already on screen. A truly dead + # stream still raises WeComStreamExpiredError below. + if not self._stream_keepalive_enabled: + stream_age = time.monotonic() - turn.start_time + if stream_age >= self._stream_safe_duration_seconds: + logger.info( + "[%s] Stream age %.0fs >= safe duration %.0fs for chat " + "%s — declining finalize frame, falling back to " + "proactive send (Layer 2 clock fallback).", + self.name, stream_age, + self._stream_safe_duration_seconds, chat, + ) + self._expire_turn(turn, turn_id) + return False + + self._cancel_keepalive(turn) + # WeCom silently drops (no ack) a final frame identical to the last + # intermediate — append a zero-width space so the content differs. + final_text = text + if text and text == turn.last_sent_content: + final_text = text + "\u200b" + await self._send_stream_reply(turn.req_id, turn.stream_id, final_text, finish=True) + turn.finalized = True + self._stream_turns.pop(f"{chat}:{turn_id or turn.req_id}", None) + else: + # Fire-and-forget: the gateway decides when to push (identity dedup + # in stream_consumer.py); no adapter-side buffering. + turn.accumulated_text = text + if turn._intermediate_frames_sent >= MAX_INTERMEDIATE_FRAMES: + return True # cap reached — drop intermediates; finalize drains the rest + if text == turn.last_sent_content: + return True + await self._send_stream_reply(turn.req_id, turn.stream_id, text, finish=False) + turn._intermediate_frames_sent += 1 + turn.last_sent_content = text + return True + + except WeComStreamExpiredError: + # An intermediate frame is overwritten by the next cumulative/final + # frame anyway; flipping the turn expired here would trip the + # consumer's send() fallback and duplicate the bubble. Only a FINAL + # frame's expiry means content is genuinely missing. + if not finalize: + logger.info( + "[%s] Intermediate stream frame expired (errcode=%d) for chat %s — dropping frame, stream stays live", + self.name, STREAM_EXPIRED_ERRCODE, chat, + ) + return True + logger.info( + "[%s] Stream expired (errcode=%d) for chat %s — switching to proactive send", + self.name, STREAM_EXPIRED_ERRCODE, chat, + ) + if turn is not None: + self._expire_turn(turn, turn_id) + else: + self._stream_expired_chats.add(chat) + return False + except Exception as exc: + if not finalize: # same intermediate/final split as above + logger.info( + "[%s] Intermediate stream frame failed (chat=%s): %s — dropping frame, stream stays live", + self.name, chat, exc, + ) + return True + logger.warning("[%s] Stream frame failed (chat=%s): %s", self.name, chat, exc) + if turn is not None: + self._retire_turn(turn, turn_id) + return False + + def supports_native_streaming( + self, chat_type: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, + ) -> bool: + """Stream frames work in DMs and groups alike (groups just need a cached inbound req_id).""" + del chat_type, metadata + return True + + async def send_typing(self, chat_id: str, metadata=None) -> None: + """No-op: the stream consumer's seed frame triggers WeCom typing; repeated + send_typing calls would open orphan streams.""" + del chat_id, metadata