ForcePilot/backend/package/yuxi/channel/extensions/zoomchat/webhook.py
Kris d5e36d33b7 feat(channel): 添加 Zalo OA、Zoom Chat 和 Zulip 渠道扩展
新增 Zalo OA、Zoom Chat、Zulip 三个渠道扩展。

Zalo OA 渠道扩展主要模块:sidecar_client, config, gateway, outbound, streaming, pairing, security, auth, dedupe, directory, monitor, status, session, reactions, tools

Zoom Chat 渠道扩展主要模块:config, gateway, webhook, outbound, streaming, pairing, security, crypto, dedupe, actions, media, mentions, monitor, status, session, reactions, threading

Zulip 渠道扩展主要模块:client, config, gateway, outbound, streaming, pairing, security, monitor, status
2026-05-21 12:06:26 +08:00

239 lines
8.9 KiB
Python

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)