import json import logging from fastapi import APIRouter, Header, HTTPException, Query, Request from fastapi.responses import JSONResponse, PlainTextResponse logger = logging.getLogger(__name__) MAX_WEBHOOK_BODY_BYTES = 1 * 1024 * 1024 router = APIRouter(tags=["zoomchat-webhook"]) _deduplicator = None def _get_deduplicator(): global _deduplicator if _deduplicator is None: from yuxi.channel.extensions.zoomchat.dedupe import ZoomMessageDeduplicator _deduplicator = ZoomMessageDeduplicator() return _deduplicator def create_webhook_app(account: dict, webhook_secret: str, queue, cancel_event) -> object: from fastapi import FastAPI app = FastAPI() app.state.account = account app.state.webhook_secret = webhook_secret app.state.queue = queue app.state.cancel_event = cancel_event app.include_router(router, prefix="/api/channel/zoomchat") return app @router.get("/webhook") async def zoom_webhook_verify(zoom_verification_token: str = Query(..., alias="zoom_verification_token")): logger.info("Zoom webhook URL verification received") return PlainTextResponse(content=zoom_verification_token) @router.post("/webhook") async def zoom_webhook_receive( request: Request, x_zm_signature: str = Header(default="", alias="x-zm-signature"), x_zm_request_timestamp: str = Header(default="", alias="x-zm-request-timestamp"), ): raw_body = await request.body() if len(raw_body) > MAX_WEBHOOK_BODY_BYTES: raise HTTPException(status_code=413, detail="Webhook body too large") from yuxi.channel.extensions.zoomchat.crypto import verify_webhook_signature webhook_secret = request.app.state.webhook_secret if not verify_webhook_signature(raw_body, x_zm_signature, x_zm_request_timestamp, webhook_secret): logger.warning("Zoom webhook signature verification failed") raise HTTPException(status_code=401, detail="Invalid webhook signature") try: payload_data = json.loads(raw_body.decode("utf-8")) except json.JSONDecodeError: raise HTTPException(status_code=400, detail="Invalid JSON payload") event_type = payload_data.get("event", "") logger.info("Zoom webhook event received: event=%s", event_type) queue = request.app.state.queue if event_type in ( "chat_message.sent", "chat_message.updated", "chat_message.replied", "team_chat.channel_message_posted", "team_chat.channel_message_updated", "team_chat.dm_message_posted", "team_chat.dm_message_updated", ): dedupe = _get_deduplicator() inner = payload_data.get("payload", {}).get("object", {}) event_ts = payload_data.get("event_ts", 0) message_id = inner.get("id", "") if isinstance(event_ts, int) and message_id and dedupe.is_duplicate(event_ts, message_id): logger.debug("Zoom webhook duplicate event skipped: %s:%s", event_ts, message_id) return JSONResponse(content={"status": "ok"}, status_code=200) from yuxi.channel.extensions.zoomchat.monitor import convert_webhook_to_message try: msg_dict = convert_webhook_to_message(payload_data, request.app.state.account) if msg_dict: await queue.put(msg_dict) except Exception: logger.exception("Failed to convert Zoom webhook event") elif event_type in ( "chat_message.reaction_added", "chat_message.reaction_removed", "team_chat.channel_reaction_added", "team_chat.channel_reaction_removed", "team_chat.dm_reaction_added", "team_chat.dm_reaction_removed", ): from yuxi.channel.extensions.zoomchat.reactions import parse_reaction_event try: reaction = parse_reaction_event(payload_data) if reaction: reaction["channel_type"] = "zoomchat" reaction["account_id"] = request.app.state.account.get("account_id", "") await queue.put(reaction) except Exception: logger.exception("Failed to parse Zoom reaction event") elif event_type in ("chat_message.deleted", "team_chat.channel_message_deleted", "team_chat.dm_message_deleted"): inner = payload_data.get("payload", {}).get("object", {}) event_ts = payload_data.get("event_ts", 0) message_id = inner.get("id", "") channel_id = inner.get("channel_id", "") deleted_by = inner.get("deleted_by", inner.get("sender", "")) deleted_time = inner.get("deleted_time", "") dedupe = _get_deduplicator() if isinstance(event_ts, int) and message_id and dedupe.is_duplicate(event_ts, message_id): return JSONResponse(content={"status": "ok"}, status_code=200) event_msg = { "msg_id": f"del-{message_id}", "channel_type": "zoomchat", "account_id": request.app.state.account.get("account_id", ""), "content": f"[消息已删除] {message_id}", "event_type": "message_deleted", "sender": { "id": deleted_by, "display_name": deleted_by, "kind": "group", "is_bot": False, "is_self": False, }, "group": {"id": channel_id, "name": channel_id, "kind": "group"}, "metadata": { "deleted_message_id": message_id, "deleted_by": deleted_by, "deleted_time": deleted_time, }, "raw_payload": payload_data, } await queue.put(event_msg) logger.info("Zoom message deleted event: msg_id=%s deleted_by=%s", message_id, deleted_by) elif event_type in ( "chat_channel.member_invited", "chat_channel.member_joined", "chat_channel.member_left", "chat_channel.member_removed", ): inner = payload_data.get("payload", {}).get("object", {}) channel_id = inner.get("channel_id", "") member_email = inner.get("email", "") operator = inner.get("operator", "") event_ts = payload_data.get("event_ts", 0) event_msg = { "msg_id": f"ch-event-{event_type}-{event_ts}", "channel_type": "zoomchat", "account_id": request.app.state.account.get("account_id", ""), "content": f"[频道事件] {event_type}: {member_email}", "event_type": event_type, "sender": { "id": operator or member_email, "display_name": operator or member_email, "kind": "group", "is_bot": False, "is_self": False, }, "group": {"id": channel_id, "name": channel_id, "kind": "group"}, "metadata": { "event_kind": event_type, "member_email": member_email, "channel_id": channel_id, }, "raw_payload": payload_data, } await queue.put(event_msg) logger.info("Zoom channel member event: %s member=%s channel=%s", event_type, member_email, channel_id) elif event_type in ( "team_chat.file_shared", "team_chat.file_uploaded", "team_chat.file_downloaded", "team_chat.file_deleted", "team_chat.file_changed", "team_chat.file_unshared", ): inner = payload_data.get("payload", {}).get("object", {}) file_info = inner.get("file", {}) channel_id = inner.get("channel_id", "") file_id = file_info.get("id", "") file_name = file_info.get("file_name", "") file_size = file_info.get("file_size", 0) download_url = file_info.get("download_url", "") operator = inner.get("operator", inner.get("sender", "")) event_msg = { "msg_id": f"file-{event_type}-{file_id}", "channel_type": "zoomchat", "account_id": request.app.state.account.get("account_id", ""), "content": f"[文件事件] {event_type}: {file_name} ({file_size} bytes)", "event_type": event_type, "sender": { "id": operator, "display_name": operator, "kind": "group", "is_bot": False, "is_self": False, }, "group": {"id": channel_id, "name": channel_id, "kind": "group"}, "media_urls": [download_url] if download_url else [], "metadata": { "event_kind": event_type, "file_id": file_id, "file_name": file_name, "file_size": file_size, "download_url": download_url, }, "raw_payload": payload_data, } await queue.put(event_msg) logger.info("Zoom file event: %s file=%s channel=%s", event_type, file_name, channel_id) else: logger.debug("Unhandled Zoom webhook event: %s", event_type) return JSONResponse(content={"status": "ok"}, status_code=200)