新增小红书、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
165 lines
4.6 KiB
Python
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 ""
|