新增淘宝(Taobao)渠道扩展,支持在 Yuxi 平台中集成淘宝电商客服渠道。 包含以下功能模块: - client: 淘宝 API 客户端封装 - config: 渠道配置管理 - gateway: SSE/WebSocket 网关接入 - webhook: Webhook 事件处理 - outbound: 外发消息管理 - message: 消息处理 - pairing: 用户配对与绑定 - security: 安全校验 - crypto: 加解密处理 - status: 会话状态管理 - tools: Agent 工具集成 - types: 类型定义
128 lines
4.5 KiB
Python
128 lines
4.5 KiB
Python
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")
|