313 lines
14 KiB
Python
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 |