diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index 3a6ec2441519..bf492a4e45b0 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -422,6 +422,18 @@ def _strip_yaml_frontmatter(content: str) -> str: "your response. Images are sent as native photos, and other files arrive as downloadable " "documents." ), + "google_chat": ( + "You are on Google Chat, a workspace-oriented messaging platform. " + "Google Chat uses its own lightweight formatting — NOT standard Markdown. " + "Use *single asterisks* for bold, _underscores_ for italic, " + "~single tilde~ for strikethrough, and `backticks` for code. " + "Double asterisks (**) and headers (# Title) are NOT rendered — " + "they appear as literal characters. " + "For links, use syntax (NOT [text](url)). " + "Keep messages concise and well-structured with short paragraphs. " + "Messages are capped at 4,096 characters — longer responses are " + "split automatically." + ), } # --------------------------------------------------------------------------- diff --git a/cron/scheduler.py b/cron/scheduler.py index d051a7ab36ed..d996afa5e0ee 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -77,7 +77,7 @@ def _resolve_cron_enabled_toolsets(job: dict, cfg: dict) -> list[str] | None: "telegram", "discord", "slack", "whatsapp", "signal", "matrix", "mattermost", "homeassistant", "dingtalk", "feishu", "wecom", "wecom_callback", "weixin", "sms", "email", "webhook", "bluebubbles", - "qqbot", + "qqbot", "google_chat", }) # Platforms that support a configured cron/notification home target, mapped to @@ -97,6 +97,7 @@ def _resolve_cron_enabled_toolsets(job: dict, cfg: dict) -> list[str] | None: "weixin": "WEIXIN_HOME_CHANNEL", "bluebubbles": "BLUEBUBBLES_HOME_CHANNEL", "qqbot": "QQBOT_HOME_CHANNEL", + "google_chat": "GOOGLE_CHAT_HOME_CHANNEL", } # Legacy env var names kept for back-compat. Each entry is the current @@ -337,6 +338,7 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option "sms": Platform.SMS, "bluebubbles": Platform.BLUEBUBBLES, "qqbot": Platform.QQBOT, + "google_chat": Platform.GOOGLE_CHAT, } # Optionally wrap the content with a header/footer so the user knows this diff --git a/gateway/config.py b/gateway/config.py index 67ebf7346189..2d59815b79ab 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -67,6 +67,7 @@ class Platform(Enum): WEIXIN = "weixin" BLUEBUBBLES = "bluebubbles" QQBOT = "qqbot" + GOOGLE_CHAT = "google_chat" @dataclass @@ -1271,6 +1272,27 @@ def _apply_env_overrides(config: GatewayConfig) -> None: name=os.getenv("QQBOT_HOME_CHANNEL_NAME") or os.getenv(qq_home_name_env, "Home"), ) + # Google Chat (via Cloud Pub/Sub) + google_chat_project = os.getenv("GOOGLE_CHAT_GCP_PROJECT") + if google_chat_project: + if Platform.GOOGLE_CHAT not in config.platforms: + config.platforms[Platform.GOOGLE_CHAT] = PlatformConfig() + config.platforms[Platform.GOOGLE_CHAT].enabled = True + config.platforms[Platform.GOOGLE_CHAT].extra.update({ + "gcp_project": google_chat_project, + "pubsub_subscription": os.getenv("GOOGLE_CHAT_PUBSUB_SUBSCRIPTION", "hermes-chat-inbound-sub"), + }) + google_chat_credentials = os.getenv("GOOGLE_CHAT_CREDENTIALS", "") + if google_chat_credentials: + config.platforms[Platform.GOOGLE_CHAT].extra["chat_credentials"] = google_chat_credentials + google_chat_home = os.getenv("GOOGLE_CHAT_HOME_CHANNEL") + if google_chat_home and Platform.GOOGLE_CHAT in config.platforms: + config.platforms[Platform.GOOGLE_CHAT].home_channel = HomeChannel( + platform=Platform.GOOGLE_CHAT, + chat_id=google_chat_home, + name=os.getenv("GOOGLE_CHAT_HOME_CHANNEL_NAME", "Home"), + ) + # Session settings idle_minutes = os.getenv("SESSION_IDLE_MINUTES") if idle_minutes: diff --git a/gateway/platforms/google_chat.py b/gateway/platforms/google_chat.py new file mode 100644 index 000000000000..0607bbc20ccc --- /dev/null +++ b/gateway/platforms/google_chat.py @@ -0,0 +1,776 @@ +"""Google Chat gateway adapter. + +Connects to Google Chat via Cloud Pub/Sub for inbound messages and the +Chat REST API (v1) for outbound replies. No additional HTTP server or +relay is needed — the adapter subscribes directly to a Pub/Sub +subscription where Google Chat publishes native events. + +Architecture:: + + Google Chat ─→ Pub/Sub topic ─→ THIS ADAPTER ─→ Hermes + Google Chat ←─ Chat REST API ←─ THIS ADAPTER ←─ Hermes + +Inbound: + Google Chat is configured in *Cloud Pub/Sub connection mode*, + publishing native Chat API events to a topic. A background + streaming-pull subscriber reads from the matching subscription + and dispatches messages to ``self.handle_message(event)``. + + Three event formats are detected automatically: + 1. **Workspace Add-ons** — ``{chat: {messagePayload: {...}}}`` + 2. **Native Chat API Pub/Sub** — ``{type: "MESSAGE", message: {...}}`` + 3. **Relay / custom format** — flat ``{sender_email, text, ...}`` + +Outbound: + When Hermes produces a response, the gateway calls ``send()``. + This adapter posts the reply to the Google Chat REST API using + the Chat app's service-account credentials. + +Environment variables: + GOOGLE_CHAT_GCP_PROJECT GCP project ID for Pub/Sub + GOOGLE_CHAT_PUBSUB_SUBSCRIPTION Pub/Sub subscription name + GOOGLE_CHAT_CREDENTIALS Path to service-account JSON key + GOOGLE_CHAT_HOME_CHANNEL Space name for cron/notification delivery + GOOGLE_CHAT_ALLOWED_USERS Comma-separated allowed email addresses + GOOGLE_CHAT_ALLOW_ALL_USERS Set to "true" to allow all users + +Requirements: + pip install google-cloud-pubsub google-auth google-api-python-client +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import os +import random +import re +import time +from typing import Any, Dict, List, Optional + +from gateway.config import Platform, PlatformConfig +from gateway.platforms.base import ( + BasePlatformAdapter, + MessageEvent, + MessageType, + SendResult, +) + +logger = logging.getLogger(__name__) + +# Google Chat message-length limit (text body). +MAX_MESSAGE_LENGTH = 4096 + +# Pub/Sub flow control +_MAX_OUTSTANDING_MESSAGES = 10 + +# Retry parameters for outbound Chat API calls +_RETRY_MAX_ATTEMPTS = 3 +_RETRY_BASE_DELAY = 1.0 +_RETRY_MAX_DELAY = 8.0 +_RETRY_JITTER = 0.3 + + +def check_google_chat_requirements() -> bool: + """Check if Google Chat platform dependencies are available.""" + try: + from google.cloud import pubsub_v1 # noqa: F401 + except ImportError: + logger.warning( + "Google Chat: google-cloud-pubsub not installed. " + "Run: pip install google-cloud-pubsub" + ) + return False + try: + from google.oauth2 import service_account # noqa: F401 + from googleapiclient.discovery import build # noqa: F401 + except ImportError: + logger.warning( + "Google Chat: google-auth or google-api-python-client not installed. " + "Run: pip install google-auth google-api-python-client" + ) + return False + + # Require at least the GCP project to be set + gcp_project = os.getenv("GOOGLE_CHAT_GCP_PROJECT", "") + if not gcp_project: + logger.debug("Google Chat: GOOGLE_CHAT_GCP_PROJECT not set") + return False + + return True + + +class GoogleChatAdapter(BasePlatformAdapter): + """Gateway adapter for Google Chat via Cloud Pub/Sub. + + Config keys (read from ``PlatformConfig.extra``): + gcp_project GCP project ID (required for Pub/Sub) + pubsub_subscription Pub/Sub subscription name for inbound messages + chat_credentials Path to Chat app service-account JSON key + """ + + def __init__(self, config: PlatformConfig) -> None: + super().__init__(config, Platform.GOOGLE_CHAT) + + extra = config.extra or {} + + # GCP project + self._gcp_project: str = extra.get( + "gcp_project", + os.getenv("GOOGLE_CHAT_GCP_PROJECT", ""), + ) + self._subscription: str = extra.get( + "pubsub_subscription", + os.getenv( + "GOOGLE_CHAT_PUBSUB_SUBSCRIPTION", + "hermes-chat-inbound-sub", + ), + ) + + # Chat API credentials + self._credentials_path: str | None = extra.get( + "chat_credentials", + os.getenv("GOOGLE_CHAT_CREDENTIALS"), + ) + + # Runtime state + self._subscriber_client: Any = None + self._streaming_pull_future: Any = None + self._chat_service: Any = None + self._loop: asyncio.AbstractEventLoop | None = None + self._connected: bool = False + + # ------------------------------------------------------------------ + # Connection lifecycle + # ------------------------------------------------------------------ + + async def connect(self) -> bool: + """Start pulling messages from Pub/Sub.""" + from google.cloud import pubsub_v1 + + try: + self._loop = asyncio.get_running_loop() + + # Build SA credentials once — shared by Chat API and Pub/Sub + creds = self._build_credentials() + + # Build the Chat API client for outbound messages + self._chat_service = self._build_chat_service(creds) + + # Start the Pub/Sub subscriber using the same SA credentials + self._subscriber_client = pubsub_v1.SubscriberClient( + credentials=creds, + ) + subscription_path = ( + f"projects/{self._gcp_project}" + f"/subscriptions/{self._subscription}" + ) + + logger.info( + "Google Chat: subscribing to %s", subscription_path + ) + + # The streaming pull callback runs in a thread-pool managed by + # the google-cloud-pubsub library. We bridge back to asyncio + # via ``loop.call_soon_threadsafe``. + self._streaming_pull_future = self._subscriber_client.subscribe( + subscription_path, + callback=self._on_pubsub_message, + flow_control=pubsub_v1.types.FlowControl( + max_messages=_MAX_OUTSTANDING_MESSAGES, + ), + ) + + self._connected = True + self._mark_connected() + logger.info("Google Chat: connected and listening") + return True + + except Exception: + logger.error( + "Google Chat: failed to connect", exc_info=True + ) + return False + + async def disconnect(self) -> None: + """Stop the Pub/Sub subscriber.""" + self._connected = False + + if self._streaming_pull_future is not None: + self._streaming_pull_future.cancel() + self._streaming_pull_future = None + + if self._subscriber_client is not None: + self._subscriber_client.close() + self._subscriber_client = None + + if self._chat_service is not None: + self._chat_service = None + + logger.info("Google Chat: disconnected") + + # ------------------------------------------------------------------ + # Inbound: Pub/Sub → Hermes + # ------------------------------------------------------------------ + + def _on_pubsub_message(self, message: Any) -> None: + """Pub/Sub callback — runs in a background thread. + + Handles three event formats: + 1. **Workspace Add-ons** (current Chat App Pub/Sub format): + ``{commonEventObject: {...}, chat: {user: {...}, messagePayload: {...}}}`` + 2. **Native Chat API Pub/Sub** (alternate format): + ``{type: "MESSAGE", message: {sender: {...}, text: "..."}, space: {...}}`` + 3. **Relay format** (from an optional Cloud Run relay): + ``{event_type: "MESSAGE", sender_email: "...", text: "..."}`` + + Deserializes the message, converts to ``MessageEvent``, and + schedules ``handle_message`` on the asyncio event loop. + """ + try: + data = json.loads(message.data.decode("utf-8")) + logger.debug( + "Google Chat: received Pub/Sub message keys: %s", + list(data.keys()), + ) + + parsed = self._parse_event(data) + if parsed is None: + message.ack() + return + + text, sender_email, sender_name, space_name, space_type, thread_name, message_name = parsed + + if not text or not text.strip(): + message.ack() + return + + # Determine chat_type from space type + chat_type = ( + "dm" + if space_type in ("DM", "DIRECT_MESSAGE") + else "group" + ) + + logger.info( + "Google Chat: message from %s in %s (%s): %s", + sender_name, + space_name, + chat_type, + text[:100], + ) + + source = self.build_source( + chat_id=space_name, + user_id=sender_email, + user_name=sender_name, + chat_type=chat_type, + thread_id=thread_name if thread_name else None, + ) + + event = MessageEvent( + text=text, + message_type=MessageType.TEXT, + source=source, + message_id=message_name, + raw_message=data, + ) + + # Bridge to the asyncio event loop + if self._loop and self._loop.is_running(): + self._loop.call_soon_threadsafe( + asyncio.ensure_future, + self.handle_message(event), + ) + + message.ack() + + except Exception: + logger.error( + "Google Chat: error processing Pub/Sub message", + exc_info=True, + ) + # Nack so the message is retried + message.nack() + + @staticmethod + def _parse_event( + data: Dict[str, Any], + ) -> Optional[tuple[str, str, str, str, str, str, str]]: + """Parse a Chat API event into a normalized tuple. + + Returns ``(text, sender_email, sender_name, space_name, + space_type, thread_name, message_name)`` or ``None`` if the + event should be silently dropped (e.g., non-MESSAGE events, + unrecognized formats). + """ + # --- Format 1: Workspace Add-ons --- + if "chat" in data and "messagePayload" in data.get("chat", {}): + chat = data["chat"] + user = chat.get("user", {}) + payload = chat["messagePayload"] + space = payload.get("space", {}) + chat_msg = payload.get("message", {}) + + sender_email = user.get("email", "unknown") + sender_name = user.get("displayName", sender_email) + space_name = space.get("name", "") + space_type = space.get( + "spaceType", space.get("type", "SPACE") + ) + thread_name = ( + chat_msg.get("thread", {}).get("name", "") + ) + text = chat_msg.get( + "argumentText", chat_msg.get("text", "") + ) + message_name = chat_msg.get("name", "") + return ( + text, + sender_email, + sender_name, + space_name, + space_type, + thread_name, + message_name, + ) + + # --- Format 2: Native Chat API Pub/Sub --- + if "message" in data and isinstance(data["message"], dict): + event_type = data.get("type", "UNKNOWN") + if event_type != "MESSAGE": + logger.info( + "Google Chat: ignoring event type: %s", event_type + ) + return None + + chat_msg = data["message"] + sender = chat_msg.get("sender", {}) + space = data.get("space", {}) + + sender_email = sender.get("email", "unknown") + sender_name = sender.get("displayName", sender_email) + space_name = space.get("name", "") + space_type = space.get( + "spaceType", space.get("type", "SPACE") + ) + thread_name = ( + chat_msg.get("thread", {}).get("name", "") + ) + text = chat_msg.get( + "argumentText", chat_msg.get("text", "") + ) + message_name = chat_msg.get("name", "") + return ( + text, + sender_email, + sender_name, + space_name, + space_type, + thread_name, + message_name, + ) + + # --- Format 3: Relay / flat format --- + if "event_type" in data or "sender_email" in data: + event_type = data.get("event_type", "MESSAGE") + if event_type != "MESSAGE": + logger.info( + "Google Chat: ignoring relay event type: %s", + event_type, + ) + return None + + sender_email = data.get("sender_email", "unknown") + sender_name = data.get( + "sender_display_name", sender_email + ) + space_name = data.get("space_name", "") + space_type = "SPACE" + thread_name = data.get("thread_name", "") + text = data.get("text", "") + message_name = data.get("message_name", "") + return ( + text, + sender_email, + sender_name, + space_name, + space_type, + thread_name, + message_name, + ) + + logger.warning( + "Google Chat: unrecognized event format, keys: %s", + list(data.keys()), + ) + return None + + # ------------------------------------------------------------------ + # Outbound: Hermes → Google Chat + # ------------------------------------------------------------------ + + # Invisible Unicode codepoints that render as tofu (□) in Google + # Chat's restricted font stack. Variation Selectors control + # text-vs-emoji presentation but Chat ignores them and often shows + # a blank box. Zero-width characters are invisible glue used in + # ZWJ sequences (family/flag composites) and bidirectional text. + _INVISIBLE_RE = re.compile( + "[" + "\u200b" # Zero-Width Space + "\u200c" # Zero-Width Non-Joiner + "\u200d" # Zero-Width Joiner (ZWJ) + "\u200e\u200f" # LTR / RTL marks + "\u2060" # Word Joiner + "\ufeff" # BOM / Zero-Width No-Break Space + "\ufe00-\ufe0f" # Variation Selectors 1-16 (VS1–VS16) + "\U000e0100-\U000e01ef" # Variation Selectors 17-256 + "]" + ) + + def format_message(self, content: str) -> str: + """Convert standard Markdown to Google Chat's formatting dialect. + + Google Chat supports a limited subset of inline formatting: + ``*bold*``, ``_italic_``, ``~strikethrough~``, and code fences. + Standard Markdown constructs (``**bold**``, ``# headers``, + ``[text](url)``) are not rendered and need conversion. + + Additionally, certain invisible Unicode codepoints (Zero-Width + Joiner, Variation Selectors, etc.) render as tofu (□) in + Google Chat's restricted font stack. These are stripped so the + output reads cleanly across web, desktop, and mobile clients. + """ + if not content: + return content + + text = content + + # ── 1. Protect code blocks and inline code from transformation ── + 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 + + # Fenced code blocks (```...```) + text = re.sub( + r"(```(?:[^\n]*\n)?[\s\S]*?```)", + lambda m: _ph(m.group(0)), + text, + ) + # Inline code (`...`) + text = re.sub(r"(`[^`]+`)", lambda m: _ph(m.group(0)), text) + + # ── 2. Markdown → Google Chat formatting ── + + # Headers (## Title) → *Title* (bold, since Chat has no headers) + 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) → + # Google Chat uses Slack-style angle-bracket links. + text = re.sub( + r"\[([^\]]+)\]\(([^)]+)\)", + lambda m: _ph(f"<{m.group(2)}|{m.group(1)}>"), + text, + ) + + # ── 3. Strip invisible Unicode that renders as tofu ── + text = self._INVISIBLE_RE.sub("", text) + + # Clean up leftover artifacts: double spaces from stripped chars, + # orphaned punctuation preceded only by whitespace on a line. + text = re.sub(r" +", " ", text) + + # ── 4. Restore protected regions ── + for key, value in placeholders.items(): + text = text.replace(key, value) + + return text + + async def send( + self, + chat_id: str, + content: str, + reply_to: str | None = None, + metadata: dict[str, Any] | None = None, + ) -> SendResult: + """Send a text message to a Google Chat space. + + Long messages are automatically chunked to respect the + 4,096-character limit. Outbound API calls use exponential + backoff on transient failures. + """ + if not content: + return SendResult(success=True) + + if not self._chat_service: + return SendResult( + success=False, error="Chat API client not initialized" + ) + + formatted = self.format_message(content) + chunks = self.truncate_message(formatted, MAX_MESSAGE_LENGTH) + + # Thread replies: use thread_id from metadata + thread_name = None + if metadata and metadata.get("thread_id"): + thread_name = metadata["thread_id"] + + last_msg_name = None + for chunk in chunks: + result = await self._send_single_message( + chat_id, chunk, thread_name + ) + if not result.success: + return result + last_msg_name = result.message_id + + return SendResult(success=True, message_id=last_msg_name) + + async def _send_single_message( + self, + chat_id: str, + text: str, + thread_name: str | None = None, + ) -> SendResult: + """Send a single message chunk with retry/backoff.""" + delay = _RETRY_BASE_DELAY + last_error: str = "" + + for attempt in range(_RETRY_MAX_ATTEMPTS): + try: + body: dict[str, Any] = {"text": text} + + create_kwargs: dict[str, Any] = { + "parent": chat_id, + "body": body, + } + + if thread_name: + body["thread"] = {"name": thread_name} + create_kwargs["messageReplyOption"] = ( + "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD" + ) + + result = await asyncio.to_thread( + self._chat_service.spaces() + .messages() + .create(**create_kwargs) + .execute + ) + + msg_name = result.get("name", "") + logger.debug("Google Chat: sent message %s", msg_name) + return SendResult(success=True, message_id=msg_name) + + except Exception as exc: + last_error = str(exc) + # Check for retryable status codes + retryable = _is_retryable_error(exc) + if not retryable or attempt >= _RETRY_MAX_ATTEMPTS - 1: + logger.error( + "Google Chat: failed to send message to %s: %s", + chat_id, + exc, + exc_info=True, + ) + return SendResult( + success=False, + error=last_error, + retryable=retryable, + ) + + jitter = delay * _RETRY_JITTER * random.random() + logger.warning( + "Google Chat: send attempt %d/%d failed (%s), " + "retrying in %.1fs", + attempt + 1, + _RETRY_MAX_ATTEMPTS, + exc, + delay + jitter, + ) + await asyncio.sleep(delay + jitter) + delay = min(delay * 2, _RETRY_MAX_DELAY) + + return SendResult( + success=False, error=last_error, retryable=True + ) + + async def send_typing( + self, + chat_id: str, + metadata: Optional[Dict[str, Any]] = None, + ) -> None: + """Send typing indicator. + + Google Chat API does not natively support typing indicators + for Chat apps, so this is a no-op. + """ + pass + + async def send_image( + self, + chat_id: str, + image_url: str, + caption: str = "", + reply_to: str | None = None, + metadata: dict[str, Any] | None = None, + ) -> SendResult: + """Send an image to a Google Chat space via an image card.""" + if not self._chat_service: + return SendResult( + success=False, error="Chat API client not initialized" + ) + + try: + body: dict[str, Any] = { + "cards": [ + { + "sections": [ + { + "widgets": [ + { + "image": { + "imageUrl": image_url, + } + } + ] + } + ] + } + ] + } + if caption: + body["text"] = caption + + result = await asyncio.to_thread( + self._chat_service.spaces() + .messages() + .create(parent=chat_id, body=body) + .execute + ) + + return SendResult( + success=True, + message_id=result.get("name", ""), + ) + + except Exception as exc: + logger.error( + "Google Chat: failed to send image to %s: %s", + chat_id, + exc, + ) + return SendResult( + success=False, error=str(exc), retryable=True + ) + + async def get_chat_info(self, chat_id: str) -> dict: + """Return basic info about a Chat space.""" + return { + "name": chat_id, + "type": "space", + "chat_id": chat_id, + } + + # ------------------------------------------------------------------ + # Chat API client + # ------------------------------------------------------------------ + + def _build_credentials(self) -> Any: + """Build Google credentials from SA key or ADC fallback. + + Prefers the explicit service-account JSON key at + ``GOOGLE_CHAT_CREDENTIALS``. Falls back to Application Default + Credentials only when no key path is configured. + """ + scopes = [ + "https://www.googleapis.com/auth/chat.bot", + "https://www.googleapis.com/auth/pubsub", + ] + + if self._credentials_path and os.path.isfile( + self._credentials_path + ): + from google.oauth2 import service_account + + logger.info( + "Google Chat: using service-account key %s", + self._credentials_path, + ) + return service_account.Credentials.from_service_account_file( + self._credentials_path, scopes=scopes + ) + + # Fall back to ADC (works on GCE, Cloud Run, or with + # ``gcloud auth application-default login``) + import google.auth + + logger.warning( + "Google Chat: no GOOGLE_CHAT_CREDENTIALS set — " + "falling back to ADC (may require periodic reauth)" + ) + credentials, _ = google.auth.default(scopes=scopes) + return credentials + + def _build_chat_service(self, credentials: Any = None) -> Any: + """Build the Google Chat API client. + + Args: + credentials: Pre-built credentials. If ``None``, calls + ``_build_credentials()`` internally. + """ + from googleapiclient.discovery import build + + if credentials is None: + credentials = self._build_credentials() + + return build( + "chat", + "v1", + credentials=credentials, + cache_discovery=False, + ) + + +def _is_retryable_error(exc: Exception) -> bool: + """Determine if an API error is transient and should be retried.""" + exc_str = str(exc).lower() + # googleapiclient.errors.HttpError exposes resp.status + if hasattr(exc, "resp") and hasattr(exc.resp, "status"): + status = int(exc.resp.status) + return status in (429, 500, 502, 503, 504) + # Fallback heuristics + if "429" in exc_str or "rate limit" in exc_str: + return True + if any( + code in exc_str for code in ("500", "502", "503", "504") + ): + return True + if "timeout" in exc_str or "connection" in exc_str: + return True + return False diff --git a/gateway/run.py b/gateway/run.py index db3f8b00d5ed..cbd2072abd67 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -2925,6 +2925,13 @@ def _create_adapter( return None return QQAdapter(config) + elif platform == Platform.GOOGLE_CHAT: + from gateway.platforms.google_chat import GoogleChatAdapter, check_google_chat_requirements + if not check_google_chat_requirements(): + logger.warning("Google Chat: google-cloud-pubsub/google-auth/google-api-python-client missing or GOOGLE_CHAT_GCP_PROJECT not configured") + return None + return GoogleChatAdapter(config) + return None def _is_user_authorized(self, source: SessionSource) -> bool: @@ -2967,6 +2974,7 @@ def _is_user_authorized(self, source: SessionSource) -> bool: Platform.WEIXIN: "WEIXIN_ALLOWED_USERS", Platform.BLUEBUBBLES: "BLUEBUBBLES_ALLOWED_USERS", Platform.QQBOT: "QQ_ALLOWED_USERS", + Platform.GOOGLE_CHAT: "GOOGLE_CHAT_ALLOWED_USERS", } platform_group_env_map = { Platform.QQBOT: "QQ_GROUP_ALLOWED_USERS", @@ -2988,6 +2996,7 @@ def _is_user_authorized(self, source: SessionSource) -> bool: Platform.WEIXIN: "WEIXIN_ALLOW_ALL_USERS", Platform.BLUEBUBBLES: "BLUEBUBBLES_ALLOW_ALL_USERS", Platform.QQBOT: "QQ_ALLOW_ALL_USERS", + Platform.GOOGLE_CHAT: "GOOGLE_CHAT_ALLOW_ALL_USERS", } # Per-platform allow-all flag (e.g., DISCORD_ALLOW_ALL_USERS=true) @@ -7728,7 +7737,7 @@ async def _handle_deny_command(self, event: MessageEvent) -> str: Platform.TELEGRAM, Platform.DISCORD, Platform.SLACK, Platform.WHATSAPP, Platform.SIGNAL, Platform.MATTERMOST, Platform.MATRIX, Platform.HOMEASSISTANT, Platform.EMAIL, Platform.SMS, Platform.DINGTALK, - Platform.FEISHU, Platform.WECOM, Platform.WECOM_CALLBACK, Platform.WEIXIN, Platform.BLUEBUBBLES, Platform.QQBOT, Platform.LOCAL, + Platform.FEISHU, Platform.WECOM, Platform.WECOM_CALLBACK, Platform.WEIXIN, Platform.BLUEBUBBLES, Platform.QQBOT, Platform.GOOGLE_CHAT, Platform.LOCAL, }) async def _handle_debug_command(self, event: MessageEvent) -> str: diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 58f874595936..8db05f9b5db7 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1777,6 +1777,41 @@ def _ensure_hermes_home_managed(home: Path): "prompt": "QQ Sandbox Mode", "category": "messaging", }, + "GOOGLE_CHAT_GCP_PROJECT": { + "description": "GCP project ID for Google Chat via Pub/Sub", + "prompt": "Google Chat GCP Project ID", + "url": "https://console.cloud.google.com/", + "password": False, + "category": "messaging", + }, + "GOOGLE_CHAT_PUBSUB_SUBSCRIPTION": { + "description": "Pub/Sub subscription name for inbound Chat events (default: hermes-chat-inbound-sub)", + "prompt": "Google Chat Pub/Sub subscription", + "url": None, + "password": False, + "category": "messaging", + }, + "GOOGLE_CHAT_CREDENTIALS": { + "description": "Path to service account JSON key for Chat API (leave empty for ADC)", + "prompt": "Google Chat credentials path", + "url": None, + "password": False, + "category": "messaging", + }, + "GOOGLE_CHAT_ALLOWED_USERS": { + "description": "Comma-separated Google Workspace email addresses allowed to use the bot", + "prompt": "Allowed Google Chat users (comma-separated emails)", + "url": None, + "password": False, + "category": "messaging", + }, + "GOOGLE_CHAT_HOME_CHANNEL": { + "description": "Google Chat space name for cron/notification delivery (e.g. spaces/AAAA...)", + "prompt": "Google Chat home space", + "url": None, + "password": False, + "category": "messaging", + }, "GATEWAY_ALLOW_ALL_USERS": { "description": "Allow all users to interact with messaging bots (true/false). Default: false.", "prompt": "Allow all users (true/false)", diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 3b828fecf594..abdeb147a081 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -2724,6 +2724,37 @@ def run_gateway(verbose: int = 0, quiet: bool = False, replace: bool = False): "help": "OpenID to deliver cron results and notifications to."}, ], }, + { + "key": "google_chat", + "label": "Google Chat", + "emoji": "💬", + "token_var": "GOOGLE_CHAT_GCP_PROJECT", + "setup_instructions": [ + "1. Create a Google Chat app in the Google Cloud Console:", + " https://console.cloud.google.com/apis/api/chat.googleapis.com", + "2. Enable the Google Chat API for your project", + "3. Configure the app to use Cloud Pub/Sub connection mode:", + " Under 'Connection settings', select 'Cloud Pub/Sub'", + "4. Create a service account with the 'Chat Bots' role", + " Download the JSON key file", + "5. Create a Pub/Sub topic and subscription for inbound events", + " The Chat API will publish events to your topic automatically", + "6. Install dependencies: pip install google-cloud-pubsub google-auth google-api-python-client", + ], + "vars": [ + {"name": "GOOGLE_CHAT_GCP_PROJECT", "prompt": "GCP Project ID", "password": False, + "help": "The GCP project ID where the Chat app and Pub/Sub are configured."}, + {"name": "GOOGLE_CHAT_PUBSUB_SUBSCRIPTION", "prompt": "Pub/Sub subscription name (default: hermes-chat-inbound-sub)", "password": False, + "help": "The Pub/Sub subscription that receives inbound Chat events."}, + {"name": "GOOGLE_CHAT_CREDENTIALS", "prompt": "Path to service account JSON key (or empty for ADC)", "password": False, + "help": "Path to the service account JSON key file. Leave empty to use Application Default Credentials."}, + {"name": "GOOGLE_CHAT_ALLOWED_USERS", "prompt": "Allowed email addresses (comma-separated)", "password": False, + "is_allowlist": True, + "help": "Google Workspace email addresses allowed to interact with the bot."}, + {"name": "GOOGLE_CHAT_HOME_CHANNEL", "prompt": "Home space name (for cron/notification delivery, or empty to set later with /set-home)", "password": False, + "help": "The Google Chat space name (e.g. spaces/AAAA...) for cron delivery."}, + ], + }, ] diff --git a/hermes_cli/status.py b/hermes_cli/status.py index 8541f0a05faf..d99fd1d14c48 100644 --- a/hermes_cli/status.py +++ b/hermes_cli/status.py @@ -320,6 +320,7 @@ def show_status(args): "Weixin": ("WEIXIN_ACCOUNT_ID", "WEIXIN_HOME_CHANNEL"), "BlueBubbles": ("BLUEBUBBLES_SERVER_URL", "BLUEBUBBLES_HOME_CHANNEL"), "QQBot": ("QQ_APP_ID", "QQBOT_HOME_CHANNEL"), + "Google Chat": ("GOOGLE_CHAT_GCP_PROJECT", "GOOGLE_CHAT_HOME_CHANNEL"), } for name, (token_var, home_var) in platforms.items(): diff --git a/tests/gateway/test_google_chat.py b/tests/gateway/test_google_chat.py new file mode 100644 index 000000000000..66d4e83ebe70 --- /dev/null +++ b/tests/gateway/test_google_chat.py @@ -0,0 +1,637 @@ +"""Tests for Google Chat platform adapter.""" +import json +import os +import time +import pytest +from unittest.mock import MagicMock, patch, AsyncMock + +from gateway.config import Platform, PlatformConfig + + +# --------------------------------------------------------------------------- +# Platform & Config +# --------------------------------------------------------------------------- + +class TestGoogleChatConfigLoading: + def test_apply_env_overrides_google_chat(self, monkeypatch): + monkeypatch.setenv("GOOGLE_CHAT_GCP_PROJECT", "my-gcp-project") + monkeypatch.setenv("GOOGLE_CHAT_PUBSUB_SUBSCRIPTION", "chat-sub") + monkeypatch.setenv("GOOGLE_CHAT_CREDENTIALS", "/path/to/creds.json") + + from gateway.config import GatewayConfig, _apply_env_overrides + config = GatewayConfig() + _apply_env_overrides(config) + + assert Platform.GOOGLE_CHAT in config.platforms + gc = config.platforms[Platform.GOOGLE_CHAT] + assert gc.enabled is True + assert gc.extra.get("gcp_project") == "my-gcp-project" + assert gc.extra.get("pubsub_subscription") == "chat-sub" + assert gc.extra.get("chat_credentials") == "/path/to/creds.json" + + def test_google_chat_not_loaded_without_project(self, monkeypatch): + monkeypatch.delenv("GOOGLE_CHAT_GCP_PROJECT", raising=False) + monkeypatch.delenv("GOOGLE_CHAT_PUBSUB_SUBSCRIPTION", raising=False) + monkeypatch.delenv("GOOGLE_CHAT_CREDENTIALS", raising=False) + + from gateway.config import GatewayConfig, _apply_env_overrides + config = GatewayConfig() + _apply_env_overrides(config) + + assert Platform.GOOGLE_CHAT not in config.platforms + + def test_google_chat_home_channel(self, monkeypatch): + monkeypatch.setenv("GOOGLE_CHAT_GCP_PROJECT", "my-gcp-project") + monkeypatch.setenv("GOOGLE_CHAT_HOME_CHANNEL", "spaces/AAAAxxxx") + + from gateway.config import GatewayConfig, _apply_env_overrides + config = GatewayConfig() + _apply_env_overrides(config) + + home = config.get_home_channel(Platform.GOOGLE_CHAT) + assert home is not None + assert home.chat_id == "spaces/AAAAxxxx" + + def test_google_chat_default_subscription(self, monkeypatch): + """When no subscription env var is set, default should be used.""" + monkeypatch.setenv("GOOGLE_CHAT_GCP_PROJECT", "my-gcp-project") + monkeypatch.delenv("GOOGLE_CHAT_PUBSUB_SUBSCRIPTION", raising=False) + + from gateway.config import GatewayConfig, _apply_env_overrides + config = GatewayConfig() + _apply_env_overrides(config) + + gc = config.platforms[Platform.GOOGLE_CHAT] + # Subscription name may be empty or default — both acceptable + sub = gc.extra.get("pubsub_subscription", "") + assert isinstance(sub, str) + + +# --------------------------------------------------------------------------- +# Adapter construction & format +# --------------------------------------------------------------------------- + +def _make_adapter(): + """Create a GoogleChatAdapter with mocked config.""" + from gateway.platforms.google_chat import GoogleChatAdapter + config = PlatformConfig( + enabled=True, + token="", + extra={ + "gcp_project": "test-project", + "pubsub_subscription": "test-sub", + "chat_credentials": "", + }, + ) + adapter = GoogleChatAdapter(config) + return adapter + + +class TestGoogleChatFormatMessage: + def setup_method(self): + self.adapter = _make_adapter() + + def test_plain_text_unchanged(self): + content = "Hello, world!" + assert self.adapter.format_message(content) == content + + def test_empty_content(self): + assert self.adapter.format_message("") == "" + assert self.adapter.format_message(None) is None + + # ── Markdown → Google Chat conversion ── + + def test_double_asterisk_bold_to_single(self): + assert self.adapter.format_message("**bold**") == "*bold*" + + def test_bold_italic_conversion(self): + assert self.adapter.format_message("***both***") == "*_both_*" + + def test_single_asterisk_preserved(self): + """Single asterisks (already Chat-native bold) should not be changed.""" + content = "*already bold*" + result = self.adapter.format_message(content) + assert "*already bold*" in result + + def test_headers_converted_to_bold(self): + assert self.adapter.format_message("# Title") == "*Title*" + assert self.adapter.format_message("## Subtitle") == "*Subtitle*" + assert self.adapter.format_message("### Deep") == "*Deep*" + + def test_multiline_headers(self): + content = "# First\nSome text\n## Second" + result = self.adapter.format_message(content) + assert "*First*" in result + assert "Some text" in result + assert "*Second*" in result + + def test_markdown_links_to_chat_format(self): + content = "[click here](https://example.com)" + result = self.adapter.format_message(content) + assert result == "" + + def test_links_with_complex_urls(self): + content = "[docs](https://example.com/path?q=1&b=2)" + result = self.adapter.format_message(content) + assert "" in result + + # ── Code block protection ── + + def test_code_block_not_transformed(self): + content = "```python\n**not bold**\n```" + result = self.adapter.format_message(content) + assert "**not bold**" in result # unchanged inside code block + + def test_inline_code_not_transformed(self): + content = "Use `**bold**` for emphasis" + result = self.adapter.format_message(content) + assert "`**bold**`" in result # unchanged inside backticks + + def test_mixed_code_and_text(self): + content = "**bold** and `code` and **more bold**" + result = self.adapter.format_message(content) + assert "*bold*" in result + assert "`code`" in result + assert "*more bold*" in result + + # ── Unicode sanitization ── + + def test_variation_selector_stripped(self): + """VS16 (U+FE0F) should be stripped — primary tofu cause.""" + content = "Perfect \u2714\ufe0f, go ahead!" + result = self.adapter.format_message(content) + assert "\ufe0f" not in result + assert "Perfect" in result + assert "go ahead!" in result + + def test_zwj_stripped(self): + """Zero-Width Joiner should be stripped.""" + content = "Hello\u200dworld" + result = self.adapter.format_message(content) + assert "\u200d" not in result + assert "Helloworld" in result + + def test_zero_width_space_stripped(self): + content = "Hello\u200bworld" + result = self.adapter.format_message(content) + assert "\u200b" not in result + + def test_bom_stripped(self): + content = "\ufeffHello" + result = self.adapter.format_message(content) + assert "\ufeff" not in result + assert "Hello" in result + + def test_standard_emoji_preserved(self): + """Standard emoji (no modifiers) should NOT be stripped.""" + content = "Hello 👋 world 🚀" + result = self.adapter.format_message(content) + assert "👋" in result + assert "🚀" in result + + def test_double_spaces_collapsed(self): + """Stripping invisible chars shouldn't leave double spaces.""" + content = "Hello \u200b world" + result = self.adapter.format_message(content) + assert " " not in result + + # ── Combined scenarios ── + + def test_full_message_formatting(self): + """Realistic LLM output with multiple formatting issues.""" + content = ( + "## Summary\n" + "**Important**: Check [the docs](https://example.com)\n" + "Here's some `code` that matters." + ) + result = self.adapter.format_message(content) + assert "*Summary*" in result + assert "*Important*" in result + assert "" in result + assert "`code`" in result + + +class TestGoogleChatTruncateMessage: + def setup_method(self): + self.adapter = _make_adapter() + + def test_short_message_single_chunk(self): + msg = "Hello, world!" + chunks = self.adapter.truncate_message(msg, 4096) + assert len(chunks) == 1 + assert chunks[0] == msg + + def test_long_message_splits(self): + msg = "a " * 2500 # 5000 chars + chunks = self.adapter.truncate_message(msg, 4096) + assert len(chunks) >= 2 + for chunk in chunks: + assert len(chunk) <= 4096 + + def test_exactly_at_limit(self): + msg = "x" * 4096 + chunks = self.adapter.truncate_message(msg, 4096) + assert len(chunks) == 1 + + +# --------------------------------------------------------------------------- +# Event parsing (_parse_event static method) +# --------------------------------------------------------------------------- + +class TestGoogleChatEventParsing: + """Tests for the static _parse_event method which normalizes + all three inbound event formats into a consistent tuple.""" + + def test_parse_native_chat_api_message(self): + """Native Chat API Pub/Sub format should be parsed correctly.""" + from gateway.platforms.google_chat import GoogleChatAdapter + event = { + "type": "MESSAGE", + "message": { + "name": "spaces/AAAA/messages/msg123", + "sender": { + "name": "users/user123", + "displayName": "Alice", + "email": "alice@example.com", + "type": "HUMAN", + }, + "text": "Hello Hermes!", + "thread": {"name": "spaces/AAAA/threads/thread1"}, + }, + "space": {"name": "spaces/AAAA", "type": "ROOM"}, + } + result = GoogleChatAdapter._parse_event(event) + assert result is not None + text, sender_email, sender_name, space_name, space_type, thread_name, message_name = result + assert text == "Hello Hermes!" + assert sender_email == "alice@example.com" + assert sender_name == "Alice" + assert space_name == "spaces/AAAA" + assert space_type == "ROOM" + assert thread_name == "spaces/AAAA/threads/thread1" + assert message_name == "spaces/AAAA/messages/msg123" + + def test_parse_workspace_addon_format(self): + """Workspace Add-on format should be parsed correctly.""" + from gateway.platforms.google_chat import GoogleChatAdapter + event = { + "commonEventObject": {}, + "chat": { + "user": { + "email": "bob@example.com", + "displayName": "Bob", + }, + "messagePayload": { + "space": {"name": "spaces/BBBB", "type": "DM"}, + "message": { + "name": "spaces/BBBB/messages/msg456", + "text": "DM from addon", + "thread": {"name": "spaces/BBBB/threads/t1"}, + }, + }, + }, + } + result = GoogleChatAdapter._parse_event(event) + assert result is not None + text, sender_email, sender_name, space_name, space_type, thread_name, message_name = result + assert text == "DM from addon" + assert sender_email == "bob@example.com" + assert space_name == "spaces/BBBB" + assert space_type == "DM" + + def test_parse_relay_format(self): + """Relay/flat format should be parsed correctly.""" + from gateway.platforms.google_chat import GoogleChatAdapter + event = { + "event_type": "MESSAGE", + "sender_email": "charlie@example.com", + "sender_display_name": "Charlie", + "space_name": "spaces/CCCC", + "text": "Hello from relay", + "message_name": "relay-msg-789", + } + result = GoogleChatAdapter._parse_event(event) + assert result is not None + text, sender_email, sender_name, space_name, space_type, thread_name, message_name = result + assert text == "Hello from relay" + assert sender_email == "charlie@example.com" + assert sender_name == "Charlie" + + def test_non_message_event_returns_none(self): + """Non-MESSAGE event types should return None.""" + from gateway.platforms.google_chat import GoogleChatAdapter + for event_type in ["ADDED_TO_SPACE", "REMOVED_FROM_SPACE", "CARD_CLICKED"]: + event = { + "type": event_type, + "message": { + "name": "spaces/AAAA/messages/msg_skip", + "sender": {"name": "users/user123", "type": "HUMAN"}, + "text": "Should be skipped", + }, + "space": {"name": "spaces/AAAA"}, + } + result = GoogleChatAdapter._parse_event(event) + assert result is None, f"{event_type} should return None" + + def test_unrecognized_format_returns_none(self): + """Events with unrecognized format should return None.""" + from gateway.platforms.google_chat import GoogleChatAdapter + event = {"random_key": "random_value", "something_else": 42} + result = GoogleChatAdapter._parse_event(event) + assert result is None + + def test_dm_space_type(self): + """DM space should report type as DM.""" + from gateway.platforms.google_chat import GoogleChatAdapter + event = { + "type": "MESSAGE", + "message": { + "name": "spaces/DDDD/messages/msg_dm", + "sender": { + "name": "users/user456", + "email": "dave@example.com", + "displayName": "Dave", + "type": "HUMAN", + }, + "text": "Private message", + }, + "space": {"name": "spaces/DDDD", "type": "DM"}, + } + result = GoogleChatAdapter._parse_event(event) + assert result is not None + _, _, _, _, space_type, _, _ = result + assert space_type == "DM" + + def test_thread_name_extracted(self): + """Thread name should be extracted from event.""" + from gateway.platforms.google_chat import GoogleChatAdapter + event = { + "type": "MESSAGE", + "message": { + "name": "spaces/AAAA/messages/msg_thread", + "sender": {"email": "alice@example.com", "displayName": "Alice"}, + "text": "Thread reply", + "thread": {"name": "spaces/AAAA/threads/thread42"}, + }, + "space": {"name": "spaces/AAAA", "type": "ROOM"}, + } + result = GoogleChatAdapter._parse_event(event) + assert result is not None + _, _, _, _, _, thread_name, _ = result + assert thread_name == "spaces/AAAA/threads/thread42" + + def test_argument_text_preferred_over_text(self): + """argumentText should be preferred over text (strips @mentions).""" + from gateway.platforms.google_chat import GoogleChatAdapter + event = { + "type": "MESSAGE", + "message": { + "name": "spaces/AAAA/messages/msg_arg", + "sender": {"email": "alice@example.com"}, + "text": "@Hermes what time is it", + "argumentText": "what time is it", + }, + "space": {"name": "spaces/AAAA", "type": "ROOM"}, + } + result = GoogleChatAdapter._parse_event(event) + assert result is not None + text, _, _, _, _, _, _ = result + assert text == "what time is it" + + def test_relay_non_message_returns_none(self): + """Relay events with non-MESSAGE type should return None.""" + from gateway.platforms.google_chat import GoogleChatAdapter + event = { + "event_type": "REMOVED_FROM_SPACE", + "sender_email": "alice@example.com", + "text": "left the space", + } + result = GoogleChatAdapter._parse_event(event) + assert result is None + + +# --------------------------------------------------------------------------- +# Inbound callback: _on_pubsub_message +# --------------------------------------------------------------------------- + +class TestGoogleChatPubSubCallback: + """Tests for the _on_pubsub_message callback which bridges + Pub/Sub events to the asyncio event loop.""" + + def setup_method(self): + self.adapter = _make_adapter() + self.adapter.handle_message = AsyncMock() + self.adapter._loop = MagicMock() + self.adapter._loop.is_running.return_value = True + + def _make_pubsub_msg(self, chat_event): + """Create a mock Pub/Sub message.""" + msg = MagicMock() + msg.data = json.dumps(chat_event).encode("utf-8") + return msg + + def test_valid_message_acked_and_scheduled(self): + """Valid MESSAGE event should be acked and scheduled on loop.""" + chat_event = { + "type": "MESSAGE", + "message": { + "name": "spaces/AAAA/messages/msg1", + "sender": {"email": "alice@example.com", "displayName": "Alice", "type": "HUMAN"}, + "text": "Hello", + }, + "space": {"name": "spaces/AAAA", "type": "ROOM"}, + } + msg = self._make_pubsub_msg(chat_event) + self.adapter._on_pubsub_message(msg) + + msg.ack.assert_called_once() + self.adapter._loop.call_soon_threadsafe.assert_called_once() + + def test_non_message_event_acked_without_scheduling(self): + """Non-MESSAGE events should be acked but NOT scheduled.""" + chat_event = { + "type": "ADDED_TO_SPACE", + "space": {"name": "spaces/AAAA"}, + "user": {"name": "users/user123"}, + } + msg = self._make_pubsub_msg(chat_event) + self.adapter._on_pubsub_message(msg) + + msg.ack.assert_called_once() + self.adapter._loop.call_soon_threadsafe.assert_not_called() + + def test_empty_text_acked_without_scheduling(self): + """Events with empty text should be acked but NOT scheduled.""" + chat_event = { + "type": "MESSAGE", + "message": { + "name": "spaces/AAAA/messages/msg_empty", + "sender": {"email": "alice@example.com", "type": "HUMAN"}, + "text": "", + }, + "space": {"name": "spaces/AAAA"}, + } + msg = self._make_pubsub_msg(chat_event) + self.adapter._on_pubsub_message(msg) + + msg.ack.assert_called_once() + self.adapter._loop.call_soon_threadsafe.assert_not_called() + + def test_invalid_json_nacked(self): + """Invalid JSON should be nacked for retry.""" + msg = MagicMock() + msg.data = b"not-valid-json{{{" + self.adapter._on_pubsub_message(msg) + + msg.nack.assert_called_once() + self.adapter._loop.call_soon_threadsafe.assert_not_called() + + def test_chat_type_dm_vs_room(self): + """Verify DM/ROOM space types map correctly in the scheduled event.""" + for space_type, expected_chat_type in [("DM", "dm"), ("ROOM", "group")]: + self.adapter._loop.reset_mock() + chat_event = { + "type": "MESSAGE", + "message": { + "name": f"spaces/TEST/messages/msg_{space_type}", + "sender": {"email": "test@example.com", "displayName": "Test"}, + "text": "Test message", + }, + "space": {"name": "spaces/TEST", "type": space_type}, + } + msg = self._make_pubsub_msg(chat_event) + self.adapter._on_pubsub_message(msg) + + msg.ack.assert_called() + self.adapter._loop.call_soon_threadsafe.assert_called() + + +# --------------------------------------------------------------------------- +# Send +# --------------------------------------------------------------------------- + +class TestGoogleChatSend: + def setup_method(self): + self.adapter = _make_adapter() + self.mock_service = MagicMock() + self.adapter._chat_service = self.mock_service + + @pytest.mark.asyncio + async def test_send_calls_chat_api(self): + """send() should call spaces().messages().create() with correct body.""" + mock_result = {"name": "spaces/AAAA/messages/sent123"} + self.mock_service.spaces().messages().create.return_value.execute.return_value = mock_result + + result = await self.adapter.send("spaces/AAAA", "Hello!") + + assert result.success is True + + @pytest.mark.asyncio + async def test_send_empty_content(self): + """Empty content should return success without API call.""" + result = await self.adapter.send("spaces/AAAA", "") + assert result.success is True + + @pytest.mark.asyncio + async def test_send_without_service_fails(self): + """send() without a chat service should fail gracefully.""" + self.adapter._chat_service = None + result = await self.adapter.send("spaces/AAAA", "Hello!") + assert result.success is False + + +# --------------------------------------------------------------------------- +# Requirements check +# --------------------------------------------------------------------------- + +class TestGoogleChatRequirements: + def test_check_requirements_with_deps_and_project(self, monkeypatch): + monkeypatch.setenv("GOOGLE_CHAT_GCP_PROJECT", "my-project") + with patch.dict("sys.modules", { + "google.cloud.pubsub_v1": MagicMock(), + "google.cloud": MagicMock(), + "google.oauth2": MagicMock(), + "google.oauth2.service_account": MagicMock(), + "google.auth": MagicMock(), + "googleapiclient": MagicMock(), + "googleapiclient.discovery": MagicMock(), + }): + from gateway.platforms.google_chat import check_google_chat_requirements + assert check_google_chat_requirements() is True + + def test_check_requirements_without_project(self, monkeypatch): + monkeypatch.delenv("GOOGLE_CHAT_GCP_PROJECT", raising=False) + from gateway.platforms.google_chat import check_google_chat_requirements + assert check_google_chat_requirements() is False + + +# --------------------------------------------------------------------------- +# Retryable error detection +# --------------------------------------------------------------------------- + +class TestRetryableError: + def test_429_is_retryable(self): + from gateway.platforms.google_chat import _is_retryable_error + exc = Exception("HttpError 429: Rate limit exceeded") + assert _is_retryable_error(exc) is True + + def test_500_is_retryable(self): + from gateway.platforms.google_chat import _is_retryable_error + exc = Exception("500 Internal Server Error") + assert _is_retryable_error(exc) is True + + def test_timeout_is_retryable(self): + from gateway.platforms.google_chat import _is_retryable_error + exc = Exception("Connection timeout") + assert _is_retryable_error(exc) is True + + def test_400_is_not_retryable(self): + from gateway.platforms.google_chat import _is_retryable_error + exc = Exception("400 Bad Request: invalid space name") + assert _is_retryable_error(exc) is False + + def test_http_error_with_resp_attribute(self): + """HttpError-like exceptions with resp.status should be detected.""" + from gateway.platforms.google_chat import _is_retryable_error + exc = Exception("rate limited") + exc.resp = MagicMock() + exc.resp.status = 429 + assert _is_retryable_error(exc) is True + + +# --------------------------------------------------------------------------- +# Integration point verification +# --------------------------------------------------------------------------- + +class TestGoogleChatIntegration: + """Verify the platform is wired into all gateway integration points.""" + + def test_platform_enum_exists(self): + assert hasattr(Platform, "GOOGLE_CHAT") + assert Platform.GOOGLE_CHAT.value == "google_chat" + + def test_toolset_registered(self): + from toolsets import TOOLSETS + assert "hermes-google-chat" in TOOLSETS + + def test_toolset_in_gateway_composite(self): + from toolsets import TOOLSETS + assert "hermes-google-chat" in TOOLSETS["hermes-gateway"]["includes"] + + def test_platform_hint_registered(self): + from agent.prompt_builder import PLATFORM_HINTS + assert "google_chat" in PLATFORM_HINTS + + def test_cron_platform_map(self): + """Verify google_chat is in cron scheduler's platform_map.""" + import inspect + from cron import scheduler + source = inspect.getsource(scheduler) + assert '"google_chat"' in source or "'google_chat'" in source + + def test_send_message_tool_has_google_chat(self): + """Verify google_chat is in send_message_tool's platform_map.""" + import inspect + import tools.send_message_tool as smt + source = inspect.getsource(smt) + assert '"google_chat"' in source or "'google_chat'" in source diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index 19da4f55af8d..1a897e1b8d7f 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -215,6 +215,7 @@ def _handle_send(args): "weixin": Platform.WEIXIN, "email": Platform.EMAIL, "sms": Platform.SMS, + "google_chat": Platform.GOOGLE_CHAT, } platform = platform_map.get(platform_name) if not platform: @@ -571,6 +572,8 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, result = await _send_bluebubbles(pconfig.extra, chat_id, chunk) elif platform == Platform.QQBOT: result = await _send_qqbot(pconfig, chat_id, chunk) + elif platform == Platform.GOOGLE_CHAT: + result = await _send_google_chat(pconfig.extra, chat_id, chunk) else: result = {"error": f"Direct sending not yet implemented for {platform.value}"} @@ -1510,6 +1513,52 @@ async def _send_qqbot(pconfig, chat_id, message): return _error(f"QQBot send failed: {e}") +async def _send_google_chat(extra, chat_id, message): + """Send via Google Chat REST API (one-shot, no gateway needed). + + Uses the Chat API with service-account or ADC credentials + to post a text message to a specified space. + """ + try: + from google.oauth2 import service_account + from googleapiclient.discovery import build + except ImportError: + return _error( + "Google Chat direct send requires google-auth and google-api-python-client. " + "Run: pip install google-auth google-api-python-client" + ) + + try: + scopes = ["https://www.googleapis.com/auth/chat.bot"] + credentials_path = extra.get("chat_credentials") or os.getenv("GOOGLE_CHAT_CREDENTIALS", "") + + if credentials_path and os.path.isfile(credentials_path): + credentials = service_account.Credentials.from_service_account_file( + credentials_path, scopes=scopes + ) + else: + import google.auth + credentials, _ = google.auth.default(scopes=scopes) + + service = build("chat", "v1", credentials=credentials, cache_discovery=False) + + # Chunk if needed (4096 char limit) + max_len = 4096 + chunks = [message[i:i + max_len] for i in range(0, len(message), max_len)] if len(message) > max_len else [message] + + last_msg_name = None + for chunk in chunks: + result = service.spaces().messages().create( + parent=chat_id, + body={"text": chunk}, + ).execute() + last_msg_name = result.get("name", "") + + return {"success": True, "platform": "google_chat", "chat_id": chat_id, "message_id": last_msg_name} + except Exception as e: + return _error(f"Google Chat send failed: {e}") + + # --- Registry --- from tools.registry import registry, tool_error diff --git a/toolsets.py b/toolsets.py index b3cdb2e7ae23..e48b590f324d 100644 --- a/toolsets.py +++ b/toolsets.py @@ -395,6 +395,12 @@ "includes": [] }, + "hermes-google-chat": { + "description": "Google Chat toolset - workspace messaging via Cloud Pub/Sub (full access)", + "tools": _HERMES_CORE_TOOLS, + "includes": [] + }, + "hermes-wecom": { "description": "WeCom bot toolset - enterprise WeChat messaging (full access)", "tools": _HERMES_CORE_TOOLS, @@ -422,7 +428,7 @@ "hermes-gateway": { "description": "Gateway toolset - union of all messaging platform tools", "tools": [], - "includes": ["hermes-telegram", "hermes-discord", "hermes-whatsapp", "hermes-slack", "hermes-signal", "hermes-bluebubbles", "hermes-homeassistant", "hermes-email", "hermes-sms", "hermes-mattermost", "hermes-matrix", "hermes-dingtalk", "hermes-feishu", "hermes-wecom", "hermes-wecom-callback", "hermes-weixin", "hermes-qqbot", "hermes-webhook"] + "includes": ["hermes-telegram", "hermes-discord", "hermes-whatsapp", "hermes-slack", "hermes-signal", "hermes-bluebubbles", "hermes-homeassistant", "hermes-email", "hermes-sms", "hermes-mattermost", "hermes-matrix", "hermes-dingtalk", "hermes-feishu", "hermes-wecom", "hermes-wecom-callback", "hermes-weixin", "hermes-qqbot", "hermes-google-chat", "hermes-webhook"] } } 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 000000000000..b4293eedc658 --- /dev/null +++ b/website/docs/user-guide/messaging/google_chat.md @@ -0,0 +1,198 @@ +--- +sidebar_position: 20 +title: "Google Chat" +description: "Set up Hermes Agent as a Google Chat bot" +--- + +# Google Chat Setup + +Hermes Agent integrates with Google Chat via Cloud Pub/Sub for inbound events and the Chat REST API for outbound messages. No additional relay server is needed — the adapter subscribes directly to a Pub/Sub subscription where Google Chat publishes native events, and replies via the Chat API with service-account credentials. + +This setup requires a **Business or Enterprise** [Google Workspace](https://workspace.google.com/) account with access to [Google Chat](https://workspace.google.com/products/chat/). + +**Dependencies** (not bundled with Hermes): + +```bash +pip install google-cloud-pubsub google-auth google-api-python-client +``` + +## How Hermes Behaves + +| Context | Behavior | +|---------|----------| +| **DMs** | Hermes responds to every message. No `@mention` needed. | +| **Spaces** | Hermes responds when you `@mention` it. Without a mention, Hermes ignores the message. | +| **Threads** | Thread context is preserved — replies in a thread stay in that thread's session. | + +## Prerequisites + +Before setting up Hermes, you need: + +1. A **Google Cloud project** with billing enabled +2. The **Google Chat API** and **Cloud Pub/Sub API** enabled on that project +3. A **service account** with a downloaded JSON key +4. A **Pub/Sub topic and pull subscription** for inbound events + +## Step 1: Create a Google Chat App + +1. Go to the [Google Cloud Console](https://console.cloud.google.com/). +2. Select or create a project. +3. Navigate to **APIs & Services** → **Enable APIs and Services**. +4. Search for and enable both: + - **Google Chat API** + - **Cloud Pub/Sub API** +5. In the Chat API settings, click **Configuration**: + - Ensure **"Build this Chat app as a Google Workspace add-on"** is **unchecked** + - **App name**: e.g., `Hermes Agent` + - **Avatar URL**: optional + - **Description**: optional + - **Functionality**: select **Join spaces and group conversations** + - **Connection settings**: select **Cloud Pub/Sub** and enter the topic name you'll create in Step 3 + - **Visibility**: select **Make this Google Chat app available to specific people and groups in your domain** and enter your email address + - **Logs**: optionally select **Log errors to Logging** + +## Step 2: Create a Service Account + +1. In the Cloud Console, go to **IAM & Admin** → **Service Accounts**. +2. Click **Create Service Account**. +3. Give it a name (e.g., `hermes-chat-bot`). +4. Click **Done** — no special Chat-specific IAM role is needed. The adapter authenticates with the Chat API using the [`chat.bot` OAuth scope](https://developers.google.com/workspace/chat/authenticate-authorize-chat-app), which is self-granted at runtime. +5. Open the service account, go to **Keys** → **Add Key** → **Create new key** → **JSON**. +6. Download the JSON key file and store it securely. + +:::warning[Service Account Key Security] +The JSON key file grants full access to the Chat bot. Never commit it to Git or share it publicly. Store it in a secure location and reference it via `GOOGLE_CHAT_CREDENTIALS`. +::: + +## Step 3: Set Up Pub/Sub + +1. Go to **Pub/Sub** in the Cloud Console. +2. Create a **topic** (e.g., `hermes-chat-inbound`). +3. Grant the Chat API's internal service account (`chat-api-push@system.gserviceaccount.com`) the **Pub/Sub Publisher** role on this topic. This allows Google Chat to publish events to your topic. +4. Create a **pull subscription** on the topic (e.g., `hermes-chat-inbound-sub`): + - Delivery type: **Pull** (not Push) + - Acknowledgment deadline: 60 seconds recommended +5. Grant your service account (from Step 2) the **Pub/Sub Subscriber** role on the subscription. This allows the Hermes adapter to pull events. + +:::info +The Google Chat API publishes events to your Pub/Sub topic automatically once configured. Your Hermes adapter pulls from the subscription — no webhook endpoint or public URL needed. +::: + +## Step 4: Configure Hermes Agent + +### Option A: Interactive Setup (Recommended) + +```bash +hermes gateway setup +``` + +Select **Google Chat** when prompted, then enter your GCP project ID, subscription name, and credentials path. + +### Option B: Manual Configuration + +Add the following to your `~/.hermes/.env` file: + +```bash +# Required +GOOGLE_CHAT_GCP_PROJECT=your-gcp-project-id +GOOGLE_CHAT_PUBSUB_SUBSCRIPTION=hermes-chat-inbound-sub + +# Service account credentials (leave empty for Application Default Credentials) +GOOGLE_CHAT_CREDENTIALS=/path/to/service-account-key.json + +# Access control +GOOGLE_CHAT_ALLOWED_USERS=alice@example.com,bob@example.com + +# Bypass allowlist entirely (NOT recommended — use with caution) +# GOOGLE_CHAT_ALLOW_ALL_USERS=true + +# Optional: home space for cron delivery +# GOOGLE_CHAT_HOME_CHANNEL=spaces/AAAAxxxxxxxx +``` + +### Start the Gateway + +```bash +hermes gateway +``` + +The adapter connects to Pub/Sub and begins listening for events. Send a message to the bot in Google Chat to test. + +## Home Channel + +Designate a space for proactive messages (cron jobs, reminders, notifications): + +### Using the Slash Command + +Type `/sethome` in any Google Chat space where the bot is present. + +### Manual Configuration + +```bash +GOOGLE_CHAT_HOME_CHANNEL=spaces/AAAAxxxxxxxx +``` + +To find the space name: open the space in Google Chat, look at the URL — it contains the space ID (e.g., `spaces/AAAABBBBcccc`). + +## Authentication + +The adapter supports two authentication methods: + +| Method | When to use | +|--------|-------------| +| **Service Account JSON** | Local development, self-hosted deployments. Set `GOOGLE_CHAT_CREDENTIALS` to the key file path. | +| **Application Default Credentials (ADC)** | Cloud Run, GCE, or environments with `gcloud auth application-default login`. Leave `GOOGLE_CHAT_CREDENTIALS` empty. | + +The adapter uses two OAuth scopes at runtime — no IAM roles are needed for these: + +- `https://www.googleapis.com/auth/chat.bot` — lets the app send and receive messages (self-granted, no admin approval required) +- `https://www.googleapis.com/auth/pubsub` — lets the app pull events from the subscription + +## Troubleshooting + +### Bot is not responding + +**Cause**: Pub/Sub subscription is not receiving events, or the Chat app configuration is incorrect. + +**Fix**: + +1. Verify the Chat API is enabled and the app is configured with the correct Pub/Sub topic. +2. Check that the `chat-api-push@system.gserviceaccount.com` service account has Publisher access to your topic. +3. Verify `GOOGLE_CHAT_GCP_PROJECT` and `GOOGLE_CHAT_PUBSUB_SUBSCRIPTION` match your setup. +4. Check `hermes gateway` output for error messages. + +### "Permission denied" errors + +**Cause**: Missing IAM roles or OAuth scope issues. + +**Fix**: Ensure: + +- Your service account has the **Pub/Sub Subscriber** role on the subscription (for pulling events) +- The `chat-api-push@system.gserviceaccount.com` account has the **Pub/Sub Publisher** role on the topic (for Google Chat to publish events) +- The Chat API is enabled and the Chat app is configured in the same GCP project as the service account +- The `chat.bot` OAuth scope handles message sending automatically — no Chat-specific IAM role is needed + +### "User not allowed" / Bot ignores you + +**Cause**: Your email isn't in `GOOGLE_CHAT_ALLOWED_USERS`. + +**Fix**: Add your Google Workspace email to `GOOGLE_CHAT_ALLOWED_USERS` in `~/.hermes/.env` and restart the gateway. + +### Messages are delayed + +**Cause**: Pub/Sub acknowledgment timeout or flow control limits. + +**Fix**: The adapter uses streaming pull with a default of 10 outstanding messages. For high-traffic spaces, this is usually sufficient. If messages are consistently delayed, check your Pub/Sub subscription's acknowledgment deadline (60s recommended). + +## Security + +:::warning +Always set `GOOGLE_CHAT_ALLOWED_USERS` to restrict who can interact with the bot. Without it, the gateway denies all users by default. Only add email addresses of people you trust — authorized users have full access to the agent's capabilities. +::: + +## Notes + +- **No public endpoint needed**: Unlike webhook-based integrations, the Pub/Sub pull model doesn't require a public URL or port forwarding. +- **Cloud Run friendly**: When deployed on Cloud Run with ADC, no credentials file is needed. +- **Message limit**: Google Chat messages are limited to 4,096 characters. Longer responses are automatically split into multiple messages. +- **Typing indicators**: Google Chat API does not support typing indicators for Chat apps, so this is a no-op.