309 lines
12 KiB
Python
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)
|