新增 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
239 lines
8.9 KiB
Python
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)
|