diff --git a/.env.example b/.env.example index 6cd9c30239..5c08a4acd6 100644 --- a/.env.example +++ b/.env.example @@ -423,3 +423,24 @@ IMAGE_TOOLS_DEBUG=false # TEAMS_HOME_CHANNEL= # Default channel/chat ID for cron delivery # TEAMS_HOME_CHANNEL_NAME= # Display name for the home channel # TEAMS_PORT=3978 # Webhook listen port (Bot Framework default) + +# ============================================================================= +# GOOGLE CHAT INTEGRATION +# ============================================================================= +# Connects via Cloud Pub/Sub pull subscription (no public URL required). +# Setup walkthrough: website/docs/user-guide/messaging/google_chat.md. +# 1. Create a GCP project, enable the Google Chat API and Cloud Pub/Sub. +# 2. Create a Service Account with roles/pubsub.subscriber on the +# subscription (NOT project-wide); download the JSON key. +# 3. Configure your Chat app at console.cloud.google.com/apis/credentials +# → Google Chat API → Configuration → Cloud Pub/Sub topic. +# 4. (Optional, for native attachment delivery) Each user runs +# `/setup-files` once in their own DM after Pub/Sub is wired up. +# +# GOOGLE_CHAT_PROJECT_ID= # GCP project hosting the topic (or set GOOGLE_CLOUD_PROJECT) +# GOOGLE_CHAT_SUBSCRIPTION_NAME= # Full path: projects//subscriptions/ +# GOOGLE_CHAT_SERVICE_ACCOUNT_JSON= # Path to SA JSON (or set GOOGLE_APPLICATION_CREDENTIALS) +# GOOGLE_CHAT_ALLOWED_USERS= # Comma-separated emails allowed to talk to the bot +# GOOGLE_CHAT_ALLOW_ALL_USERS=false # Set true to skip the allowlist +# GOOGLE_CHAT_HOME_CHANNEL= # Default space (spaces/XXXX) for cron delivery +# GOOGLE_CHAT_HOME_CHANNEL_NAME= # Display name for the home channel diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 871f452902..d7b7dcf931 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -601,7 +601,7 @@ agent: # - A preset like "hermes-cli" or "hermes-telegram" (curated tool set) # - A list of individual toolsets to compose your own (see list below) # -# Supported platform keys: cli, telegram, discord, whatsapp, slack, qqbot, teams +# Supported platform keys: cli, telegram, discord, whatsapp, slack, qqbot, teams, google_chat # # Examples: # @@ -632,6 +632,7 @@ agent: # homeassistant: hermes-homeassistant (same as telegram) # qqbot: hermes-qqbot (same as telegram) # teams: hermes-teams (same as telegram) +# google_chat: hermes-google_chat (same as telegram) # platform_toolsets: cli: [hermes-cli] @@ -644,6 +645,7 @@ platform_toolsets: qqbot: [hermes-qqbot] yuanbao: [hermes-yuanbao] teams: [hermes-teams] + google_chat: [hermes-google_chat] # ============================================================================= # Gateway Platform Settings diff --git a/docker-compose.yml b/docker-compose.yml index 910392b25c..8bdc96b7a9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -44,6 +44,15 @@ services: # - TEAMS_TENANT_ID=${TEAMS_TENANT_ID} # - TEAMS_ALLOWED_USERS=${TEAMS_ALLOWED_USERS} # - TEAMS_PORT=${TEAMS_PORT:-3978} + # Google Chat — uncomment and fill in to enable the Google Chat gateway. + # See website/docs/user-guide/messaging/google_chat.md for the full setup. + # The SA JSON path must point to a file mounted into the container — + # add a volume entry above (e.g. ``- ~/.hermes/google-chat-sa.json:/secrets/google-chat-sa.json:ro``) + # then set GOOGLE_CHAT_SERVICE_ACCOUNT_JSON to that mount path. + # - GOOGLE_CHAT_PROJECT_ID=${GOOGLE_CHAT_PROJECT_ID} + # - GOOGLE_CHAT_SUBSCRIPTION_NAME=${GOOGLE_CHAT_SUBSCRIPTION_NAME} + # - GOOGLE_CHAT_SERVICE_ACCOUNT_JSON=${GOOGLE_CHAT_SERVICE_ACCOUNT_JSON} + # - GOOGLE_CHAT_ALLOWED_USERS=${GOOGLE_CHAT_ALLOWED_USERS} command: ["gateway", "run"] dashboard: diff --git a/plugins/platforms/google_chat/__init__.py b/plugins/platforms/google_chat/__init__.py new file mode 100644 index 0000000000..d4f1d7bf0e --- /dev/null +++ b/plugins/platforms/google_chat/__init__.py @@ -0,0 +1,3 @@ +from .adapter import register + +__all__ = ["register"] diff --git a/plugins/platforms/google_chat/adapter.py b/plugins/platforms/google_chat/adapter.py new file mode 100644 index 0000000000..c371082707 --- /dev/null +++ b/plugins/platforms/google_chat/adapter.py @@ -0,0 +1,3085 @@ +""" +Google Chat platform adapter. + +Uses Google Cloud Pub/Sub (pull subscription) for inbound events and the +Google Chat REST API for outbound messages. Pattern parallels Slack Socket +Mode and Telegram long-polling: no public endpoint required. + +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). + +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). +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import os +import random +import re +from pathlib import Path as _Path +from typing import Any, Callable, Dict, List, Optional, Tuple + +try: + import httplib2 + from google.cloud import pubsub_v1 + from google.api_core import exceptions as gax_exceptions + from google.oauth2 import service_account + from google_auth_httplib2 import AuthorizedHttp + from googleapiclient.discovery import build as build_service + from googleapiclient.errors import HttpError + from googleapiclient.http import MediaFileUpload + + GOOGLE_CHAT_AVAILABLE = True +except ImportError: + GOOGLE_CHAT_AVAILABLE = False + httplib2 = None # type: ignore + pubsub_v1 = None # type: ignore + gax_exceptions = None # type: ignore + service_account = None # type: ignore + AuthorizedHttp = None # type: ignore + build_service = None # type: ignore + HttpError = Exception # type: ignore + MediaFileUpload = None # type: ignore + +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. +Platform("google_chat") +from gateway.platforms.helpers import MessageDeduplicator +from gateway.platforms.base import ( + 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``. +logger = logging.getLogger("gateway.platforms.google_chat") + + +# 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", +] + +# 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. +_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}) + + +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) + 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. + 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 + +# 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. +_TYPING_CONSUMED_SENTINEL = "" + + +def check_google_chat_requirements() -> bool: + """Check if Google Chat optional dependencies are installed.""" + return GOOGLE_CHAT_AVAILABLE + + +# 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. +_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", +) + + +def _is_google_owned_host(url: str) -> bool: + """Return True iff *url* is https and targets a Google-owned domain.""" + try: + from urllib.parse import urlparse + + parsed = urlparse(url) + except Exception: + return False + if parsed.scheme != "https": + return False + host = (parsed.hostname or "").lower() + if not host: + return False + return any(host == h or host.endswith("." + h) for h in _TRUSTED_ATTACHMENT_HOSTS) + + +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. + """ + 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"[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.iam\.gserviceaccount\.com", + "@.iam.gserviceaccount.com", + text, + ) + return text + + +def _mime_for_message_type(mime: str) -> MessageType: + """Map a MIME string to a hermes MessageType. + + 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 + return MessageType.DOCUMENT + + +class _ThreadCountStore: + """Per-(chat_id, thread_name) inbound message counter, persisted to disk. + + Drives the DM main-flow vs side-thread heuristic: + + - 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. + """ + + 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 + if not self._path.exists(): + self._counts = {} + return + try: + raw = self._path.read_text() + 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, + ) + self._counts = {} + return + except OSError as 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. + clean: Dict[str, Dict[str, int]] = {} + if isinstance(data, dict): + 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 + 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?").""" + chat_counts = self._counts.setdefault(chat_id, {}) + prev = chat_counts.get(thread_name, 0) + chat_counts[thread_name] = prev + 1 + self._save() + 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. + """ + 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=(",", ":"))) + os.replace(tmp, self._path) + except OSError as exc: + logger.warning( + "[GoogleChat] could not persist thread-count store to %s: %s", + self._path, exc, + ) + + +class GoogleChatAdapter(BasePlatformAdapter): + """ + Google Chat bot adapter using Pub/Sub pull + Chat REST API. + + Required environment (see gateway/config.py Google Chat block): + GOOGLE_CHAT_PROJECT_ID (or GOOGLE_CLOUD_PROJECT fallback) + GOOGLE_CHAT_SUBSCRIPTION_NAME (or GOOGLE_CHAT_SUBSCRIPTION fallback) + GOOGLE_CHAT_SERVICE_ACCOUNT_JSON (or GOOGLE_APPLICATION_CREDENTIALS) + + Optional: + GOOGLE_CHAT_ALLOWED_USERS, GOOGLE_CHAT_ALLOW_ALL_USERS + GOOGLE_CHAT_HOME_CHANNEL + GOOGLE_CHAT_MAX_MESSAGES (FlowControl, default 1) + GOOGLE_CHAT_MAX_BYTES (FlowControl, default 16_777_216 = 16 MiB) + """ + + MAX_MESSAGE_LENGTH = _MAX_TEXT_LENGTH + # Pub/Sub supervisor configuration. + _MAX_RECONNECT_ATTEMPTS = 10 + _RECONNECT_BASE_DELAY = 2.0 + _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). + super().__init__(config, Platform("google_chat")) + 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. + 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. + self._last_sender_by_chat: Dict[str, str] = {} + self._credentials: Optional[Any] = None + self._project_id: Optional[str] = None + self._subscription_path: Optional[str] = None + self._streaming_pull_future: Optional[Any] = None + self._supervisor_task: Optional[asyncio.Task] = None + self._loop: Optional[asyncio.AbstractEventLoop] = None + self._bot_user_id: Optional[str] = None # users/{id} + self._dedup = MessageDeduplicator() + self._typing_messages: 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. + 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). + 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._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…". + self._orphan_typing_messages: Dict[str, List[str]] = {} + # FlowControl knobs (env-configurable). + self._max_messages = int(os.getenv("GOOGLE_CHAT_MAX_MESSAGES", "1")) + self._max_bytes = int(os.getenv("GOOGLE_CHAT_MAX_BYTES", str(16 * 1024 * 1024))) + + # ------------------------------------------------------------------ + # 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 os.getenv("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( + f"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. + 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" + ) + return credentials + + def _validate_config(self) -> Tuple[str, str]: + """Return (project_id, subscription_path) after validation. + + Raises ValueError with a sanitized message on any config problem. + """ + project_id = self.config.extra.get("project_id") + subscription = self.config.extra.get("subscription_name") + if not project_id: + raise ValueError( + "GOOGLE_CHAT_PROJECT_ID (or GOOGLE_CLOUD_PROJECT) is not set." + ) + if not subscription: + raise ValueError( + "GOOGLE_CHAT_SUBSCRIPTION_NAME (or GOOGLE_CHAT_SUBSCRIPTION) is not set." + ) + match = _SUBSCRIPTION_PATH_RE.match(subscription) + if not match: + raise ValueError( + "GOOGLE_CHAT_SUBSCRIPTION_NAME must match " + "'projects//subscriptions/'." + ) + if match.group("project") != project_id: + raise ValueError( + "project_id in GOOGLE_CHAT_PROJECT_ID does not match the " + "project embedded in GOOGLE_CHAT_SUBSCRIPTION_NAME." + ) + return project_id, subscription + + # ------------------------------------------------------------------ + # Loop bridge helpers (thread -> asyncio loop) + # ------------------------------------------------------------------ + @staticmethod + def _log_background_failure(future: Any) -> None: + try: + future.result() + except Exception: + logger.exception("[GoogleChat] Background inbound processing failed") + + @staticmethod + def _loop_accepts_callbacks(loop: Optional[asyncio.AbstractEventLoop]) -> bool: + return loop is not None and not bool(getattr(loop, "is_closed", lambda: False)()) + + def _submit_on_loop(self, coro: Any) -> None: + """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. + logger.warning("[GoogleChat] Loop not accepting callbacks; dropping event") + return + try: + future = asyncio.run_coroutine_threadsafe(coro, loop) + except RuntimeError: + logger.warning("[GoogleChat] Loop closed between check and submit") + return + future.add_done_callback(self._log_background_failure) + + # ------------------------------------------------------------------ + # Bot identity resolution + # ------------------------------------------------------------------ + def _bot_id_cache_path(self) -> _Path: + """Location where the resolved bot user_id is cached across restarts.""" + base = os.getenv("HERMES_HOME", str(_Path.home() / ".hermes")) + return _Path(base) / "google_chat_bot_id.json" + + def _load_cached_bot_id(self) -> Optional[str]: + path = self._bot_id_cache_path() + if not path.exists(): + return None + try: + data = json.loads(path.read_text(encoding="utf-8")) + return data.get("bot_user_id") or None + except (OSError, json.JSONDecodeError): + return None + + def _save_cached_bot_id(self, bot_user_id: str) -> None: + 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", + ) + 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. + + 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). + """ + 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 = os.getenv("GOOGLE_CHAT_BOOTSTRAP_SPACES", "").strip() + if extra_spaces: + candidate_spaces.extend( + s.strip() for s in extra_spaces.split(",") if s.strip() + ) + for space in candidate_spaces: + try: + members = await asyncio.to_thread( + lambda s=space: self._chat_api.spaces() + .members() + .list(parent=s, pageSize=50) + .execute(http=self._new_authed_http()) + ) + except HttpError as 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": + name = member.get("member", {}).get("name") + if name: + return name + return None + + # ------------------------------------------------------------------ + # Connection lifecycle + # ------------------------------------------------------------------ + async def connect(self) -> bool: + """Validate config, authenticate, start Pub/Sub pull, resolve bot id.""" + if not GOOGLE_CHAT_AVAILABLE: + self._set_fatal_error( + code="missing_deps", + message="google-cloud-pubsub / google-api-python-client not installed", + retryable=False, + ) + return False + + self._loop = asyncio.get_running_loop() + try: + project_id, subscription_path = self._validate_config() + credentials = self._load_sa_credentials() + except (ValueError, FileNotFoundError) as exc: + msg = _redact_sensitive(str(exc)) + logger.error("[GoogleChat] Config validation failed: %s", msg) + self._set_fatal_error(code="config_invalid", message=msg, retryable=False) + return False + + self._project_id = project_id + 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, + ) + ) + except Exception as exc: + msg = _redact_sensitive(str(exc)) + logger.error("[GoogleChat] Failed to build Chat API client: %s", msg) + 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. + try: + from .oauth import ( + load_user_credentials as _load_user_creds, + build_user_chat_service as _build_user_chat, + list_authorized_emails as _list_emails, + ) + 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" + ) + authorized = await asyncio.to_thread(_list_emails) + if 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 " + "attachments will degrade to text-only fallback. " + "Each user runs /setup-files once in their own DM " + "to enable native attachments." + ) + except Exception as exc: + logger.warning( + "[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, + ) + + # Sanity check: subscription exists / SA has access. + 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, + ) + return False + except Exception as exc: + msg = _redact_sensitive(str(exc)) + logger.error("[GoogleChat] subscription.get failed: %s", msg) + self._set_fatal_error(code="subscription_check", message=msg, retryable=True) + return False + + # Resolve bot user_id (eager): cache first, then members.list. + self._bot_user_id = self._load_cached_bot_id() + if not self._bot_user_id: + self._bot_user_id = await self._resolve_bot_user_id() + 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" + ) + + # 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()) + self._mark_connected() + logger.info( + "[GoogleChat] Connected; project=%s, subscription=, " + "bot_user_id=%s, flow_control(msgs=%s, bytes=%s)", + project_id, + self._bot_user_id or "", + self._max_messages, + self._max_bytes, + ) + return True + + async def disconnect(self) -> None: + """Clean shutdown: stop accepting new messages, wait in-flight, close clients.""" + self._shutting_down = True + if self._supervisor_task and not self._supervisor_task.done(): + self._supervisor_task.cancel() + try: + 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: + 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: + await asyncio.to_thread(self._subscriber.close) + except Exception: + pass + self._subscriber = None + self._mark_disconnected() + logger.info("[GoogleChat] Disconnected") + + # ------------------------------------------------------------------ + # Pub/Sub supervisor (reconnect loop) + # ------------------------------------------------------------------ + async def _run_supervisor(self) -> None: + """Run the streaming_pull with exponential backoff; fatal after 10 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. + """ + attempt = 0 + while not self._shutting_down: + flow = pubsub_v1.types.FlowControl( + max_messages=self._max_messages, + max_bytes=self._max_bytes, + ) + try: + future = self._subscriber.subscribe( + self._subscription_path, + callback=self._on_pubsub_message, + flow_control=flow, + ) + self._streaming_pull_future = future + if attempt > 0: + logger.info("[GoogleChat] Pub/Sub stream reconnected after %d attempts", attempt) + attempt = 0 + # Blocks until stream dies or cancel(). + 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, + ) + return + except gax_exceptions.PermissionDenied: + self._set_fatal_error( + 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, + ) + if attempt >= self._MAX_RECONNECT_ATTEMPTS: + self._set_fatal_error( + code="pubsub_reconnect_exhausted", + message=f"Pub/Sub reconnect failed {attempt} times; giving up", + 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) + try: + await asyncio.sleep(sleep_for) + except asyncio.CancelledError: + return + + # ------------------------------------------------------------------ + # Inbound event handling (Pub/Sub callback runs in a thread) + # ------------------------------------------------------------------ + @staticmethod + 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)``. + + 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. + """ + # 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. + chat_block = envelope.get("chat") or {} + msg_payload_wrapper = chat_block.get("messagePayload") if chat_block else None + if msg_payload_wrapper: + msg = msg_payload_wrapper.get("message") or {} + 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. + if isinstance(envelope.get("message"), dict): + if envelope.get("type", "") != "MESSAGE": + return None + msg = envelope["message"] + 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. + 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(".", "_") + ) + text = envelope.get("text", "") or "" + msg: Dict[str, Any] = { + "name": envelope.get("message_name", "") or "", + "sender": { + "name": sender_name_surrogate, + "email": sender_email, + "displayName": sender_display, + "type": "HUMAN", + }, + "text": text, + "argumentText": text, + } + thread_name = envelope.get("thread_name") or "" + if thread_name: + msg["thread"] = {"name": thread_name} + space = { + "name": envelope.get("space_name", "") or "", + "spaceType": envelope.get("space_type", "SPACE"), + } + return msg, space, "relay_flat" + + return None + + def _on_pubsub_message(self, message: Any) -> None: + """Pub/Sub callback — parse envelope and dispatch to 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} + """ + if self._shutting_down: + message.nack() + return + try: + envelope = json.loads(message.data.decode("utf-8")) + except Exception: + logger.exception("[GoogleChat] Could not parse Pub/Sub envelope") + message.ack() + return + + 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, + ) + if os.getenv("GOOGLE_CHAT_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. + try: + from agent.redact import redact_sensitive_text + + dump = redact_sensitive_text(json.dumps(envelope)) + except Exception: + dump = "" + logger.debug("[GoogleChat] RAW envelope (redacted): %s", dump[:2000]) + + 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 {} + membership = mpl.get("membership") or {} + if "created" in ce_type: + # ADDED_TO_SPACE for this bot — resolve self user_id. + member = membership.get("member") or {} + if member.get("type") == "BOT" and not self._bot_user_id: + name = member.get("name") + if name: + self._bot_user_id = name + self._save_cached_bot_id(name) + logger.info( + "[GoogleChat] ADDED_TO_SPACE %s", space.get("name", "?") + ) + else: + 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)" + ) + 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)) + message.ack() + except Exception: + logger.exception("[GoogleChat] Error in _on_pubsub_message") + try: + message.ack() + except Exception: + pass + + 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. + + 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. + """ + 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 + ) + handled = await self._handle_setup_files_command( + chat_id=event.source.chat_id, + thread_id=event.source.thread_id, + raw_text=text, + sender_email=sender_email, + ) + 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, + ) -> bool: + """Run the in-chat OAuth setup flow for native attachment delivery. + + Returns ``True`` if the message was consumed (no agent dispatch), + ``False`` if it should fall through. + + 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 on the host. 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]: + """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" + space_type = (space.get("type") or space.get("spaceType") or "").upper() + thread = msg.get("thread") or {} + thread_name = thread.get("name") or None + sender = msg.get("sender") or {} + sender_name = sender.get("name") or "" + 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. + 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() + + # Slash command: emit MessageType.COMMAND with normalized text. + slash = msg.get("slashCommand") or {} + is_slash = bool(slash) + if is_slash: + command_id = str(slash.get("commandId") or "") + 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: + try: + local_path, mime = await self._download_attachment(att) + except Exception: + logger.exception("[GoogleChat] attachment download failed") + continue + if not local_path: + continue + media_urls.append(local_path) + media_types.append(mime or "application/octet-stream") + # 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. + prev_thread_count = 0 + if thread_name and space_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. + 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. + 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 + + source = self.build_source( + 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. + user_id=(sender_email or sender_name), + user_name=sender_display, + thread_id=session_thread_id, + user_id_alt=(sender_name or None), + ) + return MessageEvent( + text=text, + message_type=message_type, + source=source, + raw_message=msg, + message_id=msg.get("name") or None, + media_urls=media_urls, + media_types=media_types, + ) + + 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. + """ + 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 "" + 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. + if source == "DRIVE_FILE" and not resource_name: + 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, + ) + from googleapiclient.http import MediaIoBaseDownload + import io + + buf = io.BytesIO() + downloader = MediaIoBaseDownload(buf, req) + done = False + while not done: + _status, done = downloader.next_chunk() + return buf.getvalue() + + try: + data = await asyncio.to_thread(_fetch_media) + except HttpError as 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" + ) + return None, mime + + def _fetch_uri() -> bytes: + import google.auth.transport.requests as gar + + authed_session = gar.AuthorizedSession(self._credentials) + resp = authed_session.get(download_uri, timeout=30) + resp.raise_for_status() + return resp.content + + try: + data = await asyncio.to_thread(_fetch_uri) + except Exception as exc: + logger.warning( + "[GoogleChat] downloadUri fetch failed (SA tokens often " + "lack access here; this is expected for user-uploaded " + "content): %s", + _redact_sensitive(str(exc)), + ) + return None, mime + + 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. + filename = name.split("/")[-1] if name else "attachment" + if "." in filename: + ext = "." + filename.rsplit(".", 1)[-1].lower() + else: + ext = "" + if mime.startswith("image/"): + local = cache_image_from_bytes(data, ext=ext or ".jpg") + elif mime.startswith("audio/"): + local = cache_audio_from_bytes(data, ext=ext or ".ogg") + elif mime.startswith("video/"): + local = cache_video_from_bytes(data, ext=ext or ".mp4") + else: + local = cache_document_from_bytes(data, filename) + return local, mime + + # ------------------------------------------------------------------ + # Outbound send paths + # ------------------------------------------------------------------ + async def send( + self, + chat_id: str, + content: str, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Send a text message. + + 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. + """ + 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. + 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. + if thread_id and (idx > 0 or not typing_msg_name): + body["thread"] = {"name": thread_id} + try: + if idx == 0 and typing_msg_name: + result = await self._patch_message(typing_msg_name, body) + patched_typing = True + else: + result = await self._create_message(chat_id, body) + last_result = result + except HttpError as exc: + status = getattr(getattr(exc, "resp", None), "status", None) + if status == 403: + self._set_fatal_error( + code="chat_forbidden", + message="Bot lacks access (removed from space or perms revoked)", + retryable=False, + ) + 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. + if idx == 0 and typing_msg_name: + logger.info( + "[GoogleChat] Typing card disappeared; creating new message" + ) + typing_msg_name = None + result = await self._create_message(chat_id, body) + last_result = result + continue + 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], + ) + 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. + if patched_typing: + self._typing_messages[chat_id] = _TYPING_CONSUMED_SENTINEL + return last_result + finally: + self.resume_typing_for_chat(chat_id) + + 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``. + + 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. + """ + 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)) + ) + 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. + """ + 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()) + ) + + 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): + return False + 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: + """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" + + # Patch body cannot carry thread (immutable). + patch_body = {k: v for k, v in body.items() if k not in ("thread",)} + + def _do_patch() -> Dict[str, Any]: + return ( + self._chat_api.spaces() + .messages() + .patch(name=message_name, updateMask=update_mask, body=patch_body) + .execute(http=self._new_authed_http()) + ) + + resp = await asyncio.to_thread(_do_patch) + return SendResult(success=True, message_id=resp.get("name", message_name)) + + def _chunk_text(self, text: str) -> List[str]: + if not text: + return [] + if len(text) <= _MAX_TEXT_LENGTH: + return [text] + chunks: List[str] = [] + remaining = text + while remaining: + if len(remaining) <= _MAX_TEXT_LENGTH: + chunks.append(remaining) + break + # Try to split on a newline near the cutoff. + cut = remaining.rfind("\n", 0, _MAX_TEXT_LENGTH) + if cut < _MAX_TEXT_LENGTH // 2: + cut = _MAX_TEXT_LENGTH + chunks.append(remaining[:cut]) + 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 + + def _resolve_thread_id( + 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. + + 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. + """ + if metadata: + for key in ("thread_id", "thread_name", "thread_ts"): + value = metadata.get(key) + if value: + return str(value) + if reply_to and "/threads/" in reply_to and "/messages/" not in reply_to: + return reply_to + if chat_id: + cached = self._last_inbound_thread.get(chat_id) + if cached: + return cached + 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). + """ + 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: + """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. + """ + 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: + 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, + ) + try: + await asyncio.sleep(wait) + except asyncio.CancelledError: + raise + 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. + 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")) + + async def send_typing(self, chat_id: str, metadata: Any = None) -> None: + """Post a visible 'Hermes is thinking…' marker message. + + 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). + + 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. + """ + # 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 + return + + thread_id = self._resolve_thread_id( + reply_to=None, metadata=metadata, chat_id=chat_id, + ) + body: Dict[str, Any] = {"text": "Hermes is thinking…"} + if thread_id: + body["thread"] = {"name": thread_id} + + completed = asyncio.Event() + self._typing_card_inflight[chat_id] = completed + + async def _create_and_record() -> None: + 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) + except Exception: + 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. + 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. + """ + current = self._typing_messages.get(chat_id) + if not current: + return + if current == _TYPING_CONSUMED_SENTINEL: + self._typing_messages.pop(chat_id, None) + return + # 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. + """ + 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)" + ) + 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: + try: + await self._patch_message(orphan_id, {"text": "·"}) + except Exception: + 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 + ) + + # ------------------------------------------------------------------ + # Attachment send paths + # ------------------------------------------------------------------ + 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)"). + """ + 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 + raise + + 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: + """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. + """ + 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) + + try: + patched = await self._consume_typing_card_with_text(chat_id, text) + if patched is not None: + return patched + body: Dict[str, Any] = {"text": text} + if thread_id: + body["thread"] = {"name": thread_id} + return await self._create_message(chat_id, body) + 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, + ) -> SendResult: + 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), + ) + + 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, + ) -> 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, + ) + + async def send_voice( + 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), + ) + + async def send_video( + 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), + ) + + async def send_animation( + 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, + ) + + # ------------------------------------------------------------------ + # 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. + # ------------------------------------------------------------------ + @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). + """ + from .oauth import ( + load_user_credentials as _load, + build_user_chat_service as _build, + refresh_or_none as _refresh, + ) + + cached_api = self._user_chat_api_by_email.get(email) + cached_creds = self._user_creds_by_email.get(email) + if cached_api is not None and cached_creds is not None: + try: + refreshed = await asyncio.to_thread(_refresh, cached_creds, email) + except Exception: + 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) + self._user_creds_by_email.pop(email, None) + return None + self._user_creds_by_email[email] = refreshed + return cached_api + + try: + creds = await asyncio.to_thread(_load, email) + if creds is None: + 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, + ) + 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]]: + """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. + """ + if sender_email: + api = await self._load_per_user_chat_api(sender_email) + if api is not None: + return api, sender_email + + 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, + ) + except Exception: + 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" + ) + self._user_credentials = None + self._user_chat_api = None + return None, None + self._user_credentials = refreshed + return self._user_chat_api, self._LEGACY_USER_IDENTITY + + 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. + """ + if not identity: + return + if identity == self._LEGACY_USER_IDENTITY: + self._user_credentials = None + self._user_chat_api = None + return + self._user_creds_by_email.pop(identity, None) + 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, + ) -> SendResult: + """Native Chat attachment via user-OAuth media.upload. + + 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. + """ + 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, + ) + + # 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, + ) + + 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() + ) + + try: + upload_resp = await asyncio.to_thread(_upload) + except HttpError as exc: + status = getattr(getattr(exc, "resp", None), "status", None) + if status in (401, 403): + logger.warning( + "[GoogleChat] media.upload auth failure for identity=%s " + "(token revoked or scope missing) — falling back to " + "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)) + ) + + attachment_ref = upload_resp.get("attachmentDataRef") + if not attachment_ref: + return SendResult( + success=False, + error="upload returned no attachmentDataRef", + ) + + 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. + create_kwargs: Dict[str, Any] = {"parent": chat_id, "body": body} + if thread_id: + 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() + ) + + 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"), + ) + except HttpError as 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], + ) -> 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. + """ + lines = [] + if caption: + lines.append(caption) + lines.extend([ + f"⚠️ No he podido adjuntar **{filename}**.", + "Google Chat sólo permite adjuntar archivos cuando el bot tiene " + "permiso explícito tuyo (OAuth de usuario). Es un consentimiento " + "único que se hace desde este chat.", + "**Para activarlo:** envía `/setup-files` y sigue las instrucciones.", + f"Mientras tanto el archivo está en el host: `{path}`", + ]) + body: Dict[str, Any] = {"text": "\n".join(lines)} + if thread_id: + body["thread"] = {"name": thread_id} + try: + await self._create_message(chat_id, body) + except Exception: + 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", + ) + + # ------------------------------------------------------------------ + # Metadata + # ------------------------------------------------------------------ + async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: + """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()) + ) + except HttpError as 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, + "type": "dm" if space_type in ("DIRECT_MESSAGE", "DM") else "group", + "chat_id": chat_id, + } + + +# --------------------------------------------------------------------------- +# Plugin entry point +# --------------------------------------------------------------------------- + + +def _validate_config(config: PlatformConfig) -> bool: + """Plugin-side config gate: require both Pub/Sub project and subscription. + + Mirrors the legacy dispatch entry in ``gateway/config.py`` so the + registry can decide whether the platform is configured without + importing the legacy table. + """ + extra = getattr(config, "extra", {}) or {} + return bool( + extra.get("project_id") and extra.get("subscription_name") + ) + + +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. + """ + if not check_google_chat_requirements(): + return False + project = ( + os.getenv("GOOGLE_CHAT_PROJECT_ID") + or os.getenv("GOOGLE_CLOUD_PROJECT") + ) + subscription = ( + os.getenv("GOOGLE_CHAT_SUBSCRIPTION_NAME") + or os.getenv("GOOGLE_CHAT_SUBSCRIPTION") + ) + return bool(project and subscription) + + +def _is_connected(config: PlatformConfig) -> bool: + """``GatewayConfig.get_connected_platforms()`` polls this.""" + return bool(getattr(config, "enabled", False)) and _validate_config(config) + + +def _env_enablement() -> Optional[Dict[str, Any]]: + """Seed ``PlatformConfig.extra`` from env vars during + ``_apply_env_overrides``. + + 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``. + """ + project = ( + os.getenv("GOOGLE_CHAT_PROJECT_ID") + or os.getenv("GOOGLE_CLOUD_PROJECT") + ) + subscription = ( + os.getenv("GOOGLE_CHAT_SUBSCRIPTION_NAME") + or os.getenv("GOOGLE_CHAT_SUBSCRIPTION") + ) + if not (project and subscription): + return None + seed: Dict[str, Any] = { + "project_id": project, + "subscription_name": subscription, + } + sa_json = ( + os.getenv("GOOGLE_CHAT_SERVICE_ACCOUNT_JSON") + or os.getenv("GOOGLE_APPLICATION_CREDENTIALS") + ) + if sa_json: + seed["service_account_json"] = sa_json + home = os.getenv("GOOGLE_CHAT_HOME_CHANNEL") + if home: + seed["home_channel"] = { + "chat_id": home, + "name": os.getenv("GOOGLE_CHAT_HOME_CHANNEL_NAME", "Home"), + } + return seed + + +def interactive_setup() -> None: + """Walk the user through Google Chat configuration via ``hermes setup``. + + 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.config import ( + get_env_value, + save_env_value, + prompt, + prompt_yes_no, + print_info, + print_success, + print_warning, + ) + + existing_sub = get_env_value("GOOGLE_CHAT_SUBSCRIPTION_NAME") + if existing_sub: + print_info(f"Google Chat: already configured (subscription: {existing_sub})") + if not prompt_yes_no("Reconfigure Google Chat?", False): + return + + 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()) + + 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 "", + ) + if allowed: + save_env_value("GOOGLE_CHAT_ALLOWED_USERS", allowed.replace(" ", "")) + print_success("Allowlist configured") + else: + save_env_value("GOOGLE_CHAT_ALLOWED_USERS", "") + else: + save_env_value("GOOGLE_CHAT_ALLOW_ALL_USERS", "true") + print_warning("⚠️ Open access — anyone who can DM the bot can command it.") + + home = prompt( + "Home space for cron/notification delivery (e.g. spaces/AAAA, or empty)", + default=get_env_value("GOOGLE_CHAT_HOME_CHANNEL") or "", + ) + if home: + save_env_value("GOOGLE_CHAT_HOME_CHANNEL", home.strip()) + + print() + print_success("Google Chat configuration saved to ~/.hermes/.env") + print_info("Restart the gateway: hermes gateway restart") + + +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. + """ + ctx.register_platform( + name="google_chat", + label="Google Chat", + adapter_factory=lambda cfg: GoogleChatAdapter(cfg), + check_fn=_check_for_registry, + validate_config=_validate_config, + is_connected=_is_connected, + required_env=[ + "GOOGLE_CHAT_PROJECT_ID", + "GOOGLE_CHAT_SUBSCRIPTION_NAME", + "GOOGLE_CHAT_SERVICE_ACCOUNT_JSON", + ], + install_hint="pip install 'hermes-agent[google_chat]'", + 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``. + 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_deliver_env_var="GOOGLE_CHAT_HOME_CHANNEL", + # 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. + max_message_length=4000, + emoji="💬", + allow_update_command=True, + platform_hint=( + "You are on Google Chat. Limited markdown subset is rendered: " + "*bold*, _italic_, ~strike~, `code`. No headings or lists. " + "Message size limit: 4000 characters; longer responses are split " + "across multiple messages. You are in a space (DM or group). " + "Images render inline; audio, video, and document attachments " + "render as download cards (no native voice/video UI). To send " + "files, include MEDIA:/absolute/path/to/file in your response. " + "Native file attachments require the user to run /setup-files " + "once in their own DM — until they do, file requests fall back " + "to a text notice with the host path. Do NOT generate interactive " + "Card v2 buttons — Google Chat interactivity is not yet supported " + "by this gateway; ask for typed confirmations instead. While you " + "are generating a response, a 'Hermes is thinking…' marker message " + "appears in the space and is deleted once your response is ready. " + "You do NOT have access to Google Chat-specific APIs — you cannot " + "search space history, list space members, or manage spaces. Do " + "not promise to perform these actions; explain that you can only " + "read messages sent directly to you and respond in the same " + "space/thread." + ), + ) diff --git a/plugins/platforms/google_chat/oauth.py b/plugins/platforms/google_chat/oauth.py new file mode 100644 index 0000000000..8c581133fc --- /dev/null +++ b/plugins/platforms/google_chat/oauth.py @@ -0,0 +1,638 @@ +"""User OAuth helper for the Google Chat gateway adapter. + +Google Chat's ``media.upload`` REST endpoint hard-rejects service-account +authentication: + + "This method doesn't support app authentication with a service + account. Authenticate with a user account." + +(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. + +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`` +- Shared OAuth client (one per host): + ``${HERMES_HOME}/google_chat_user_client_secret.json`` +""" + +from __future__ import annotations + +import argparse +import json +import logging +import os +import re +import subprocess +import sys +from pathlib import Path +from typing import Any, List, Optional, Tuple + +# Pin the legacy logger name so operator-side log filters keep matching +# after the in-tree → plugin migration. See adapter.py for context. +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). + def get_hermes_home() -> Path: + val = os.environ.get("HERMES_HOME", "").strip() + return Path(val) if val else Path.home() / ".hermes" + + def display_hermes_home() -> str: + home = get_hermes_home() + try: + return "~/" + str(home.relative_to(Path.home())) + except ValueError: + return str(home) + + +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.""" + 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. +_EMAIL_FS_RE = re.compile(r"[^a-z0-9._@-]+") + + +def _sanitize_email(email: str) -> str: + cleaned = _EMAIL_FS_RE.sub("_", (email or "").strip().lower()) + 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.""" + if email: + return _user_tokens_dir() / f"{_sanitize_email(email)}.json" + return _legacy_token_path() + + +def _client_secret_path() -> Path: + return _hermes_home() / "google_chat_user_client_secret.json" + + +def _pending_auth_path(email: Optional[str] = None) -> Path: + if email: + return _user_pending_dir() / f"{_sanitize_email(email)}.json" + return _legacy_pending_path() + + +# 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. +SCOPES: List[str] = [ + "https://www.googleapis.com/auth/chat.messages.create", +] + +# Pip packages required for the OAuth flow. +_REQUIRED_PACKAGES = [ + "google-api-python-client", + "google-auth-oauthlib", + "google-auth-httplib2", +] + +# 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. +_REDIRECT_URI = "http://localhost:1" + + +# ============================================================================= +# Library API — called from the adapter at runtime +# ============================================================================= + + +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. + """ + token_path = _token_path(email) + if not token_path.exists(): + return None + + try: + from google.oauth2.credentials import Credentials + from google.auth.transport.requests import Request + except ImportError: + logger.warning( + "[google_chat_user_oauth] google-auth not installed; user-OAuth " + "attachment delivery is disabled. Install hermes-agent[google_chat]." + ) + 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. + 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, + ) + 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 + + +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. + """ + 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, + ) + 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. + """ + 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. + """ + 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 + + +def _persist_credentials(creds: Any, token_path: Path) -> None: + """Atomic-ish JSON write of refreshed credentials.""" + try: + token_path.parent.mkdir(parents=True, exist_ok=True) + token_path.write_text( + json.dumps( + _normalize_authorized_user_payload(json.loads(creds.to_json())), + indent=2, + ) + ) + except Exception: + logger.debug( + "[google_chat_user_oauth] failed to persist credentials at %s", + token_path, exc_info=True, + ) + + +# ============================================================================= +# CLI commands — driven by the agent via /setup-files +# ============================================================================= + + +def _normalize_authorized_user_payload(payload: dict) -> dict: + """Ensure the persisted token JSON has the type field google-auth expects.""" + normalized = dict(payload) + if not normalized.get("type"): + normalized["type"] = "authorized_user" + return normalized + + +def _ensure_deps() -> None: + """Check deps available; install if not; exit on failure.""" + try: + import googleapiclient # noqa: F401 + import google_auth_oauthlib # noqa: F401 + except ImportError: + if not install_deps(): + sys.exit(1) + + +def install_deps() -> bool: + try: + import googleapiclient # noqa: F401 + import google_auth_oauthlib # noqa: F401 + print("Dependencies already installed.") + return True + except ImportError: + pass + + print("Installing Google Chat OAuth dependencies...") + try: + subprocess.check_call( + [sys.executable, "-m", "pip", "install", "--quiet"] + _REQUIRED_PACKAGES, + stdout=subprocess.DEVNULL, + ) + print("Dependencies installed.") + return True + except subprocess.CalledProcessError as exc: + print(f"ERROR: Failed to install dependencies: {exc}") + print("Or install via the optional extra:") + print(" pip install 'hermes-agent[google_chat]'") + return False + + +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. + """ + 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: + print(f"TOKEN_INVALID: Re-run /setup-files (path: {token_path})") + return False + + print(f"AUTHENTICATED: Token valid at {token_path}") + return True + + +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) + + try: + data = json.loads(src.read_text()) + except json.JSONDecodeError: + print("ERROR: File is not valid JSON.") + sys.exit(1) + + if "installed" not in data and "web" not in data: + print( + "ERROR: Not a Google OAuth client secret file (missing " + "'installed' or 'web' key)." + ) + print( + "Download from: https://console.cloud.google.com/apis/credentials" + ) + sys.exit(1) + + target = _client_secret_path() + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(json.dumps(data, indent=2)) + 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) + pending.parent.mkdir(parents=True, exist_ok=True) + pending.write_text( + json.dumps( + { + "state": state, + "code_verifier": code_verifier, + "redirect_uri": _REDIRECT_URI, + "email": email or "", + }, + indent=2, + ) + ) + + +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) + try: + data = json.loads(pending.read_text()) + 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) + 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) + return data + + +def _extract_code_and_state(code_or_url: str) -> Tuple[str, Optional[str]]: + """Accept a raw auth code OR the full failed-redirect URL the user pastes.""" + if not code_or_url.startswith("http"): + return code_or_url, None + + from urllib.parse import parse_qs, urlparse + + parsed = urlparse(code_or_url) + params = parse_qs(parsed.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 + + +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) + + _ensure_deps() + from google_auth_oauthlib.flow import Flow + + flow = Flow.from_client_secrets_file( + str(_client_secret_path()), + scopes=SCOPES, + redirect_uri=_REDIRECT_URI, + autogenerate_code_verifier=True, + ) + 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) + + 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) + + _ensure_deps() + from google_auth_oauthlib.flow import Flow + from urllib.parse import parse_qs, urlparse + + granted_scopes = list(SCOPES) + if isinstance(raw_callback, str) and raw_callback.startswith("http"): + params = parse_qs(urlparse(raw_callback).query) + scope_val = (params.get("scope") or [""])[0].strip() + if scope_val: + granted_scopes = scope_val.split() + + flow = Flow.from_client_secrets_file( + str(_client_secret_path()), + scopes=granted_scopes, + redirect_uri=pending_auth.get("redirect_uri", _REDIRECT_URI), + 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) + + 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 [] + ) + if actually_granted: + token_payload["scopes"] = actually_granted + elif granted_scopes != SCOPES: + token_payload["scopes"] = granted_scopes + + token_path = _token_path(email) + token_path.parent.mkdir(parents=True, exist_ok=True) + token_path.write_text(json.dumps(token_payload, indent=2)) + _pending_auth_path(email).unlink(missing_ok=True) + + print(f"OK: Authenticated. Token saved to {token_path}") + rel_label = ( + f"{display_hermes_home()}/google_chat_user_tokens/{_sanitize_email(email)}.json" + if email + else f"{display_hermes_home()}/google_chat_user_token.json" + ) + print(f"Profile path: {rel_label}") + + +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. + """ + token_path = _token_path(email) + if not token_path.exists(): + print("No token to revoke.") + return + + _ensure_deps() + from google.oauth2.credentials import Credentials + from google.auth.transport.requests import Request + + try: + creds = Credentials.from_authorized_user_file(str(token_path), SCOPES) + if creds.expired and creds.refresh_token: + creds.refresh(Request()) + + import urllib.request + urllib.request.urlopen( + urllib.request.Request( + f"https://oauth2.googleapis.com/revoke?token={creds.token}", + method="POST", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + ) + print("Token revoked with Google.") + except Exception as exc: + print(f"Remote revocation failed (token may already be invalid): {exc}") + + token_path.unlink(missing_ok=True) + _pending_auth_path(email).unlink(missing_ok=True) + print(f"Deleted {token_path}") + + +def main() -> None: + parser = argparse.ArgumentParser( + 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)") + args = parser.parse_args() + + email = args.email or None + if args.check: + sys.exit(0 if check_auth(email) else 1) + elif args.client_secret: + store_client_secret(args.client_secret) + elif args.auth_url: + get_auth_url(email) + elif args.auth_code: + exchange_auth_code(args.auth_code, email) + elif args.revoke: + revoke(email) + elif args.install_deps: + sys.exit(0 if install_deps() else 1) + + +if __name__ == "__main__": + main() diff --git a/plugins/platforms/google_chat/plugin.yaml b/plugins/platforms/google_chat/plugin.yaml new file mode 100644 index 0000000000..1a8b90c43a --- /dev/null +++ b/plugins/platforms/google_chat/plugin.yaml @@ -0,0 +1,39 @@ +name: google_chat-platform +label: Google Chat +kind: platform +version: 1.0.0 +description: > + Google Chat gateway adapter for Hermes Agent. + Connects via Cloud Pub/Sub pull subscription for inbound events and the + Google Chat REST API for outbound messages — same ergonomics as Slack + Socket Mode or Telegram long-polling, no public URL required. Native + file attachments are delivered via per-user OAuth (each user runs + /setup-files once in their own DM). +author: Ramón Fernández +# ``requires_env`` entries are surfaced in ``hermes config`` UI via the +# platform-plugin env var injector in ``hermes_cli/config.py``. Using the +# rich-dict form lets us contribute description/url/prompt metadata so users +# see helpful guidance instead of the auto-generated fallback text. +requires_env: + - name: GOOGLE_CHAT_PROJECT_ID + description: "GCP project ID hosting the Pub/Sub topic for Chat events. Falls back to GOOGLE_CLOUD_PROJECT." + prompt: "GCP project ID" + url: "https://console.cloud.google.com/" + password: false + - name: GOOGLE_CHAT_SUBSCRIPTION_NAME + description: "Full Pub/Sub subscription path: projects//subscriptions/. Legacy alias: GOOGLE_CHAT_SUBSCRIPTION." + prompt: "Pub/Sub subscription name" + password: false + - name: GOOGLE_CHAT_SERVICE_ACCOUNT_JSON + description: "Path to Service Account JSON key (or inline JSON). Leave empty to use Application Default Credentials on Cloud Run / GCE. Falls back to GOOGLE_APPLICATION_CREDENTIALS." + prompt: "Path to SA JSON (or empty for ADC)" + password: true +optional_env: + - name: GOOGLE_CHAT_ALLOWED_USERS + description: "Comma-separated user emails allowed to interact with the bot." + prompt: "Allowed user emails (comma-separated)" + password: false + - name: GOOGLE_CHAT_HOME_CHANNEL + description: "Default space for cron / notification delivery (e.g. spaces/AAAA...)." + prompt: "Home space ID (or empty)" + password: false diff --git a/pyproject.toml b/pyproject.toml index 7717e167ac..7325b2fa1f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -90,6 +90,20 @@ google = [ "google-auth-oauthlib>=1.0,<2", "google-auth-httplib2>=0.2,<1", ] +google_chat = [ + # Google Chat gateway adapter (plugins/platforms/google_chat/): Pub/Sub for + # inbound events, Chat REST API for outbound. Shares the api-client and + # httplib2 transport with [google] but adds the Pub/Sub library. + # google-auth-oauthlib is required for the user-OAuth consent flow that + # backs native attachment delivery — Chat's media.upload endpoint rejects + # service-account auth, so the user grants chat.messages.create once via + # /setup-files in chat. See plugins/platforms/google_chat/oauth.py. + "google-cloud-pubsub>=2.20,<3", + "google-api-python-client>=2.100,<3", + "google-auth>=2.20,<3", + "google-auth-httplib2>=0.2,<1", + "google-auth-oauthlib>=1.0,<2", +] # `hermes dashboard` (localhost SPA + API). Not in core to keep the default install lean. web = ["fastapi>=0.104.0,<1", "uvicorn[standard]>=0.24.0,<1"] rl = [ @@ -124,6 +138,7 @@ all = [ "hermes-agent[dingtalk]", "hermes-agent[feishu]", "hermes-agent[google]", + "hermes-agent[google_chat]", "hermes-agent[mistral]", "hermes-agent[bedrock]", "hermes-agent[web]", diff --git a/tests/gateway/test_google_chat.py b/tests/gateway/test_google_chat.py new file mode 100644 index 0000000000..140c11b6b5 --- /dev/null +++ b/tests/gateway/test_google_chat.py @@ -0,0 +1,2582 @@ +""" +Tests for Google Chat platform adapter. + +Covers: platform registration, env config loading, adapter init, connect +validation, Pub/Sub callback routing (message / membership / card / error), +outbound send with typing patch-in-place and chunking, attachment send paths, +SSRF guard on attachment download, supervisor reconnect, and authorization +(including the user_id_alt email match for GOOGLE_CHAT_ALLOWED_USERS). + +Note: the Google libraries may not be installed in the test environment. +We shim the imports at module load so collection doesn't fail. +""" + +import asyncio +import json +import os +import sys +import types +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from gateway.config import Platform, PlatformConfig, load_gateway_config + + +# --------------------------------------------------------------------------- +# Mock the google-* packages if they are not installed +# --------------------------------------------------------------------------- + +class _FakeHttpError(Exception): + """Stand-in for googleapiclient.errors.HttpError with .resp.status.""" + + def __init__(self, status=500, content=b"", reason=""): + self.resp = MagicMock() + self.resp.status = status + self.content = content + self.reason = reason + super().__init__(f"HTTP {status}: {reason or 'error'}") + + +def _ensure_google_mocks(): + """Install mock google-* modules so GoogleChatAdapter can be imported.""" + if "google.cloud.pubsub_v1" in sys.modules and hasattr( + sys.modules["google.cloud.pubsub_v1"], "__file__" + ): + return # Real libraries installed, use them. + + # --- google.cloud.pubsub_v1 --- + google = MagicMock() + google_cloud = MagicMock() + pubsub_v1 = MagicMock() + pubsub_v1.SubscriberClient = MagicMock + pubsub_v1.types.FlowControl = MagicMock + + # --- google.api_core.exceptions --- + gax = MagicMock() + gax.NotFound = type("NotFound", (Exception,), {}) + gax.PermissionDenied = type("PermissionDenied", (Exception,), {}) + gax.Unauthenticated = type("Unauthenticated", (Exception,), {}) + + # --- google.oauth2.service_account --- + oauth2 = MagicMock() + oauth2.Credentials.from_service_account_info = MagicMock(return_value=MagicMock()) + oauth2.Credentials.from_service_account_file = MagicMock(return_value=MagicMock()) + + # --- google_auth_httplib2 + httplib2 --- + httplib2 = MagicMock() + httplib2.Http = MagicMock() + google_auth_httplib2 = MagicMock() + google_auth_httplib2.AuthorizedHttp = MagicMock() + + # --- googleapiclient --- + gapi = MagicMock() + gapi_discovery = MagicMock() + gapi_discovery.build = MagicMock() + gapi_errors = MagicMock() + gapi_errors.HttpError = _FakeHttpError + gapi_http = MagicMock() + gapi_http.MediaFileUpload = MagicMock + + modules = { + "google": google, + "google.cloud": google_cloud, + "google.cloud.pubsub_v1": pubsub_v1, + "google.api_core": MagicMock(exceptions=gax), + "google.api_core.exceptions": gax, + "google.oauth2": MagicMock(service_account=oauth2), + "google.oauth2.service_account": oauth2, + "google_auth_httplib2": google_auth_httplib2, + "httplib2": httplib2, + "googleapiclient": gapi, + "googleapiclient.discovery": gapi_discovery, + "googleapiclient.errors": gapi_errors, + "googleapiclient.http": gapi_http, + } + for name, mod in modules.items(): + sys.modules.setdefault(name, mod) + + +_ensure_google_mocks() + + +# Patch the availability flag before importing, so the adapter doesn't bail +# out at the "missing deps" gate during construction. +# +# Note on imports: Teams' test suite uses +# ``tests.gateway._plugin_adapter_loader.load_plugin_adapter`` to load +# its adapter under a unique ``plugin_adapter_`` module name. That +# helper assumes the plugin is a single ``adapter.py`` file with no +# companion modules — it does not set ``__package__`` on the loaded +# module, so any relative import (e.g. our adapter's ``from .oauth import``) +# raises ``ImportError: attempted relative import with no known parent +# package``. +# +# Our google_chat plugin has a companion ``oauth.py`` module (the +# OAuth helper for native attachment delivery), so we need a real package +# context. The fully-qualified package import below resolves correctly +# because ``plugins/__init__.py`` and ``plugins/platforms/__init__.py`` +# exist as regular packages on disk. The conftest anti-pattern guard +# (which targets bare ``import adapter`` / ``from adapter import …`` and +# ``sys.path.insert`` into ``plugins/platforms/``) does not flag this +# fully-qualified form. +import plugins.platforms.google_chat.adapter as _gc_mod # noqa: E402 + +_gc_mod.GOOGLE_CHAT_AVAILABLE = True + +from gateway.platforms.base import MessageEvent, MessageType, ProcessingOutcome # noqa: E402 +from plugins.platforms.google_chat.adapter import ( # noqa: E402 + GoogleChatAdapter, + _is_google_owned_host, + _mime_for_message_type, + _redact_sensitive, + check_google_chat_requirements, +) + + +# --------------------------------------------------------------------------- +# Helpers / fixtures +# --------------------------------------------------------------------------- + + +def _base_config(**extra): + cfg = PlatformConfig(enabled=True) + cfg.extra.update({ + "project_id": "test-project", + "subscription_name": "projects/test-project/subscriptions/test-sub", + "service_account_json": "/tmp/fake-sa.json", + }) + cfg.extra.update(extra) + return cfg + + +@pytest.fixture() +def adapter(tmp_path): + """Build an adapter with its loop captured and Chat client mocked. + + Redirects the persistent thread-count store to a tmp file so tests + don't pollute (or read state from) the developer's real + ~/.hermes/google_chat_thread_counts.json. + """ + from plugins.platforms.google_chat.adapter import _ThreadCountStore + a = GoogleChatAdapter(_base_config()) + a._loop = asyncio.get_event_loop_policy().new_event_loop() + a._chat_api = MagicMock() + a._subscriber = MagicMock() + a._credentials = MagicMock() + a._project_id = "test-project" + a._subscription_path = "projects/test-project/subscriptions/test-sub" + a._new_authed_http = MagicMock(return_value=MagicMock()) + a.handle_message = AsyncMock() + # Replace the production store (which would write to ~/.hermes/...) + # with a tmp-path one so tests can roundtrip without side effects. + a._thread_count_store = _ThreadCountStore( + tmp_path / "google_chat_thread_counts.json" + ) + yield a + try: + a._loop.close() + except Exception: + pass + + +def _make_pubsub_message(data: dict, *, attributes=None): + """Build a Mock Pub/Sub Message with ack/nack trackers.""" + msg = MagicMock() + msg.data = json.dumps(data).encode("utf-8") + msg.attributes = attributes or {} + msg.ack = MagicMock() + msg.nack = MagicMock() + return msg + + +def _make_chat_envelope(text="hello", sender_email="u@example.com", sender_type="HUMAN", + msg_name=None, thread_name=None, attachments=None, + slash_command=None): + """Build a realistic Google Chat CloudEvents-style envelope body.""" + msg = { + "name": msg_name or "spaces/S/messages/M.M", + "sender": { + "name": "users/12345", + "email": sender_email, + "displayName": "User Name", + "type": sender_type, + }, + "text": text, + "argumentText": text, + "thread": {"name": thread_name or "spaces/S/threads/T"}, + "space": {"name": "spaces/S", "spaceType": "DIRECT_MESSAGE"}, + } + if attachments is not None: + msg["attachment"] = attachments + if slash_command is not None: + msg["slashCommand"] = slash_command + + return { + "chat": { + "messagePayload": { + "space": msg["space"], + "message": msg, + } + } + } + + +# =========================================================================== +# Platform registration + requirements +# =========================================================================== + + +class TestPlatformRegistration: + def test_enum_value(self): + assert Platform.GOOGLE_CHAT.value == "google_chat" + + def test_requirements_check_returns_true_when_available(self): + # The shim flag is True in this test module. + assert check_google_chat_requirements() is True + + +# =========================================================================== +# Env-var config loading +# =========================================================================== + + +class TestEnvConfigLoading: + _ENV_VARS = ( + "GOOGLE_CHAT_PROJECT_ID", + "GOOGLE_CLOUD_PROJECT", + "GOOGLE_CHAT_SUBSCRIPTION_NAME", + "GOOGLE_CHAT_SUBSCRIPTION", + "GOOGLE_CHAT_SERVICE_ACCOUNT_JSON", + "GOOGLE_APPLICATION_CREDENTIALS", + "GOOGLE_CHAT_HOME_CHANNEL", + "GOOGLE_CHAT_HOME_CHANNEL_NAME", + ) + + def _clean_env(self, monkeypatch): + for v in self._ENV_VARS: + monkeypatch.delenv(v, raising=False) + + def test_project_id_primary(self, monkeypatch): + self._clean_env(monkeypatch) + monkeypatch.setenv("GOOGLE_CHAT_PROJECT_ID", "my-proj") + monkeypatch.setenv("GOOGLE_CHAT_SUBSCRIPTION_NAME", + "projects/my-proj/subscriptions/my-sub") + cfg = load_gateway_config() + gc = cfg.platforms[Platform.GOOGLE_CHAT] + assert gc.enabled is True + assert gc.extra["project_id"] == "my-proj" + + def test_project_id_falls_back_to_google_cloud_project(self, monkeypatch): + self._clean_env(monkeypatch) + monkeypatch.setenv("GOOGLE_CLOUD_PROJECT", "fallback-proj") + monkeypatch.setenv("GOOGLE_CHAT_SUBSCRIPTION", + "projects/fallback-proj/subscriptions/s") + cfg = load_gateway_config() + gc = cfg.platforms[Platform.GOOGLE_CHAT] + assert gc.extra["project_id"] == "fallback-proj" + + def test_subscription_accepts_legacy_alias(self, monkeypatch): + self._clean_env(monkeypatch) + monkeypatch.setenv("GOOGLE_CHAT_PROJECT_ID", "p") + monkeypatch.setenv("GOOGLE_CHAT_SUBSCRIPTION", "projects/p/subscriptions/s") + cfg = load_gateway_config() + gc = cfg.platforms[Platform.GOOGLE_CHAT] + assert gc.extra["subscription_name"] == "projects/p/subscriptions/s" + + def test_sa_path_falls_back_to_google_application_credentials(self, monkeypatch): + self._clean_env(monkeypatch) + monkeypatch.setenv("GOOGLE_CHAT_PROJECT_ID", "p") + monkeypatch.setenv("GOOGLE_CHAT_SUBSCRIPTION_NAME", + "projects/p/subscriptions/s") + monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS", "/opt/sa.json") + cfg = load_gateway_config() + gc = cfg.platforms[Platform.GOOGLE_CHAT] + assert gc.extra["service_account_json"] == "/opt/sa.json" + + def test_missing_subscription_does_not_enable(self, monkeypatch): + self._clean_env(monkeypatch) + monkeypatch.setenv("GOOGLE_CHAT_PROJECT_ID", "p") + # No subscription. + cfg = load_gateway_config() + assert Platform.GOOGLE_CHAT not in cfg.platforms + + def test_missing_project_does_not_enable(self, monkeypatch): + self._clean_env(monkeypatch) + monkeypatch.setenv("GOOGLE_CHAT_SUBSCRIPTION_NAME", + "projects/p/subscriptions/s") + cfg = load_gateway_config() + assert Platform.GOOGLE_CHAT not in cfg.platforms + + def test_home_channel_populated(self, monkeypatch): + self._clean_env(monkeypatch) + monkeypatch.setenv("GOOGLE_CHAT_PROJECT_ID", "p") + monkeypatch.setenv("GOOGLE_CHAT_SUBSCRIPTION_NAME", + "projects/p/subscriptions/s") + monkeypatch.setenv("GOOGLE_CHAT_HOME_CHANNEL", "spaces/HOME") + cfg = load_gateway_config() + gc = cfg.platforms[Platform.GOOGLE_CHAT] + assert gc.home_channel is not None + assert gc.home_channel.chat_id == "spaces/HOME" + + def test_connected_platforms_recognises_via_extras(self, monkeypatch): + self._clean_env(monkeypatch) + monkeypatch.setenv("GOOGLE_CHAT_PROJECT_ID", "p") + monkeypatch.setenv("GOOGLE_CHAT_SUBSCRIPTION_NAME", + "projects/p/subscriptions/s") + cfg = load_gateway_config() + assert Platform.GOOGLE_CHAT in cfg.get_connected_platforms() + + +# =========================================================================== +# Pure helpers +# =========================================================================== + + +class TestHelpers: + def test_mime_image_maps_to_photo(self): + assert _mime_for_message_type("image/png") == MessageType.PHOTO + + def test_mime_audio_maps_to_audio(self): + assert _mime_for_message_type("audio/ogg") == MessageType.AUDIO + + def test_mime_video_maps_to_video(self): + assert _mime_for_message_type("video/mp4") == MessageType.VIDEO + + def test_mime_other_maps_to_document(self): + assert _mime_for_message_type("application/pdf") == MessageType.DOCUMENT + + def test_mime_empty_maps_to_document(self): + assert _mime_for_message_type("") == MessageType.DOCUMENT + + +class TestRedactSensitive: + def test_redacts_subscription_path(self): + out = _redact_sensitive("error on projects/proj-a/subscriptions/sub-b please") + assert "proj-a" not in out + assert "sub-b" not in out + assert "please" in out # surrounding text preserved + + def test_redacts_topic_path(self): + out = _redact_sensitive("publisher on projects/p/topics/t") + assert "projects/p/topics/t" not in out + assert "" in out + + def test_redacts_service_account_email(self): + out = _redact_sensitive("bot@my-project-123.iam.gserviceaccount.com is the principal") + assert "bot" not in out + assert "my-project-123" not in out + assert "principal" in out + + def test_empty_text_passes_through(self): + assert _redact_sensitive("") == "" + assert _redact_sensitive(None) is None + + +class TestGoogleOwnedHost: + @pytest.mark.parametrize("url", [ + "https://chat.googleapis.com/v1/x", + "https://www.googleapis.com/upload/chat/v1/x", + "https://drive.google.com/file/d/abc", + "https://lh3.googleusercontent.com/photo.jpg", + ]) + def test_accepts_google_hosts(self, url): + assert _is_google_owned_host(url) is True + + @pytest.mark.parametrize("url", [ + "https://evil.com/foo", + "https://169.254.169.254/latest/meta-data/", + "https://metadata.internal/computeMetadata/v1/", + "https://chat.google.com.attacker.example/", # subdomain hijack + "http://chat.googleapis.com/", # http is rejected + "ftp://drive.google.com/x", # non-https rejected + "not a url", + ]) + def test_rejects_non_google_or_insecure(self, url): + assert _is_google_owned_host(url) is False + + +# =========================================================================== +# Config validation (inside connect()) +# =========================================================================== + + +class TestValidateConfig: + def test_missing_project_raises(self): + a = GoogleChatAdapter(PlatformConfig(enabled=True)) + with pytest.raises(ValueError, match="PROJECT"): + a._validate_config() + + def test_missing_subscription_raises(self): + cfg = PlatformConfig(enabled=True) + cfg.extra["project_id"] = "p" + a = GoogleChatAdapter(cfg) + with pytest.raises(ValueError, match="SUBSCRIPTION"): + a._validate_config() + + def test_subscription_format_rejected(self): + cfg = _base_config(subscription_name="not-a-valid-path") + a = GoogleChatAdapter(cfg) + with pytest.raises(ValueError, match="projects/"): + a._validate_config() + + def test_subscription_project_mismatch_rejected(self): + cfg = _base_config( + subscription_name="projects/other-proj/subscriptions/s", + project_id="my-proj", + ) + a = GoogleChatAdapter(cfg) + with pytest.raises(ValueError, match="does not match"): + a._validate_config() + + def test_validate_config_happy(self): + a = GoogleChatAdapter(_base_config()) + project, sub = a._validate_config() + assert project == "test-project" + assert sub == "projects/test-project/subscriptions/test-sub" + + +# =========================================================================== +# _chunk_text +# =========================================================================== + + +class TestChunkText: + def test_empty_returns_empty_list(self, adapter): + assert adapter._chunk_text("") == [] + + def test_short_returns_single_chunk(self, adapter): + assert adapter._chunk_text("hola") == ["hola"] + + def test_long_splits_into_multiple(self, adapter): + text = "a" * 10000 + chunks = adapter._chunk_text(text) + assert len(chunks) >= 2 + assert all(len(c) <= 4000 for c in chunks) + assert "".join(chunks) == text + + def test_splits_on_newline_near_boundary(self, adapter): + # Build a ~5000-char string with a newline near the 4000 cut. + text = "a" * 3800 + "\n" + "b" * 1500 + chunks = adapter._chunk_text(text) + assert len(chunks) == 2 + # First chunk ends at the newline (3800 a's, no trailing b's) + assert chunks[0].endswith("a") + assert "\n" not in chunks[0][-5:] # the split already ate the newline + + +# =========================================================================== +# _on_pubsub_message — event routing +# =========================================================================== + + +class TestOnPubsubMessage: + """Pub/Sub callback routing. The callback runs in a thread and dispatches + to the asyncio loop; here we assert ack/nack behaviour and that + handle_message is scheduled only for MESSAGE events.""" + + def test_shutting_down_nacks(self, adapter): + adapter._shutting_down = True + msg = _make_pubsub_message({"whatever": 1}) + adapter._on_pubsub_message(msg) + msg.nack.assert_called_once() + msg.ack.assert_not_called() + + def test_malformed_json_acks_without_dispatch(self, adapter): + msg = MagicMock() + msg.data = b"not valid json {" + msg.attributes = {} + msg.ack = MagicMock() + msg.nack = MagicMock() + adapter._on_pubsub_message(msg) + msg.ack.assert_called_once() + msg.nack.assert_not_called() + + def test_membership_created_caches_bot_user_id(self, adapter, tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + adapter._bot_user_id = None + envelope = { + "chat": { + "membershipPayload": { + "space": {"name": "spaces/S"}, + "membership": {"member": {"name": "users/BOT_ID", "type": "BOT"}}, + } + } + } + msg = _make_pubsub_message( + envelope, + attributes={"ce-type": "google.workspace.chat.membership.v1.created"}, + ) + adapter._on_pubsub_message(msg) + assert adapter._bot_user_id == "users/BOT_ID" + msg.ack.assert_called_once() + + def test_membership_deleted_acks_no_dispatch(self, adapter): + envelope = { + "chat": { + "membershipPayload": { + "space": {"name": "spaces/S"}, + "membership": {"member": {"name": "users/BOT_ID", "type": "BOT"}}, + } + } + } + msg = _make_pubsub_message( + envelope, + attributes={"ce-type": "google.workspace.chat.membership.v1.deleted"}, + ) + adapter._on_pubsub_message(msg) + msg.ack.assert_called_once() + + def test_bot_sender_is_filtered(self, adapter): + env = _make_chat_envelope(sender_type="BOT") + msg = _make_pubsub_message(env) + with patch.object(adapter, "_submit_on_loop") as submit: + adapter._on_pubsub_message(msg) + submit.assert_not_called() + msg.ack.assert_called_once() + + def test_duplicate_message_dropped(self, adapter): + env = _make_chat_envelope(msg_name="spaces/S/messages/DUP.DUP") + # Prime dedup + adapter._dedup.is_duplicate("spaces/S/messages/DUP.DUP") + msg = _make_pubsub_message(env) + with patch.object(adapter, "_submit_on_loop") as submit: + adapter._on_pubsub_message(msg) + submit.assert_not_called() + msg.ack.assert_called_once() + + def test_text_message_submits_to_loop(self, adapter): + env = _make_chat_envelope(text="hola") + msg = _make_pubsub_message(env) + with patch.object(adapter, "_submit_on_loop") as submit: + adapter._on_pubsub_message(msg) + submit.assert_called_once() + msg.ack.assert_called_once() + + def test_callback_exception_does_not_escape(self, adapter): + env = _make_chat_envelope(text="hola") + msg = _make_pubsub_message(env) + with patch.object( + adapter, "_submit_on_loop", side_effect=RuntimeError("boom") + ): + # Must not re-raise (would trigger Pub/Sub infinite redelivery). + adapter._on_pubsub_message(msg) + msg.ack.assert_called_once() + + +class TestExtractMessagePayload: + """Three Pub/Sub envelope formats are accepted. + + The Workspace Add-ons format (current default) was already exercised + by the rest of TestOnPubsubMessage; these tests pin the contract for + the two alternative formats so the multi-format helper does not + regress when operators have non-standard Chat app configurations. + + Patterns adapted from PR #14965 by @ArnarValur. + """ + + def test_native_chat_api_format_extracts_msg_and_space(self): + """Format 2: top-level ``message`` + ``space`` + ``type=MESSAGE``. + + Used by Chat apps configured WITHOUT the Workspace Add-ons + wrapper — events arrive directly from the Chat API publisher. + """ + envelope = { + "type": "MESSAGE", + "message": { + "name": "spaces/S/messages/M.M", + "sender": { + "name": "users/12345", + "email": "alice@example.com", + "displayName": "Alice", + "type": "HUMAN", + }, + "text": "hello", + "argumentText": "hello", + "thread": {"name": "spaces/S/threads/T"}, + }, + "space": {"name": "spaces/S", "spaceType": "DIRECT_MESSAGE"}, + } + result = GoogleChatAdapter._extract_message_payload(envelope, ce_type="") + assert result is not None + msg, space, fmt = result + assert fmt == "native_chat_api" + assert msg.get("name") == "spaces/S/messages/M.M" + assert msg.get("sender", {}).get("email") == "alice@example.com" + assert space.get("name") == "spaces/S" + assert space.get("spaceType") == "DIRECT_MESSAGE" + + def test_native_chat_api_format_drops_non_message_events(self): + """Format 2 with ``type != MESSAGE`` returns None — caller acks.""" + envelope = { + "type": "ADDED_TO_SPACE", + "message": {"name": "spaces/S/messages/M"}, + "space": {"name": "spaces/S"}, + } + assert GoogleChatAdapter._extract_message_payload(envelope) is None + + def test_relay_flat_format_synthesizes_chat_api_shape(self): + """Format 3: flat fields from a custom Cloud Run relay. + + Some self-hosted setups put a relay in front of Pub/Sub to keep + GCP credentials off the Hermes host. The relay flattens Chat + events into top-level ``sender_email`` / ``text`` / ``space_name`` + / etc. The helper synthesizes a Chat-API-shaped ``message`` dict + so downstream code (``_dispatch_message`` → + ``_build_message_event``) consumes it without branching. + """ + envelope = { + "event_type": "MESSAGE", + "sender_email": "bob@example.com", + "sender_display_name": "Bob", + "text": "ping", + "space_name": "spaces/RELAY", + "thread_name": "spaces/RELAY/threads/T1", + "message_name": "spaces/RELAY/messages/M.M", + } + result = GoogleChatAdapter._extract_message_payload(envelope) + assert result is not None + msg, space, fmt = result + assert fmt == "relay_flat" + # Synthesized to look like the canonical Chat API shape so + # _build_message_event reads it the same way as format 1/2. + assert msg["text"] == "ping" + assert msg["argumentText"] == "ping" + assert msg["sender"]["email"] == "bob@example.com" + assert msg["sender"]["displayName"] == "Bob" + assert msg["sender"]["type"] == "HUMAN" + # Resource name is unknown for relay events; helper synthesizes + # a deterministic surrogate so dedup keys stay stable across + # at-least-once redelivery. + assert msg["sender"]["name"].startswith("users/relay-") + assert msg["thread"]["name"] == "spaces/RELAY/threads/T1" + assert msg["name"] == "spaces/RELAY/messages/M.M" + assert space["name"] == "spaces/RELAY" + + def test_unrecognized_envelope_returns_none(self): + """Random JSON with no known shape returns None (caller acks).""" + envelope = {"foo": "bar", "baz": 123} + assert GoogleChatAdapter._extract_message_payload(envelope) is None + + +# =========================================================================== +# _build_message_event — payload parsing +# =========================================================================== + + +class TestBuildMessageEvent: + @pytest.mark.asyncio + async def test_dm_first_message_in_thread_is_main_flow(self, adapter): + """Google Chat DMs spawn a fresh thread per top-level user + message in the input box. The FIRST message in any new thread + is treated as 'main flow' — thread_id is NOT propagated to the + source so all top-level messages share one DM session and the + agent retains continuity. The thread is still cached for + outbound reply placement.""" + env = _make_chat_envelope(text="hola", thread_name="spaces/S/threads/T1") + msg = env["chat"]["messagePayload"]["message"] + event = await adapter._build_message_event(msg, env) + assert event is not None + assert event.text == "hola" + assert event.source.chat_id == "spaces/S" + # First message in this thread → main-flow → no thread_id on source. + assert event.source.thread_id is None + # Identity convention (post-#14965 absorption): the sender's email + # is the canonical ``user_id``; the Chat resource name moves to + # ``user_id_alt`` for traceability and Chat-API operations. + assert event.source.user_id == "u@example.com" + assert event.source.user_id_alt == "users/12345" + # Cache MUST be empty for main-flow so outbound bot reply lands + # at top-level (Chat creates a separate thread for it). If we + # cached the user's auto-thread name and replied with thread.name + # set, Chat would show the pair as an expandable thread under + # the user's message instead of two adjacent top-level cards. + assert "spaces/S" not in adapter._last_inbound_thread + # Counter populated for next-time decision (persisted store). + assert adapter._thread_count_store.get( + "spaces/S", "spaces/S/threads/T1" + ) == 1 + + @pytest.mark.asyncio + async def test_dm_second_message_in_same_thread_is_side_thread(self, adapter): + """If we've SEEN a thread before (count > 0), the user explicitly + re-engaged it (clicked 'Reply in thread' on a prior message). + Isolate to its own session so old top-level chatter doesn't + leak in. + + Without this isolation the bug Ramón reported reappears: he + opens a new thread, says 'Hola!', asks 'dime los mensajes + anteriores' and the bot answers with messages from OTHER + threads — because all DM threads were sharing one session.""" + env1 = _make_chat_envelope(text="primera vez", thread_name="spaces/S/threads/T1") + msg1 = env1["chat"]["messagePayload"]["message"] + event1 = await adapter._build_message_event(msg1, env1) + assert event1.source.thread_id is None # first time = main flow + + env2 = _make_chat_envelope(text="segunda vez", thread_name="spaces/S/threads/T1") + msg2 = env2["chat"]["messagePayload"]["message"] + event2 = await adapter._build_message_event(msg2, env2) + # Second time same thread = user re-engaged → isolated session. + assert event2.source.thread_id == "spaces/S/threads/T1" + + @pytest.mark.asyncio + async def test_dm_side_thread_caches_thread_for_outbound(self, adapter): + """When a thread is identified as side-thread, the cache MUST + be populated so the bot's reply lands inside it. Without this + the bot would respond at top-level and the user's threaded + question would look unanswered.""" + # First message → main flow (cache stays clear). + env1 = _make_chat_envelope(text="primera", thread_name="spaces/S/threads/SIDE") + await adapter._build_message_event( + env1["chat"]["messagePayload"]["message"], env1 + ) + assert "spaces/S" not in adapter._last_inbound_thread + + # Second message in same thread → side thread → cache populated. + env2 = _make_chat_envelope(text="segunda", thread_name="spaces/S/threads/SIDE") + await adapter._build_message_event( + env2["chat"]["messagePayload"]["message"], env2 + ) + assert adapter._last_inbound_thread["spaces/S"] == "spaces/S/threads/SIDE" + + @pytest.mark.asyncio + async def test_dm_main_flow_after_side_thread_clears_cache(self, adapter): + """User was in a side thread, then returns to top-level (input + box). Main-flow cache must be CLEARED so the bot reply doesn't + accidentally land in the abandoned side thread.""" + # Two messages in T_side → side thread, cache populated. + for _ in range(2): + env = _make_chat_envelope(text="x", thread_name="spaces/S/threads/T_side") + await adapter._build_message_event( + env["chat"]["messagePayload"]["message"], env + ) + assert adapter._last_inbound_thread["spaces/S"] == "spaces/S/threads/T_side" + + # User types in input box: NEW thread T_new (count goes 0→1, main flow). + env_main = _make_chat_envelope(text="back to top", thread_name="spaces/S/threads/T_new") + await adapter._build_message_event( + env_main["chat"]["messagePayload"]["message"], env_main + ) + # Cache cleared so outbound reply lands top-level. + assert "spaces/S" not in adapter._last_inbound_thread + + @pytest.mark.asyncio + async def test_dm_different_top_level_threads_share_session(self, adapter): + """Three separate top-level user messages → three different + thread.names from Chat. None should appear on source.thread_id + so they all share one DM session.""" + for tid in ("T_a", "T_b", "T_c"): + env = _make_chat_envelope(text=f"msg in {tid}", + thread_name=f"spaces/S/threads/{tid}") + msg = env["chat"]["messagePayload"]["message"] + event = await adapter._build_message_event(msg, env) + assert event.source.thread_id is None, ( + f"thread {tid} (count=1) should be main-flow, got isolated" + ) + + @pytest.mark.asyncio + async def test_group_keeps_thread_id_on_source(self, adapter): + """In group spaces, threads are real conversational containers — + keep thread_id on the source from the FIRST message so different + threads get isolated sessions (Telegram forum / Discord thread + parity).""" + env = _make_chat_envelope(text="ping", thread_name="spaces/G/threads/T1") + env["chat"]["messagePayload"]["space"]["spaceType"] = "SPACE" + env["chat"]["messagePayload"]["message"]["space"]["spaceType"] = "SPACE" + msg = env["chat"]["messagePayload"]["message"] + event = await adapter._build_message_event(msg, env) + assert event.source.chat_type == "group" + assert event.source.thread_id == "spaces/G/threads/T1" + + @pytest.mark.asyncio + async def test_slash_command_yields_command_type(self, adapter): + env = _make_chat_envelope( + text="foo bar", + slash_command={"commandId": "42"}, + ) + msg = env["chat"]["messagePayload"]["message"] + event = await adapter._build_message_event(msg, env) + assert event.message_type == MessageType.COMMAND + assert event.text.startswith("/cmd_42") + + @pytest.mark.asyncio + async def test_attachment_image_triggers_download(self, adapter): + attachments = [{ + "name": "att/img.png", + "contentType": "image/png", + "downloadUri": "https://chat.googleapis.com/media/x", + }] + env = _make_chat_envelope(text="", attachments=attachments) + msg = env["chat"]["messagePayload"]["message"] + with patch.object( + adapter, "_download_attachment", + new=AsyncMock(return_value=("/cache/img.png", "image/png")), + ): + event = await adapter._build_message_event(msg, env) + assert event.media_urls == ["/cache/img.png"] + assert event.media_types == ["image/png"] + # With no text, the message type should reflect the first attachment. + assert event.message_type == MessageType.PHOTO + + +# =========================================================================== +# send() — text, patch-in-place, chunking, error handling +# =========================================================================== + + +class TestSend: + @pytest.mark.asyncio + async def test_text_send_creates_message(self, adapter): + adapter._create_message = AsyncMock( + return_value=type("R", (), {"success": True, "message_id": "m/1", + "error": None})() + ) + result = await adapter.send("spaces/S", "hola") + adapter._create_message.assert_called() + assert result.success is True + + @pytest.mark.asyncio + async def test_create_message_passes_messageReplyOption_when_thread_set(self, adapter): + """Critical Google Chat API quirk: when messages.create is called + with body.thread.name set BUT WITHOUT messageReplyOption query + param, Google SILENTLY ignores the thread and creates a new + thread. From official docs: 'Default. Starts a new thread. + Using this option ignores any thread ID or threadKey that's + included.' + + This test pins down the messageReplyOption= + REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD parameter so a future + refactor doesn't silently regress threading. (The user-visible + symptom of regression: bot replies land at top-level instead of + inside the user's thread.)""" + # Capture the kwargs handed to .create() — this is what hits + # Google's API. The mock chain is: spaces() -> messages() -> + # create(**kwargs) -> .execute(...). + create_call = MagicMock() + create_call.return_value.execute = MagicMock( + return_value={"name": "spaces/S/messages/M"} + ) + adapter._chat_api.spaces.return_value.messages.return_value.create = create_call + + body = { + "text": "respuesta", + "thread": {"name": "spaces/S/threads/USER_THREAD"}, + } + await adapter._create_message("spaces/S", body) + kwargs = create_call.call_args.kwargs + assert kwargs.get("parent") == "spaces/S" + assert kwargs.get("body") == body + assert kwargs.get("messageReplyOption") == "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD" + + @pytest.mark.asyncio + async def test_create_message_omits_messageReplyOption_when_no_thread(self, adapter): + """No thread.name in body → no messageReplyOption needed. + Sending it would imply a thread intent we don't have.""" + create_call = MagicMock() + create_call.return_value.execute = MagicMock( + return_value={"name": "spaces/S/messages/M"} + ) + adapter._chat_api.spaces.return_value.messages.return_value.create = create_call + + await adapter._create_message("spaces/S", {"text": "hola"}) + kwargs = create_call.call_args.kwargs + assert "messageReplyOption" not in kwargs + + @pytest.mark.asyncio + async def test_with_typing_card_patches_instead_of_creating(self, adapter): + adapter._typing_messages["spaces/S"] = "spaces/S/messages/THINK" + adapter._patch_message = AsyncMock( + return_value=type("R", (), {"success": True, + "message_id": "spaces/S/messages/THINK", + "error": None})() + ) + adapter._create_message = AsyncMock() + result = await adapter.send( + "spaces/S", "hola", + metadata={"thread_id": "spaces/S/threads/T"}, + ) + adapter._patch_message.assert_awaited_once() + adapter._create_message.assert_not_called() + assert result.success is True + # After patch, the typing slot holds the consumed sentinel so the + # base class's _keep_typing loop cannot post a fresh marker that + # the cleanup pass would later delete and tombstone. + from plugins.platforms.google_chat.adapter import _TYPING_CONSUMED_SENTINEL + assert adapter._typing_messages["spaces/S"] == _TYPING_CONSUMED_SENTINEL + + @pytest.mark.asyncio + async def test_long_text_splits_and_sends_multiple(self, adapter): + adapter._create_message = AsyncMock( + return_value=type("R", (), {"success": True, "message_id": "m", + "error": None})() + ) + long_text = "x" * 9000 + await adapter.send("spaces/S", long_text) + assert adapter._create_message.await_count >= 2 + + @pytest.mark.asyncio + async def test_403_sets_fatal_error(self, adapter): + exc = _FakeHttpError(status=403, reason="Forbidden") + adapter._create_message = AsyncMock(side_effect=exc) + result = await adapter.send("spaces/S", "hola") + assert result.success is False + assert adapter.has_fatal_error is True + + @pytest.mark.asyncio + async def test_404_returns_target_not_found(self, adapter): + exc = _FakeHttpError(status=404, reason="Not Found") + adapter._create_message = AsyncMock(side_effect=exc) + result = await adapter.send("spaces/S", "hola") + assert result.success is False + assert "not found" in (result.error or "") + + @pytest.mark.asyncio + async def test_429_increments_rate_limit_counter_and_raises(self, adapter): + exc = _FakeHttpError(status=429, reason="Too Many Requests") + adapter._create_message = AsyncMock(side_effect=exc) + with pytest.raises(_FakeHttpError): + await adapter.send("spaces/S", "hola") + assert adapter._rate_limit_hits.get("spaces/S") == 1 + + +# =========================================================================== +# send_typing / stop_typing +# =========================================================================== + + +class TestTypingLifecycle: + @pytest.mark.asyncio + async def test_send_typing_posts_and_tracks(self, adapter): + adapter._create_message = AsyncMock( + return_value=type("R", (), {"success": True, + "message_id": "spaces/S/messages/THINK", + "error": None})() + ) + await adapter.send_typing("spaces/S") + adapter._create_message.assert_awaited_once() + assert adapter._typing_messages["spaces/S"] == "spaces/S/messages/THINK" + + @pytest.mark.asyncio + async def test_send_typing_skips_when_already_tracking(self, adapter): + adapter._typing_messages["spaces/S"] = "spaces/S/messages/EXIST" + adapter._create_message = AsyncMock() + await adapter.send_typing("spaces/S") + adapter._create_message.assert_not_called() + + @pytest.mark.asyncio + async def test_send_typing_inherits_inbound_thread(self, adapter): + """The typing card must be created in the same thread as the + user's message, otherwise send() will patch a top-level card and + the bot's whole reply ends up outside the user's thread (Chat + messages.patch cannot change thread — it's immutable). Regression + test for the 'reply lands at top-level instead of in my thread' + UX bug.""" + adapter._last_inbound_thread["spaces/S"] = "spaces/S/threads/USER_THREAD" + adapter._create_message = AsyncMock( + return_value=type("R", (), {"success": True, + "message_id": "spaces/S/messages/THINK", + "error": None})() + ) + await adapter.send_typing("spaces/S") + # Verify the body sent to _create_message included the thread. + sent_body = adapter._create_message.call_args.args[1] + assert sent_body.get("thread") == {"name": "spaces/S/threads/USER_THREAD"} + + @pytest.mark.asyncio + async def test_send_typing_no_thread_when_cache_empty(self, adapter): + """If no inbound thread has been seen yet, typing card creates + without thread (Chat will assign a default). Defensive — first + bot push without prior user message.""" + adapter._create_message = AsyncMock( + return_value=type("R", (), {"success": True, + "message_id": "spaces/S/messages/THINK", + "error": None})() + ) + await adapter.send_typing("spaces/S") + sent_body = adapter._create_message.call_args.args[1] + assert "thread" not in sent_body + + @pytest.mark.asyncio + async def test_send_typing_concurrent_calls_create_only_one_card(self, adapter): + """When _keep_typing fires send_typing twice in flight (the + first call slow, the second arriving before the first stores + its msg_id), only ONE create should hit the API. Without this + guard the second call would create a duplicate card → orphan + 'Hermes is thinking…' stuck in chat. Race fix via + _typing_card_inflight Event. + """ + call_count = 0 + first_call_started = asyncio.Event() + release_first_call = asyncio.Event() + + async def _slow_create(chat_id, body): + nonlocal call_count + call_count += 1 + first_call_started.set() + await release_first_call.wait() + return type("R", (), {"success": True, + "message_id": f"spaces/S/messages/CARD_{call_count}", + "error": None})() + + adapter._create_message = _slow_create + + # Fire two send_typing tasks concurrently (mimics _keep_typing + # firing while a previous tick is still in-flight). + t1 = asyncio.create_task(adapter.send_typing("spaces/S")) + await first_call_started.wait() + t2 = asyncio.create_task(adapter.send_typing("spaces/S")) + # Give t2 a moment to bail out via the in-flight check. + await asyncio.sleep(0.05) + # Release the first call to complete. + release_first_call.set() + await asyncio.gather(t1, t2) + + assert call_count == 1 + assert adapter._typing_messages["spaces/S"] == "spaces/S/messages/CARD_1" + + @pytest.mark.asyncio + async def test_send_typing_survives_caller_cancellation(self, adapter): + """base.py's _keep_typing wraps send_typing in + asyncio.wait_for(timeout=1.5). When the create-API call takes + longer than 1.5s, wait_for cancels the awaiter — but the create + itself MUST complete and the msg_id MUST land in the slot, + otherwise the next tick spawns a SECOND card (orphan). + + This test simulates that: cancel the awaiter while the create + is in flight. The shielded background task should still + populate the slot. + """ + first_call_started = asyncio.Event() + release_first_call = asyncio.Event() + + async def _slow_create(chat_id, body): + first_call_started.set() + await release_first_call.wait() + return type("R", (), {"success": True, + "message_id": "spaces/S/messages/CARD_X", + "error": None})() + + adapter._create_message = _slow_create + + task = asyncio.create_task(adapter.send_typing("spaces/S")) + await first_call_started.wait() + # Simulate wait_for timeout cancelling the awaiter. + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + # The shielded background create is still running. Release it. + release_first_call.set() + # Give the background task time to complete + record. + for _ in range(20): + await asyncio.sleep(0.05) + if "spaces/S" in adapter._typing_messages: + break + # Slot SHOULD be populated despite the cancellation. + assert adapter._typing_messages.get("spaces/S") == "spaces/S/messages/CARD_X" + + @pytest.mark.asyncio + async def test_orphan_typing_cards_reaped_on_completion(self, adapter): + """If a background send_typing task created a card AFTER send() + already populated the slot (race), the orphan id is tracked in + _orphan_typing_messages. on_processing_complete must patch each + orphan to a benign marker so users don't see stuck + 'Hermes is thinking…' messages.""" + from plugins.platforms.google_chat.adapter import _TYPING_CONSUMED_SENTINEL + adapter._orphan_typing_messages["spaces/S"] = [ + "spaces/S/messages/ORPHAN1", + "spaces/S/messages/ORPHAN2", + ] + adapter._typing_messages["spaces/S"] = _TYPING_CONSUMED_SENTINEL + adapter._patch_message = AsyncMock( + return_value=type("R", (), {"success": True, + "message_id": "x", + "error": None})() + ) + event = MagicMock() + event.source = MagicMock() + event.source.chat_id = "spaces/S" + await adapter.on_processing_complete(event, ProcessingOutcome.SUCCESS) + # Both orphans patched (typing_messages cleared too). + assert adapter._patch_message.await_count == 2 + patched_ids = [ + call.args[0] for call in adapter._patch_message.call_args_list + ] + assert "spaces/S/messages/ORPHAN1" in patched_ids + assert "spaces/S/messages/ORPHAN2" in patched_ids + assert "spaces/S" not in adapter._orphan_typing_messages + + @pytest.mark.asyncio + async def test_stop_typing_is_noop_for_live_card(self, adapter): + """Anti-tombstone: stop_typing leaves a real msg_id in place so + send() can patch it. Deleting would create a "Message deleted by + its author" tombstone.""" + adapter._typing_messages["spaces/S"] = "spaces/S/messages/THINK" + delete_mock = MagicMock() + delete_mock.return_value.execute = MagicMock(return_value={}) + adapter._chat_api.spaces.return_value.messages.return_value.delete = delete_mock + + await adapter.stop_typing("spaces/S") + # Slot retained, no API delete fired. + assert adapter._typing_messages["spaces/S"] == "spaces/S/messages/THINK" + delete_mock.assert_not_called() + + @pytest.mark.asyncio + async def test_stop_typing_pops_sentinel(self, adapter): + """After send() patches the typing card, the slot holds the + sentinel; stop_typing pops it so the next turn starts fresh.""" + from plugins.platforms.google_chat.adapter import _TYPING_CONSUMED_SENTINEL + adapter._typing_messages["spaces/S"] = _TYPING_CONSUMED_SENTINEL + await adapter.stop_typing("spaces/S") + assert "spaces/S" not in adapter._typing_messages + + @pytest.mark.asyncio + async def test_stop_typing_noop_when_nothing_tracked(self, adapter): + delete_mock = MagicMock() + adapter._chat_api.spaces.return_value.messages.return_value.delete = delete_mock + await adapter.stop_typing("spaces/S") + delete_mock.assert_not_called() + + @pytest.mark.asyncio + async def test_on_processing_complete_pops_sentinel_on_success(self, adapter): + """SUCCESS path: send() set the sentinel; cleanup just pops it.""" + from plugins.platforms.google_chat.adapter import _TYPING_CONSUMED_SENTINEL + adapter._typing_messages["spaces/S"] = _TYPING_CONSUMED_SENTINEL + adapter._patch_message = AsyncMock() + event = MagicMock() + event.source = MagicMock() + event.source.chat_id = "spaces/S" + await adapter.on_processing_complete(event, ProcessingOutcome.SUCCESS) + assert "spaces/S" not in adapter._typing_messages + adapter._patch_message.assert_not_called() + + @pytest.mark.asyncio + async def test_on_processing_complete_patches_stranded_card(self, adapter): + """CANCELLED path: send() never ran. Patch the typing card with a + benign final state instead of deleting (no tombstone).""" + adapter._typing_messages["spaces/S"] = "spaces/S/messages/THINK" + adapter._patch_message = AsyncMock( + return_value=type("R", (), {"success": True, + "message_id": "spaces/S/messages/THINK", + "error": None})() + ) + event = MagicMock() + event.source = MagicMock() + event.source.chat_id = "spaces/S" + await adapter.on_processing_complete(event, ProcessingOutcome.CANCELLED) + adapter._patch_message.assert_awaited_once() + # Patched with a final-state label, not deleted. + args, kwargs = adapter._patch_message.call_args + assert "interrupted" in args[1]["text"].lower() + assert "spaces/S" not in adapter._typing_messages + + +# =========================================================================== +# edit_message / delete_message — required by gateway tool-progress + streaming +# =========================================================================== + + +class TestEditMessage: + @pytest.mark.asyncio + async def test_edit_message_patches_via_messages_patch(self, adapter): + adapter._patch_message = AsyncMock( + return_value=type("R", (), {"success": True, + "message_id": "spaces/S/messages/M", + "error": None})() + ) + result = await adapter.edit_message( + "spaces/S", "spaces/S/messages/M", "edited content", + ) + assert result.success is True + adapter._patch_message.assert_awaited_once_with( + "spaces/S/messages/M", {"text": "edited content"}, + ) + + @pytest.mark.asyncio + async def test_edit_message_truncates_overlong_text(self, adapter): + adapter._patch_message = AsyncMock( + return_value=type("R", (), {"success": True, "message_id": "m", + "error": None})() + ) + long_text = "x" * 9000 + await adapter.edit_message("spaces/S", "spaces/S/messages/M", long_text) + sent = adapter._patch_message.call_args[0][1]["text"] + # Truncated to MAX_MESSAGE_LENGTH (4000) with ellipsis. + assert len(sent) <= 4000 + + @pytest.mark.asyncio + async def test_edit_message_missing_id_returns_failure(self, adapter): + result = await adapter.edit_message("spaces/S", "", "x") + assert result.success is False + + @pytest.mark.asyncio + async def test_edit_message_429_increments_rate_limit_counter(self, adapter): + exc = _FakeHttpError(status=429, reason="Too Many Requests") + adapter._patch_message = AsyncMock(side_effect=exc) + result = await adapter.edit_message( + "spaces/S", "spaces/S/messages/M", "content", + ) + assert result.success is False + assert adapter._rate_limit_hits.get("spaces/S") == 1 + + @pytest.mark.asyncio + async def test_edit_message_overrides_base_so_progress_pipeline_runs(self, adapter): + """The gateway tool-progress flow at gateway/run.py:10199 gates on + ``type(adapter).edit_message is BasePlatformAdapter.edit_message``. + If our subclass doesn't override edit_message, no tool progress is + ever shown to the user — so this test guards against a future + accidental removal.""" + from gateway.platforms.base import BasePlatformAdapter + from plugins.platforms.google_chat.adapter import GoogleChatAdapter + assert GoogleChatAdapter.edit_message is not BasePlatformAdapter.edit_message + + +class TestDeleteMessage: + @pytest.mark.asyncio + async def test_delete_message_calls_api(self, adapter): + delete_mock = MagicMock() + delete_mock.return_value.execute = MagicMock(return_value={}) + adapter._chat_api.spaces.return_value.messages.return_value.delete = delete_mock + result = await adapter.delete_message("spaces/S", "spaces/S/messages/M") + assert result is True + delete_mock.assert_called_once() + + @pytest.mark.asyncio + async def test_delete_message_swallows_404(self, adapter): + exc = _FakeHttpError(status=404, reason="Not Found") + delete_mock = MagicMock() + delete_mock.return_value.execute = MagicMock(side_effect=exc) + adapter._chat_api.spaces.return_value.messages.return_value.delete = delete_mock + assert await adapter.delete_message("spaces/S", "spaces/S/messages/M") is False + + @pytest.mark.asyncio + async def test_delete_message_missing_id_returns_false(self, adapter): + assert await adapter.delete_message("spaces/S", "") is False + + +# =========================================================================== +# Native attachment delivery via user OAuth +# +# Google Chat's media.upload endpoint hard-rejects bot/SA auth, so the +# adapter calls it through a SEPARATE user-authed Chat API client built +# from a refresh token the user grants once via /setup-files. +# These tests cover: +# - _send_file falls back to text notice when no user creds present +# - _send_file does the two-step upload + create-with-attachment when +# user creds ARE present +# - the /setup-files slash command intercepts before the agent +# - 401/403 from media.upload triggers a clean fallback (token revoked) +# =========================================================================== + + +class TestNativeAttachmentDelivery: + @pytest.mark.asyncio + async def test_send_file_posts_setup_notice_when_no_user_oauth(self, adapter, tmp_path): + """Without user creds, _send_file posts a clear setup notice and + returns success=False so callers know delivery did not land.""" + f = tmp_path / "report.pdf" + f.write_bytes(b"%PDF-fake") + adapter._user_chat_api = None + adapter._user_credentials = None + adapter._create_message = AsyncMock( + return_value=type("R", (), {"success": True, "message_id": "m/notice", + "error": None})() + ) + + result = await adapter._send_file( + "spaces/S", str(f), caption="Aquí va el PDF", + mime_hint="application/pdf", + ) + assert result.success is False + adapter._create_message.assert_awaited() + sent_body = adapter._create_message.call_args.args[1] + assert "/setup-files" in sent_body["text"] + assert "report.pdf" in sent_body["text"] + + @pytest.mark.asyncio + async def test_send_file_two_step_native_upload_when_user_oauth_ready(self, adapter, tmp_path): + """With user creds, _send_file calls media.upload then + messages.create with the attachmentDataRef — both via the + user-authed Chat client.""" + f = tmp_path / "report.pdf" + f.write_bytes(b"%PDF-fake") + + upload_call = MagicMock() + upload_call.return_value.execute = MagicMock( + return_value={"attachmentDataRef": {"resourceName": "ref-abc"}} + ) + create_call = MagicMock() + create_call.return_value.execute = MagicMock( + return_value={"name": "spaces/S/messages/MID"} + ) + adapter._user_chat_api = MagicMock() + adapter._user_chat_api.media.return_value.upload = upload_call + adapter._user_chat_api.spaces.return_value.messages.return_value.create = create_call + adapter._user_credentials = MagicMock(valid=True) + adapter._consume_typing_card_with_text = AsyncMock(return_value=None) + + result = await adapter._send_file( + "spaces/S", str(f), caption="caption", + mime_hint="application/pdf", + thread_id="spaces/S/threads/T", + ) + + assert result.success is True + upload_call.assert_called_once() + create_call.assert_called_once() + # Verify the messages.create body referenced the attachment ref. + body_passed = create_call.call_args.kwargs["body"] + assert body_passed["attachment"][0]["attachmentDataRef"] == { + "resourceName": "ref-abc" + } + + @pytest.mark.asyncio + async def test_send_file_falls_back_to_notice_on_401(self, adapter, tmp_path): + """A 401 from media.upload (token revoked / scope missing) should + clear in-memory creds and post the setup notice.""" + f = tmp_path / "x.pdf" + f.write_bytes(b"%PDF-fake") + upload_call = MagicMock() + upload_call.return_value.execute = MagicMock( + side_effect=_FakeHttpError(status=401, reason="Unauthorized") + ) + adapter._user_chat_api = MagicMock() + adapter._user_chat_api.media.return_value.upload = upload_call + adapter._user_credentials = MagicMock(valid=True) + adapter._consume_typing_card_with_text = AsyncMock(return_value=None) + adapter._create_message = AsyncMock( + return_value=type("R", (), {"success": True, "message_id": "m", + "error": None})() + ) + + result = await adapter._send_file( + "spaces/S", str(f), caption=None, + mime_hint="application/pdf", + ) + assert result.success is False + # In-memory creds cleared so subsequent uploads short-circuit. + assert adapter._user_chat_api is None + assert adapter._user_credentials is None + # User saw a setup notice. + adapter._create_message.assert_awaited() + + @pytest.mark.asyncio + async def test_send_file_returns_error_on_unrelated_http_error(self, adapter, tmp_path): + """Non-auth HTTP errors propagate as SendResult.error without + clearing user creds (transient failures shouldn't disable the + feature).""" + f = tmp_path / "x.pdf" + f.write_bytes(b"%PDF-fake") + upload_call = MagicMock() + upload_call.return_value.execute = MagicMock( + side_effect=_FakeHttpError(status=500, reason="Server error") + ) + adapter._user_chat_api = MagicMock() + adapter._user_chat_api.media.return_value.upload = upload_call + adapter._user_credentials = MagicMock(valid=True) + adapter._consume_typing_card_with_text = AsyncMock(return_value=None) + + result = await adapter._send_file( + "spaces/S", str(f), caption=None, + mime_hint="application/pdf", + ) + assert result.success is False + assert "500" in (result.error or "") + # Creds NOT cleared on transient failure. + assert adapter._user_chat_api is not None + + +class TestSetupFilesSlashCommand: + @pytest.mark.asyncio + async def test_slash_command_intercepted_before_agent(self, adapter): + """/setup-files is bot-side admin, not agent input. The dispatch + path must short-circuit and not call handle_message.""" + adapter._handle_setup_files_command = AsyncMock(return_value=True) + adapter._build_message_event = AsyncMock( + return_value=MessageEvent( + text="/setup-files", + message_type=MessageType.TEXT, + source=adapter.build_source( + chat_id="spaces/S", + chat_name="DM", + chat_type="dm", + user_id="users/1", + user_name="Ramón", + thread_id="spaces/S/threads/T", + ), + raw_message={}, + message_id="spaces/S/messages/M", + ) + ) + await adapter._dispatch_message({}, {}) + adapter._handle_setup_files_command.assert_awaited_once() + adapter.handle_message.assert_not_called() + + @pytest.mark.asyncio + async def test_no_arg_status_when_unconfigured(self, adapter, tmp_path, monkeypatch): + """Without client_secret AND without token, status reply tells the + user how to provide credentials on the host.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + adapter._create_message = AsyncMock( + return_value=type("R", (), {"success": True, "message_id": "m", + "error": None})() + ) + handled = await adapter._handle_setup_files_command( + chat_id="spaces/S", + thread_id="spaces/S/threads/T", + raw_text="/setup-files", + ) + assert handled is True + sent = adapter._create_message.call_args.args[1]["text"] + assert "client_secret.json" in sent or "Create credentials" in sent + + @pytest.mark.asyncio + async def test_revoke_clears_in_memory_creds(self, adapter, tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + adapter._user_chat_api = MagicMock() + adapter._user_credentials = MagicMock(valid=True) + adapter._create_message = AsyncMock( + return_value=type("R", (), {"success": True, "message_id": "m", + "error": None})() + ) + await adapter._handle_setup_files_command( + chat_id="spaces/S", + thread_id=None, + raw_text="/setup-files revoke", + ) + assert adapter._user_chat_api is None + assert adapter._user_credentials is None + + +class TestUserOAuthHelper: + def test_load_user_credentials_returns_none_when_no_token(self, tmp_path, monkeypatch): + """Missing token file is the expected no-op case (user hasn't + run /setup-files yet). Must NOT raise.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + from plugins.platforms.google_chat.oauth import load_user_credentials + assert load_user_credentials() is None + + def test_load_user_credentials_returns_none_on_corrupt_token(self, tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + (tmp_path / "google_chat_user_token.json").write_text("not json") + from plugins.platforms.google_chat.oauth import load_user_credentials + assert load_user_credentials() is None + + def test_scopes_are_minimal(self): + """The OAuth flow should request ONLY chat.messages.create — no + Drive, no broader Chat scopes. Defends against scope creep.""" + from plugins.platforms.google_chat.oauth import SCOPES + assert SCOPES == ["https://www.googleapis.com/auth/chat.messages.create"] + + def test_sanitize_email_lowercases_and_replaces_unsafe_chars(self): + """Path components must be filesystem-safe across users. + ``a@B.com`` and ``A@b.com`` must collapse to the same key, and + path-traversal characters must NOT escape into the filename.""" + from plugins.platforms.google_chat.oauth import _sanitize_email + assert _sanitize_email("Ramon@NTTData.com") == "ramon@nttdata.com" + assert _sanitize_email("user+tag@x.io") == "user_tag@x.io" + # Slashes are stripped (path separator); dots inside names are + # preserved for the .com / .json suffix UX. The resulting filename + # is harmless when joined onto a directory. + assert _sanitize_email("../etc/passwd") == ".._etc_passwd" + assert _sanitize_email("") == "_unknown_" + + def test_per_user_token_path_isolated_from_legacy(self, tmp_path, monkeypatch): + """Per-user files live under a dedicated subdirectory so the + legacy single-user JSON stays addressable on disk.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + from plugins.platforms.google_chat.oauth import ( + _token_path, _legacy_token_path, + ) + per_user = _token_path("alice@example.com") + legacy = _legacy_token_path() + assert per_user.parent.name == "google_chat_user_tokens" + assert per_user != legacy + assert per_user.name == "alice@example.com.json" + + def test_load_user_credentials_per_email_returns_none_when_missing( + self, tmp_path, monkeypatch + ): + """A user who has not authorized has no token file; load returns + ``None`` and never throws — same contract as the legacy path.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + from plugins.platforms.google_chat.oauth import load_user_credentials + assert load_user_credentials("nobody@example.com") is None + + def test_list_authorized_emails_lists_per_user_files( + self, tmp_path, monkeypatch + ): + """``list_authorized_emails`` enumerates the per-user dir; the + legacy file is intentionally excluded (its owner is unknown).""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + users_dir = tmp_path / "google_chat_user_tokens" + users_dir.mkdir(parents=True) + (users_dir / "alice@example.com.json").write_text("{}") + (users_dir / "bob@example.com.json").write_text("{}") + # Legacy file should NOT appear in the list. + (tmp_path / "google_chat_user_token.json").write_text("{}") + + from plugins.platforms.google_chat.oauth import list_authorized_emails + assert list_authorized_emails() == [ + "alice@example.com", "bob@example.com", + ] + + def test_list_authorized_emails_empty_when_dir_missing( + self, tmp_path, monkeypatch + ): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + from plugins.platforms.google_chat.oauth import list_authorized_emails + assert list_authorized_emails() == [] + + def test_pending_auth_path_is_per_user_when_email_given( + self, tmp_path, monkeypatch + ): + """Two users running /setup-files start in parallel must not + clobber each other's PKCE verifier — the pending state file + is namespaced by email.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + from plugins.platforms.google_chat.oauth import _pending_auth_path + a = _pending_auth_path("alice@example.com") + b = _pending_auth_path("bob@example.com") + legacy = _pending_auth_path(None) + assert a != b + assert a != legacy + assert "google_chat_user_oauth_pending" in str(a.parent) + + +class TestPerUserAttachmentRouting: + """The bot must use the *requesting user's* OAuth token when sending + an attachment, not the first user who happened to have one stored. + Backward compat: when no per-user token exists, fall back to a legacy + single-user token; only when both are missing does the user see the + setup-instructions notice.""" + + @pytest.mark.asyncio + async def test_build_message_event_caches_sender_email(self, adapter): + """The asker's email is captured per chat_id at inbound time so + a later outbound attachment can pick the right per-user token.""" + envelope = _make_chat_envelope( + text="hi", sender_email="Alice@Example.com", + ) + msg = envelope["chat"]["messagePayload"]["message"] + await adapter._build_message_event(msg, envelope["chat"]["messagePayload"]) + # Lower-cased to match the on-disk sanitized key. + assert adapter._last_sender_by_chat["spaces/S"] == "alice@example.com" + + @pytest.mark.asyncio + async def test_send_file_uses_per_user_token_when_sender_known( + self, adapter, tmp_path, monkeypatch + ): + """sender_email maps to a per-user file → that user's API client + is built and used for the upload, NOT the legacy fallback.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + users_dir = tmp_path / "google_chat_user_tokens" + users_dir.mkdir(parents=True) + (users_dir / "alice@example.com.json").write_text(json.dumps({ + "type": "authorized_user", + "client_id": "cid", "client_secret": "csec", + "refresh_token": "rtok", "token": "atok", + })) + adapter._last_sender_by_chat["spaces/S"] = "alice@example.com" + + per_user_api = MagicMock() + per_user_api.media.return_value.upload.return_value.execute.return_value = { + "attachmentDataRef": {"resourceName": "ref-alice"} + } + per_user_api.spaces.return_value.messages.return_value.create.return_value.execute.return_value = { + "name": "spaces/S/messages/MID", + "thread": {"name": "spaces/S/threads/T"}, + } + # Force legacy path NOT to be picked even if per-user breaks. + adapter._user_chat_api = MagicMock() + adapter._user_credentials = MagicMock(valid=True) + adapter._consume_typing_card_with_text = AsyncMock(return_value=None) + + from plugins.platforms.google_chat import oauth as helper + with patch.object( + helper, "load_user_credentials", + return_value=MagicMock(valid=True), + ), patch.object( + helper, "build_user_chat_service", return_value=per_user_api, + ): + f = tmp_path / "doc.pdf" + f.write_bytes(b"%PDF") + result = await adapter._send_file( + "spaces/S", str(f), caption=None, + mime_hint="application/pdf", + ) + + assert result.success is True + # Per-user client was used; legacy was untouched. + per_user_api.media.return_value.upload.assert_called_once() + adapter._user_chat_api.media.assert_not_called() + # Cache populated for next call. + assert "alice@example.com" in adapter._user_chat_api_by_email + + @pytest.mark.asyncio + async def test_send_file_falls_back_to_legacy_when_per_user_missing( + self, adapter, tmp_path, monkeypatch + ): + """sender known but no per-user token → legacy creds fill in. + This is the migration window: legacy keeps working until each + user runs /setup-files.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + adapter._last_sender_by_chat["spaces/S"] = "newuser@example.com" + + legacy_api = MagicMock() + legacy_api.media.return_value.upload.return_value.execute.return_value = { + "attachmentDataRef": {"resourceName": "ref-legacy"} + } + legacy_api.spaces.return_value.messages.return_value.create.return_value.execute.return_value = { + "name": "spaces/S/messages/MID", + "thread": {"name": "spaces/S/threads/T"}, + } + adapter._user_chat_api = legacy_api + adapter._user_credentials = MagicMock(valid=True) + adapter._consume_typing_card_with_text = AsyncMock(return_value=None) + + f = tmp_path / "doc.pdf" + f.write_bytes(b"%PDF") + result = await adapter._send_file( + "spaces/S", str(f), caption=None, + mime_hint="application/pdf", + ) + + assert result.success is True + legacy_api.media.return_value.upload.assert_called_once() + # Cache untouched — the per-user slot stays empty so the next + # /setup-files for newuser will write into a clean state. + assert "newuser@example.com" not in adapter._user_chat_api_by_email + + @pytest.mark.asyncio + async def test_send_file_no_creds_anywhere_posts_setup_notice( + self, adapter, tmp_path + ): + """Sender unknown AND no legacy fallback → setup-instructions + notice. Same shape as the existing single-user path; the test + confirms the multi-user routing didn't accidentally bypass it.""" + adapter._last_sender_by_chat["spaces/S"] = "ghost@example.com" + adapter._user_chat_api = None + adapter._user_credentials = None + adapter._create_message = AsyncMock( + return_value=type("R", (), {"success": True, "message_id": "m", + "error": None})() + ) + + f = tmp_path / "x.pdf" + f.write_bytes(b"%PDF") + from plugins.platforms.google_chat import oauth as helper + with patch.object(helper, "load_user_credentials", return_value=None): + result = await adapter._send_file( + "spaces/S", str(f), caption=None, + mime_hint="application/pdf", + ) + + assert result.success is False + sent = adapter._create_message.call_args.args[1]["text"] + assert "/setup-files" in sent + + @pytest.mark.asyncio + async def test_send_file_per_user_401_evicts_only_that_user( + self, adapter, tmp_path, monkeypatch + ): + """A 401 from one user's token must NOT clobber another user's + cache nor the legacy slot. The eviction is scoped.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + adapter._last_sender_by_chat["spaces/S"] = "alice@example.com" + + alice_api = MagicMock() + alice_api.media.return_value.upload.return_value.execute.side_effect = ( + _FakeHttpError(status=401, reason="Unauthorized") + ) + bob_api = MagicMock() + adapter._user_chat_api_by_email["alice@example.com"] = alice_api + adapter._user_creds_by_email["alice@example.com"] = MagicMock(valid=True) + adapter._user_chat_api_by_email["bob@example.com"] = bob_api + adapter._user_creds_by_email["bob@example.com"] = MagicMock(valid=True) + # Legacy untouched. + adapter._user_chat_api = MagicMock() + adapter._user_credentials = MagicMock(valid=True) + adapter._consume_typing_card_with_text = AsyncMock(return_value=None) + adapter._create_message = AsyncMock( + return_value=type("R", (), {"success": True, "message_id": "m", + "error": None})() + ) + + f = tmp_path / "x.pdf" + f.write_bytes(b"%PDF") + result = await adapter._send_file( + "spaces/S", str(f), caption=None, + mime_hint="application/pdf", + ) + + assert result.success is False + # Alice evicted, Bob and legacy preserved. + assert "alice@example.com" not in adapter._user_chat_api_by_email + assert "bob@example.com" in adapter._user_chat_api_by_email + assert adapter._user_chat_api is not None + assert adapter._user_credentials is not None + + @pytest.mark.asyncio + async def test_setup_files_writes_to_per_user_path( + self, adapter, tmp_path, monkeypatch + ): + """``/setup-files `` from sender alice writes to alice's + token slot; bob's slot stays untouched.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + adapter._create_message = AsyncMock( + return_value=type("R", (), {"success": True, "message_id": "m", + "error": None})() + ) + from plugins.platforms.google_chat import oauth as helper + # Stub the costly bits; we're verifying routing, not OAuth I/O. + alice_creds = MagicMock(valid=True) + with patch.object(helper, "exchange_auth_code") as ex, \ + patch.object(helper, "load_user_credentials", return_value=alice_creds), \ + patch.object(helper, "build_user_chat_service", + return_value=MagicMock()): + await adapter._handle_setup_files_command( + chat_id="spaces/S", + thread_id=None, + raw_text="/setup-files PASTED_CODE", + sender_email="alice@example.com", + ) + + # Helper was invoked with the sender email, so the token lands in + # the per-user path (not the legacy file). + assert ex.call_args.args[0] == "PASTED_CODE" + assert ex.call_args.args[1] == "alice@example.com" + # Adapter cache populated for alice only. + assert "alice@example.com" in adapter._user_chat_api_by_email + assert "bob@example.com" not in adapter._user_chat_api_by_email + + @pytest.mark.asyncio + async def test_setup_files_revoke_drops_only_that_user( + self, adapter, tmp_path, monkeypatch + ): + """Per-user revoke clears alice's slot; bob and the legacy + fallback both keep working. Alice's choice to revoke must not + knock out unrelated users.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + adapter._user_chat_api_by_email["alice@example.com"] = MagicMock() + adapter._user_creds_by_email["alice@example.com"] = MagicMock() + adapter._user_chat_api_by_email["bob@example.com"] = MagicMock() + adapter._user_creds_by_email["bob@example.com"] = MagicMock() + legacy_api = MagicMock() + legacy_creds = MagicMock() + adapter._user_chat_api = legacy_api + adapter._user_credentials = legacy_creds + adapter._create_message = AsyncMock( + return_value=type("R", (), {"success": True, "message_id": "m", + "error": None})() + ) + + from plugins.platforms.google_chat import oauth as helper + with patch.object(helper, "revoke") as rev: + await adapter._handle_setup_files_command( + chat_id="spaces/S", + thread_id=None, + raw_text="/setup-files revoke", + sender_email="alice@example.com", + ) + + # Helper called with alice's email + assert rev.call_args.args[0] == "alice@example.com" + assert "alice@example.com" not in adapter._user_chat_api_by_email + assert "bob@example.com" in adapter._user_chat_api_by_email + # Legacy fallback survives an unrelated user's revoke. + assert adapter._user_chat_api is legacy_api + assert adapter._user_credentials is legacy_creds + + +# =========================================================================== +# Persistent thread-count store (restart-safe side-thread heuristic) +# =========================================================================== + + +class TestThreadCountStore: + def test_missing_file_returns_zero_counts(self, tmp_path): + from plugins.platforms.google_chat.adapter import _ThreadCountStore + store = _ThreadCountStore(tmp_path / "nonexistent.json") + store.load() + assert store.get("spaces/X", "spaces/X/threads/T") == 0 + + def test_corrupt_json_treated_as_empty(self, tmp_path): + """A garbage file shouldn't crash the adapter — log warn, treat + as fresh, move on. The next incr() will overwrite.""" + from plugins.platforms.google_chat.adapter import _ThreadCountStore + path = tmp_path / "counts.json" + path.write_text("not valid json {") + store = _ThreadCountStore(path) + store.load() + assert store.get("spaces/X", "spaces/X/threads/T") == 0 + # Next write should overwrite cleanly. + prev = store.incr("spaces/X", "spaces/X/threads/T") + assert prev == 0 + # File now has valid JSON. + import json + data = json.loads(path.read_text()) + assert data == {"spaces/X": {"spaces/X/threads/T": 1}} + + def test_incr_returns_pre_increment_value(self, tmp_path): + """The PRE-increment count is the heuristic input — it answers + 'have we seen this thread BEFORE this message?'. Off-by-one in + either direction would break the main-flow vs side-thread call.""" + from plugins.platforms.google_chat.adapter import _ThreadCountStore + store = _ThreadCountStore(tmp_path / "counts.json") + store.load() + assert store.incr("spaces/X", "spaces/X/threads/T") == 0 + assert store.incr("spaces/X", "spaces/X/threads/T") == 1 + assert store.incr("spaces/X", "spaces/X/threads/T") == 2 + assert store.get("spaces/X", "spaces/X/threads/T") == 3 + + def test_round_trip_persists_across_load(self, tmp_path): + """Two store instances on the same file behave like a single + store split across a process boundary. This is the exact + restart-safety property the store exists to provide.""" + from plugins.platforms.google_chat.adapter import _ThreadCountStore + path = tmp_path / "counts.json" + + store_a = _ThreadCountStore(path) + store_a.load() + store_a.incr("spaces/X", "spaces/X/threads/T") + store_a.incr("spaces/X", "spaces/X/threads/T") + store_a.incr("spaces/Y", "spaces/Y/threads/U") + + # Simulate gateway restart: fresh store instance, same file. + store_b = _ThreadCountStore(path) + store_b.load() + assert store_b.get("spaces/X", "spaces/X/threads/T") == 2 + assert store_b.get("spaces/Y", "spaces/Y/threads/U") == 1 + # Next incr in store_b returns the persisted prev count. + assert store_b.incr("spaces/X", "spaces/X/threads/T") == 2 + + def test_invalid_shape_dropped_silently(self, tmp_path): + """If someone hand-edits the file with weird shapes, drop the + bad entries but keep the valid ones.""" + from plugins.platforms.google_chat.adapter import _ThreadCountStore + import json + path = tmp_path / "counts.json" + path.write_text(json.dumps({ + "spaces/OK": {"spaces/OK/threads/T": 3}, + "spaces/BAD_VALUE": "not a dict", + "spaces/BAD_COUNT": {"spaces/BAD_COUNT/threads/T": "five"}, + })) + store = _ThreadCountStore(path) + store.load() + assert store.get("spaces/OK", "spaces/OK/threads/T") == 3 + assert store.get("spaces/BAD_VALUE", "any") == 0 + assert store.get("spaces/BAD_COUNT", "spaces/BAD_COUNT/threads/T") == 0 + + @pytest.mark.asyncio + async def test_outbound_thread_tracked_for_user_reply_in_bot_thread(self, adapter): + """The bug Ramón hit on the live mac-mini: when the bot replies + in a fresh thread (Chat-created for the bot's outbound message), + a future user 'Reply in thread' on that bot message should be + recognized as a SIDE THREAD (not main flow). For that, the + outbound thread must be in the count store BEFORE the user's + reply arrives. + + Regression pin: counting only inbound left bot-created threads + invisible. User 'Reply in thread' on the bot's response was + misclassified as main-flow because prev_count was 0.""" + # Stub _create_message's underlying create call — we want to + # exercise the real _create_message body so the count-tracking + # branch actually fires. + create_call = MagicMock() + create_call.return_value.execute = MagicMock( + return_value={ + "name": "spaces/S/messages/BOT_REPLY", + "thread": {"name": "spaces/S/threads/BOT_THREAD"}, + } + ) + adapter._chat_api.spaces.return_value.messages.return_value.create = create_call + + # Bot sends a top-level reply (no thread.name in body — main flow). + await adapter._create_message("spaces/S", {"text": "hola"}) + + # Outbound thread must now be in the store with count >= 1. + assert adapter._thread_count_store.get( + "spaces/S", "spaces/S/threads/BOT_THREAD" + ) == 1 + + # Now user clicks "Reply in thread" on the bot's message → + # inbound arrives in spaces/S/threads/BOT_THREAD. + env = _make_chat_envelope( + text="follow-up", thread_name="spaces/S/threads/BOT_THREAD" + ) + msg = env["chat"]["messagePayload"]["message"] + event = await adapter._build_message_event(msg, env) + + # MUST be classified as side thread (isolated session + + # outbound stays in the thread). + assert event.source.thread_id == "spaces/S/threads/BOT_THREAD" + assert adapter._last_inbound_thread["spaces/S"] == "spaces/S/threads/BOT_THREAD" + + @pytest.mark.asyncio + async def test_side_thread_detection_survives_restart(self, adapter, tmp_path): + """End-to-end regression for the bug Ramón hit across 4 + iterations: gateway restart must NOT demote an active side + thread back to main flow. + + Flow: + 1. User has an existing thread (count >= 1 from prior turn). + 2. Gateway restarts (fresh adapter instance with same store path). + 3. User sends another message in that thread. + 4. Adapter must STILL classify it as side thread (isolated + session + outbound thread) — otherwise main-flow context + leaks in. + """ + # Turn 1: simulate prior engagement of T_existing. + env1 = _make_chat_envelope(text="first", thread_name="spaces/S/threads/T_existing") + await adapter._build_message_event(env1["chat"]["messagePayload"]["message"], env1) + env2 = _make_chat_envelope(text="second", thread_name="spaces/S/threads/T_existing") + await adapter._build_message_event(env2["chat"]["messagePayload"]["message"], env2) + # After two turns, this is a known side-thread. The store on disk + # has count >= 2. + assert adapter._thread_count_store.get( + "spaces/S", "spaces/S/threads/T_existing" + ) == 2 + + # Simulate restart: build a fresh adapter pointing at the SAME + # persistence file the previous one used. + from plugins.platforms.google_chat.adapter import ( + GoogleChatAdapter, _ThreadCountStore, + ) + store_path = adapter._thread_count_store._path + fresh = GoogleChatAdapter(_base_config()) + fresh._chat_api = MagicMock() + fresh._credentials = MagicMock() + fresh._new_authed_http = MagicMock(return_value=MagicMock()) + fresh.handle_message = AsyncMock() + fresh._thread_count_store = _ThreadCountStore(store_path) + fresh._thread_count_store.load() + + # Turn 3 (post-restart, same thread). + env3 = _make_chat_envelope(text="third", thread_name="spaces/S/threads/T_existing") + event3 = await fresh._build_message_event( + env3["chat"]["messagePayload"]["message"], env3 + ) + # MUST be classified as side thread (isolated session). + assert event3.source.thread_id == "spaces/S/threads/T_existing" + # Outbound cache populated for in-thread reply. + assert fresh._last_inbound_thread["spaces/S"] == "spaces/S/threads/T_existing" + + +# =========================================================================== +# Inbound attachment download SSRF guard +# =========================================================================== + + +class TestAttachmentSSRFGuard: + @pytest.mark.asyncio + async def test_drive_picker_only_skipped_when_no_resource_name(self, adapter): + """Pure Drive-picker shares (source=DRIVE_FILE, no resourceName) + cannot be downloaded with bot SA — skip silently.""" + attachment = { + "source": "DRIVE_FILE", + "contentType": "application/pdf", + "downloadUri": "https://drive.google.com/file/d/abc", + } + path, mime = await adapter._download_attachment(attachment) + assert path is None + assert mime == "application/pdf" + + @pytest.mark.asyncio + async def test_drive_file_with_resource_name_uses_bot_path(self, adapter, tmp_path, monkeypatch): + """Drag-and-drop chat uploads ALSO carry source=DRIVE_FILE but + come with attachmentDataRef.resourceName — bot media.download_media + works against those. Regression test for the original bug where + we skipped them all (left users with 'I don't see any PDF').""" + attachment = { + "source": "DRIVE_FILE", + "contentType": "application/pdf", + "name": "spaces/S/messages/M/attachments/A", + "attachmentDataRef": { + "resourceName": "spaces/S/messages/M/attachments/A", + }, + } + + # Patch the inner _fetch_media path by hijacking asyncio.to_thread + # — return some bytes directly, no need to walk the full + # google-api-client mock chain. + async def _fake_to_thread(fn, *args, **kwargs): + return b"%PDF-fake" + + monkeypatch.setattr(asyncio, "to_thread", _fake_to_thread) + from plugins.platforms.google_chat import adapter as gc_mod + monkeypatch.setattr( + gc_mod, "cache_document_from_bytes", + lambda data, ext=None, filename=None: str(tmp_path / "out.pdf"), + raising=False, + ) + + path, mime = await adapter._download_attachment(attachment) + assert path == str(tmp_path / "out.pdf") + assert mime == "application/pdf" + + @pytest.mark.asyncio + async def test_rejects_non_google_host(self, adapter): + attachment = { + "contentType": "image/png", + "downloadUri": "https://evil.com/steal", + } + path, mime = await adapter._download_attachment(attachment) + assert path is None + assert mime == "image/png" + + @pytest.mark.asyncio + async def test_rejects_metadata_endpoint(self, adapter): + attachment = { + "contentType": "image/png", + "downloadUri": "https://169.254.169.254/computeMetadata/v1/", + } + path, mime = await adapter._download_attachment(attachment) + assert path is None + + +# =========================================================================== +# Outbound thread routing (anti-top-level fallback in DMs) +# =========================================================================== + + +class TestOutboundThreadRouting: + def test_resolve_uses_metadata_thread_id(self, adapter): + result = adapter._resolve_thread_id( + reply_to=None, + metadata={"thread_id": "spaces/X/threads/EXPLICIT"}, + chat_id="spaces/X", + ) + assert result == "spaces/X/threads/EXPLICIT" + + def test_resolve_falls_back_to_cached_thread_for_dm(self, adapter): + """In DMs the source.thread_id is None, so the metadata passed + to send() lacks a thread. Without the cache fallback, replies + would land at top-level (visually disconnected from the user's + thread).""" + adapter._last_inbound_thread["spaces/X"] = "spaces/X/threads/CACHED" + result = adapter._resolve_thread_id( + reply_to=None, + metadata=None, + chat_id="spaces/X", + ) + assert result == "spaces/X/threads/CACHED" + + def test_resolve_metadata_overrides_cache(self, adapter): + """Explicit metadata (e.g. agent replying to a specific event) + wins over the cached thread.""" + adapter._last_inbound_thread["spaces/X"] = "spaces/X/threads/CACHED" + result = adapter._resolve_thread_id( + reply_to=None, + metadata={"thread_id": "spaces/X/threads/EXPLICIT"}, + chat_id="spaces/X", + ) + assert result == "spaces/X/threads/EXPLICIT" + + def test_resolve_returns_none_when_no_inputs(self, adapter): + result = adapter._resolve_thread_id( + reply_to=None, metadata=None, chat_id="spaces/UNKNOWN", + ) + assert result is None + + +# =========================================================================== +# Send file delegation (voice/video/animation route through send_document) +# =========================================================================== + + +class TestMediaDelegation: + @pytest.mark.asyncio + async def test_send_voice_delegates_to_document_with_audio_mime(self, adapter, tmp_path): + f = tmp_path / "voice.ogg" + f.write_bytes(b"audio-bytes") + adapter._send_file = AsyncMock( + return_value=type("R", (), {"success": True, "message_id": "m", + "error": None})() + ) + await adapter.send_voice("spaces/S", str(f)) + _, kwargs = adapter._send_file.await_args + assert kwargs.get("mime_hint") == "audio/ogg" + + @pytest.mark.asyncio + async def test_send_video_delegates_with_video_mime(self, adapter, tmp_path): + f = tmp_path / "clip.mp4" + f.write_bytes(b"video-bytes") + adapter._send_file = AsyncMock( + return_value=type("R", (), {"success": True, "message_id": "m", + "error": None})() + ) + await adapter.send_video("spaces/S", str(f)) + _, kwargs = adapter._send_file.await_args + assert kwargs.get("mime_hint") == "video/mp4" + + @pytest.mark.asyncio + async def test_send_animation_delegates_to_image(self, adapter): + """Google Chat has no native animation type; the adapter falls back + to send_image (which posts the URL inline). Animations and images + share the same render path on Chat so we just delegate.""" + adapter.send_image = AsyncMock( + return_value=type("R", (), {"success": True, "message_id": "m", + "error": None})() + ) + await adapter.send_animation( + "spaces/S", "https://example.com/dance.gif", caption="hop" + ) + adapter.send_image.assert_awaited_once() + args, kwargs = adapter.send_image.await_args + assert args[1] == "https://example.com/dance.gif" + assert kwargs.get("caption") == "hop" + + @pytest.mark.asyncio + async def test_send_file_missing_path_returns_error(self, adapter): + result = await adapter._send_file("spaces/S", "/no/such/file.pdf", + None, mime_hint="application/pdf") + assert result.success is False + assert "not found" in (result.error or "").lower() + + +# =========================================================================== +# Outbound retry (transient API failure handling) +# =========================================================================== + + +class TestOutboundRetry: + """Outbound message creation retries on transient failures. + + Without retry, a single 503/429 from Google's Chat REST API drops the + user-visible reply. The retry wrapper handles 429/5xx/timeout/connection + errors with exponential backoff + jitter; permanent errors (auth, + client errors) bubble up on the first attempt. + + Pattern lifted from PR #14965 by @ArnarValur. + """ + + @pytest.mark.asyncio + async def test_retries_on_503_then_succeeds(self, adapter, monkeypatch): + """A 503 from messages.create triggers backoff + retry. + + On the second attempt the call succeeds, so the user sees the + reply with no visible failure. The wrapper's sleep is patched + out so the test runs instantly. + """ + from plugins.platforms.google_chat import adapter as gc_mod + async def _no_sleep(*_a, **_kw): + return None + monkeypatch.setattr(gc_mod.asyncio, "sleep", _no_sleep) + + # First attempt 503, second attempt OK. + execute = MagicMock() + execute.execute.side_effect = [ + _FakeHttpError(status=503, reason="Service unavailable"), + {"name": "spaces/S/messages/M", "thread": {"name": "spaces/S/threads/T"}}, + ] + adapter._chat_api.spaces.return_value.messages.return_value.create.return_value = execute + + result = await adapter._create_message("spaces/S", {"text": "hi"}) + + assert result.success is True + assert result.message_id == "spaces/S/messages/M" + # Two execute() calls — initial + one retry. + assert execute.execute.call_count == 2 + + @pytest.mark.asyncio + async def test_gives_up_after_max_attempts(self, adapter, monkeypatch): + """Three consecutive 503s exhaust the retry budget; the call raises.""" + from plugins.platforms.google_chat import adapter as gc_mod + async def _no_sleep(*_a, **_kw): + return None + monkeypatch.setattr(gc_mod.asyncio, "sleep", _no_sleep) + + execute = MagicMock() + execute.execute.side_effect = _FakeHttpError(status=503, reason="Down") + adapter._chat_api.spaces.return_value.messages.return_value.create.return_value = execute + + with pytest.raises(_FakeHttpError): + await adapter._create_message("spaces/S", {"text": "hi"}) + # _RETRY_MAX_ATTEMPTS = 3 → 3 calls total. + assert execute.execute.call_count == 3 + + @pytest.mark.asyncio + async def test_does_not_retry_on_400(self, adapter, monkeypatch): + """A 400 (client error) is permanent — no retry, fails immediately.""" + from plugins.platforms.google_chat import adapter as gc_mod + async def _no_sleep(*_a, **_kw): + return None + monkeypatch.setattr(gc_mod.asyncio, "sleep", _no_sleep) + + execute = MagicMock() + execute.execute.side_effect = _FakeHttpError(status=400, reason="Bad request") + adapter._chat_api.spaces.return_value.messages.return_value.create.return_value = execute + + with pytest.raises(_FakeHttpError): + await adapter._create_message("spaces/S", {"text": "hi"}) + # Only one attempt — 400 is not retryable. + assert execute.execute.call_count == 1 + + def test_is_retryable_error_classifier(self): + """Spot-check the retryable-error taxonomy.""" + from plugins.platforms.google_chat.adapter import _is_retryable_error + + # Retryable: 429, 5xx, timeout-flavored exceptions + assert _is_retryable_error(_FakeHttpError(status=429, reason="rate")) + assert _is_retryable_error(_FakeHttpError(status=500, reason="oops")) + assert _is_retryable_error(_FakeHttpError(status=502, reason="bad gw")) + assert _is_retryable_error(_FakeHttpError(status=503, reason="down")) + assert _is_retryable_error(_FakeHttpError(status=504, reason="gw timeout")) + assert _is_retryable_error(TimeoutError("connection timed out")) + assert _is_retryable_error(ConnectionResetError("connection reset")) + # NOT retryable: client errors, auth, programmer errors + assert not _is_retryable_error(_FakeHttpError(status=400, reason="bad")) + assert not _is_retryable_error(_FakeHttpError(status=401, reason="auth")) + assert not _is_retryable_error(_FakeHttpError(status=403, reason="forbidden")) + assert not _is_retryable_error(_FakeHttpError(status=404, reason="not found")) + assert not _is_retryable_error(ValueError("typed wrong thing")) + + +class TestFormatMessage: + """Markdown→Chat dialect conversion + invisible Unicode stripping. + + `format_message` runs on EVERY outbound message, so the regex + behavior is the safety surface. Tests cover happy paths, code-block + protection, edge cases the LLM emits in practice (URLs with parens, + unmatched syntax, mixed bold+italic), and the Unicode strip's + interaction with composite emoji. + + Pattern lifted from PR #14965 by @ArnarValur. + """ + + def test_bold_double_asterisk_to_single(self): + """**bold** → *bold* (Chat's bold syntax uses single asterisks).""" + out = GoogleChatAdapter.format_message("hello **world**") + assert out == "hello *world*" + + def test_bold_italic_combo_to_chat_dialect(self): + """***x*** → *_x_* (bold-italic compound).""" + out = GoogleChatAdapter.format_message("***fancy*** word") + assert out == "*_fancy_* word" + + def test_markdown_link_to_chat_anglebracket(self): + """[text](url) → (Slack-style anglebracket links).""" + out = GoogleChatAdapter.format_message("see [docs](https://example.com)") + assert out == "see " + + def test_header_to_bold_at_line_start_only(self): + """# Title → *Title* but only at line-start; mid-line `#` untouched.""" + out = GoogleChatAdapter.format_message("# Heading\nbody with # mid-line hash") + assert out == "*Heading*\nbody with # mid-line hash" + + def test_fenced_code_block_protected(self): + """**asterisks** inside a fenced code block do NOT convert. + + Without protection, the regex would mangle code samples emitted + by the LLM (e.g. Python or shell with literal `**` operators). + """ + src = "before\n```python\nx = 2 ** 10\n```\nafter" + out = GoogleChatAdapter.format_message(src) + # Code block content survives verbatim. + assert "```python\nx = 2 ** 10\n```" in out + # Surrounding text untouched (no asterisks to convert). + assert out.startswith("before") + assert out.endswith("after") + + def test_inline_code_protected(self): + """`**text**` inside inline backticks does NOT convert.""" + out = GoogleChatAdapter.format_message("see `**literal**` for syntax") + assert "`**literal**`" in out + + def test_url_with_parens_in_path(self): + """`[txt](https://x.com/foo(bar))` — pin the documented limitation. + + The regex captures the URL up to the FIRST closing paren, so + URLs with parens in the path get truncated. This pins the + behavior so any future regex change is intentional. Real + Wikipedia / docs URLs with parens (e.g. ``Halting_(disambiguation)``) + are an edge case; the LLM rarely emits them and operators can + URL-encode if needed. + """ + out = GoogleChatAdapter.format_message("[wiki](https://x.com/foo(bar))") + # URL captured up to first ')'; trailing paren left as text. + assert "" in out + + def test_mixed_bold_italic_orderings(self): + """**bold** _italic_ in the same line — both surface conversions.""" + # Italic stays as `_italic_` (Chat's italic dialect matches our + # input form, no transform needed). + out = GoogleChatAdapter.format_message("**bold** and _italic_ together") + assert "*bold*" in out + assert "_italic_" in out + + def test_strips_zwj_and_variation_selector(self): + """ZWJ (U+200D) + Variation Selector 16 (U+FE0F) get stripped. + + These appear in composite emoji like 👨‍👩‍👧 (family) — Chat's + restricted font can't render them and shows tofu. Stripping + means the underlying base emoji renders cleanly even if the + composite breaks; better than tofu boxes. + """ + # Family emoji: man + ZWJ + woman + ZWJ + girl. + src = "hello \U0001f468‍\U0001f469‍\U0001f467 world" + out = GoogleChatAdapter.format_message(src) + assert "‍" not in out # ZWJ gone + # Base codepoints survive (man, woman, girl). + assert "\U0001f468" in out + assert "\U0001f469" in out + assert "\U0001f467" in out + + def test_strips_bom_and_bidi_marks(self): + """BOM, LTR/RTL marks stripped — they break Chat's font rendering.""" + src = " hello ‎ world ‏" + out = GoogleChatAdapter.format_message(src) + assert "" not in out + assert "‎" not in out + assert "‏" not in out + assert "hello" in out and "world" in out + + def test_empty_and_none_safe(self): + """Empty / None pass through without raising. + + The double-space collapser runs on every non-empty input — that's + intentional cleanup after Unicode stripping. So pure-whitespace + input collapses to a single space; documented as expected. + """ + assert GoogleChatAdapter.format_message("") == "" + assert GoogleChatAdapter.format_message(None) is None + # Multi-space input collapses to single space (the cleanup step + # runs unconditionally; cheap correctness over rare preservation). + assert GoogleChatAdapter.format_message(" ") == " " + + def test_unmatched_asterisks_left_alone(self): + """A lone `**` with no closing pair is not transformed. + + Defensive: the regex requires a closing `**`. Unmatched syntax + from a partial LLM stream stays visible as-is rather than + consuming the rest of the message. + """ + out = GoogleChatAdapter.format_message("rate is ** TBD") + assert "**" in out # not converted + + +class TestADCFallback: + """When no SA JSON is configured, fall back to Application Default Credentials. + + Critical for Cloud Run / GCE / GKE deploys where workload identity + means key files are unnecessary and a security risk to manage. + Pattern lifted from PR #14965. + """ + + def test_load_credentials_uses_adc_when_no_sa_path(self, adapter, monkeypatch): + """No SA path → google.auth.default() is called.""" + adapter.config.extra.pop("service_account_json", None) + monkeypatch.delenv("GOOGLE_APPLICATION_CREDENTIALS", raising=False) + monkeypatch.delenv("GOOGLE_CHAT_SERVICE_ACCOUNT_JSON", raising=False) + + adc_creds = MagicMock(name="adc_credentials") + fake_default = MagicMock(return_value=(adc_creds, "fake-project")) + # ``google`` is mocked at module load via _ensure_google_mocks; patch + # the attribute path the adapter uses (``google.auth.default``). + google_pkg = sys.modules.get("google") or types.SimpleNamespace() + fake_auth_module = types.SimpleNamespace(default=fake_default) + monkeypatch.setattr(google_pkg, "auth", fake_auth_module, raising=False) + monkeypatch.setitem(sys.modules, "google", google_pkg) + monkeypatch.setitem(sys.modules, "google.auth", fake_auth_module) + + result = adapter._load_sa_credentials() + + assert result is adc_creds + fake_default.assert_called_once() + + def test_load_credentials_raises_when_no_sa_and_adc_unavailable( + self, adapter, monkeypatch + ): + """ADC failure surfaces a useful error pointing at the two fixes.""" + adapter.config.extra.pop("service_account_json", None) + monkeypatch.delenv("GOOGLE_APPLICATION_CREDENTIALS", raising=False) + monkeypatch.delenv("GOOGLE_CHAT_SERVICE_ACCOUNT_JSON", raising=False) + + def _boom(*_a, **_kw): + raise Exception("no credentials") + google_pkg = sys.modules.get("google") or types.SimpleNamespace() + fake_auth_module = types.SimpleNamespace(default=_boom) + monkeypatch.setattr(google_pkg, "auth", fake_auth_module, raising=False) + monkeypatch.setitem(sys.modules, "google", google_pkg) + monkeypatch.setitem(sys.modules, "google.auth", fake_auth_module) + + with pytest.raises(ValueError) as ei: + adapter._load_sa_credentials() + msg = str(ei.value).lower() + assert "default credentials" in msg or "adc" in msg + assert "google_chat_service_account_json" in msg + + +# =========================================================================== +# Supervisor reconnect (backoff + fatal) +# =========================================================================== + + +class TestSupervisorReconnect: + @pytest.mark.asyncio + async def test_fatal_after_max_retries(self, adapter, monkeypatch): + """Simulate 10+ failing subscribe() calls and assert fatal error set.""" + # Stub out sleep so the test doesn't actually wait minutes. + async def _instant(*args, **kwargs): + return None + monkeypatch.setattr( + "plugins.platforms.google_chat.adapter.asyncio.sleep", _instant + ) + + def _fail(*args, **kwargs): + raise RuntimeError("stream died") + adapter._subscriber.subscribe = _fail + + # Keep the test fast — run supervisor until it exhausts retries. + await adapter._run_supervisor() + assert adapter.has_fatal_error is True + assert adapter.fatal_error_code == "pubsub_reconnect_exhausted" + + +# =========================================================================== +# Authorization: email-path check via user_id_alt +# =========================================================================== + + +class TestAuthorizationEmailMatch: + """`GOOGLE_CHAT_ALLOWED_USERS=email` matches naturally without a bridge. + + Post-#14965 absorption: the adapter sets ``source.user_id = + sender_email`` directly, so the generic allowlist match in + ``_is_user_authorized`` finds it without any platform-specific + code path. Pinning here so the bridge can never silently come + back without a test failing. + """ + + def test_allowlist_matches_when_user_id_is_email(self, monkeypatch): + """Email allowlist match — the canonical case. + + The adapter assigns ``user_id = sender_email`` so the generic + check_ids path picks it up. No platform-specific bridge needed. + """ + from gateway.config import GatewayConfig + from gateway.run import GatewayRunner + from gateway.session import SessionSource + + monkeypatch.setenv("GOOGLE_CHAT_ALLOWED_USERS", "alice@example.com") + cfg = GatewayConfig() + runner = GatewayRunner(cfg) + runner.pairing_store = MagicMock() + runner.pairing_store.is_approved = MagicMock(return_value=False) + + source = SessionSource( + platform=Platform.GOOGLE_CHAT, + chat_id="spaces/S", + chat_type="dm", + user_id="alice@example.com", # post-swap: email is canonical + user_name="Alice", + user_id_alt="users/12345", # resource name moves to alt + ) + assert runner._is_user_authorized(source) is True + + def test_allowlist_denies_wrong_email(self, monkeypatch): + from gateway.config import GatewayConfig + from gateway.run import GatewayRunner + from gateway.session import SessionSource + + monkeypatch.setenv("GOOGLE_CHAT_ALLOWED_USERS", "alice@example.com") + cfg = GatewayConfig() + runner = GatewayRunner(cfg) + runner.pairing_store = MagicMock() + runner.pairing_store.is_approved = MagicMock(return_value=False) + + source = SessionSource( + platform=Platform.GOOGLE_CHAT, + chat_id="spaces/S", + chat_type="dm", + user_id="bob@example.com", + user_name="Bob", + user_id_alt="users/99999", + ) + assert runner._is_user_authorized(source) is False + + def test_allowlist_falls_back_to_resource_name_when_no_email( + self, monkeypatch + ): + """If sender has no email, ``user_id`` falls back to the resource + name. Operators who allowlist by ``users/{id}`` still match. + """ + from gateway.config import GatewayConfig + from gateway.run import GatewayRunner + from gateway.session import SessionSource + + monkeypatch.setenv("GOOGLE_CHAT_ALLOWED_USERS", "users/77777") + cfg = GatewayConfig() + runner = GatewayRunner(cfg) + runner.pairing_store = MagicMock() + runner.pairing_store.is_approved = MagicMock(return_value=False) + + source = SessionSource( + platform=Platform.GOOGLE_CHAT, + chat_id="spaces/S", + chat_type="dm", + user_id="users/77777", # no email available — resource name wins + user_name="System", + user_id_alt=None, + ) + assert runner._is_user_authorized(source) is True + + +# =========================================================================== +# Cron scheduler registry (regression guard from /review) +# +# After the generic-plugin-interface migration, Google Chat no longer lives in +# the hardcoded ``_KNOWN_DELIVERY_PLATFORMS`` / ``_HOME_TARGET_ENV_VARS`` sets +# in ``cron/scheduler.py``. It earns cron delivery via +# ``PlatformEntry.cron_deliver_env_var``, which the scheduler consults through +# ``_is_known_delivery_platform`` and ``_resolve_home_env_var``. The tests +# below check that public resolver behavior, not the hardcoded sets. +# =========================================================================== + + +class TestCronSchedulerRegistry: + def _ensure_registered(self): + """Force the plugin system to register the Google Chat adapter. + + The adapter's ``register(ctx)`` is only invoked during plugin + discovery; module-level import alone does not register it. We call + discover + manually invoke the register hook so the resolver sees + ``cron_deliver_env_var``. + """ + from gateway.platform_registry import platform_registry + if platform_registry.get("google_chat") is not None: + return + # Discover first so the plugin is loaded at all. + try: + from hermes_cli.plugins import discover_plugins + discover_plugins() + except Exception: + pass + if platform_registry.get("google_chat") is not None: + return + # Fallback: construct a minimal ctx and call register directly. + from plugins.platforms.google_chat.adapter import register as _register + class _Ctx: + class _M: + name = "google_chat-platform" + manifest = _M() + _manager = type("_Mgr", (), {"_plugin_platform_names": set()})() + def register_platform(self, **kwargs): + from gateway.platform_registry import PlatformEntry + entry = PlatformEntry(source="plugin", **kwargs) + platform_registry.register(entry) + _register(_Ctx()) + + def test_google_chat_is_known_delivery_platform(self): + self._ensure_registered() + from cron.scheduler import _is_known_delivery_platform + + assert _is_known_delivery_platform("google_chat") is True + + def test_google_chat_home_env_var_resolves(self): + self._ensure_registered() + from cron.scheduler import _resolve_home_env_var + + assert _resolve_home_env_var("google_chat") == "GOOGLE_CHAT_HOME_CHANNEL" diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index bfb2e2ebbf..61b3aebaaf 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -267,6 +267,17 @@ For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETI | `SLACK_ALLOWED_USERS` | Comma-separated Slack user IDs | | `SLACK_HOME_CHANNEL` | Default Slack channel for cron delivery | | `SLACK_HOME_CHANNEL_NAME` | Display name for the Slack home channel | +| `GOOGLE_CHAT_PROJECT_ID` | GCP project hosting the Pub/Sub topic (falls back to `GOOGLE_CLOUD_PROJECT`) | +| `GOOGLE_CHAT_SUBSCRIPTION_NAME` | Full Pub/Sub subscription path, `projects/{proj}/subscriptions/{sub}` (legacy alias: `GOOGLE_CHAT_SUBSCRIPTION`) | +| `GOOGLE_CHAT_SERVICE_ACCOUNT_JSON` | Path to Service Account JSON, or the JSON inline (falls back to `GOOGLE_APPLICATION_CREDENTIALS`) | +| `GOOGLE_CHAT_ALLOWED_USERS` | Comma-separated user emails allowed to chat with the bot | +| `GOOGLE_CHAT_ALLOW_ALL_USERS` | Allow any Google Chat user to trigger the bot (dev only) | +| `GOOGLE_CHAT_HOME_CHANNEL` | Default space (e.g. `spaces/AAAA...`) for cron delivery | +| `GOOGLE_CHAT_HOME_CHANNEL_NAME` | Display name for the Google Chat home space | +| `GOOGLE_CHAT_MAX_MESSAGES` | Pub/Sub FlowControl max in-flight messages (default: `1`) | +| `GOOGLE_CHAT_MAX_BYTES` | Pub/Sub FlowControl max in-flight bytes (default: `16777216`, 16 MiB) | +| `GOOGLE_CHAT_BOOTSTRAP_SPACES` | Comma-separated extra space IDs to probe at startup when resolving the bot's own `users/{id}` | +| `GOOGLE_CHAT_DEBUG_RAW` | Set to any value to log redacted Pub/Sub envelopes at DEBUG level (debugging only) | | `WHATSAPP_ENABLED` | Enable the WhatsApp bridge (`true`/`false`) | | `WHATSAPP_MODE` | `bot` (separate number) or `self-chat` (message yourself) | | `WHATSAPP_ALLOWED_USERS` | Comma-separated phone numbers (with country code, no `+`), or `*` to allow all senders | diff --git a/website/docs/user-guide/messaging/google_chat.md b/website/docs/user-guide/messaging/google_chat.md new file mode 100644 index 0000000000..6fda2b179a --- /dev/null +++ b/website/docs/user-guide/messaging/google_chat.md @@ -0,0 +1,370 @@ +--- +sidebar_position: 12 +title: "Google Chat" +description: "Set up Hermes Agent as a Google Chat bot using Cloud Pub/Sub" +--- + +# Google Chat Setup + +Connect Hermes Agent to Google Chat as a bot. The integration uses Cloud Pub/Sub +pull subscriptions for inbound events and the Chat REST API for outbound messages. +Equivalent ergonomics to Slack Socket Mode or Telegram long-polling: your Hermes +process does not need a public URL, a tunnel, or a TLS certificate. It connects, +authenticates, and listens on a subscription — the same way a Telegram bot listens +on a token. + +:::note Workspace edition +Google Chat is part of Google Workspace. You can use this integration with a +personal Workspace (`@yourdomain.com` registered through Google) or a work +Workspace where you have the Admin rights to publish an app. Gmail-only accounts +cannot host Chat apps. +::: + +## Overview + +| Component | Value | +|-----------|-------| +| **Libraries** | `google-cloud-pubsub`, `google-api-python-client`, `google-auth` | +| **Inbound transport** | Cloud Pub/Sub pull subscription (no public endpoint) | +| **Outbound transport** | Chat REST API (`chat.googleapis.com`) | +| **Authentication** | Service Account JSON with `roles/pubsub.subscriber` on the subscription | +| **User identification** | Chat resource names (`users/{id}`) + email | + +--- + +## Step 1: Create or pick a GCP project + +You need a Google Cloud project to host the Pub/Sub topic. If you don't have one, +create it at [console.cloud.google.com](https://console.cloud.google.com) — +personal accounts get a free tier that easily covers bot traffic. + +Note the project ID (e.g., `my-chat-bot-123`). You'll use it in every subsequent +step. + +--- + +## Step 2: Enable two APIs + +In the console, go to **APIs & Services → Library** and enable: + +- **Google Chat API** +- **Cloud Pub/Sub API** + +Both are free for the volumes a personal bot generates. + +--- + +## Step 3: Create a Service Account + +**IAM & Admin → Service Accounts → Create Service Account.** + +- Name: `hermes-chat-bot` +- Skip the "Grant this service account access to project" step. IAM on the specific + subscription is all you need — do **NOT** grant project-level Pub/Sub roles. + +After creation, open the SA, go to **Keys → Add Key → Create new key → JSON** and +download the file. Save it somewhere only Hermes can read (e.g., +`~/.hermes/google-chat-sa.json`, `chmod 600`). + +:::caution There is NO "Chat Bot Caller" role +A common mistake is to search for a Chat-specific IAM role and grant it at the +project level. That role doesn't exist. Chat bot authority comes from being +installed in a space, not from IAM. All your SA needs is Pub/Sub subscriber on +the subscription you create in the next step. +::: + +--- + +## Step 4: Create the Pub/Sub topic and subscription + +**Pub/Sub → Topics → Create topic.** + +- Topic ID: `hermes-chat-events` +- Leave the defaults for everything else. + +After creation, the topic's detail page has a **Subscriptions** tab. Create one: + +- Subscription ID: `hermes-chat-events-sub` +- Delivery type: **Pull** +- Message retention: **7 days** (so backlog survives a hermes restart) +- Leave the rest default. + +--- + +## Step 5: IAM binding on the topic (critical) + +On the **topic** (not the subscription), add an IAM principal: + +- Principal: `chat-api-push@system.gserviceaccount.com` +- Role: `Pub/Sub Publisher` + +Without this, Google Chat cannot publish events to your topic and your bot will +never receive anything. + +--- + +## Step 6: IAM binding on the subscription + +On the **subscription**, add your own Service Account as a principal: + +- Principal: `hermes-chat-bot@.iam.gserviceaccount.com` +- Role: `Pub/Sub Subscriber` + +Also grant `Pub/Sub Viewer` on the same subscription — Hermes calls +`subscription.get()` at startup as a reachability check. + +--- + +## Step 7: Configure the Chat app + +Go to **APIs & Services → Google Chat API → Configuration**. + +- **App name**: whatever you want users to see ("Hermes" is reasonable). +- **Avatar URL**: any public PNG (Google has some defaults). +- **Description**: a short sentence shown in the app directory. +- **Functionality**: enable **Receive 1:1 messages** and **Join spaces and group + conversations**. +- **Connection settings**: select **Cloud Pub/Sub**, enter the topic name + `projects//topics/hermes-chat-events`. +- **Visibility**: restrict to your workspace (or specific users) — do not publish + to everyone while you're testing. + +Save. + +--- + +## Step 8: Install the bot in a test space + +Open Google Chat in a browser. Start a DM with your app by searching for its name +in the **+ New Chat** menu. The first time you message it, Google sends an +`ADDED_TO_SPACE` event that Hermes uses to cache the bot's own `users/{id}` for +self-message filtering. + +--- + +## Step 9: Configure Hermes + +Add the Google Chat section to `~/.hermes/.env`: + +```bash +# Required +GOOGLE_CHAT_PROJECT_ID=my-chat-bot-123 +GOOGLE_CHAT_SUBSCRIPTION_NAME=projects/my-chat-bot-123/subscriptions/hermes-chat-events-sub +GOOGLE_CHAT_SERVICE_ACCOUNT_JSON=/home/you/.hermes/google-chat-sa.json + +# Authorization — paste the emails of people allowed to talk to the bot +GOOGLE_CHAT_ALLOWED_USERS=you@yourdomain.com,coworker@yourdomain.com + +# Optional +GOOGLE_CHAT_HOME_CHANNEL=spaces/AAAA... # default delivery destination for cron jobs +GOOGLE_CHAT_MAX_MESSAGES=1 # Pub/Sub FlowControl; 1 serializes commands per session +GOOGLE_CHAT_MAX_BYTES=16777216 # 16 MiB — cap on in-flight message bytes +``` + +The project ID also falls back to `GOOGLE_CLOUD_PROJECT`, and the SA path falls +back to `GOOGLE_APPLICATION_CREDENTIALS` — use whichever convention you prefer. + +Install Hermes with the optional dependencies: + +```bash +pip install 'hermes-agent[google_chat]' +``` + +Start the gateway: + +```bash +hermes gateway +``` + +You should see a log line like: + +``` +[GoogleChat] Connected; project=my-chat-bot-123, subscription=, + bot_user_id=users/XXXX, flow_control(msgs=1, bytes=16777216) +``` + +Send "hola" in the test DM. The bot posts a "Hermes is thinking…" marker, then +edits that same message in place with the real response — no "message deleted" +tombstones. + +--- + +## Formatting and capabilities + +Google Chat renders a limited markdown subset: + +| Supported | Not supported | +|-----------|---------------| +| `*bold*`, `_italic_`, `~strike~`, `` `code` `` | Headings, lists | +| Inline images via URL | Interactive Card v2 buttons (v1 of this gateway) | +| Native file attachments (after `/setup-files` — see Step 10) | Native voice notes / circular video notes | + +The agent's system prompt includes a Google Chat–specific hint so it knows these +limits and avoids formatting that won't render. + +Message size limit: 4000 characters per message. Longer agent responses are +automatically split across multiple messages. + +Thread support: when a user replies inside a thread, Hermes detects the +`thread.name` and posts its reply in the same thread, so each thread gets a +separate Hermes session. + +--- + +## Step 10: Native attachment delivery (optional) + +Out of the box the bot can post text, inline images via URL, and download cards +for audio/video/documents. To deliver **native** Chat attachments — the same +file widget you get when a human drags-and-drops a file — each user authorizes +the bot once via a per-user OAuth flow. + +### Why a separate flow + +Google Chat's `media.upload` endpoint hard-rejects service-account auth: + +> This method doesn't support app authentication with a service account. +> Authenticate with a user account. + +There's no IAM role or scope that fixes this. The endpoint only accepts user +credentials. So the bot has to act *as a user* whenever it uploads a file — +specifically, as the user who asked for the file. + +### One-time host setup + +1. Go to **APIs & Services → Credentials** in the same GCP project. +2. **Create credentials → OAuth client ID → Desktop app**. +3. Download the JSON. Move it onto the host that runs Hermes. +4. On the host, register the client with Hermes: + +```bash +python -m gateway.platforms.google_chat_user_oauth \ + --client-secret /path/to/client_secret.json +``` + +That writes `~/.hermes/google_chat_user_client_secret.json`. This is shared +infrastructure — it identifies the OAuth *app*, not any individual user. One +file per host is enough no matter how many users authorize later. + +### Per-user authorization (in chat) + +Each user runs the flow once, in their own DM with the bot: + +1. They send `/setup-files` to the bot. It replies with status and the next + step. +2. They send `/setup-files start`. The bot replies with an OAuth URL. +3. They open the URL, click **Allow**, and watch the browser fail to load + `http://localhost:1/?...&code=...`. That failure is expected — the auth + code is in the URL bar. +4. They copy the failed URL (or just the `code=...` value) and paste it back + into chat as `/setup-files `. The bot exchanges it for a + refresh token. + +The token lands at `~/.hermes/google_chat_user_tokens/.json`. +Subsequent file requests in that user's DM use *their* token, so the bot +uploads as them and the message lands in their space. + +To revoke later: `/setup-files revoke` deletes only that user's token. Other +users' tokens are untouched. + +### Scope + +The flow requests exactly one scope: `chat.messages.create`. That covers both +`media.upload` and the `messages.create` that references the uploaded +`attachmentDataRef`. No Drive, no broader Chat scopes — this is least-privilege +on purpose. + +### Multi-user behavior + +When the asker has no per-user token yet, the bot falls back to a legacy +single-user token at `~/.hermes/google_chat_user_token.json` (if present from +a pre-multi-user install). When neither is available, the bot posts a clear +text notice telling the asker to run `/setup-files`. + +A user revoking only clears their own slot. A 401/403 from one user's token +evicts only that user's cache. Users don't disrupt each other. + +--- + +## Troubleshooting + +**Bot stays silent after sending "hola."** + +1. Check the Pub/Sub subscription has undelivered messages in the console. + If it does, Hermes isn't authenticated — verify `GOOGLE_CHAT_SERVICE_ACCOUNT_JSON` + and that the SA is listed as `Pub/Sub Subscriber` on the subscription. +2. If the subscription has zero messages, Google Chat isn't publishing. + Double-check the IAM binding on the **topic**: + `chat-api-push@system.gserviceaccount.com` must have `Pub/Sub Publisher`. +3. Check `hermes gateway` logs for `[GoogleChat] Connected`. If you see + `[GoogleChat] Config validation failed`, the error message tells you which + env var to fix. + +**Bot replies but an error message appears instead of the agent's answer.** + +Check logs for `[GoogleChat] Pub/Sub stream died` — if these repeat, your SA +credentials may have been rotated or the subscription deleted. After 10 attempts +the adapter marks itself fatal. + +**"403 Forbidden" on every outbound message.** + +The bot was removed from the space, or you revoked it in the Chat API console. +Re-install it in the space (the next `ADDED_TO_SPACE` event will re-enable +messaging automatically). + +**Too many "Rate limit hit" warnings.** + +The Chat API's default quotas allow 60 messages per space per minute. If your +agent produces long streaming responses that exceed that, the adapter retries +with exponential backoff — but you'll still see user-visible latency. Consider +concise responses or raising the quota in the GCP console. + +**Bot keeps posting the "/setup-files" notice instead of files.** + +The asker has no per-user OAuth token and there's no legacy fallback. Run +`/setup-files` in their DM and follow Step 10. After the exchange completes +the next file request uploads natively without a gateway restart. + +**`/setup-files start` says "No client credentials stored on the host."** + +The one-time host setup wasn't done. From a terminal on the host that runs +Hermes: + +```bash +python -m gateway.platforms.google_chat_user_oauth \ + --client-secret /path/to/client_secret.json +``` + +Then send `/setup-files start` again. + +**`/setup-files ` says "Token exchange failed."** + +The auth code is single-use and short-lived (typically a few minutes). Send +`/setup-files start` to get a fresh URL and retry. + +--- + +## Security notes + +- **Service Account scope**: the adapter requests `chat.bot` and `pubsub` scopes. + IAM should be the actual enforcement — grant your SA the minimum + (`roles/pubsub.subscriber` + `roles/pubsub.viewer` on the subscription), not + project-level or org-level Pub/Sub roles. +- **Attachment download protection**: Hermes will only attach the SA bearer + token to URLs whose host matches a short allowlist of Google-owned domains + (`googleapis.com`, `drive.google.com`, `lh[3-6].googleusercontent.com`, and + a few others). Any other host is rejected before the HTTP request, to + protect against SSRF scenarios where a crafted event could redirect the + bearer token to the GCE metadata service. +- **Redaction**: Service Account emails, subscription paths, and topic paths + are stripped from log output by `agent/redact.py`. The debug envelope dump + (`GOOGLE_CHAT_DEBUG_RAW=1`) routes through the same redaction filter and + logs at DEBUG level. +- **Compliance**: if you plan to connect this bot to a regulated workspace + (anything with a data-residency or AI-governance policy), get that approval + before the first install. +- **User OAuth scope**: the per-user attachment flow requests *only* + `chat.messages.create` — the minimum that covers `media.upload` plus the + follow-up `messages.create`. Tokens are persisted as plain JSON at + `~/.hermes/google_chat_user_tokens/.json` (filesystem + permissions are the protection — same model as the SA key file). Each + token is owned by exactly one user; revoke is scoped to that user. diff --git a/website/docs/user-guide/messaging/index.md b/website/docs/user-guide/messaging/index.md index 25e8e4598f..866fcc1d33 100644 --- a/website/docs/user-guide/messaging/index.md +++ b/website/docs/user-guide/messaging/index.md @@ -17,6 +17,7 @@ For the full voice feature set — including CLI microphone mode, spoken replies | Telegram | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ | | Discord | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Slack | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Google Chat | — | ✅ | ✅ | ✅ | — | ✅ | — | | WhatsApp | — | ✅ | ✅ | — | — | ✅ | ✅ | | Signal | — | ✅ | ✅ | — | — | ✅ | ✅ | | SMS | — | — | — | — | — | — | — | @@ -46,6 +47,7 @@ flowchart TB dc[Discord] wa[WhatsApp] sl[Slack] + gc[Google Chat] sig[Signal] sms[SMS] em[Email] @@ -74,6 +76,7 @@ flowchart TB dc --> store wa --> store sl --> store + gc --> store sig --> store sms --> store em --> store @@ -383,6 +386,7 @@ Each platform has its own toolset: | Discord | `hermes-discord` | Full tools including terminal | | WhatsApp | `hermes-whatsapp` | Full tools including terminal | | Slack | `hermes-slack` | Full tools including terminal | +| Google Chat | `hermes-google-chat` | Full tools including terminal | | Signal | `hermes-signal` | Full tools including terminal | | SMS | `hermes-sms` | Full tools including terminal | | Email | `hermes-email` | Full tools including terminal | @@ -406,6 +410,7 @@ Each platform has its own toolset: - [Telegram Setup](telegram.md) - [Discord Setup](discord.md) - [Slack Setup](slack.md) +- [Google Chat Setup](google_chat.md) - [WhatsApp Setup](whatsapp.md) - [Signal Setup](signal.md) - [SMS Setup (Twilio)](sms.md)