ForcePilot/backend/package/yuxi/channel/extensions/googlechat/webhook.py
Kris c4e301a5e0 feat(googlechat): 新增Google Chat渠道完整实现
新增Google Chat插件的所有核心功能模块,包括账户配置、消息收发、权限控制、媒体处理、会话管理等完整能力
2026-05-21 10:49:33 +08:00

309 lines
12 KiB
Python

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)