feat(channel): 添加淘宝渠道扩展

新增淘宝(Taobao)渠道扩展,支持在 Yuxi 平台中集成淘宝电商客服渠道。

包含以下功能模块:
- client: 淘宝 API 客户端封装
- config: 渠道配置管理
- gateway: SSE/WebSocket 网关接入
- webhook: Webhook 事件处理
- outbound: 外发消息管理
- message: 消息处理
- pairing: 用户配对与绑定
- security: 安全校验
- crypto: 加解密处理
- status: 会话状态管理
- tools: Agent 工具集成
- types: 类型定义
This commit is contained in:
Kris 2026-05-21 11:46:45 +08:00
parent 156f716600
commit 904ab9a6c2
14 changed files with 1611 additions and 0 deletions

View File

@ -0,0 +1,286 @@
import time
from collections import defaultdict
from yuxi.channel.capabilities import ChannelCapabilities
from yuxi.channel.extensions.base import BaseChannelPlugin
from yuxi.channel.extensions.taobao.config import TaobaoConfigAdapter
from yuxi.channel.extensions.taobao.gateway import TaobaoGateway
from yuxi.channel.extensions.taobao.outbound import MAX_TEXT_LENGTH, send_media, send_text, set_global_client
from yuxi.channel.extensions.taobao.pairing import TaobaoPairing
from yuxi.channel.extensions.taobao.security import TaobaoSecurity
from yuxi.channel.extensions.taobao.status import TaobaoStatus
from yuxi.channel.extensions.taobao.tools import TaobaoTools
from yuxi.channel.protocols import SessionResolution
from yuxi.channel.plugins.registry import ChannelPluginRegistry
DEDUPE_TTL_SECONDS = 300
class TaobaoPlugin(BaseChannelPlugin):
id = "taobao"
name = "淘宝/千牛"
order = 120
label = "淘宝/千牛 (TOP API)"
aliases = ["taobao", "qianniu", "千牛", "淘宝客服"]
resolve_reply_to_mode = "off"
def __init__(self):
self._config_adapter = TaobaoConfigAdapter()
self._gateway = TaobaoGateway()
self._status = TaobaoStatus()
self._security = TaobaoSecurity()
self._pairing = TaobaoPairing()
self._tools: TaobaoTools | None = None
self._dedupe: dict[str, dict[str, float]] = defaultdict(dict)
@property
def capabilities(self) -> ChannelCapabilities:
return ChannelCapabilities(
chat_types=["direct"],
message_types=["text", "image", "rich"],
reactions=False,
typing_indicator=False,
threads=False,
edit=False,
unsend=False,
reply=False,
media=True,
effects=False,
native_commands=False,
polls=False,
group_management=False,
streaming=True,
streaming_mode="block",
block_streaming=True,
block_streaming_chunk_min_chars=800,
block_streaming_chunk_max_chars=1200,
block_streaming_coalesce_min_chars=400,
block_streaming_coalesce_max_chars=800,
)
def list_account_ids(self, config: dict) -> list[str]:
return self._config_adapter.list_account_ids(config)
async def resolve_account(self, account_id: str) -> dict:
account = self._config_adapter.resolve_account(account_id)
self._security.load_config(account_id, account)
return account
def is_configured(self, account: dict) -> bool:
return self._config_adapter.is_configured(account)
def is_enabled(self, account: dict) -> bool:
return self._config_adapter.is_enabled(account)
def disabled_reason(self, account: dict) -> str:
return self._config_adapter.disabled_reason(account)
def describe_account(self, account: dict) -> dict:
return self._config_adapter.describe_account(account)
def config_schema(self) -> dict:
return self._config_adapter.config_schema()
async def start(self, ctx) -> object:
result = await self._gateway.start(ctx)
if result.get("client"):
set_global_client(result["client"])
self._tools = TaobaoTools(result["client"])
return result
async def stop(self, ctx) -> None:
await self._gateway.stop(ctx)
set_global_client(None)
self._tools = None
async def send_text(
self,
target_id: str,
content: str,
*,
reply_to_id: str | None = None,
thread_id: str | None = None,
account_id: str | None = None,
) -> None:
content = content[:MAX_TEXT_LENGTH]
await send_text(target_id, content, reply_to_id=reply_to_id, thread_id=thread_id, account_id=account_id)
async def send_media(
self,
target_id: str,
media_url: str,
media_type: str,
reply_to_id: str | None = None,
thread_id: str | None = None,
) -> None:
await send_media(target_id, media_url, media_type, reply_to_id=reply_to_id, thread_id=thread_id)
async def probe(self, account: dict) -> bool:
return await self._status.probe(account)
def build_summary(self, snapshot: object) -> dict:
return self._status.build_summary(snapshot)
def build_account_snapshot(
self,
account: dict,
config: dict,
runtime: object | None = None,
probe_result: object | None = None,
audit: object | None = None,
):
return self._status.build_account_snapshot(account, config, runtime, probe_result, audit)
async def check_allowlist(self, peer_id: str, channel_type: str) -> bool:
account = await self._config_adapter.resolve_account("default")
return await self._security.check_allowlist(peer_id, channel_type, account=account)
def resolve_dm_policy(self) -> dict:
return self._security.resolve_dm_policy()
def collect_warnings(self, config: dict, account_id: str | None = None, account: dict | None = None) -> list[str]:
return self._security.collect_warnings(config, account_id, account)
async def generate_code(self, peer_id: str) -> str:
return await self._pairing.generate_code(peer_id)
async def verify_code(self, peer_id: str, code: str) -> bool:
return await self._pairing.verify_code(peer_id, code)
def normalize_allow_entry(self, entry: str) -> str:
return self._pairing.normalize_allow_entry(entry)
async def notify_approval(self, config: dict, peer_id: str, account_id: str | None = None) -> None:
await self._pairing.notify_approval(config, peer_id, account_id)
@property
def channel_format_instructions(self) -> str | None:
return (
"你正在通过淘宝千牛客服渠道与买家沟通。"
"消息最多 2000 字符,超长会自动分段发送。"
"可以使用纯文本和 Markdown 进行格式化。"
"如果消息中包含订单信息order_id可以调用订单查询工具获取详情。"
)
@property
def streaming_mode(self) -> str:
return "block"
@property
def block_streaming_enabled(self) -> bool:
return True
def get_agent_tools(self) -> list:
if self._tools is None:
return []
return self._tools.get_agent_tools()
async def execute_agent_tool(self, tool_name: str, params: dict, context: dict) -> dict:
if self._tools is None:
return {"success": False, "error": "Tools not initialized"}
return await self._tools.execute_agent_tool(tool_name, params, context)
def resolve_session(self, msg: object) -> SessionResolution:
buyer_nick = None
if hasattr(msg, "raw") and isinstance(msg.raw, dict):
buyer_nick = msg.raw.get("buyer_nick", "") or msg.raw.get("from_user", "")
if not buyer_nick and hasattr(msg, "sender") and msg.sender:
buyer_nick = msg.sender.id
if not buyer_nick:
return SessionResolution(kind="direct", conversation_id="taobao:unknown")
return SessionResolution(
kind="direct",
conversation_id=f"taobao:dm:{buyer_nick}",
label=buyer_nick,
)
def build_system_prompt(self, context) -> str | None:
account_name = getattr(context, "account_name", "") or "淘宝店铺"
return (
f"你是{account_name}的 AI 客服助手,通过千牛工作台与买家沟通。\n\n"
"你必须遵守以下规则:\n"
"1. 回复简洁专业,可使用 Markdown 进行排版\n"
"2. 买家询问订单/物流/退款时,主动使用工具查询并给出准确答案\n"
"3. 遇到退货退款请求,按照淘宝平台规则引导买家操作\n"
"4. 不得承诺商家未授权的事项(如免单、赔偿金额等)\n"
"5. 遇到自身无法处理的问题,告知买家可转接人工客服\n"
"6. 敏感问题(投诉、纠纷、法律风险)立即建议转人工\n"
"7. 不引导买家到站外联系,不索要买家密码/验证码"
)
async def on_config_changed(self, prev_cfg: dict, next_cfg: dict, account_id: str) -> None:
if prev_cfg == next_cfg:
return
import logging as _logging
_logger = _logging.getLogger(__name__)
_logger.info("[taobao:%s] Config changed, reloading...", account_id)
from yuxi.channel.context import ChannelContext
ctx = ChannelContext(
channel_type="taobao",
account_id=account_id,
config=next_cfg,
)
await self._gateway.stop(ctx)
result = await self._gateway.start(ctx)
if result.get("client"):
set_global_client(result["client"])
self._tools = TaobaoTools(result["client"])
_logger.info("[taobao:%s] Config reloaded", account_id)
async def on_message_sending(self, msg: object, content: str) -> str | None:
if not content:
return content
return content[:MAX_TEXT_LENGTH]
async def on_message_received(self, msg: object) -> object | None:
return msg
def classify_error(self, error: BaseException) -> object:
from yuxi.channel.extensions.taobao.client import TaobaoAPIError
from yuxi.channel.protocols import ClassifiedError, ErrorSeverity
if isinstance(error, TaobaoAPIError):
sub_code = error.sub_code
if sub_code in ("isv.invalid-parameter", "isv.missing-parameter"):
return ClassifiedError(severity=ErrorSeverity.FATAL, original_error=error, error_message=str(error))
if sub_code in ("isp.top-remote-connection-timeout", "isp.service-unavailable"):
return ClassifiedError(severity=ErrorSeverity.RETRYABLE, original_error=error, error_message=str(error))
if sub_code in ("isv.token-invalid", "isv.token-expired"):
return ClassifiedError(severity=ErrorSeverity.FATAL, original_error=error, error_message=str(error))
return ClassifiedError(severity=ErrorSeverity.RETRYABLE, original_error=error, error_message=str(error))
from yuxi.channel.errors import classify_error as _classify_error
return _classify_error(error)
def is_duplicate(self, key: str) -> bool:
now = time.monotonic()
account_dedupe = self._dedupe["default"]
if key in account_dedupe and now - account_dedupe[key] < DEDUPE_TTL_SECONDS:
return True
account_dedupe[key] = now
cutoff = now - DEDUPE_TTL_SECONDS
self._dedupe["default"] = {k: v for k, v in account_dedupe.items() if v >= cutoff}
return False
def mark_seen(self, key: str) -> None:
self._dedupe["default"][key] = time.monotonic()
def sanitize_text(self, text: str) -> str:
if not text:
return ""
text = text.replace("\\n", "\n").replace("\\t", "\t")
return text[:MAX_TEXT_LENGTH]
def markdown_to_native(self, md_text: str) -> str:
return md_text
ChannelPluginRegistry.register(TaobaoPlugin())

