ForcePilot/backend/package/yuxi/channels/adapters/googlechat/adapter.py
Kris 87fc26cd15 refactor(googlechat): 整理并优化Google Chat适配器代码
- 调整依赖导入顺序与清理冗余空行
- 修复setup wizard步骤存储逻辑
- 新增消息编辑/转发标记、卡片控件支持
- 完善目标校验、媒体上传、配对存储功能
- 优化流式响应与错误处理逻辑
- 增强SSRF防护与配置灵活性
- 新增/扩展内置命令与卡片处理能力
- 调整媒体大小限制与类型参数
2026-05-13 16:09:31 +08:00

1559 lines
61 KiB
Python

from __future__ import annotations
import asyncio
import json
import os
import time
from collections import defaultdict
from collections.abc import AsyncIterator, Callable
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,
EventType,
HealthStatus,
)
from yuxi.channels.registry import register_builtin_adapter
from yuxi.utils.logging_config import logger
from . import auth, proxy
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
_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] = 200
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=200,
)
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
stream_cfg: dict = {}
if config:
stream_cfg = config.get("streaming", {}) if isinstance(config.get("streaming"), dict) else {}
block_cfg = stream_cfg.get("block", {}) if isinstance(stream_cfg, dict) else {}
if not isinstance(block_cfg, dict):
block_cfg = {}
update_interval = (config.get("streamUpdateIntervalMs") if config else None) or block_cfg.get(
"update_interval_ms", 800
)
coalesce_min = (config.get("blockStreamingCoalesceMinChars") if config else None) or block_cfg.get(
"coalesce_min_chars", 1500
)
coalesce_idle = (config.get("blockStreamingCoalesceIdleMs") if config else None) or block_cfg.get(
"coalesce_idle_ms", 1000
)
self._stream_mgr = StreamManager(
update_interval_ms=update_interval,
coalesce_min_chars=coalesce_min,
coalesce_idle_ms=coalesce_idle,
)
self._rate_limiter = ChatRateLimiter(ops_per_second=(config or {}).get("minSendIntervalMs", 1000) / 1000.0)
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] = {}
self._session_contexts: dict[str, tuple[float, dict]] = {}
self._session_ttl_s = config.get("sessionTtlS", 1800) if config else 1800
self._session_cleanup_task: asyncio.Task | None = None
self._card_handlers: dict[str, Callable] = {}
cfg = config or {}
self._connect_timeout_s = cfg.get("connectTimeout", _CONNECT_TIMEOUT_S)
self._preauth_body_max_bytes = cfg.get("preauthBodyMaxBytes", _PREAUTH_BODY_MAX_BYTES)
async def connect(self) -> None:
self._status = ChannelStatus.CONNECTING
try:
await asyncio.wait_for(
self._connect_impl(),
timeout=self._connect_timeout_s,
)
except TimeoutError:
self._status = ChannelStatus.ERROR
raise ChannelException(
f"Google Chat connection timed out after {self._connect_timeout_s}s",
retryable=True,
)
self._status = ChannelStatus.CONNECTED
self._connected_at = time.time()
self._start_session_cleanup()
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:
status = 0
try:
import googleapiclient.errors
if isinstance(e, googleapiclient.errors.HttpError):
status = e.resp.status
except Exception:
pass
if status in (401, 403):
raise ChannelAuthenticationError(f"Google Chat API auth failed: {e}") from e
raise ChannelException(
f"Google Chat API probe failed: {e}",
retryable=status >= 500,
)
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
tasks = list(self._message_queue_tasks.values())
for task in tasks:
task.cancel()
if tasks:
try:
await asyncio.wait_for(
asyncio.gather(*tasks, return_exceptions=True),
timeout=5.0,
)
except TimeoutError:
logger.warning("Timed out waiting for message queue tasks to finish during disconnect")
await self._stream_mgr.clear()
self._message_queue_tasks.clear()
self._message_queues.clear()
self._circuit_breaker = CircuitBreaker(failure_threshold=5, recovery_timeout=60.0)
self._chat_service = None
self._credentials = None
self._session_contexts.clear()
self._card_handlers.clear()
if self._session_cleanup_task:
self._session_cleanup_task.cancel()
self._session_cleanup_task = None
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:
import googleapiclient.errors
error_detail = str(e)
status_code = None
if isinstance(e, googleapiclient.errors.HttpError):
status_code = e.resp.status
error_detail = f"HTTP {status_code}: {error_detail}"
return {"ok": False, "status": "error", "error": error_detail, "status_code": status_code}
def register_card_handler(self, action_name: str, handler: Callable) -> None:
self._card_handlers[action_name] = handler
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,
cards: list[dict] | None = None,
update_fields: list[str] | None = None,
) -> DeliveryResult:
space_name = chat_id.split("/threads/")[0]
await self._rate_limiter.acquire(space_name)
body: dict[str, Any] = {"text": content}
if cards:
body["cards_v2"] = cards
update_fields = list(set((update_fields or ["text"]) + ["cardsV2"]))
mask = ",".join(update_fields or ["text"])
async def _do_send():
return await update_message(self._chat_service, msg_id, body, update_mask=mask)
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, custom_emoji_uid: 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, custom_emoji_uid=custom_emoji_uid)
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)
msg_id = await self._stream_mgr.create_typing_message(self._chat_service, chat_id, self._bot_user or "Bot")
if msg_id:
return DeliveryResult(success=True, message_id=msg_id)
return DeliveryResult(success=False, error="Failed to create typing indicator")
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:
await self._stream_mgr.register_message(chat_id, result.message_id, chunk)
return result
await 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)
await 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]:
raise NotImplementedError(
"Google Chat adapter uses Pub/Sub push-based message delivery. "
"Messages are dispatched via on_message callback registered through "
"_handle_pubsub_event, not via receive(). Do not call receive() directly."
)
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/<email>' 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/<id>' 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/<email>' 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) > self._preauth_body_max_bytes:
logger.warning(f"Google Chat webhook: body too large ({len(body)} bytes > {self._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) > self._preauth_body_max_bytes:
return False, 400, f"Body too large ({len(body)} bytes > {self._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 get_message(self, msg_id: str) -> dict | None:
if not self._chat_service:
raise ChannelNotConnectedError()
from .send import get_message as _get_message
return await _get_message(self._chat_service, msg_id)
async def list_messages(
self, space_name: str, page_size: int = 50, page_token: str | None = None
) -> tuple[list[dict], str | None]:
if not self._chat_service:
raise ChannelNotConnectedError()
from .send import list_messages as _list_messages
return await _list_messages(self._chat_service, space_name, page_size, page_token)
async def list_spaces(self, page_size: int = 50) -> tuple[list[dict], str | None]:
if not self._chat_service:
raise ChannelNotConnectedError()
from .directory import list_spaces as _list_spaces
return await _list_spaces(self._chat_service, page_size)
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:
import google.auth.exceptions
if isinstance(e, google.auth.exceptions.RefreshError):
logger.warning(f"Token refresh failed (credentials invalid): {e}")
return False
logger.warning(f"Token refresh failed (retryable): {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", "")
thread_id = msg.metadata.get("thread_id", msg.identity.channel_chat_id)
self._touch_session(thread_id)
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.event_type == EventType.CARD_ACTION:
action_data = json.loads(msg.content) if msg.content else {}
card_handler = self._card_handlers.get(action_data.get("function", ""))
if card_handler:
await card_handler(msg, action_data)
else:
logger.warning(f"No handler for card action: {action_data}")
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 asyncio.CancelledError:
raise
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}")
def _start_session_cleanup(self) -> None:
async def _cleanup_loop():
while self._status == ChannelStatus.CONNECTED:
await asyncio.sleep(300)
now = time.monotonic()
expired = [k for k, (ts, _) in self._session_contexts.items() if now - ts > self._session_ttl_s]
for k in expired:
del self._session_contexts[k]
if expired:
logger.debug(f"Cleaned up {len(expired)} expired Google Chat sessions")
self._session_cleanup_task = asyncio.ensure_future(_cleanup_loop())
def _touch_session(self, thread_id: str) -> None:
if not thread_id:
return
if thread_id not in self._session_contexts:
self._session_contexts[thread_id] = (time.monotonic(), {})
else:
ts, ctx = self._session_contexts[thread_id]
self._session_contexts[thread_id] = (time.monotonic(), ctx)
async def _handle_slash_command(self, msg: ChannelMessage, space_name: str) -> bool:
command = msg.metadata.get("slash_command", "")
if not command:
return False
chat_id = msg.identity.channel_chat_id
def _make_identity():
return ChannelIdentity(
channel_id=self.channel_id,
channel_type=self.channel_type,
channel_user_id="",
channel_chat_id=chat_id,
)
if command == "/help":
from .slash_commands import get_command_help
help_text = get_command_help()
await self.send(ChannelResponse(identity=_make_identity(), content=help_text))
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}"
)
await self.send(ChannelResponse(identity=_make_identity(), content=status_text))
return True
if command == "/reset":
thread_id = msg.metadata.get("thread_id", chat_id)
self._session_contexts.pop(thread_id, None)
self._touch_session(thread_id)
await self.send(
ChannelResponse(
identity=_make_identity(),
content="会话上下文已清除,可以开始新的对话。",
)
)
return True
if command == "/history":
thread_id = msg.metadata.get("thread_id", chat_id)
_, ctx = self._session_contexts.get(thread_id, (0, {}))
history = ctx.get("history_summary", "暂无对话历史")
await self.send(
ChannelResponse(
identity=_make_identity(),
content=f"*对话历史摘要*\n{history}",
)
)
return True
if command == "/context":
thread_id = msg.metadata.get("thread_id", chat_id)
_, ctx = self._session_contexts.get(thread_id, (0, {}))
ctx_info = json.dumps(ctx, ensure_ascii=False, indent=2)
await self.send(
ChannelResponse(
identity=_make_identity(),
content=f"*当前上下文*\n```json\n{ctx_info}\n```",
)
)
return True
if command == "/summary":
msg.metadata["request_summary"] = True
await self._handle_message(msg)
return True
await self.send(
ChannelResponse(
identity=_make_identity(),
content=f"命令 `{command}` 暂未实现,即将支持。发送 `/help` 查看可用命令。",
)
)
return True
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)
publisher = pubsub_v1.PublisherClient(credentials=self._credentials)
try:
publisher.get_topic(topic=topic_path)
logger.info(f"Pub/Sub topic exists: {topic_path}")
except Exception:
try:
publisher.create_topic(name=topic_path)
logger.info(f"Created Pub/Sub topic: {topic_path}")
except Exception as topic_err:
if hasattr(topic_err, "code") and getattr(topic_err, "code") == 409:
logger.info(f"Pub/Sub topic already exists (race): {topic_path}")
else:
logger.error(f"Failed to create Pub/Sub topic {topic}: {topic_err}")
return
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}")