ForcePilot/backend/package/yuxi/channel/extensions/zalo/webhook.py
Kris 5946478772 feat(channel): 添加小红书、XMPP、元宝和 Zalo 渠道扩展
新增小红书、XMPP、元宝、Zalo 四个渠道扩展。

小红书渠道扩展主要模块:config, gateway, webhook, outbound, streaming, pairing, security, dedupe, media, status, window

XMPP 渠道扩展主要模块:plugin, config, gateway, outbound, streaming, pairing, security, dedupe, accounts, commands, muc, rate_limiter, stanza_utils, status, monitor

元宝渠道扩展主要模块:plugin, client, config_schema, gateway, outbound(chunk/queue/transport), inbound(dispatcher), streaming, pairing, security, accounts, actions, commands, codec(biz/conn), session, shared, utils

Zalo 渠道扩展主要模块:api, config, gateway, webhook, outbound, pairing, security, session, polling, monitor, status
2026-05-21 12:04:05 +08:00

165 lines
4.6 KiB
Python

import asyncio
import hmac
import json
import logging
import time
logger = logging.getLogger(__name__)
_dedup_cache: dict[str, float] = {}
DEDUP_TTL_SECONDS = 300
DEDUP_MAX_ENTRIES = 5000
def _verify_signature(body: bytes, secret_token: str, header_value: str | None) -> bool:
if not secret_token:
logger.warning("Zalo webhook_secret not set, skipping signature verification")
return True
if not header_value:
logger.warning("X-Bot-Api-Secret-Token header missing")
return False
return hmac.compare_digest(header_value, secret_token)
def _check_dedup(
path: str,
account_id: str,
event_name: str,
chat_id: str,
sender_id: str,
message_id: str,
) -> bool:
key = f"{path}|{account_id}|{event_name}|{chat_id}|{sender_id}|{message_id}"
now = time.monotonic()
if key in _dedup_cache:
if now - _dedup_cache[key] < DEDUP_TTL_SECONDS:
return True
_dedup_cache[key] = now
_cleanup_dedup_cache()
return False
def _cleanup_dedup_cache():
if len(_dedup_cache) <= DEDUP_MAX_ENTRIES:
return
now = time.monotonic()
expired = [k for k, v in _dedup_cache.items() if now - v >= DEDUP_TTL_SECONDS]
for k in expired:
_dedup_cache.pop(k, None)
async def handle_webhook(
body: bytes,
secret_token: str,
headers: dict,
account: dict,
gateway,
webhook_path: str = "/api/webhook/zalo",
) -> tuple[int, dict]:
if not _verify_signature(body, secret_token, headers.get("X-Bot-Api-Secret-Token")):
return 401, {"error": "Invalid signature"}
content_type = headers.get("Content-Type", "")
if "application/json" not in content_type:
return 415, {"error": "Unsupported Content-Type, expected application/json"}
try:
payload = json.loads(body)
except json.JSONDecodeError:
return 400, {"error": "Invalid JSON body"}
event_name = payload.get("event_name", "")
if not event_name:
return 400, {"error": "Missing event_name"}
message = payload.get("message", {})
sender = message.get("from", {})
chat = message.get("chat", {})
message_id = message.get("message_id", "")
account_id = account.get("account_id", "default")
if _check_dedup(
path=webhook_path,
account_id=account_id,
event_name=event_name,
chat_id=chat.get("id", ""),
sender_id=sender.get("id", ""),
message_id=message_id,
):
logger.debug("Zalo webhook duplicate event ignored: %s/%s", event_name, message_id)
return 200, {"status": "duplicate"}
await gateway.process_update(payload, account)
return 200, {"status": "ok"}
async def register_webhook(account: dict) -> bool:
bot_token = account.get("bot_token", "")
webhook_url = account.get("webhook_url", "")
webhook_secret = account.get("webhook_secret", "")
if not bot_token or not webhook_url:
logger.error("Zalo webhook registration failed: missing token or url")
return False
if not webhook_url.startswith("https://"):
logger.error("Zalo webhook URL must start with https://")
return False
if not webhook_secret or len(webhook_secret) < 8:
logger.error("Zalo webhook secret must be at least 8 characters")
return False
from yuxi.channel.extensions.zalo.api import ZaloBotApi
api = ZaloBotApi(bot_token, proxy=account.get("proxy"))
try:
await api.set_webhook(webhook_url, webhook_secret)
logger.info("Zalo webhook registered: %s", webhook_url)
return True
except Exception as e:
logger.error("Zalo webhook registration failed: %s", e)
return False
finally:
await api.close()
async def unregister_webhook(account: dict) -> None:
bot_token = account.get("bot_token", "")
if not bot_token:
return
from yuxi.channel.extensions.zalo.api import ZaloBotApi
api = ZaloBotApi(bot_token, proxy=account.get("proxy"))
try:
await asyncio.wait_for(api.delete_webhook(), timeout=5.0)
logger.info("Zalo webhook deleted")
except TimeoutError:
logger.warning("Zalo webhook delete timed out")
except Exception as e:
logger.warning("Zalo webhook delete failed: %s", e)
finally:
await api.close()
def resolve_webhook_path(account: dict) -> str:
path = account.get("webhook_path", "")
if path.strip():
return path.strip()
webhook_url = account.get("webhook_url", "")
if webhook_url:
from urllib.parse import urlparse
parsed = urlparse(webhook_url)
return parsed.path or "/api/webhook/zalo"
return ""