from __future__ import annotations import asyncio import json import logging from dataclasses import dataclass from fastapi import APIRouter, Request from fastapi.responses import Response from yuxi.channel.extensions.lazada.format import strip_html from yuxi.channel.extensions.lazada.outbound import LazadaOutbound from yuxi.channel.extensions.lazada.signature import verify_webhook_signature from yuxi.channel.extensions.lazada.types import InboundLazadaMessage from yuxi.channel.runtime.manager import gateway logger = logging.getLogger(__name__) router = APIRouter(prefix="/webhook/lazada", tags=["lazada"]) def _get_lazada_plugin(): gw = _get_lazada_gateway() if gw and gw._plugin: return gw._plugin return None def _get_lazada_gateway(): from yuxi.channel.extensions.lazada.gateway import _get_gateway return _get_gateway() def parse_inbound_message(payload: dict) -> InboundLazadaMessage | None: try: if payload.get("message_type") != 2: return None data = payload.get("data", payload) return InboundLazadaMessage( message_id=str(data.get("message_id", "")), session_id=str(data.get("session_id", "")), from_account_id=str(data.get("from_account_id", "")), from_account_type=data.get("from_account_type", 1), to_account_id=str(data.get("to_account_id", "")), to_account_type=data.get("to_account_type", 1), template_id=data.get("template_id", 1), content=data.get("content", ""), send_time=data.get("send_time", 0), site_id=data.get("site_id", "SG"), msg_type=data.get("type", 1), auto_reply=data.get("auto_reply", False), status=data.get("status", 0), video_id=str(data.get("video_id", "")), item_id=str(data.get("item_id", "")), order_id=str(data.get("order_id", "")), promotion_id=str(data.get("promotion_id", "")), raw_event=payload, ) except Exception: logger.exception("解析 Lazada 入站消息失败") return None @dataclass class LazadaLpmEvent: msg_type: int event_type: str order_id: str status: str timestamp: int raw: dict def parse_lpm_event(payload: dict) -> LazadaLpmEvent | None: msg_type = payload.get("message_type") if msg_type == 0: data = payload.get("data", payload) return LazadaLpmEvent( msg_type=0, event_type="order", order_id=str(data.get("order_id", "")), status=data.get("order_status", ""), timestamp=data.get("timestamp", 0), raw=payload, ) if msg_type == 14: data = payload.get("data", payload) return LazadaLpmEvent( msg_type=14, event_type="fulfillment", order_id=str(data.get("order_id", "")), status=data.get("fulfillment_status", ""), timestamp=data.get("timestamp", 0), raw=payload, ) return None @router.post("/callback") async def lazada_webhook(request: Request): gw = _get_lazada_gateway() account = gw.account if gw else None plugin = _get_lazada_plugin() raw_body = await request.body() body_str = raw_body.decode("utf-8") if account and account.app_secret: auth_header = request.headers.get("Authorization", "") if auth_header.startswith("Bearer "): auth_header = auth_header[7:] if auth_header and not verify_webhook_signature(account.app_key, account.app_secret, body_str, auth_header): logger.warning("Lazada Webhook 签名验证失败") return Response( content=json.dumps({"code": "1", "msg": "signature_invalid"}), status_code=403, media_type="application/json", ) try: payload = json.loads(body_str) except json.JSONDecodeError: return Response( content=json.dumps({"code": "1", "msg": "invalid_json"}), status_code=400, media_type="application/json", ) inbound = parse_inbound_message(payload) if inbound is None: lpm_event = parse_lpm_event(payload) if lpm_event: logger.info( "Lazada LPM event: type=%s order=%s status=%s", lpm_event.event_type, lpm_event.order_id, lpm_event.status, ) return Response( content=json.dumps({"code": "0", "msg": "ok"}), media_type="application/json", ) if plugin is not None and plugin.is_duplicate(inbound.message_id): logger.debug("Lazada 重复消息已过滤: %s", inbound.message_id) return Response( content=json.dumps({"code": "0", "msg": "ok"}), media_type="application/json", ) if not account: return Response( content=json.dumps({"code": "0", "msg": "ok"}), media_type="application/json", ) if plugin is not None: dm_policy = plugin.resolve_dm_policy() policy_mode = dm_policy.get("mode", "open") else: policy_mode = "open" if policy_mode == "disabled": return Response( content=json.dumps({"code": "0", "msg": "ok"}), media_type="application/json", ) from_account_id = inbound.from_account_id if policy_mode == "pairing": if plugin is not None: if not await plugin.check_allowlist(from_account_id, "lazada"): content = strip_html(inbound.content) if content.strip().startswith("配对 "): code_input = content.strip()[3:].strip() if await plugin.verify_code(from_account_id, code_input): plugin._security.add_to_allowlist(from_account_id) await _send_lazada_text(from_account_id, "配对成功!现在可以开始对话了。") else: await _send_lazada_text(from_account_id, "配对码无效或已过期,请重新发送消息获取配对码。") else: code = await plugin.generate_code(from_account_id) if code: await _send_lazada_text( from_account_id, f"首次对话需要验证身份,请输入以下配对码:\n\n配对 {code}\n\n(配对码有效期 5 分钟)", ) else: await _send_lazada_text(from_account_id, "配对请求过于频繁,请稍后再试。") return Response( content=json.dumps({"code": "0", "msg": "ok"}), media_type="application/json", ) if policy_mode == "allowlist": if plugin is not None: if not await plugin.check_allowlist(from_account_id, "lazada"): return Response( content=json.dumps({"code": "0", "msg": "ok"}), media_type="application/json", ) content = strip_html(inbound.content) inbound.content = content unified = plugin.parse_to_unified(payload, "default") if plugin else None if unified is None: return Response( content=json.dumps({"code": "0", "msg": "ok"}), media_type="application/json", ) processor = getattr(gateway, "_processor", None) if gateway else None if processor is None: logger.warning("Message processor not available, cannot dispatch Lazada message") return Response( content=json.dumps({"code": "0", "msg": "ok"}), media_type="application/json", ) asyncio.create_task( _dispatch_to_agent(processor, unified), name=f"lazada-dispatch-{from_account_id}", ) return Response( content=json.dumps({"code": "0", "msg": "ok"}), media_type="application/json", ) async def _dispatch_to_agent(processor, msg) -> None: try: await asyncio.wait_for(processor.process(msg), timeout=120.0) except TimeoutError: logger.error("Agent response timeout for Lazada user %s", msg.sender.id) except Exception: logger.exception("Failed to process Lazada message for user %s", msg.sender.id) async def _send_lazada_text(session_id: str, content: str) -> None: gw = _get_lazada_gateway() if gw is None: logger.warning("No Lazada gateway available for sending text") return outbound = LazadaOutbound(gw) try: await outbound.send_text(session_id, content) except Exception: logger.exception("Failed to send Lazada text to %s", session_id)