View File

@ -0,0 +1,197 @@
import hashlib
import hmac
import json
import logging
import time
import httpx
logger = logging.getLogger(__name__)
GATEWAY_URL = "https://eco.taobao.com/router/rest"
SANDBOX_GATEWAY_URL = "https://gw.api.tbsandbox.com/router/rest"
class TaobaoAPIError(Exception):
def __init__(self, code: str, msg: str, sub_code: str = "", sub_msg: str = ""):
self.code = code
self.msg = msg
self.sub_code = sub_code
self.sub_msg = sub_msg
super().__init__(f"[{code}] {msg}" + (f" ({sub_code}: {sub_msg})" if sub_code else ""))
class TaobaoClient:
def __init__(
self,
app_key: str,
app_secret: str,
sign_method: str = "md5",
sandbox: bool = False,
timeout: int = 10,
):
self.app_key = app_key
self.app_secret = app_secret
self.sign_method = sign_method
self.gateway = SANDBOX_GATEWAY_URL if sandbox else GATEWAY_URL
self.timeout = timeout
def _sign(self, params: dict) -> str:
sorted_keys = sorted(k for k in params if k != "sign" and params[k] is not None)
raw = "".join(f"{k}{params[k]}" for k in sorted_keys)
raw = f"{self.app_secret}{raw}{self.app_secret}"
if self.sign_method == "hmac-sha256":
return (
hmac.new(
self.app_secret.encode("utf-8"),
raw.encode("utf-8"),
hashlib.sha256,
)
.hexdigest()
.upper()
)
return hashlib.md5(raw.encode("utf-8")).hexdigest().upper()
def _build_payload(self, method: str, params: dict, session: str | None = None) -> dict:
payload = {
"method": method,
"app_key": self.app_key,
"timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
"format": "json",
"v": "2.0",
"sign_method": self.sign_method,
**params,
}
if session:
payload["session"] = session
payload["sign"] = self._sign(payload)
return payload
async def execute(
self,
method: str,
params: dict | None = None,
session: str | None = None,
) -> dict:
payload = self._build_payload(method, params or {}, session)
async with httpx.AsyncClient(timeout=self.timeout) as http:
resp = await http.post(self.gateway, data=payload)
resp.raise_for_status()
data = resp.json()
if "error_response" in data:
err = data["error_response"]
raise TaobaoAPIError(
code=err.get("code", "unknown"),
msg=err.get("msg", ""),
sub_code=err.get("sub_code", ""),
sub_msg=err.get("sub_msg", ""),
)
return data
async def get_token(self, code: str, redirect_uri: str) -> dict:
return await self.execute(
"taobao.top.auth.token.create",
{"code": code, "redirect_uri": redirect_uri},
)
async def refresh_token(self, refresh_token: str) -> dict:
return await self.execute(
"taobao.top.auth.token.refresh",
{"refresh_token": refresh_token},
)
async def send_customer_message(
self,
to_user: str,
content: str,
msg_type: int = 0,
media_id: str | None = None,
session: str | None = None,
) -> dict:
context = {"text": content} if msg_type == 0 else {"media_id": media_id or ""}
return await self.execute(
"taobao.openim.custmsg.push",
{
"to_user": to_user,
"msg_type": msg_type,
"context": json.dumps(context, ensure_ascii=False),
},
session=session,
)
async def get_trade_fullinfo(self, tid: int, session: str) -> dict:
return await self.execute(
"taobao.trade.fullinfo.get",
{
"tid": tid,
"fields": "tid,type,status,payment,orders,logistics",
},
session=session,
)
async def get_logistics_trace(self, tid: int, session: str) -> dict:
return await self.execute(
"taobao.logistics.trace.search",
{"tid": tid},
session=session,
)
async def get_userservice(self, user_id: str, session: str) -> dict:
return await self.execute(
"taobao.openim.userservice.get",
{"user_id": user_id},
session=session,
)
async def qianniu_sellerorder_query(self, buyer_nick: str, session: str) -> dict:
return await self.execute(
"taobao.qianniu.sellerorder.buyer.query",
{"buyer_nick": buyer_nick},
session=session,
)
async def qianniu_refund_get(self, refund_id: str, session: str) -> dict:
return await self.execute(
"taobao.qianniu.refund.get",
{"refund_id": refund_id},
session=session,
)
async def qianniu_buyer_tag_get(self, buyer_nick: str, session: str) -> dict:
return await self.execute(
"taobao.qianniu.buyer.tag.get",
{"buyer_nick": buyer_nick},
session=session,
)
async def qianniu_coupon_buyer_get(self, buyer_nick: str, session: str) -> dict:
return await self.execute(
"taobao.qianniu.coupon.buyer.get",
{"buyer_nick": buyer_nick},
session=session,
)
async def qianniu_cloudkefu_forward(self, buyer_nick: str, to_nick: str, session: str) -> dict:
return await self.execute(
"taobao.qianniu.cloudkefu.forward",
{"buyer_nick": buyer_nick, "to_nick": to_nick},
session=session,
)
async def get_item(self, item_id: int, session: str) -> dict:
return await self.execute(
"taobao.item.get",
{"item_id": item_id, "fields": "title,price,pic_url,desc"},
session=session,
)
async def get_chatlogs(self, user_id: str, begin: str, end: str, session: str) -> dict:
return await self.execute(
"taobao.openim.chatlogs.get",
{"user_id": user_id, "begin": begin, "end": end},
session=session,
)

