- 调整依赖导入顺序与清理冗余空行 - 修复setup wizard步骤存储逻辑 - 新增消息编辑/转发标记、卡片控件支持 - 完善目标校验、媒体上传、配对存储功能 - 优化流式响应与错误处理逻辑 - 增强SSRF防护与配置灵活性 - 新增/扩展内置命令与卡片处理能力 - 调整媒体大小限制与类型参数
1559 lines
61 KiB
Python
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}")
|