新增 Google Chat 对接的全套工具模块,包括: - 会话线程管理、消息解析与格式化 - Pub/Sub 消息解码、提及和命令识别 - 权限审批、目录管理和审计日志 - 消息缓存、媒体上传下载和 SSFR 防护 - 策略配置、卡片构建和流式回复支持
1374 lines
53 KiB
Python
1374 lines
53 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import os
|
|
import time
|
|
from collections import defaultdict
|
|
from collections.abc import AsyncIterator
|
|
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,
|
|
HealthStatus,
|
|
)
|
|
from yuxi.channels.registry import register_builtin_adapter
|
|
from yuxi.utils.logging_config import logger
|
|
|
|
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
|
|
from . import auth
|
|
from . import proxy
|
|
|
|
_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] = 20
|
|
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=20,
|
|
)
|
|
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
|
|
self._stream_mgr = StreamManager(
|
|
update_interval_ms=config.get("streamUpdateIntervalMs", 800) if config else 800,
|
|
coalesce_min_chars=config.get("blockStreamingCoalesceMinChars", 1500) if config else 1500,
|
|
coalesce_idle_ms=config.get("blockStreamingCoalesceIdleMs", 1000) if config else 1000,
|
|
)
|
|
self._rate_limiter = ChatRateLimiter()
|
|
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] = {}
|
|
|
|
async def connect(self) -> None:
|
|
self._status = ChannelStatus.CONNECTING
|
|
|
|
try:
|
|
await asyncio.wait_for(
|
|
self._connect_impl(),
|
|
timeout=_CONNECT_TIMEOUT_S,
|
|
)
|
|
except TimeoutError:
|
|
self._status = ChannelStatus.ERROR
|
|
raise ChannelException(
|
|
f"Google Chat connection timed out after {_CONNECT_TIMEOUT_S}s",
|
|
retryable=True,
|
|
)
|
|
|
|
self._status = ChannelStatus.CONNECTED
|
|
self._connected_at = time.time()
|
|
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:
|
|
raise ChannelException(
|
|
f"Google Chat API probe failed: {e}",
|
|
retryable=False,
|
|
)
|
|
|
|
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
|
|
self._stream_mgr.clear()
|
|
self._circuit_breaker = CircuitBreaker(failure_threshold=5, recovery_timeout=60.0)
|
|
self._chat_service = None
|
|
self._credentials = None
|
|
for task in self._message_queue_tasks.values():
|
|
task.cancel()
|
|
self._message_queue_tasks.clear()
|
|
self._message_queues.clear()
|
|
|
|
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:
|
|
return {"ok": False, "status": "error", "error": str(e)}
|
|
|
|
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) -> DeliveryResult:
|
|
space_name = chat_id.split("/threads/")[0]
|
|
await self._rate_limiter.acquire(space_name)
|
|
body: dict[str, Any] = {"text": content}
|
|
|
|
async def _do_send():
|
|
return await update_message(self._chat_service, msg_id, body)
|
|
|
|
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) -> 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)
|
|
|
|
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)
|
|
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="",
|
|
)
|
|
)
|
|
|
|
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 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:
|
|
self._stream_mgr.register_message(chat_id, result.message_id, chunk)
|
|
return result
|
|
|
|
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)
|
|
|
|
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]:
|
|
return
|
|
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) > _PREAUTH_BODY_MAX_BYTES:
|
|
logger.warning(f"Google Chat webhook: body too large ({len(body)} bytes > {_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) > _PREAUTH_BODY_MAX_BYTES:
|
|
return False, 400, f"Body too large ({len(body)} bytes > {_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 _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:
|
|
logger.warning(f"Token refresh failed: {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", "")
|
|
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.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 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}")
|
|
|
|
async def _handle_slash_command(self, msg: ChannelMessage, space_name: str) -> bool:
|
|
command = msg.metadata.get("slash_command", "")
|
|
chat_id = msg.identity.channel_chat_id
|
|
|
|
if command == "/help":
|
|
from .slash_commands import get_command_help
|
|
|
|
help_text = get_command_help()
|
|
response = ChannelResponse(
|
|
identity=ChannelIdentity(
|
|
channel_id=self.channel_id,
|
|
channel_type=self.channel_type,
|
|
channel_user_id="",
|
|
channel_chat_id=chat_id,
|
|
),
|
|
content=help_text,
|
|
)
|
|
await self.send(response)
|
|
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}"
|
|
)
|
|
response = ChannelResponse(
|
|
identity=ChannelIdentity(
|
|
channel_id=self.channel_id,
|
|
channel_type=self.channel_type,
|
|
channel_user_id="",
|
|
channel_chat_id=chat_id,
|
|
),
|
|
content=status_text,
|
|
)
|
|
await self.send(response)
|
|
return True
|
|
|
|
return False
|
|
|
|
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)
|
|
|
|
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}")
|