View File

@ -0,0 +1,109 @@
import os
from yuxi.channel.extensions.taobao.types import TaobaoAccount
class TaobaoConfigAdapter:
def __init__(self):
self._config: dict = {}
self._accounts: dict = {}
def list_account_ids(self, config: dict) -> list[str]:
self._config = config
self._accounts = config.get("accounts", {})
if self._accounts:
return list(self._accounts.keys())
return ["default"]
def resolve_account(self, account_id: str) -> dict:
raw = self._accounts.get(account_id, {})
if not raw:
raw = {"account_id": account_id}
return self.build_account(account_id, raw).to_dict()
def is_configured(self, account: dict) -> bool:
acc = self._to_account(account)
return acc.is_configured()
def is_enabled(self, account: dict) -> bool:
return account.get("enabled", True)
def disabled_reason(self, account: dict) -> str:
if not account.get("enabled", True):
return "账户已禁用"
return ""
def describe_account(self, account: dict) -> dict:
acc = self._to_account(account)
return {
"account_id": acc.account_id,
"name": acc.name or acc.seller_nick or acc.account_id,
"configured": acc.is_configured(),
"has_token": acc.has_valid_token(),
}
def build_account(self, account_id: str, raw: dict) -> TaobaoAccount:
return TaobaoAccount(
account_id=account_id,
app_key=raw.get("app_key", os.getenv("TAOBAO_APP_KEY", "")),
app_secret=raw.get("app_secret", os.getenv("TAOBAO_APP_SECRET", "")),
sign_method=raw.get("sign_method", "md5"),
access_token=raw.get("access_token", ""),
refresh_token=raw.get("refresh_token", ""),
callback_url=raw.get("callback_url", os.getenv("TAOBAO_CALLBACK_URL", "")),
rsa_public_key=raw.get("rsa_public_key", ""),
rsa_private_key=raw.get("rsa_private_key", ""),
encrypt_messages=raw.get("encrypt_messages", False),
seller_nick=raw.get("seller_nick", ""),
name=raw.get("name", ""),
dm_policy=raw.get("dm_policy", "open"),
allow_from=raw.get("allow_from", []),
timeout=raw.get("timeout", 10),
sandbox=raw.get("sandbox", False),
)
def _to_account(self, account: dict) -> TaobaoAccount:
account_id = account.get("account_id", "default")
if account.get("app_key"):
return self.build_account(account_id, account)
return TaobaoAccount(account_id=account_id)
def config_schema(self) -> dict:
return {
"type": "object",
"properties": {
"accounts": {
"type": "object",
"description": "多商家账号配置",
"additionalProperties": {
"type": "object",
"properties": {
"app_key": {"type": "string", "description": "淘宝开放平台 AppKey"},
"app_secret": {"type": "string", "description": "AppSecret"},
"sign_method": {
"type": "string",
"enum": ["md5", "hmac-sha256"],
"default": "md5",
},
"callback_url": {"type": "string", "description": "Webhook 回调地址"},
"rsa_public_key": {"type": "string", "description": "淘宝分配的 RSA 公钥"},
"rsa_private_key": {"type": "string", "description": "ISV RSA 私钥"},
"encrypt_messages": {"type": "boolean", "default": False},
"seller_nick": {"type": "string", "description": "商家淘宝昵称"},
"dm_policy": {
"type": "string",
"enum": ["open", "pairing", "allowlist", "disabled"],
},
"allow_from": {
"type": "array",
"items": {"type": "string"},
"description": "允许交互的买家 nick 列表",
},
"timeout": {"type": "integer", "default": 10},
"sandbox": {"type": "boolean", "default": False},
},
"required": ["app_key", "app_secret"],
},
}
},
}

