from __future__ import annotations import asyncio import logging from yuxi.channel.extensions.googlechat.auth import verify_webhook_token from yuxi.channel.extensions.googlechat.formatting import sanitize_text from yuxi.channel.extensions.googlechat.monitor import GoogleChatMonitor from yuxi.channel.extensions.googlechat.types import GoogleChatEvent, ResolvedGoogleChatAccount logger = logging.getLogger(__name__) _PRE_AUTH_BODY_LIMIT = 16 * 1024 _PRE_AUTH_TIMEOUT = 3.0 class GoogleChatWebhookHandler: def __init__(self, monitor: GoogleChatMonitor | None = None): self._monitor = monitor or GoogleChatMonitor() self._targets: dict[str, ResolvedGoogleChatAccount] = {} def register_target(self, webhook_path: str, account: ResolvedGoogleChatAccount) -> None: self._targets[webhook_path] = account def unregister_target(self, webhook_path: str) -> None: self._targets.pop(webhook_path, None) def _resolve_account( self, token: str = "", webhook_path: str = "" ) -> ResolvedGoogleChatAccount | None: if webhook_path and webhook_path in self._targets: return self._targets[webhook_path] if token and self._targets: from yuxi.channel.extensions.googlechat.auth import decode_unverified_jwt_payload payload = decode_unverified_jwt_payload(token) target_audience = payload.get("aud", "") for path, account in self._targets.items(): if account.audience and account.audience == target_audience: return account for account in self._targets.values(): return account return None async def handle_webhook(self, payload: dict) -> dict: token = payload.get("_bearer_token", "") raw_body = payload.get("_raw_body") account = self._resolve_account(token=token) audience_type = account.audience_type or "app-url" if account else "app-url" audience = account.audience or "" if account else "" app_principal = account.app_principal if account else None if token: ok, verified_payload, error = await verify_webhook_token( token, audience_type, audience, app_principal ) if not ok: logger.warning("Webhook auth failed: %s", error) return {"error": "unauthorized", "detail": error} body = raw_body or payload if not token: if isinstance(body, bytes) and len(body) > _PRE_AUTH_BODY_LIMIT: return {"error": "payload too large"} event = GoogleChatEvent.from_payload(body) return await self._process_event(body, event, account) async def _process_event( self, raw_payload: dict, event: GoogleChatEvent, account: ResolvedGoogleChatAccount | None = None ) -> dict: if event.type == "ADDED_TO_SPACE": return await self._handle_added_to_space(event, account) if event.type == "REMOVED_FROM_SPACE": return await self._handle_removed_from_space(event, account) if event.type == "CARD_CLICKED": return await self._handle_card_clicked(event, account) if event.type == "APP_HOME": return await self._handle_app_home(event, account) if event.type != "MESSAGE": return {"status": "ignored", "reason": f"event type: {event.type}"} if not event.message: return {"status": "ignored", "reason": "no message in event"} msg = event.message sender = msg.sender allow_bots = account.allow_bots if account else False if sender.type == "BOT" and not allow_bots: return {"status": "ignored", "reason": "bot message"} if sender.name == "users/app": return {"status": "ignored", "reason": "self message"} text = msg.argument_text or msg.text if not text: return {"status": "ignored", "reason": "empty text"} safe_text = sanitize_text(text) if msg.thread and account: thread_name = msg.thread.get("name", "") if thread_name: try: thread_ctx = await asyncio.wait_for( self._monitor.inject_thread_context( account, event.effective_space_name, thread_name ), timeout=5.0, ) if thread_ctx: safe_text = f"{thread_ctx}\n{safe_text}" except (asyncio.TimeoutError, Exception): pass try: unified_msg = self._monitor.event_to_unified_message( raw_payload, event, safe_text, account=account ) if unified_msg is None: return {"status": "filtered"} from yuxi.channel.runtime.manager import gateway processor = getattr(gateway, "_processor", None) or getattr(gateway, "processor", None) if processor: await asyncio.wait_for(processor.process(unified_msg), timeout=120.0) return {"status": "processed"} except asyncio.TimeoutError: logger.error("Agent response timeout for Google Chat message") return {"status": "timeout"} except Exception: logger.exception("Failed to process Google Chat message") return {"status": "error"} async def _handle_added_to_space( self, event: GoogleChatEvent, account: ResolvedGoogleChatAccount | None = None ) -> dict: space_name = event.effective_space_name if not space_name or not account: return {"status": "skipped", "reason": "no space or account"} space_display = ( event.effective_space.display_name if event.effective_space and event.effective_space.display_name else "this space" ) welcome_text = ( f"Hi! I'm a bot powered by ForcePilot. " f"I've been added to *{space_display}*.\n\n" f"• Mention me with @ to ask questions\n" f"• I can participate in threads\n" f"• Use /help to see available commands" ) try: from yuxi.channel.extensions.googlechat.api import send_message await send_message(account, space_name, welcome_text) logger.info("Sent welcome message to space: %s", space_name) return {"status": "processed", "action": "welcome"} except Exception: logger.exception("Failed to send welcome message to space: %s", space_name) return {"status": "error", "action": "welcome"} async def _handle_removed_from_space( self, event: GoogleChatEvent, account: ResolvedGoogleChatAccount | None = None ) -> dict: space_name = event.effective_space_name if not space_name: return {"status": "skipped", "reason": "no space"} logger.info( "Bot removed from space: %s, user: %s", space_name, event.user.name if event.user else "unknown", ) if account: from yuxi.channel.runtime.manager import gateway channel_ctx = getattr(gateway, "_channel_contexts", {}).get(account.account_id) if channel_ctx and hasattr(channel_ctx, "webhook_handler"): channel_ctx.webhook_handler.unregister_target(account.webhook_path) return {"status": "processed", "action": "cleanup"} async def _handle_card_clicked( self, event: GoogleChatEvent, account: ResolvedGoogleChatAccount | None = None ) -> dict: import time common = event.common or {} action = common.get("invokedFunction", "") parameters = common.get("parameters", {}) user = event.user user_id = user.name if user else "unknown" space_name = event.effective_space_name logger.info("CARD_CLICKED: action=%s user=%s space=%s", action, user_id, space_name) from yuxi.channel.message.models import ( MessageType, PeerInfo, UnifiedMessage, ) from yuxi.channel.routing.models import PeerKind unified_msg = UnifiedMessage( msg_id=f"card_clicked:{user_id}:{int(time.time())}", channel_type="googlechat", account_id=account.account_id if account else "default", content=f"[card_action:{action}]", sender=PeerInfo( kind=PeerKind.GROUP if space_name else PeerKind.DIRECT, id=user_id, display_name=user.display_name if user else None, ), message_type=MessageType.COMMAND, metadata={ "ChatType": "direct" if not space_name else "group", "InteractionType": "card_clicked", "InvokedFunction": action, "Parameters": parameters, "space_name": space_name, }, raw_payload=event.__dict__ if hasattr(event, "__dict__") else {}, ) try: from yuxi.channel.runtime.manager import gateway processor = getattr(gateway, "_processor", None) or getattr(gateway, "processor", None) if processor: await asyncio.wait_for(processor.process(unified_msg), timeout=120.0) return {"status": "processed", "action": "card_clicked"} except asyncio.TimeoutError: logger.error("Agent response timeout for CARD_CLICKED event") return {"status": "timeout", "action": "card_clicked"} except Exception: logger.exception("Failed to process CARD_CLICKED event") return {"status": "error", "action": "card_clicked"} async def _handle_app_home( self, event: GoogleChatEvent, account: ResolvedGoogleChatAccount | None = None ) -> dict: space_name = event.effective_space_name if not space_name or not account: return {"status": "skipped", "reason": "no space or account"} cards_v2 = [{ "cardId": "app_home", "card": { "header": {"title": "ForcePilot AI Assistant"}, "sections": [{ "widgets": [ { "textParagraph": { "text": "Ask me anything! I'm here to help you with tasks, research, and more." } }, { "buttonList": { "buttons": [ { "text": "Start a Conversation", "onClick": {"action": {"function": "start_conversation"}}, }, { "text": "Help", "onClick": {"action": {"function": "show_help"}}, }, ] } }, ], }], }, }] try: from yuxi.channel.extensions.googlechat.api import send_card_message await send_card_message( account, space_name, cards_v2, fallback_text="ForcePilot AI Assistant" ) return {"status": "processed", "action": "app_home"} except Exception: logger.exception("Failed to render APP_HOME") return {"status": "error", "action": "app_home"} async def handle_webhook_request(self, raw_body: dict) -> dict: if not raw_body or not raw_body.get("event"): return {"error": "invalid request", "detail": "No valid event in payload"} return await self.handle_webhook(raw_body)