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 ""