from __future__ import annotations import json import logging import os import re from pathlib import Path from yuxi.channel.extensions.googlechat.types import ResolvedGoogleChatAccount logger = logging.getLogger(__name__) _AUDIENCE_VARIANTS: dict[str, set[str]] = { "app-url": {"app-url", "app_url", "app"}, "project-number": {"project-number", "project_number", "project"}, } _DEFAULT_WEBHOOK_PATH = "/api/webhook/googlechat" _REQUIRED_SERVICE_ACCOUNT_FIELDS = {"client_email", "private_key"} class GoogleChatConfigAdapter: def list_account_ids(self, config: dict) -> list[str]: gc_config = config.get("channels", {}).get("googlechat", {}) accounts = gc_config.get("accounts", {}) default_account = gc_config.get("defaultAccount", "default") if not accounts: return [default_account] return list(accounts.keys()) async def resolve_account(self, account_id: str) -> dict: from yuxi.channel.runtime.manager import gateway config = gateway.global_config if gateway is not None else {} gc_config = config.get("channels", {}).get("googlechat", {}) if config else {} defaults = gc_config.get("accounts", {}).get("default", {}) account_cfg = gc_config.get("accounts", {}).get(account_id, {}) merged = {**gc_config, **defaults, **account_cfg} credential_source, sa_info, sa_file = self._resolve_credential(merged, account_id, account_id == "default") resolved = ResolvedGoogleChatAccount( account_id=account_id, name=merged.get("name", account_id), credential_source=credential_source, service_account_info=sa_info, service_account_file=sa_file, audience_type=self.normalize_audience_type(merged.get("audienceType")), audience=merged.get("audience"), webhook_path=merged.get("webhookPath", _DEFAULT_WEBHOOK_PATH), webhook_url=merged.get("webhookUrl"), bot_user=merged.get("botUser"), app_principal=merged.get("appPrincipal"), group_policy=merged.get("groupPolicy", "allowlist"), require_mention=merged.get("requireMention", True), allow_text_commands=merged.get("allowTextCommands", False), reply_to_mode=merged.get("replyToMode", "off"), text_chunk_limit=merged.get("textChunkLimit", 4000), media_max_mb=merged.get("mediaMaxMb", 20), typing_indicator=merged.get("typingIndicator", "message"), allow_bots=merged.get("allowBots", False), dangerously_allow_name_matching=merged.get("dangerouslyAllowNameMatching", False), actions_reactions=merged.get("actions", {}).get("reactions", True), dm_policy=merged.get("dm", {}).get("policy", "pairing"), dm_enabled=merged.get("dm", {}).get("enabled", True), dm_allow_from=merged.get("dm", {}).get("allowFrom", []), groups=merged.get("groups", {}), block_streaming_coalesce_min_chars=merged.get("blockStreamingCoalesce", {}).get("minChars", 1500), block_streaming_coalesce_idle_ms=merged.get("blockStreamingCoalesce", {}).get("idleMs", 1000), enabled=merged.get("enabled", True), config=merged, ) return { "account_id": account_id, "name": resolved.name, "credential_source": resolved.credential_source, "service_account_info": resolved.service_account_info, "service_account_file": resolved.service_account_file, "audience_type": resolved.audience_type, "audience": resolved.audience, "webhook_path": resolved.webhook_path, "webhook_url": resolved.webhook_url, "bot_user": resolved.bot_user, "app_principal": resolved.app_principal, "group_policy": resolved.group_policy, "require_mention": resolved.require_mention, "allow_text_commands": resolved.allow_text_commands, "reply_to_mode": resolved.reply_to_mode, "text_chunk_limit": resolved.text_chunk_limit, "media_max_mb": resolved.media_max_mb, "typing_indicator": resolved.typing_indicator, "allow_bots": resolved.allow_bots, "dangerously_allow_name_matching": resolved.dangerously_allow_name_matching, "actions_reactions": resolved.actions_reactions, "dm_policy": resolved.dm_policy, "dm_enabled": resolved.dm_enabled, "dm_allow_from": resolved.dm_allow_from, "groups": resolved.groups, "block_streaming_coalesce_min_chars": resolved.block_streaming_coalesce_min_chars, "block_streaming_coalesce_idle_ms": resolved.block_streaming_coalesce_idle_ms, "enabled": resolved.enabled, "config": resolved.config, "resolved": resolved, } def is_configured(self, account: dict) -> bool: resolved = account.get("resolved") if isinstance(resolved, ResolvedGoogleChatAccount): return resolved.is_configured return bool(account.get("credential_source") != "none") def is_enabled(self, account: dict) -> bool: return bool(account.get("enabled", True)) def disabled_reason(self, account: dict) -> str: if not self.is_configured(account): return "Service Account credentials are required" return "" async def resolve_allow_from(self, config: dict, account_id: str) -> list[str] | None: account = await self.resolve_account(account_id) return account.get("dm_allow_from", []) def describe_account(self, account: dict) -> dict: return { "account_id": account.get("account_id", ""), "name": account.get("name", ""), "configured": self.is_configured(account), "credential_source": account.get("credential_source", "none"), } def config_schema(self) -> dict: return { "type": "object", "properties": { "enabled": {"type": "boolean", "default": True}, "name": {"type": "string"}, "serviceAccount": { "type": "string", "description": "Google Cloud Service Account JSON (inline string)", }, "serviceAccountFile": { "type": "string", "description": "Path to Service Account JSON file", }, "audienceType": { "type": "string", "enum": ["app-url", "project-number"], }, "audience": {"type": "string"}, "webhookPath": {"type": "string", "default": "/api/webhook/googlechat"}, "webhookUrl": {"type": "string"}, "botUser": {"type": "string"}, "appPrincipal": {"type": "string"}, "groupPolicy": { "type": "string", "enum": ["open", "allowlist", "disabled"], "default": "allowlist", }, "requireMention": {"type": "boolean", "default": True}, "allowTextCommands": {"type": "boolean", "default": False}, "replyToMode": { "type": "string", "enum": ["off", "first", "all", "batched"], "default": "off", }, "textChunkLimit": {"type": "integer", "default": 4000}, "mediaMaxMb": {"type": "integer", "default": 20}, "typingIndicator": { "type": "string", "enum": ["message", "reaction", "off"], "default": "message", }, "allowBots": {"type": "boolean", "default": False}, "dangerouslyAllowNameMatching": {"type": "boolean", "default": False}, "dm": { "type": "object", "properties": { "policy": { "type": "string", "enum": ["open", "pairing", "allowlist"], "default": "pairing", }, "allowFrom": {"type": "array", "items": {"type": "string"}}, "enabled": {"type": "boolean", "default": True}, }, }, "groups": { "type": "object", "additionalProperties": { "type": "object", "properties": { "requireMention": {"type": "boolean"}, "enabled": {"type": "boolean"}, "users": {"type": "array", "items": {"type": "string"}}, "systemPrompt": {"type": "string"}, }, }, }, "actions": { "type": "object", "properties": { "reactions": {"type": "boolean", "default": True}, }, }, "accounts": { "type": "object", "additionalProperties": { "type": "object", "properties": { "name": {"type": "string"}, "serviceAccount": {"type": "string"}, "serviceAccountFile": {"type": "string"}, "audienceType": {"type": "string", "enum": ["app-url", "project-number"]}, "audience": {"type": "string"}, "webhookPath": {"type": "string"}, "webhookUrl": {"type": "string"}, "botUser": {"type": "string"}, "appPrincipal": {"type": "string"}, "dm": { "type": "object", "properties": { "policy": {"type": "string", "enum": ["open", "pairing", "allowlist"]}, "allowFrom": {"type": "array", "items": {"type": "string"}}, "enabled": {"type": "boolean"}, }, }, }, }, }, "defaultAccount": {"type": "string", "default": "default"}, }, } @staticmethod def normalize_audience_type(value: str | None) -> str | None: if not value: return None normalized = value.strip().lower() for canonical, variants in _AUDIENCE_VARIANTS.items(): if normalized in variants: return canonical return None @staticmethod def _resolve_credential( merged: dict, account_id: str, is_default: bool ) -> tuple[str, dict | None, str | None]: sa_file = merged.get("serviceAccountFile") if sa_file: try: with open(sa_file, encoding="utf-8") as f: sa_info = json.load(f) return "file", sa_info, sa_file except Exception: logger.warning("Failed to read serviceAccountFile: %s", sa_file) sa_inline = merged.get("serviceAccount") if sa_inline: try: sa_info = json.loads(sa_inline) if isinstance(sa_inline, str) else sa_inline return "inline", sa_info, None except Exception: logger.warning("Failed to parse inline serviceAccount") if is_default: sa_env = os.getenv("GOOGLE_CHAT_SERVICE_ACCOUNT") if sa_env: try: sa_info = json.loads(sa_env) return "env", sa_info, None except Exception: logger.warning("Failed to parse GOOGLE_CHAT_SERVICE_ACCOUNT env var") sa_file_env = os.getenv("GOOGLE_CHAT_SERVICE_ACCOUNT_FILE") if sa_file_env: try: with open(sa_file_env, encoding="utf-8") as f: sa_info = json.load(f) return "env_file", sa_info, sa_file_env except Exception: logger.warning("Failed to read GOOGLE_CHAT_SERVICE_ACCOUNT_FILE: %s", sa_file_env) return "none", None, None @staticmethod def validate_service_account(sa_info: dict) -> list[str]: errors = [] if sa_info.get("type") and sa_info["type"] != "service_account": errors.append(f"invalid type: {sa_info['type']}, expected service_account") if not sa_info.get("client_email"): errors.append("missing client_email") if not sa_info.get("private_key"): errors.append("missing private_key") universe = sa_info.get("universe_domain") if universe and universe != "googleapis.com": errors.append(f"unexpected universe_domain: {universe}, expected googleapis.com") auth_uri = sa_info.get("auth_uri") if auth_uri and auth_uri != "https://accounts.google.com/o/oauth2/auth": errors.append(f"unexpected auth_uri: {auth_uri}") token_uri = sa_info.get("token_uri") if token_uri and token_uri != "https://oauth2.googleapis.com/token": errors.append(f"unexpected token_uri: {token_uri}") cert_url = sa_info.get("auth_provider_x509_cert_url") if cert_url and cert_url != "https://www.googleapis.com/oauth2/v1/certs": errors.append(f"unexpected auth_provider_x509_cert_url: {cert_url}") client_cert = sa_info.get("client_x509_cert_url") if client_cert and not client_cert.startswith("https://www.googleapis.com/robot/v1/metadata/x509/"): errors.append(f"unexpected client_x509_cert_url: {client_cert}") return errors