View File

@ -0,0 +1,63 @@
import base64
import hashlib
import hmac as hmac_module
import logging
logger = logging.getLogger(__name__)
try:
from Crypto.Cipher import AES, PKCS1_v1_5
from Crypto.PublicKey import RSA
_CRYPTO_AVAILABLE = True
except ImportError:
_CRYPTO_AVAILABLE = False
def verify_top_sign(params: dict, secret: str, expected_sign: str, sign_method: str = "md5") -> bool:
sorted_keys = sorted(k for k in params if k != "sign" and params[k] is not None)
raw = "".join(f"{k}{params[k]}" for k in sorted_keys)
raw = f"{secret}{raw}{secret}"
if sign_method == "hmac-sha256":
computed = hmac_module.new(secret.encode("utf-8"), raw.encode("utf-8"), hashlib.sha256).hexdigest().upper()
else:
computed = hashlib.md5(raw.encode("utf-8")).hexdigest().upper()
return computed == expected_sign
def decrypt_message(
encrypt_data: str,
encrypt_key: str,
sign: str,
timestamp: str,
app_secret: str,
rsa_private_key_pem: str,
) -> str | None:
if not _CRYPTO_AVAILABLE:
logger.error("pycryptodome not installed, cannot decrypt TOP message")
return None
try:
rsa_key = RSA.import_key(rsa_private_key_pem)
cipher_rsa = PKCS1_v1_5.new(rsa_key)
session_key = cipher_rsa.decrypt(base64.b64decode(encrypt_key), None)
if session_key is None:
logger.error("RSA decrypt session key failed")
return None
expected_sign = hashlib.sha256(
f"{timestamp}{app_secret}{session_key.decode('utf-8')}{encrypt_data}".encode()
).hexdigest()
if sign != expected_sign:
logger.error("Message sign verification failed")
return None
cipher_aes = AES.new(session_key[:16], AES.MODE_CBC, session_key[:16])
plain = cipher_aes.decrypt(base64.b64decode(encrypt_data))
pad_len = plain[-1]
return plain[:-pad_len].decode("utf-8")
except Exception as e:
logger.exception("Message decryption failed: %s", e)
return None

View File

@ -0,0 +1,105 @@
import asyncio
import logging
import time
from datetime import datetime, timedelta
from yuxi.channel.extensions.taobao.client import TaobaoClient
from yuxi.channel.extensions.taobao.config import TaobaoConfigAdapter
from yuxi.channel.extensions.taobao.types import TaobaoAccount
logger = logging.getLogger(__name__)
_current_gateway: TaobaoGateway | None = None
class TaobaoGateway:
def __init__(self):
self._running = False
self._account: TaobaoAccount | None = None
self._client: TaobaoClient | None = None
self._token_refresh_task: asyncio.Task | None = None
self._message_queue: asyncio.Queue | None = None
self._last_message_at: float | None = None
async def start(self, ctx) -> dict:
global _current_gateway
account = _resolve_account(ctx)
if not account.is_configured():
logger.warning("Taobao account %s not configured, skipping", account.account_id)
return {"running": False, "reason": "not-configured", "account_id": account.account_id}
self._account = account
self._client = TaobaoClient(
app_key=account.app_key,
app_secret=account.app_secret,
sign_method=account.sign_method,
sandbox=account.sandbox,
timeout=account.timeout,
)
self._message_queue = asyncio.Queue(maxsize=1000)
_current_gateway = self
if account.has_valid_token():
self._token_refresh_task = asyncio.create_task(self._token_refresh_loop())
self._running = True
logger.info("Taobao gateway started for account %s", account.account_id)
return {"running": True, "account_id": account.account_id, "client": self._client}
async def stop(self, ctx) -> None:
self._running = False
if self._token_refresh_task:
self._token_refresh_task.cancel()
self._token_refresh_task = None
self._message_queue = None
self._client = None
self._account = None
logger.info("Taobao gateway stopped")
async def _token_refresh_loop(self):
while self._running:
await asyncio.sleep(600)
if not self._account or not self._account.refresh_token:
continue
try:
resp = await self._client.refresh_token(self._account.refresh_token)
token_data = resp.get("top_auth_token_create_response", {}).get("token_result", {})
if token_data:
self._account.access_token = token_data.get("access_token", "")
self._account.refresh_token = token_data.get("refresh_token", "")
expires_in = token_data.get("expires_in", 0)
self._account.token_expires_at = datetime.now() + timedelta(seconds=expires_in)
logger.info("Taobao token refreshed for %s", self._account.account_id)
except asyncio.CancelledError:
break
except Exception as e:
logger.error("Taobao token refresh failed for %s: %s", self._account.account_id, e)
async def enqueue_message(self, unified_msg: dict) -> None:
if self._message_queue is not None:
self._last_message_at = time.monotonic()
await self._message_queue.put(unified_msg)
def get_message_queue(self) -> asyncio.Queue | None:
return self._message_queue
def get_last_message_at(self) -> float | None:
return self._last_message_at
def _resolve_account(ctx) -> TaobaoAccount:
config = getattr(ctx, "config", {}) if ctx else {}
accounts = config.get("accounts", {})
account_id = getattr(ctx, "account_id", "default")
raw = accounts.get(account_id, {})
adapter = TaobaoConfigAdapter()
return adapter.build_account(account_id, raw)
def get_current_gateway() -> TaobaoGateway | None:
return _current_gateway

