ForcePilot/backend/package/yuxi/channel/extensions/googlechat/config.py
Kris c4e301a5e0 feat(googlechat): 新增Google Chat渠道完整实现
新增Google Chat插件的所有核心功能模块,包括账户配置、消息收发、权限控制、媒体处理、会话管理等完整能力
2026-05-21 10:49:33 +08:00

313 lines
14 KiB
Python

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