ForcePilot/backend/package/yuxi/channel/extensions/lazada/webhook.py
Kris d523babb91 feat(channel): 添加 Lazada 渠道扩展
新增 Lazada 渠道扩展,支持在 Yuxi 平台中集成 Lazada 电商客服渠道。

包含以下功能模块:
- client: Lazada API 客户端封装
- config: 渠道配置管理
- gateway: SSE/WebSocket 网关接入
- webhook: Webhook 事件处理
- outbound: 外发消息管理
- pairing: 用户配对与绑定
- security: 安全校验
- signature: 请求签名验证
- dedupe: 消息去重
- monitor: 渠道状态监控
- status: 会话状态管理
- session: 会话管理
- tools: Agent 工具集成
- tools_config: 工具配置
- types: 类型定义
2026-05-21 11:13:39 +08:00

259 lines
8.7 KiB
Python

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)