View File

@ -0,0 +1,85 @@
import logging
from yuxi.channel.extensions.taobao.types import InboundTaobaoMessage
logger = logging.getLogger(__name__)
def parse_to_unified_message(raw: dict) -> dict | None:
msg_type = str(raw.get("msg_type", raw.get("type", "text")))
content_map = {
"text": lambda r: r.get("content", "") or r.get("text", ""),
"0": lambda r: r.get("content", "") or r.get("text", ""),
"image": lambda r: "[图片消息]",
"1": lambda r: "[图片消息]",
"order": lambda r: _format_order_message(r),
"trade": lambda r: _format_order_message(r),
"system": lambda r: "[系统消息]",
"ordering": lambda r: _format_ordering_message(r),
"item": lambda r: _format_item_card(r),
"coupon": lambda r: _format_coupon_card(r),
}
handler = content_map.get(msg_type, lambda r: r.get("content", "") or r.get("text", ""))
content = handler(raw)
buyer_nick = raw.get("buyer_nick", "") or raw.get("from_user", "") or raw.get("from_id", "")
msg_id = raw.get("msg_id", "") or raw.get("id", "") or str(hash(f"{buyer_nick}{content}"))
if not buyer_nick or not content:
return None
unified = {
"sender": {"id": buyer_nick, "name": buyer_nick},
"content": content,
"message_id": msg_id,
"channel_type": "taobao",
"chat_type": "direct",
"timestamp": raw.get("timestamp"),
"raw": raw,
}
order_id = raw.get("order_id") or raw.get("trade_id") or raw.get("tid")
if order_id:
unified["order_id"] = str(order_id)
return unified
def _format_order_message(raw: dict) -> str:
order_id = raw.get("order_id", raw.get("trade_id", ""))
status = raw.get("status", raw.get("order_status", ""))
buyer = raw.get("buyer_nick", "")
return f"[订单消息] 订单ID: {order_id}, 状态: {status}, 买家: {buyer}"
def _format_ordering_message(raw: dict) -> str:
item_name = raw.get("item_title", raw.get("item_name", "未知商品"))
price = raw.get("price", "")
return f"[商品卡片] {item_name}" + (f" ¥{price}" if price else "")
def _format_item_card(raw: dict) -> str:
item_name = raw.get("item_title", raw.get("item_name", "未知商品"))
price = raw.get("price", "")
return f"[商品推荐] {item_name}" + (f" ¥{price}" if price else "")
def _format_coupon_card(raw: dict) -> str:
coupon_name = raw.get("coupon_name", "优惠券")
amount = raw.get("amount", raw.get("denomination", ""))
return f"[优惠券] {coupon_name}" + (f" ¥{amount}" if amount else "")
def parse_raw_to_taobao_message(raw: dict) -> InboundTaobaoMessage:
return InboundTaobaoMessage(
msg_id=raw.get("msg_id", raw.get("id", "")),
buyer_nick=raw.get("buyer_nick", raw.get("from_user", "")),
seller_nick=raw.get("seller_nick", ""),
content=raw.get("content", raw.get("text", "")),
msg_type=raw.get("msg_type", "text"),
order_id=raw.get("order_id") or raw.get("trade_id"),
item_id=raw.get("item_id"),
raw_payload=raw,
)

View File

@ -0,0 +1,89 @@
import logging
from yuxi.channel.extensions.taobao.client import TaobaoAPIError, TaobaoClient
logger = logging.getLogger(__name__)
MAX_TEXT_LENGTH = 2000
_GLOBAL_CLIENT: TaobaoClient | None = None
def set_global_client(client: TaobaoClient | None) -> None:
global _GLOBAL_CLIENT
_GLOBAL_CLIENT = client
def get_client() -> TaobaoClient | None:
return _GLOBAL_CLIENT
async def send_text(
target_id: str,
content: str,
*,
reply_to_id: str | None = None,
thread_id: str | None = None,
account_id: str | None = None,
session: str | None = None,
) -> None:
client = _GLOBAL_CLIENT
if client is None:
logger.error("Taobao client not initialized")
return
try:
resp = await client.send_customer_message(
to_user=target_id,
content=content[:MAX_TEXT_LENGTH],
msg_type=0,
session=session,
)
logger.debug("Taobao message sent to %s: %s", target_id, resp)
except TaobaoAPIError as e:
logger.error("Taobao send_text failed to %s: %s", target_id, e)
async def send_media(
target_id: str,
media_url: str,
media_type: str,
*,
reply_to_id: str | None = None,
thread_id: str | None = None,
session: str | None = None,
) -> None:
client = _GLOBAL_CLIENT
if client is None:
logger.error("Taobao client not initialized")
return
msg_type = _media_type_to_int(media_type)
try:
await client.send_customer_message(
to_user=target_id,
content="",
msg_type=msg_type,
media_id=media_url,
session=session,
)
except TaobaoAPIError as e:
logger.error("Taobao send_media failed to %s: %s", target_id, e)
def _media_type_to_int(media_type: str) -> int:
mapping = {"image": 1, "file": 4}
return mapping.get(media_type, 1)
def chunk_text(text: str, limit: int = MAX_TEXT_LENGTH) -> list[str]:
chunks = []
while len(text) > limit:
split_at = text.rfind("\n", 0, limit)
if split_at == -1:
split_at = limit
chunks.append(text[:split_at])
text = text[split_at:].lstrip("\n")
if text:
chunks.append(text)
return chunks

