from __future__ import annotations import asyncio import json import os import time from collections import defaultdict from collections.abc import AsyncIterator from datetime import UTC from typing import Any, ClassVar from yuxi.channels.base import BaseChannelAdapter from yuxi.channels.capabilities import ChannelCapabilities from yuxi.channels.exceptions import ( ChannelAuthenticationError, ChannelException, ChannelNotConnectedError, ) from yuxi.channels.infra.circuit_breaker import CircuitBreaker, CircuitBreakerOpenError from yuxi.channels.meta import ChannelMeta from yuxi.channels.models import ( ChannelIdentity, ChannelMessage, ChannelResponse, ChannelStatus, ChannelType, DeliveryResult, HealthStatus, ) from yuxi.channels.registry import register_builtin_adapter from yuxi.utils.logging_config import logger from .formatter import format_outbound from .media import download_media as _download_media from .normalizer import is_bot_message, normalize_inbound from .policy import GoogleChatPolicy from .send import ( delete_message as _delete_message, ) from .send import ( send_media, send_message, send_reaction, update_message, upload_file_message, upload_image_message, ) from .streaming import StreamManager from . import auth from . import proxy _CONNECT_TIMEOUT_S = 30.0 _PREAUTH_BODY_MAX_BYTES = 16 * 1024 _PREAUTH_TIMEOUT_S = 3.0 _GOOGLE_CHAT_ISSUER_RE = r"^https://chat\.google\.com$" _ADDON_ISSUER_RE = r"^(https://workspace\.google\.com/)?accounts\.google\.com$" _ADDON_SA_ISSUER_PATTERN = r"^service-\d+@gcp-sa-gsuiteaddons\.iam\.gserviceaccount\.com$" _AUDIENCE_TYPE_CANONICAL: dict[str, str] = { "app-url": "app-url", "app_url": "app-url", "app": "app-url", "project-number": "project-number", "project_number": "project-number", "project": "project-number", } def _verify_jwt(token: str, audience: str) -> bool: try: from google.auth.transport import requests as ga_requests from google.oauth2 import id_token except ImportError: logger.info("google-auth not installed, skipping JWT verification") return True try: id_token.verify_oauth2_token(token, ga_requests.Request(), audience=audience) return True except Exception as e: logger.warning(f"JWT verification failed: {e}") return False class ChatRateLimiter: def __init__(self, ops_per_second: float = 0.9): self._interval = 1.0 / ops_per_second self._last_call: dict[str, float] = defaultdict(float) async def acquire(self, space_id: str) -> None: now = time.monotonic() wait = self._last_call[space_id] + self._interval - now if wait > 0: await asyncio.sleep(wait) self._last_call[space_id] = time.monotonic() @register_builtin_adapter class GoogleChatAdapter(BaseChannelAdapter): channel_id: ClassVar[str] = "googlechat" channel_type: ClassVar[ChannelType] = ChannelType.GOOGLE_CHAT webhook_path: ClassVar[str | None] = "/api/webhook/googlechat" text_chunk_limit: ClassVar[int] = 4000 supports_markdown: ClassVar[bool] = True supports_streaming: ClassVar[bool] = True streaming_modes: ClassVar[list[str]] = ["off", "partial", "block"] max_media_size_mb: ClassVar[int] = 20 min_send_interval_ms: ClassVar[int] = 1000 capabilities = ChannelCapabilities( chat_types=["direct", "group", "thread"], reply=True, threads=True, media=True, reactions=True, edit=True, unsend=True, supports_markdown=True, supports_streaming=True, streaming_modes=["off", "partial", "block"], text_chunk_limit=4000, max_media_size_mb=20, ) meta = ChannelMeta( id="googlechat", label="Google Chat", aliases=["gchat", "google-chat"], markdown_capable=True, selection_label="Google Chat (Google Workspace)", system_image="https://www.gstatic.com/companion/icon_assets/chat_2x.png", docs_path="/docs/channels/googlechat", docs_label="Google Chat Setup Guide", selection_docs_prefix="googlechat", blurb="AI bot for Google Chat spaces and direct messages via Google Workspace", ) def __init__(self, config: dict[str, Any] | None = None): super().__init__(config) self._status = ChannelStatus.DISCONNECTED self._chat_service = None self._credentials = None self._service_account_email: str = "" self._pubsub_subscription: str = "" self._connected_at: float | None = None self._stream_mgr = StreamManager( update_interval_ms=config.get("streamUpdateIntervalMs", 800) if config else 800, coalesce_min_chars=config.get("blockStreamingCoalesceMinChars", 1500) if config else 1500, coalesce_idle_ms=config.get("blockStreamingCoalesceIdleMs", 1000) if config else 1000, ) self._rate_limiter = ChatRateLimiter() self._circuit_breaker = CircuitBreaker(failure_threshold=5, recovery_timeout=60.0) self._webhook_semaphore = asyncio.Semaphore(64) self._auth_cache: dict[str, Any] = {} self._auth_cache_max = 32 self._credential_source: str | None = None self._audience_type = self._normalize_audience_type(config) self._audience = config.get("audience", "") if config else "" self._bot_user = config.get("bot_user", config.get("botUser", "")) if config else "" self._app_principal = config.get("app_principal", config.get("appPrincipal", "")) if config else "" self._allow_bots = bool(config.get("allow_bots", config.get("allowBots", False))) if config else False self._reply_to_mode = config.get("reply_to_mode", config.get("replyToMode", "off")) if config else "off" self._actions = config.get("actions", {}) if config else {} self._proxy_config = proxy.resolve_proxy_config(config) if config else proxy.resolve_proxy_config() self._tls_config = proxy.resolve_tls_config(config) if config else {} self._default_account = ( config.get("default_account", config.get("defaultAccount", "default")) if config else "default" ) self._per_account_reply_to_mode: dict[str, str] = self._parse_per_account_reply_to_mode(config) self._policy = GoogleChatPolicy.from_config(config) self._accounts_config: dict[str, dict[str, Any]] = self._parse_accounts_config(config) self._message_queues: dict[str, asyncio.Queue] = {} self._message_queue_tasks: dict[str, asyncio.Task] = {} async def connect(self) -> None: self._status = ChannelStatus.CONNECTING try: await asyncio.wait_for( self._connect_impl(), timeout=_CONNECT_TIMEOUT_S, ) except TimeoutError: self._status = ChannelStatus.ERROR raise ChannelException( f"Google Chat connection timed out after {_CONNECT_TIMEOUT_S}s", retryable=True, ) self._status = ChannelStatus.CONNECTED self._connected_at = time.time() logger.info(f"Google Chat connected. SA: {self._service_account_email}") async def _connect_impl(self) -> None: await self._init_service_account() try: self._chat_service.spaces().list(pageSize=1).execute() except Exception as e: raise ChannelException( f"Google Chat API probe failed: {e}", retryable=False, ) await self._init_pubsub_subscription() @staticmethod def _normalize_audience_type(config: dict[str, Any] | None) -> str: if not config: return "app-url" raw = str(config.get("audience_type", config.get("audienceType", ""))).strip().lower() if not raw: return "app-url" canonical = _AUDIENCE_TYPE_CANONICAL.get(raw) if canonical: return canonical logger.warning(f"Unknown audienceType '{raw}', falling back to 'app-url'") return "app-url" async def disconnect(self) -> None: self._status = ChannelStatus.DISCONNECTED self._stream_mgr.clear() self._circuit_breaker = CircuitBreaker(failure_threshold=5, recovery_timeout=60.0) self._chat_service = None self._credentials = None for task in self._message_queue_tasks.values(): task.cancel() self._message_queue_tasks.clear() self._message_queues.clear() async def probe(self) -> dict[str, Any]: if not self._chat_service: return {"ok": False, "status": "not_connected", "error": "chat_service not initialized"} try: result = self._chat_service.spaces().list(pageSize=1).execute() return { "ok": True, "status": "connected", "spaces_count_hint": len(result.get("spaces", [])), "service_account_email": self._service_account_email, "credential_source": self._credential_source, "audience_type": self._audience_type, } except Exception as e: return {"ok": False, "status": "error", "error": str(e)} async def send(self, response: ChannelResponse) -> DeliveryResult: chat_id = response.identity.channel_chat_id space_name = chat_id.split("/threads/")[0] await self._rate_limiter.acquire(space_name) if chat_id.startswith("users/"): resolved = await self.find_direct_message_space(chat_id) if resolved: response.identity.channel_chat_id = resolved chat_id = resolved else: return DeliveryResult(success=False, error=f"Cannot resolve user target to DM space: {chat_id}") body = format_outbound(response) async def _do_send(): return await send_message(self._chat_service, chat_id, body) try: return await self._circuit_breaker.call(_do_send) except CircuitBreakerOpenError: return DeliveryResult(success=False, error="Circuit breaker open") async def find_direct_message_space(self, user_name: str) -> str | None: from .send import find_direct_message_space as _find_dm try: return await _find_dm(self._chat_service, user_name) except Exception as e: logger.warning(f"find_direct_message_space failed for {user_name}: {e}") return None async def resolve_outbound_space(self, target: str) -> str | None: from .send import resolve_outbound_space as _resolve try: return await _resolve(self._chat_service, target) except Exception as e: logger.warning(f"resolve_outbound_space failed for {target}: {e}") return None async def send_media(self, chat_id: str, media_type: str, data: Any) -> DeliveryResult: space_name = chat_id.split("/threads/")[0] await self._rate_limiter.acquire(space_name) async def _do_send(): if isinstance(data, str): return await send_media(self._chat_service, chat_id, data) if isinstance(data, bytes): if media_type in ("image", "IMAGE"): return await upload_image_message(self._chat_service, chat_id, data) return await upload_file_message( self._chat_service, chat_id, data, filename="upload", mime_type=media_type, ) return DeliveryResult(success=False, error=f"Unsupported media data type: {type(data)}") try: return await self._circuit_breaker.call(_do_send) except CircuitBreakerOpenError: return DeliveryResult(success=False, error="Circuit breaker open") async def edit_message(self, chat_id: str, msg_id: str, content: str) -> DeliveryResult: space_name = chat_id.split("/threads/")[0] await self._rate_limiter.acquire(space_name) body: dict[str, Any] = {"text": content} async def _do_send(): return await update_message(self._chat_service, msg_id, body) try: return await self._circuit_breaker.call(_do_send) except CircuitBreakerOpenError: return DeliveryResult(success=False, error="Circuit breaker open") async def delete_message(self, chat_id: str, msg_id: str) -> DeliveryResult: space_name = chat_id.split("/threads/")[0] await self._rate_limiter.acquire(space_name) async def _do_send(): return await _delete_message(self._chat_service, msg_id) try: return await self._circuit_breaker.call(_do_send) except CircuitBreakerOpenError: return DeliveryResult(success=False, error="Circuit breaker open") async def send_reaction(self, chat_id: str, msg_id: str, emoji: str) -> DeliveryResult: reactions_enabled = self._actions.get("reactions", True) if not reactions_enabled: return DeliveryResult(success=False, error="Agent reactions disabled by actions.reactions config") space_name = chat_id.split("/threads/")[0] await self._rate_limiter.acquire(space_name) async def _do_send(): return await send_reaction(self._chat_service, msg_id, emoji) try: return await self._circuit_breaker.call(_do_send) except CircuitBreakerOpenError: return DeliveryResult(success=False, error="Circuit breaker open") async def list_reactions(self, msg_id: str) -> list[dict[str, Any]]: from .send import list_reactions as _list_reactions try: return await _list_reactions(self._chat_service, msg_id) except Exception as e: logger.warning(f"list_reactions failed: {e}") return [] async def delete_reaction(self, reaction_id: str) -> DeliveryResult: from .send import delete_reaction as _delete_reaction try: return await _delete_reaction(self._chat_service, reaction_id) except Exception as e: return DeliveryResult(success=False, error=str(e)) async def send_typing_indicator(self, chat_id: str) -> DeliveryResult: space_name = chat_id.split("/threads/")[0] await self._rate_limiter.acquire(space_name) body = format_outbound( ChannelResponse( identity=ChannelIdentity( channel_id=self.channel_id, channel_type=self.channel_type, channel_user_id="", channel_chat_id=chat_id, ), content="", ) ) async def _do_send(): return await send_message(self._chat_service, chat_id, body) try: return await self._circuit_breaker.call(_do_send) except CircuitBreakerOpenError: return DeliveryResult(success=False, error="Circuit breaker open") async def send_stream_chunk( self, chat_id: str, msg_id: str, chunk: str, finished: bool, ) -> DeliveryResult: if not msg_id: space_name = chat_id.split("/threads/")[0] await self._rate_limiter.acquire(space_name) body = format_outbound( ChannelResponse( identity=ChannelIdentity( channel_id=self.channel_id, channel_type=self.channel_type, channel_user_id="", channel_chat_id=chat_id, ), content=chunk, ) ) async def _do_send(): return await send_message(self._chat_service, chat_id, body) try: result = await self._circuit_breaker.call(_do_send) except CircuitBreakerOpenError: return DeliveryResult(success=False, error="Circuit breaker open") if result.success and result.message_id: self._stream_mgr.register_message(chat_id, result.message_id, chunk) return result self._stream_mgr.append_text(chat_id, chunk) if not self._stream_mgr.should_update(chat_id) and not finished: return DeliveryResult(success=True, message_id=msg_id) self._stream_mgr.mark_update(chat_id) result = await self._stream_mgr.send_update(self._chat_service, chat_id, finished=finished) if result: return result return DeliveryResult(success=False, error="No pending stream message") async def receive(self) -> AsyncIterator[ChannelMessage]: return yield # type: ignore[misc] def normalize_inbound(self, raw: dict) -> ChannelMessage: return normalize_inbound(self.channel_id, self.channel_type, raw, self._bot_user) def format_outbound(self, response: ChannelResponse) -> dict: return format_outbound(response) async def health_check(self) -> HealthStatus: from datetime import datetime if self._status != ChannelStatus.CONNECTED: return HealthStatus(status="unhealthy", last_error="not connected") start = time.monotonic() try: self._chat_service.spaces().list(pageSize=1).execute() latency_ms = (time.monotonic() - start) * 1000 warnings = self.collect_security_warnings() status_issues = self.collect_status_issues() return HealthStatus( status="healthy", latency_ms=latency_ms, last_connected_at=datetime.fromtimestamp(self._connected_at, tz=UTC) if self._connected_at else None, metadata={ "service_account": self._service_account_email, "project_id": self.config.get("project_id"), "pubsub_subscription": self._pubsub_subscription, "pending_stream_msgs": self._stream_mgr.pending_count, "circuit_breaker_state": self._circuit_breaker.state, "audience_type": self._audience_type, "audience": self._audience, "credential_source": self._credential_source, "webhook_path": self._resolve_webhook_path(self.config.get("accountId", "default")), "warnings": warnings, "status_issues": status_issues, }, ) except Exception as e: return HealthStatus(status="unhealthy", last_error=str(e)) def collect_security_warnings(self) -> list[str]: warnings: list[str] = [] if self._policy.dm_policy == "open": warnings.append("dmPolicy is 'open': any user can DM the bot") if self._policy.group_policy == "open": warnings.append("groupPolicy is 'open': bot responds in all groups") allow_from = self._policy.allow_from for entry in allow_from: if "@" in entry and not entry.startswith("users/") and not entry.startswith("user:"): warnings.append(f"allowFrom contains email '{entry}': consider migrating to 'users/' format") group_allow_from = self._policy.group_allow_from for entry in group_allow_from: if "@" in entry and not entry.startswith("spaces/"): warnings.append( f"groupAllowFrom contains email '{entry}': group allowlist should use 'spaces/' format" ) if not self._audience: warnings.append("audience is not configured: webhook JWT audience validation may be incomplete") if not self._audience_type: warnings.append("audienceType is not configured: using default 'app-url'") if self._policy.dm_policy == "allowlist" and not allow_from: warnings.append("dmPolicy is 'allowlist' but allowFrom is empty: no user can DM the bot") if self._policy.group_policy == "allowlist" and not group_allow_from: warnings.append("groupPolicy is 'allowlist' but groupAllowFrom is empty: bot won't respond in any group") return warnings def collect_status_issues(self) -> list[dict[str, Any]]: issues: list[dict[str, Any]] = [] if not self._audience: issues.append( { "severity": "warning", "field": "audience", "message": "audience is not configured: webhook JWT audience validation may be incomplete", } ) if not self._audience_type: issues.append( { "severity": "warning", "field": "audienceType", "message": "audienceType is not configured: using default 'app-url'", } ) if not self._service_account_email: issues.append( { "severity": "error", "field": "serviceAccount", "message": "No service account email resolved: credentials may not be loaded", } ) if self._policy.dm_policy == "open": issues.append( { "severity": "info", "field": "dmPolicy", "message": "dmPolicy is 'open': any user can DM the bot", } ) if self._policy.group_policy == "open": issues.append( { "severity": "info", "field": "groupPolicy", "message": "groupPolicy is 'open': bot responds in all groups", } ) allow_from = self._policy.allow_from has_email_entries = any( "@" in entry and not entry.startswith("users/") and not entry.startswith("user:") for entry in allow_from ) if has_email_entries: issues.append( { "severity": "warning", "field": "allowFrom", "message": "allowFrom contains email addresses: consider migrating to 'users/' format", } ) if not self.config.get("project_id"): issues.append( { "severity": "info", "field": "projectId", "message": "GCP_PROJECT_ID not configured: Pub/Sub event subscription will not be initialized", } ) if self._status.value != "connected": issues.append( { "severity": "error", "field": "connection", "message": f"Adapter status is '{self._status.value}': channel not operational", } ) return issues async def verify_webhook_signature(self, headers: dict, body: bytes) -> bool: content_type = headers.get("Content-Type", headers.get("content-type", "")) if "application/json" not in content_type: logger.warning("Google Chat webhook: invalid Content-Type, expected application/json") return False if len(body) > _PREAUTH_BODY_MAX_BYTES: logger.warning(f"Google Chat webhook: body too large ({len(body)} bytes > {_PREAUTH_BODY_MAX_BYTES})") return False body_json: dict = {} try: body_json = json.loads(body) except (json.JSONDecodeError, TypeError): pass common_event = body_json.get("commonEventObject", {}) if common_event: if not self._validate_addon_common_event(common_event): return False auth_header = headers.get("Authorization", "") if auth_header.startswith("Bearer "): token = auth_header.removeprefix("Bearer ").strip() if token: return self._verify_addon_token(token, body_json) logger.debug("Google Chat webhook: accepted Add-on commonEventObject format (no token)") return True auth_header = headers.get("Authorization", "") webhook_urls = self._get_webhook_urls(body_json) if auth_header.startswith("Bearer "): token = auth_header.removeprefix("Bearer ").strip() if not token: return False if self._audience_type == "project-number" and self._audience: return self._verify_project_number_token(token) for url in webhook_urls: if _verify_jwt(token, url): return True return False system_token = body_json.get("systemIdToken", "") if system_token: if self._audience_type == "project-number" and self._audience: return self._verify_project_number_token(system_token) for url in webhook_urls: if _verify_jwt(system_token, url): return True return False logger.warning("Google Chat webhook: no Authorization header or systemIdToken found") return False def verify_webhook_with_status(self, headers: dict, body: bytes) -> tuple[bool, int, str]: if not body: return False, 400, "Empty request body" content_type = headers.get("Content-Type", headers.get("content-type", "")) if "application/json" not in content_type: return False, 400, "Invalid Content-Type, expected application/json" if len(body) > _PREAUTH_BODY_MAX_BYTES: return False, 400, f"Body too large ({len(body)} bytes > {_PREAUTH_BODY_MAX_BYTES})" try: body_json = json.loads(body) except (json.JSONDecodeError, TypeError): return False, 400, "Invalid JSON body" common_event = body_json.get("commonEventObject", {}) if common_event: if not self._validate_addon_common_event(common_event): return False, 401, "Add-on event validation failed" auth_header = headers.get("Authorization", "") if auth_header.startswith("Bearer "): token = auth_header.removeprefix("Bearer ").strip() if token: if self._verify_addon_token(token, body_json): return True, 200, "OK" return False, 401, "Add-on token verification failed" return True, 200, "OK" auth_header = headers.get("Authorization", "") webhook_urls = self._get_webhook_urls(body_json) if auth_header.startswith("Bearer "): token = auth_header.removeprefix("Bearer ").strip() if not token: return False, 401, "Empty Bearer token" if self._audience_type == "project-number" and self._audience: if self._verify_project_number_token(token): return True, 200, "OK" return False, 401, "Project-number token verification failed" for url in webhook_urls: if _verify_jwt(token, url): return True, 200, "OK" return False, 401, "JWT verification failed" system_token = body_json.get("systemIdToken", "") if system_token: if self._audience_type == "project-number" and self._audience: if self._verify_project_number_token(system_token): return True, 200, "OK" return False, 401, "Project-number systemIdToken verification failed" for url in webhook_urls: if _verify_jwt(system_token, url): return True, 200, "OK" return False, 401, "systemIdToken verification failed" return False, 401, "No valid authentication found" def _verify_project_number_token(self, token: str) -> bool: import asyncio try: cert_cache = auth.get_cert_cache() except Exception as e: logger.warning(f"Failed to get cert cache: {e}") return False loop = None try: loop = asyncio.get_running_loop() except RuntimeError: loop = self._get_or_create_event_loop() if loop is not None: certs = loop.run_until_complete(cert_cache.get_certs()) if not loop.is_running() else {} else: import asyncio as _asyncio certs = _asyncio.run(cert_cache.get_certs()) return auth.verify_project_number_token(token, self._audience, certs) @staticmethod def _get_or_create_event_loop(): import asyncio as _asyncio try: return _asyncio.get_event_loop() except RuntimeError: loop = _asyncio.new_event_loop() _asyncio.set_event_loop(loop) return loop def _get_webhook_urls(self, body_json: dict) -> list[str]: base_url = self.config.get("base_url", "") default_path = self.config.get("webhook_path", "/api/webhook/googlechat") urls = [self.config.get("webhook_url", f"{base_url}{default_path}")] account_id = self._extract_account_id_from_event(body_json) if account_id: account_path = self._resolve_webhook_path(account_id) account_url = self.config.get("webhook_url", f"{base_url}{account_path}") if account_url not in urls: urls.append(account_url) return urls def _extract_account_id_from_event(self, body_json: dict) -> str | None: space = body_json.get("event", {}).get("space", {}) or body_json.get("space", {}) space_name = space.get("name", "") if not space_name: return None for account_id, cfg in self._accounts_config.items(): project_id = cfg.get("project_id", "") if project_id and project_id in space_name: return account_id return None def _validate_addon_common_event(self, common_event: dict) -> bool: if not common_event.get("type"): return False if self._app_principal: user = common_event.get("user", {}) if user.get("name", "") != self._app_principal: logger.warning( f"Add-on event appPrincipal mismatch: expected {self._app_principal}, got {user.get('name', '')}" ) return False return True def _verify_addon_token(self, token: str, body_json: dict) -> bool: import re try: from google.auth.transport import requests as ga_requests from google.oauth2 import id_token except ImportError: logger.info("google-auth not installed, accepting Add-on token without verification") return True try: payload = id_token.verify_oauth2_token(token, ga_requests.Request(), audience=None) except Exception as e: logger.warning(f"Add-on token verification failed: {e}") return False issuer = payload.get("iss", "") is_standard = bool(re.match(_ADDON_ISSUER_RE, issuer)) is_sa_issuer = bool(re.match(_ADDON_SA_ISSUER_PATTERN, issuer)) if not is_standard and not is_sa_issuer: logger.warning(f"Add-on token issuer mismatch: {issuer}") return False event_user = body_json.get("commonEventObject", {}).get("user", {}) event_email = event_user.get("email", "") token_email = payload.get("email", "") if event_email and token_email and event_email.lower() != token_email.lower(): logger.warning(f"Add-on token email mismatch: event={event_email}, token={token_email}") return False return True async def get_user_info(self, channel_user_id: str) -> dict[str, Any]: try: result = self._chat_service.users().get(userId=channel_user_id).execute() return { "name": result.get("name", ""), "display_name": result.get("displayName", ""), "email": result.get("email", ""), "avatar_url": result.get("avatarUrl", ""), } except Exception: return {} async def download_media(self, file_id: str) -> bytes: if not self._chat_service: raise ChannelNotConnectedError() return await _download_media(self._chat_service, file_id) async def _refresh_token_if_needed(self) -> bool: if not self._credentials or not self._credentials.valid: return False try: ga_request = proxy.build_google_auth_request(self._proxy_config) if not ga_request: return False if self._credentials.expired and self._credentials.refresh_token: self._credentials.refresh(ga_request) logger.info("Google Chat credentials refreshed") return True except Exception as e: logger.warning(f"Token refresh failed: {e}") return False async def _handle_pubsub_event(self, event_data: dict) -> None: async with self._webhook_semaphore: common_event = event_data.get("commonEventObject") if common_event: event_data = self._convert_addon_event(common_event) message = event_data.get("event", {}).get("message", {}) if not self._allow_bots and is_bot_message(message): logger.debug("Google Chat webhook: filtered bot message") return msg = self.normalize_inbound(event_data) if not self._policy.check_inbound(msg): logger.debug( "Google Chat webhook: rejected by policy: " f"user={msg.identity.channel_user_id}, " f"space={msg.metadata.get('space_name')}" ) return space_name = msg.metadata.get("space_name", "") queue = self._message_queues.setdefault(space_name, asyncio.Queue()) await queue.put(msg) if space_name not in self._message_queue_tasks or self._message_queue_tasks[space_name].done(): task = asyncio.ensure_future(self._process_message_queue(space_name)) self._message_queue_tasks[space_name] = task async def _process_message_queue(self, space_name: str) -> None: queue = self._message_queues.get(space_name) if queue is None: return try: while not queue.empty(): msg = await queue.get() try: if msg.chat_type == "direct" and self._policy.dm_policy == "pairing": user_id = msg.identity.channel_user_id if self._chat_service: from .pairing import check_pairing_approval, send_pairing_challenge if not await check_pairing_approval( self._chat_service, space_name, user_id, msg.content or "" ): await send_pairing_challenge(self._chat_service, space_name, user_id) queue.task_done() continue if msg.message_type == "command" and msg.metadata.get("slash_command"): handled = await self._handle_slash_command(msg, space_name) if handled: queue.task_done() continue per_group_prompt = self._policy.get_per_group_system_prompt(space_name) if per_group_prompt: msg.metadata["per_group_system_prompt"] = per_group_prompt await self._handle_message(msg) except Exception as e: logger.error(f"Error processing message in {space_name}: {e}") finally: queue.task_done() except asyncio.CancelledError: pass except Exception as e: logger.error(f"Message queue processing error for {space_name}: {e}") async def _handle_slash_command(self, msg: ChannelMessage, space_name: str) -> bool: command = msg.metadata.get("slash_command", "") chat_id = msg.identity.channel_chat_id if command == "/help": from .slash_commands import get_command_help help_text = get_command_help() response = ChannelResponse( identity=ChannelIdentity( channel_id=self.channel_id, channel_type=self.channel_type, channel_user_id="", channel_chat_id=chat_id, ), content=help_text, ) await self.send(response) return True if command == "/status": health = await self.health_check() status_text = ( f"*Google Chat Bot 状态*\n" f"• 状态: {health.status}\n" f"• 服务帐户: {self._service_account_email}\n" f"• 延迟: {health.latency_ms:.0f}ms\n" f"• 待处理流消息: {self._stream_mgr.pending_count}\n" f"• 熔断器: {self._circuit_breaker.state}" ) response = ChannelResponse( identity=ChannelIdentity( channel_id=self.channel_id, channel_type=self.channel_type, channel_user_id="", channel_chat_id=chat_id, ), content=status_text, ) await self.send(response) return True return False def _convert_addon_event(self, common_event: dict) -> dict: event_type = common_event.get("type", "MESSAGE") return { "event": { "type": event_type, "space": common_event.get("space", {}), "message": common_event.get("message", {}), "user": common_event.get("user", {}), } } async def _init_service_account(self) -> None: from googleapiclient.discovery import build creds = await self._load_credentials() if not creds: raise ChannelAuthenticationError("No valid Google Chat service account credentials found") self._credentials = creds self._service_account_email = getattr(creds, "service_account_email", "") self._chat_service = build("chat", "v1", credentials=creds, cache_discovery=False) async def _load_credentials(self): SCOPES = ["https://www.googleapis.com/auth/chat.bot"] account_id = self.config.get("accountId", "default") account_config = self._resolve_account_config(account_id) cache_key_prefix = self._build_auth_cache_key_prefix(account_config) cached = self._auth_cache.get(cache_key_prefix) if cached: self._credential_source = "cache" logger.debug(f"Auth cache hit for key: {cache_key_prefix}") return cached self._credential_source = None creds = self._try_inline_credentials(SCOPES, account_config) if creds: self._cache_credential(cache_key_prefix, creds) return creds creds = self._try_file_credentials(SCOPES, account_config) if creds: self._cache_credential(cache_key_prefix, creds) return creds creds = self._try_env_json_credentials(SCOPES) if creds: self._cache_credential(cache_key_prefix, creds) return creds creds = self._try_env_file_credentials(SCOPES) if creds: self._cache_credential(cache_key_prefix, creds) return creds creds = self._try_secret_ref_credentials(SCOPES, account_config) if creds: self._cache_credential(cache_key_prefix, creds) return creds return None def _cache_credential(self, cache_key: str, creds) -> None: if cache_key in self._auth_cache: cached = self._auth_cache[cache_key] cached_email = getattr(cached, "service_account_email", "") new_email = getattr(creds, "service_account_email", "") if cached_email and new_email and cached_email != new_email: logger.info(f"Auth cache: credential changed (email: {cached_email} -> {new_email}), rebuilding") self._auth_cache.pop(cache_key) if len(self._auth_cache) >= self._auth_cache_max: first_key = next(iter(self._auth_cache)) self._auth_cache.pop(first_key) self._auth_cache[cache_key] = creds @staticmethod def _build_auth_cache_key_prefix(account_config: dict[str, Any]) -> str: raw = account_config.get("service_account") if raw: import hashlib if isinstance(raw, dict): raw = json.dumps(raw, sort_keys=True) inline_hash = hashlib.sha256(str(raw).encode()).hexdigest()[:12] return f"inline:{inline_hash}" file_path = account_config.get("service_account_file", "") if file_path: return f"file:{file_path}" env_json = os.getenv("GOOGLE_CHAT_SERVICE_ACCOUNT", "") if env_json: import hashlib env_hash = hashlib.sha256(env_json.encode()).hexdigest()[:12] return f"env_json:{env_hash}" env_file = os.getenv("GOOGLE_SERVICE_ACCOUNT_FILE", "") or os.getenv("GOOGLE_CHAT_SERVICE_ACCOUNT_FILE", "") if env_file: return f"env_file:{env_file}" secret_ref = account_config.get("serviceAccountRef", account_config.get("service_account_ref", "")) if secret_ref: return f"secret_ref:{secret_ref}" return "auth:default" def _parse_accounts_config(self, config: dict[str, Any] | None) -> dict[str, dict[str, Any]]: if not config: return {} accounts = config.get("accounts", {}) if not isinstance(accounts, dict): return {} result: dict[str, dict[str, Any]] = {} for key, value in accounts.items(): if isinstance(value, dict) and value.get("enabled", True): result[key] = value return result def _resolve_account_config(self, account_id: str = "default") -> dict[str, Any]: if account_id == "default" and self._default_account != "default": account_id = self._default_account merged = dict(self.config) account_overrides = self._accounts_config.get(account_id, {}) if account_overrides: merged.update(account_overrides) return merged @staticmethod def _parse_per_account_reply_to_mode(config: dict[str, Any] | None) -> dict[str, str]: if not config: return {} accounts = config.get("accounts", {}) if not isinstance(accounts, dict): return {} result: dict[str, str] = {} for account_id, cfg in accounts.items(): if isinstance(cfg, dict): mode = cfg.get("replyToMode", cfg.get("reply_to_mode", "")) if mode: result[account_id] = mode return result def get_reply_to_mode(self, account_id: str = "default") -> str: per_account = self._per_account_reply_to_mode.get(account_id, "") if per_account: return per_account return self._reply_to_mode def list_accounts(self) -> list[dict[str, Any]]: accounts = [] if not self._accounts_config: return [{"account_id": "default", "email": self._service_account_email}] for account_id, cfg in self._accounts_config.items(): accounts.append( { "account_id": account_id, "email": cfg.get("service_account_email", ""), "project_id": cfg.get("project_id", ""), "enabled": cfg.get("enabled", True), } ) return accounts def _resolve_webhook_path(self, account_id: str | None = None) -> str: if account_id and account_id in self._accounts_config: override = self._accounts_config[account_id].get("webhook_path", "") if override: return override return f"/api/webhook/googlechat/{account_id}" return "/api/webhook/googlechat" def _try_inline_credentials(self, scopes: list[str], config: dict[str, Any] | None = None): cfg = config if config is not None else self.config raw = cfg.get("service_account") if not raw: return None sa_json = self._validate_service_account_json(raw) if not sa_json: return None from google.oauth2 import service_account creds = service_account.Credentials.from_service_account_info(sa_json, scopes=scopes) self._credential_source = "inline" return creds def _try_file_credentials(self, scopes: list[str], config: dict[str, Any] | None = None): cfg = config if config is not None else self.config file_path = cfg.get("service_account_file", "") if not file_path: return None expanded = os.path.expanduser(file_path) if not self._validate_credential_file(expanded): return None sa_json = self._load_and_validate_sa_file(expanded) if not sa_json: return None from google.oauth2 import service_account creds = service_account.Credentials.from_service_account_info(sa_json, scopes=scopes) self._credential_source = "file" return creds def _try_env_json_credentials(self, scopes: list[str]): env_val = os.getenv("GOOGLE_CHAT_SERVICE_ACCOUNT", "") if not env_val: return None from google.oauth2 import service_account try: sa_json = json.loads(env_val) except (json.JSONDecodeError, TypeError): logger.warning("GOOGLE_CHAT_SERVICE_ACCOUNT is not valid JSON") return None sa_json = self._validate_service_account_json(sa_json) if not sa_json: return None creds = service_account.Credentials.from_service_account_info(sa_json, scopes=scopes) self._credential_source = "env" return creds def _try_env_file_credentials(self, scopes: list[str]): file_path = os.getenv("GOOGLE_SERVICE_ACCOUNT_FILE", "") or os.getenv("GOOGLE_CHAT_SERVICE_ACCOUNT_FILE", "") if not file_path: return None expanded = os.path.expanduser(file_path) if not self._validate_credential_file(expanded): return None sa_json = self._load_and_validate_sa_file(expanded) if not sa_json: return None from google.oauth2 import service_account creds = service_account.Credentials.from_service_account_info(sa_json, scopes=scopes) self._credential_source = "env" return creds def _try_secret_ref_credentials(self, scopes: list[str], config: dict[str, Any] | None = None): cfg = config if config is not None else self.config secret_ref = cfg.get("serviceAccountRef", cfg.get("service_account_ref", "")) if not secret_ref: return None try: from yuxi.channels.secret import resolve_secret_ref resolved = resolve_secret_ref(secret_ref) if not resolved: return None sa_json = resolved if isinstance(sa_json, str): try: sa_json = json.loads(sa_json) except (json.JSONDecodeError, TypeError): logger.warning(f"serviceAccountRef resolved to invalid JSON: {secret_ref}") return None sa_json = self._validate_service_account_json(sa_json) if not sa_json: return None from google.oauth2 import service_account creds = service_account.Credentials.from_service_account_info(sa_json, scopes=scopes) self._credential_source = "secret_ref" return creds except ImportError: logger.warning("Secret ref resolution not available") return None except Exception as e: logger.warning(f"Failed to resolve serviceAccountRef '{secret_ref}': {e}") return None def _validate_credential_file(self, path: str) -> bool: if not os.path.isfile(path): logger.warning(f"Credential path is not a regular file: {path}") return False try: size = os.path.getsize(path) except OSError: logger.warning(f"Cannot stat credential file: {path}") return False if size > 64 * 1024: logger.warning(f"Credential file too large ({size} bytes > 64KB): {path}") return False if size == 0: logger.warning(f"Credential file is empty: {path}") return False return True def _load_and_validate_sa_file(self, path: str) -> dict | None: try: with open(path) as f: sa_json = json.load(f) except (json.JSONDecodeError, OSError) as e: logger.warning(f"Failed to read credential file {path}: {e}") return None return self._validate_service_account_json(sa_json) def _validate_service_account_json(self, sa_json: dict | str) -> dict | None: if isinstance(sa_json, str): try: sa_json = json.loads(sa_json) except (json.JSONDecodeError, TypeError): return None if not isinstance(sa_json, dict): return None if sa_json.get("type") != "service_account": logger.warning("Credential type is not 'service_account'") return None if not sa_json.get("private_key"): logger.warning("Credential missing 'private_key'") return None if not sa_json.get("client_email"): logger.warning("Credential missing 'client_email'") return None if not sa_json.get("token_uri"): logger.warning("Credential missing 'token_uri'") return None auth_uri = sa_json.get("auth_uri", "") if auth_uri and "google.com" not in auth_uri: logger.warning(f"Credential auth_uri points to non-Google domain: {auth_uri}") if not auth_uri: logger.debug("Credential missing 'auth_uri' (may be valid for some setups)") client_x509 = sa_json.get("client_x509_cert_url", "") if not client_x509: logger.debug("Credential missing 'client_x509_cert_url' (may be valid for some setups)") universe_domain = sa_json.get("universe_domain", "googleapis.com") if universe_domain != "googleapis.com": logger.warning(f"Credential universe_domain is '{universe_domain}', expected 'googleapis.com'") client_email = sa_json["client_email"] if client_email: self._service_account_email = client_email return sa_json async def _init_pubsub_subscription(self) -> None: project_id = self.config.get("project_id") if not project_id: logger.info("GCP_PROJECT_ID not configured, skipping Pub/Sub init") return if not self._credentials: logger.warning("No credentials available, skipping Pub/Sub init") return try: from google.cloud import pubsub_v1 except ImportError: logger.warning("google-cloud-pubsub not installed, skipping Pub/Sub") return topic = self.config.get("pubsub_topic", "forcepilot-googlechat-events") sub = self.config.get("pubsub_subscription", "forcepilot-googlechat-sub") try: subscriber = pubsub_v1.SubscriberClient(credentials=self._credentials) sub_path = subscriber.subscription_path(project_id, sub) try: subscriber.get_subscription(subscription=sub_path) logger.info(f"Pub/Sub subscription already exists: {sub_path}") except Exception: base_url = self.config.get("base_url", "") webhook_url = self.config.get( "webhook_url", f"{base_url}/api/webhook/googlechat", ) push_config = pubsub_v1.PushConfig(push_endpoint=webhook_url) topic_path = subscriber.topic_path(project_id, topic) try: subscriber.create_subscription( name=sub_path, topic=topic_path, push_config=push_config, ack_deadline_seconds=60, ) logger.info(f"Created Pub/Sub subscription: {sub_path} -> {webhook_url}") except Exception as create_err: if hasattr(create_err, "code") and getattr(create_err, "code") == 409: logger.info(f"Pub/Sub subscription already exists (race): {sub_path}") else: logger.error(f"Failed to create Pub/Sub subscription: {create_err}") return self._pubsub_subscription = sub_path except Exception as e: logger.error(f"Pub/Sub initialization failed for project={project_id}: {e}")