import json import logging import time from collections import defaultdict from fastapi import APIRouter, Request, Response from yuxi.channel.extensions.taobao.config import TaobaoConfigAdapter from yuxi.channel.extensions.taobao.crypto import decrypt_message, verify_top_sign from yuxi.channel.extensions.taobao.gateway import get_current_gateway from yuxi.channel.extensions.taobao.message import parse_to_unified_message logger = logging.getLogger(__name__) router = APIRouter(prefix="/webhook/taobao", tags=["taobao"]) _config_adapter = TaobaoConfigAdapter() _dedupe_sets: dict[str, dict[str, float]] = defaultdict(dict) DEDUPE_TTL_SECONDS = 300 def _get_config(account_id: str = "default"): config = _config_adapter.resolve_account(account_id) return _config_adapter.build_account(account_id, config) def _dedupe_check(account_id: str, msg_id: str) -> bool: now = time.monotonic() dedupe = _dedupe_sets[account_id] if msg_id in dedupe and now - dedupe[msg_id] < DEDUPE_TTL_SECONDS: return True dedupe[msg_id] = now cutoff = now - DEDUPE_TTL_SECONDS _dedupe_sets[account_id] = {k: v for k, v in dedupe.items() if v >= cutoff} return False @router.get("/callback") async def verify_callback_default(request: Request): return await _verify_callback(request, "default") @router.get("/account/{account_id}/callback") async def verify_callback_account(request: Request, account_id: str): return await _verify_callback(request, account_id) async def _verify_callback(request: Request, account_id: str): config = _get_config(account_id) if not config.is_configured(): return Response(status_code=503, content="not configured") params = dict(request.query_params) sign = params.pop("sign", "") computed_ok = verify_top_sign(params, config.app_secret, sign, config.sign_method) if computed_ok: return Response(content="success") return Response(status_code=403, content="sign error") @router.post("/callback") async def handle_callback_default(request: Request): return await _handle_callback(request, "default") @router.post("/account/{account_id}/callback") async def handle_callback_account(request: Request, account_id: str): return await _handle_callback(request, account_id) async def _handle_callback(request: Request, account_id: str): gateway = get_current_gateway() if gateway is None: return Response(status_code=503, content="gateway not started") queue = gateway.get_message_queue() if queue is None: return Response(status_code=503, content="gateway not started") config = _get_config(account_id) if not config.is_configured(): return Response(status_code=503, content="not configured") try: body = await request.body() content_type = request.headers.get("content-type", "") if "application/x-www-form-urlencoded" in content_type or "multipart/form-data" in content_type: form = await request.form() params = {k: v for k, v in form.items()} else: params = json.loads(body.decode("utf-8")) sign = params.pop("sign", "") if not verify_top_sign(params, config.app_secret, sign, config.sign_method): return Response(status_code=403, content="sign verification failed") message_data = params if config.encrypt_messages and "encrypt_data" in params: decrypted = decrypt_message( encrypt_data=params.get("encrypt_data", ""), encrypt_key=params.get("encrypt_key", ""), sign=params.get("encrypt_sign", ""), timestamp=params.get("timestamp", ""), app_secret=config.app_secret, rsa_private_key_pem=config.rsa_private_key, ) if decrypted is None: return Response(status_code=400, content="decrypt failed") message_data = json.loads(decrypted) msg_id = message_data.get("msg_id", "") or message_data.get("id", "") if msg_id and _dedupe_check(account_id, msg_id): logger.debug("Duplicate message ignored: %s", msg_id) return Response(content="ok") unified = parse_to_unified_message(message_data) if unified: await gateway.enqueue_message(unified) return Response(content="ok") except Exception: logger.exception("Taobao webhook processing error") return Response(status_code=500, content="internal error")