View File

@ -0,0 +1,72 @@
import logging
import random
import string
import time
from collections import defaultdict
logger = logging.getLogger(__name__)
CODE_LENGTH = 6
CODE_TTL_SECONDS = 600
class TaobaoPairing:
id_label = "buyer_nick"
def __init__(self):
self._codes: dict[str, dict[str, tuple[str, float]]] = defaultdict(dict)
async def generate_code(self, peer_id: str, account_id: str = "default") -> str:
code = "".join(random.choices(string.digits, k=CODE_LENGTH))
self._codes[account_id][code] = (peer_id, time.monotonic())
self._cleanup_expired(account_id)
logger.info("Pairing code generated for %s:%s: %s", account_id, peer_id, code)
return code
async def verify_code(self, peer_id: str, code: str, account_id: str = "default") -> bool:
self._cleanup_expired(account_id)
account_codes = self._codes.get(account_id, {})
entry = account_codes.get(code)
if entry is None:
return False
stored_peer_id, created_at = entry
if time.monotonic() - created_at > CODE_TTL_SECONDS:
account_codes.pop(code, None)
return False
if stored_peer_id != peer_id:
return False
account_codes.pop(code, None)
logger.info("Pairing code verified for %s:%s", account_id, peer_id)
return True
def normalize_allow_entry(self, entry: str) -> str:
return entry.strip().lower()
async def notify_approval(self, config: dict, peer_id: str, account_id: str | None = None) -> None:
logger.info("Pairing approval notification for %s:%s", account_id, peer_id)
try:
from yuxi.channel.extensions.taobao.outbound import get_client
client = get_client()
if client is None:
logger.warning("Cannot notify approval: client not initialized")
return
await client.send_customer_message(
to_user=peer_id,
content="您的配对已通过审批,现在可以开始与我对话了!请问有什么可以帮助您的?",
msg_type=0,
)
except Exception as e:
logger.error("Failed to send approval notification to %s: %s", peer_id, e)
def _cleanup_expired(self, account_id: str):
now = time.monotonic()
account_codes = self._codes.get(account_id, {})
expired = [k for k, (_, created) in account_codes.items() if now - created > CODE_TTL_SECONDS]
for k in expired:
account_codes.pop(k, None)

View File

@ -0,0 +1,31 @@
{
"id": "taobao",
"name": "淘宝/千牛",
"version": "0.1.0",
"label": "淘宝/千牛 (TOP API)",
"aliases": ["taobao", "qianniu", "千牛", "淘宝客服"],
"description": "淘宝千牛客服消息渠道插件,基于 TOP OpenAPI 实现消息收发",
"author": "ForcePilot Team",
"order": 120,
"dependencies": ["httpx"],
"capabilities": {
"chat_types": ["direct"],
"message_types": ["text", "image", "rich"],
"reactions": false,
"typing_indicator": false,
"threads": false,
"edit": false,
"unsend": false,
"reply": false,
"media": true,
"effects": false,
"native_commands": false,
"polls": false,
"group_management": false,
"streaming": true,
"streaming_mode": "block",
"block_streaming": true
},
"enabled": true,
"python_requires": ">=3.12"
}

View File

@ -0,0 +1,50 @@
import logging
from collections import defaultdict
logger = logging.getLogger(__name__)
class TaobaoSecurity:
def __init__(self):
self._allowlists: dict[str, set[str]] = defaultdict(set)
def load_config(self, account_id: str, account: dict) -> None:
allow_from = account.get("allow_from", [])
self._allowlists[account_id] = {entry.strip().lower() for entry in allow_from if entry and entry.strip()}
async def check_allowlist(self, peer_id: str, channel_type: str, account_id: str | None = None, account: dict | None = None) -> bool:
if account is None:
return True
policy = account.get("dm_policy", "open")
if policy == "disabled":
return False
if policy in ("allowlist", "pairing"):
if account_id:
allow_set = self._allowlists.get(account_id, set())
if not allow_set:
allow_from = account.get("allow_from", [])
allow_set = {entry.strip().lower() for entry in allow_from if entry and entry.strip()}
return peer_id in allow_set
return peer_id in account.get("allow_from", [])
return True
def resolve_dm_policy(self, account_id: str | None = None, account: dict | None = None) -> dict:
if account is None:
return {"mode": "open", "allow_from": []}
return {
"mode": account.get("dm_policy", "open"),
"allow_from": account.get("allow_from", []),
}
def collect_warnings(self, config: dict, account_id: str | None = None, account: dict | None = None) -> list[str]:
warnings = []
acc = account or {}
if acc.get("dm_policy") == "open":
warnings.append("DM 策略为 'open',任何人可发送消息")
if not acc.get("callback_url"):
warnings.append("未配置 Webhook 回调 URL无法接收消息")
return warnings

View File

