新增 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: 类型定义
259 lines
8.7 KiB
Python
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)
|