@ -0,0 +1,64 @@
import logging
from yuxi.channel.protocols import ChannelAccountSnapshot
logger = logging.getLogger(__name__)
class TaobaoStatus:
async def probe(self, account: dict) -> bool:
app_key = account.get("app_key", "")
app_secret = account.get("app_secret", "")
if not app_key or not app_secret:
return False
from yuxi.channel.extensions.taobao.client import TaobaoClient
client = TaobaoClient(
app_key=app_key,
app_secret=app_secret,
sign_method=account.get("sign_method", "md5"),
sandbox=account.get("sandbox", False),
)
try:
resp = await client.execute("taobao.time.get")
return "time_get_response" in resp
except Exception as e:
logger.warning("Taobao probe failed for %s: %s", account.get("account_id", "default"), e)
return False
def build_summary(self, snapshot: object) -> dict:
if isinstance(snapshot, ChannelAccountSnapshot):
return {
"channel": "taobao",
"account_id": snapshot.account_id,
"configured": snapshot.configured,
"connected": snapshot.connected,
"last_message_at": snapshot.last_message_at,
"last_error": snapshot.last_error,
}
return {}
def build_account_snapshot(
self,
account: dict,
config: dict,
runtime: ChannelAccountSnapshot | None = None,
probe_result: object | None = None,
audit: object | None = None,
) -> ChannelAccountSnapshot:
acc_id = account.get("account_id", "default")
configured = bool(account.get("app_key") and account.get("app_secret"))
return ChannelAccountSnapshot(
account_id=acc_id,
name=account.get("name", account.get("seller_nick", acc_id)),
enabled=account.get("enabled", True),
configured=configured,
connected=probe_result if isinstance(probe_result, bool) else False,
last_message_at=runtime.last_message_at if runtime else None,
last_error=getattr(runtime, "last_error", None) if runtime else None,
dm_policy=account.get("dm_policy", "open"),
allow_from=account.get("allow_from", []),
)

View File

@ -0,0 +1,257 @@
from __future__ import annotations
import logging
from datetime import datetime, timedelta
logger = logging.getLogger(__name__)
TRADE_STATUS_MAP = {
"WAIT_BUYER_PAY": "等待买家付款",
"WAIT_SELLER_SEND_GOODS": "等待卖家发货",
"WAIT_BUYER_CONFIRM_GOODS": "等待买家确认收货",
"TRADE_FINISHED": "交易完成",
"TRADE_CLOSED": "交易关闭",
"TRADE_CLOSED_BY_TAOBAO": "交易被淘宝关闭",
}
class TaobaoTools:
def __init__(self, client):
self._client = client
async def query_order(self, order_id: str, session: str | None = None) -> str:
try:
data = await self._client.get_trade_fullinfo(int(order_id), session)
trade = data.get("trade_fullinfo_get_response", {}).get("trade", {})
status = TRADE_STATUS_MAP.get(trade.get("status", ""), trade.get("status", "未知"))
payment = trade.get("payment", "未知")
buyer_nick = trade.get("buyer_nick", "")
orders = trade.get("orders", {}).get("order", [])
lines = [
f"📦 订单 {order_id}",
f"状态: {status}",
f"金额: ¥{payment}",
f"买家: {buyer_nick}",
]
if orders:
lines.append("商品:")
for o in orders if isinstance(orders, list) else [orders]:
lines.append(f" - {o.get('title', '未知')} ×{o.get('num', 1)} ¥{o.get('price', 0)}")
return "\n".join(lines)
except Exception as e:
logger.exception("Query order failed: %s", order_id)
return f"查询订单 {order_id} 失败: {e}"
async def query_logistics(self, order_id: str, session: str | None = None) -> str:
try:
data = await self._client.get_logistics_trace(int(order_id), session)
resp = data.get("logistics_trace_search_response", {})
traces = resp.get("trace_list", resp.get("traces", []))
if not traces:
return f"📦 订单 {order_id} 暂无物流信息"
lines = [f"📦 订单 {order_id} 物流轨迹:"]
for t in (traces if isinstance(traces, list) else [traces])[:10]:
lines.append(f" [{t.get('time', '')}] {t.get('status_desc', t.get('desc', ''))}")
return "\n".join(lines)
except Exception as e:
logger.exception("Query logistics failed: %s", order_id)
return f"查询物流 {order_id} 失败: {e}"
async def query_item(self, item_id: str, session: str | None = None) -> str:
try:
data = await self._client.get_item(int(item_id), session)
item = data.get("item_get_response", {}).get("item", {})
title = item.get("title", "未知商品")
price = item.get("price", "")
desc = item.get("desc", "")[:200]
lines = [f"🛒 {title}"]
if price:
lines.append(f"价格: ¥{price}")
if desc:
lines.append(f"描述: {desc}")
return "\n".join(lines)
except Exception as e:
logger.exception("Query item failed: %s", item_id)
return f"查询商品 {item_id} 失败: {e}"
async def query_refund(self, refund_id: str, session: str | None = None) -> str:
try:
data = await self._client.qianniu_refund_get(refund_id, session)
refund = data.get("qianniu_refund_get_response", {})
status = refund.get("status", "未知")
amount = refund.get("refund_amount", refund.get("amount", "未知"))
reason = refund.get("refund_reason", refund.get("reason", "未知"))
return f"🔙 退款 {refund_id}\n状态: {status}\n退款金额: ¥{amount}\n申请原因: {reason}"
except Exception as e:
logger.exception("Query refund failed: %s", refund_id)
return f"查询退款 {refund_id} 失败: {e}"
async def query_buyer_tags(self, buyer_nick: str, session: str | None = None) -> str:
try:
data = await self._client.qianniu_buyer_tag_get(buyer_nick, session)
tags = data.get("qianniu_buyer_tag_get_response", {}).get("tags", [])
if not tags:
return f"买家 {buyer_nick} 暂无标签"
tag_names = [t.get("tag_name", str(t)) for t in tags] if isinstance(tags, list) else [str(tags)]
return f"买家 {buyer_nick} 标签: {', '.join(tag_names)}"
except Exception as e:
logger.exception("Query buyer tags failed: %s", buyer_nick)
return f"查询买家标签失败: {e}"
async def transfer_human(self, buyer_nick: str, reason: str = "", session: str | None = None) -> str:
try:
if not hasattr(self._client, "qianniu_cloudkefu_forward"):
return "转人工功能暂不可用(千牛客服转发 API 未接入)"
await self._client.qianniu_cloudkefu_forward(
buyer_nick=buyer_nick,
to_nick="", # 不指定分配到哪个客服,由分流系统自动分配
session=session,
)
return f"已将买家 {buyer_nick} 转接人工客服" + (f",原因: {reason}" if reason else "")
except Exception as e:
logger.exception("Transfer to human failed: %s", buyer_nick)
return f"转人工失败: {e}"
async def query_chat_history(self, buyer_nick: str, session: str | None = None) -> str:
end = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
begin = (datetime.now() - timedelta(hours=24)).strftime("%Y-%m-%d %H:%M:%S")
try:
data = await self._client.get_chatlogs(buyer_nick, begin, end, session)
logs_data = data.get("openim_chatlogs_get_response", {}).get("chatlogs", {})
logs = logs_data.get("chatlog", [])
if not logs:
return "无近24小时聊天记录"
lines = ["近24小时聊天记录:"]
for log in (logs if isinstance(logs, list) else [logs])[:10]:
role = "买家" if log.get("role") == "buyer" else "客服"
msg = str(log.get("message", ""))[:100]
lines.append(f" [{log.get('time', '')}] {role}: {msg}")
return "\n".join(lines)
except Exception as e:
logger.exception("Query chat history failed: %s", buyer_nick)
return f"查询聊天记录失败: {e}"
def get_agent_tools(self) -> list[dict]:
return [
{
"name": "taobao_query_order",
"description": "查询淘宝订单详情,获取订单状态、金额、买家、商品列表等信息",
"parameters": {
"type": "object",
"properties": {
"order_id": {"type": "string", "description": "淘宝订单号(tid)"},
},
"required": ["order_id"],
},
"handler": self.query_order,
},
{
"name": "taobao_query_logistics",
"description": "查询淘宝订单物流轨迹",
"parameters": {
"type": "object",
"properties": {
"order_id": {"type": "string", "description": "淘宝订单号(tid)"},
},
"required": ["order_id"],
},
"handler": self.query_logistics,
},
{
"name": "taobao_query_item",
"description": "查询淘宝商品详情,包括标题、价格、描述等",
"parameters": {
"type": "object",
"properties": {
"item_id": {"type": "string", "description": "淘宝商品ID(num_iid)"},
},
"required": ["item_id"],
},
"handler": self.query_item,
},
{
"name": "taobao_query_refund",
"description": "查询淘宝退款/售后状态和详情",
"parameters": {
"type": "object",
"properties": {
"refund_id": {"type": "string", "description": "退款单号(refund_id)"},
},
"required": ["refund_id"],
},
"handler": self.query_refund,
},
{
"name": "taobao_query_buyer_tags",
"description": "查询买家标签VIP/黑名单等)",
"parameters": {
"type": "object",
"properties": {
"buyer_nick": {"type": "string", "description": "买家淘宝昵称"},
},
"required": ["buyer_nick"],
},
"handler": self.query_buyer_tags,
},
{
"name": "taobao_transfer_human",
"description": "将当前会话转接人工客服",
"parameters": {
"type": "object",
"properties": {
"buyer_nick": {"type": "string", "description": "买家淘宝昵称"},
"reason": {
"type": "string",
"description": "转接原因(可选)",
},
},
"required": ["buyer_nick"],
},
"handler": self.transfer_human,
},
{
"name": "taobao_query_chat_history",
"description": "查询与指定买家的近24小时聊天记录",
"parameters": {
"type": "object",
"properties": {
"buyer_nick": {"type": "string", "description": "买家淘宝昵称"},
},
"required": ["buyer_nick"],
},
"handler": self.query_chat_history,
},
]
async def execute_agent_tool(self, tool_name: str, params: dict, context: dict) -> dict:
tool_map = {
"taobao_query_order": self.query_order,
"taobao_query_logistics": self.query_logistics,
"taobao_query_item": self.query_item,
"taobao_query_refund": self.query_refund,
"taobao_query_buyer_tags": self.query_buyer_tags,
"taobao_transfer_human": self.transfer_human,
"taobao_query_chat_history": self.query_chat_history,
}
handler = tool_map.get(tool_name)
if handler is None:
return {"success": False, "error": f"Unknown tool: {tool_name}"}
try:
session = context.get("session") or context.get("access_token")
result = await handler(**params, session=session)
return {"success": True, "result": result}
except Exception as e:
return {"success": False, "error": str(e)}

View File

@ -0,0 +1,76 @@
from dataclasses import dataclass, field
from datetime import datetime, timedelta
@dataclass
class TaobaoAccount:
account_id: str = "default"
app_key: str = ""
app_secret: str = ""
sign_method: str = "md5"
access_token: str = ""
refresh_token: str = ""
token_expires_at: datetime | None = None
callback_url: str = ""
rsa_public_key: str = ""
rsa_private_key: str = ""
encrypt_messages: bool = False
seller_nick: str = ""
name: str = ""
dm_policy: str = "open"
allow_from: list[str] = field(default_factory=list)
timeout: int = 10
max_retries: int = 3
sandbox: bool = False
def is_configured(self) -> bool:
return bool(self.app_key and self.app_secret and self.callback_url)
def is_token_expired(self) -> bool:
if not self.token_expires_at:
return True
return datetime.now() > self.token_expires_at - timedelta(minutes=10)
def has_valid_token(self) -> bool:
return bool(self.access_token) and not self.is_token_expired()
def to_dict(self) -> dict:
return {
"account_id": self.account_id,
"app_key": self.app_key,
"app_secret": self.app_secret,
"sign_method": self.sign_method,
"access_token": self.access_token,
"refresh_token": self.refresh_token,
"callback_url": self.callback_url,
"rsa_public_key": self.rsa_public_key,
"rsa_private_key": self.rsa_private_key,
"encrypt_messages": self.encrypt_messages,
"seller_nick": self.seller_nick,
"name": self.name,
"dm_policy": self.dm_policy,
"allow_from": self.allow_from,
"timeout": self.timeout,
"sandbox": self.sandbox,
"enabled": True,
}
@dataclass
class InboundTaobaoMessage:
msg_id: str
buyer_nick: str
seller_nick: str = ""
content: str = ""
msg_type: str = "text"
order_id: str | None = None
trade_id: str | None = None
item_id: str | None = None
timestamp: datetime | None = None
raw_payload: dict = field(default_factory=dict)

View File

@ -0,0 +1,127 @@
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")