新增拼多多(Pinduoduo)渠道扩展,支持在 Yuxi 平台中集成拼多多电商客服渠道。 包含以下功能模块: - client: 拼多多 API 客户端封装 - config: 渠道配置管理 - gateway: SSE/WebSocket 网关接入 - webhook: Webhook 事件处理 - outbound: 外发消息管理 - pairing: 用户配对与绑定 - security: 安全校验 - signature: 请求签名验证 - token: Token 管理 - dedupe: 消息去重 - monitor: 渠道状态监控 - status: 会话状态管理 - tools: Agent 工具集成 - window: 窗口管理 - types: 类型定义
301 lines
9.8 KiB
Python
301 lines
9.8 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import time
|
|
from typing import Any
|
|
|
|
import httpx
|
|
|
|
from yuxi.channel.extensions.pinduoduo.signature import generate_sign
|
|
from yuxi.channel.extensions.pinduoduo.token import PddTokenManager
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Alternative gateway: https://gw-api.pinduoduo.com/api/router
|
|
PDD_API_URL = "https://open-api.pinduoduo.com/api/router"
|
|
MAX_RETRY_COUNT = 3
|
|
RETRY_BACKOFF_BASE = 2.0
|
|
TOKEN_EXPIRED_ERROR_CODES = {10005, 10006, 10007}
|
|
RATE_LIMIT_ERROR_CODE = 70001
|
|
|
|
|
|
class PddApiError(Exception):
|
|
def __init__(self, error_code: int, error_msg: str, retryable: bool = False):
|
|
self.error_code = error_code
|
|
self.error_msg = error_msg
|
|
self.retryable = retryable
|
|
super().__init__(f"[{error_code}] {error_msg}")
|
|
|
|
|
|
class PddApiClient:
|
|
def __init__(self, token_manager: PddTokenManager, sign_method: str = "md5"):
|
|
self._token_manager = token_manager
|
|
self._sign_method = sign_method
|
|
self._http_client: httpx.AsyncClient | None = None
|
|
|
|
def _get_client(self) -> httpx.AsyncClient:
|
|
if self._http_client is None:
|
|
self._http_client = httpx.AsyncClient(timeout=httpx.Timeout(30.0))
|
|
return self._http_client
|
|
|
|
async def call(self, api_type: str, params: dict | None = None) -> dict[str, Any]:
|
|
token = await self._token_manager.get_token()
|
|
client = self._get_client()
|
|
|
|
base_params: dict[str, Any] = {
|
|
"type": api_type,
|
|
"client_id": self._token_manager.client_id,
|
|
"access_token": token,
|
|
"timestamp": int(time.time()),
|
|
"data_type": "JSON",
|
|
}
|
|
if params:
|
|
base_params.update(params)
|
|
|
|
sign = generate_sign(base_params, self._token_manager.client_secret, self._sign_method)
|
|
base_params["sign"] = sign
|
|
|
|
last_error = None
|
|
for attempt in range(MAX_RETRY_COUNT):
|
|
try:
|
|
resp = await client.post(PDD_API_URL, json=base_params)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
|
|
if "error_response" in data:
|
|
err = data["error_response"]
|
|
err_code = err.get("error_code", -1)
|
|
err_msg = err.get("error_msg", "unknown error")
|
|
|
|
if err_code in TOKEN_EXPIRED_ERROR_CODES:
|
|
logger.info("Token expired, refreshing...")
|
|
await self._token_manager.invalidate_token()
|
|
token = await self._token_manager.get_token()
|
|
base_params["access_token"] = token
|
|
base_params["sign"] = generate_sign(base_params, self._token_manager.client_secret, self._sign_method)
|
|
continue
|
|
|
|
if err_code == RATE_LIMIT_ERROR_CODE:
|
|
wait_time = RETRY_BACKOFF_BASE**attempt
|
|
logger.warning(
|
|
"Rate limited, waiting %.1fs (attempt %d)",
|
|
wait_time,
|
|
attempt + 1,
|
|
)
|
|
await asyncio.sleep(wait_time)
|
|
last_error = PddApiError(err_code, err_msg, retryable=True)
|
|
continue
|
|
|
|
raise PddApiError(err_code, err_msg)
|
|
|
|
return data
|
|
|
|
except httpx.HTTPStatusError as e:
|
|
raise PddApiError(
|
|
e.response.status_code,
|
|
f"HTTP {e.response.status_code}",
|
|
retryable=e.response.status_code >= 500,
|
|
) from e
|
|
except httpx.RequestError as e:
|
|
last_error = PddApiError(-1, str(e), retryable=True)
|
|
if attempt < MAX_RETRY_COUNT - 1:
|
|
wait_time = RETRY_BACKOFF_BASE**attempt
|
|
await asyncio.sleep(wait_time)
|
|
continue
|
|
|
|
raise last_error or PddApiError(-1, "Max retries exceeded")
|
|
|
|
async def close(self) -> None:
|
|
if self._http_client:
|
|
await self._http_client.aclose()
|
|
self._http_client = None
|
|
|
|
|
|
class PddCSClient:
|
|
def __init__(self, api_client: PddApiClient):
|
|
self._api = api_client
|
|
|
|
async def send_text(self, session_id: str, from_user: str, to_user: str, mall_id: str, content: str) -> dict:
|
|
return await self._api.call(
|
|
"pdd.logistics.cs.session.send",
|
|
{
|
|
"session_id": session_id,
|
|
"from_user": from_user,
|
|
"to_user": to_user,
|
|
"mall_id": mall_id,
|
|
"message_type": 0,
|
|
"message_content": content,
|
|
},
|
|
)
|
|
|
|
async def send_image(self, session_id: str, from_user: str, to_user: str, mall_id: str, image_url: str) -> dict:
|
|
return await self._api.call(
|
|
"pdd.logistics.cs.session.send",
|
|
{
|
|
"session_id": session_id,
|
|
"from_user": from_user,
|
|
"to_user": to_user,
|
|
"mall_id": mall_id,
|
|
"message_type": 1,
|
|
"message_content": json.dumps({"url": image_url}),
|
|
},
|
|
)
|
|
|
|
async def send_order_card(self, session_id: str, from_user: str, to_user: str, mall_id: str, order_sn: str) -> dict:
|
|
return await self._api.call(
|
|
"pdd.logistics.cs.session.send",
|
|
{
|
|
"session_id": session_id,
|
|
"from_user": from_user,
|
|
"to_user": to_user,
|
|
"mall_id": mall_id,
|
|
"message_type": 3,
|
|
"message_content": json.dumps({"order_sn": order_sn}),
|
|
},
|
|
)
|
|
|
|
async def poll_messages(
|
|
self,
|
|
mall_id: str,
|
|
start_modified: str,
|
|
end_modified: str,
|
|
page_num: int = 1,
|
|
page_size: int = 100,
|
|
) -> dict:
|
|
return await self._api.call(
|
|
"pdd.logistics.cs.message.get",
|
|
{
|
|
"mall_id": mall_id,
|
|
"start_modified": start_modified,
|
|
"end_modified": end_modified,
|
|
"page_num": page_num,
|
|
"page_size": page_size,
|
|
},
|
|
)
|
|
|
|
|
|
class PddOrderClient:
|
|
def __init__(self, api_client: PddApiClient):
|
|
self._api = api_client
|
|
|
|
async def get_order_detail(self, order_sn: str) -> dict:
|
|
return await self._api.call(
|
|
"pdd.order.information.get",
|
|
{"order_sn": order_sn},
|
|
)
|
|
|
|
async def get_recent_orders(
|
|
self, start_updated_at: str, end_updated_at: str, page: int = 1, page_size: int = 20
|
|
) -> dict:
|
|
return await self._api.call(
|
|
"pdd.order.list.get",
|
|
{
|
|
"start_updated_at": start_updated_at,
|
|
"end_updated_at": end_updated_at,
|
|
"page": page,
|
|
"page_size": page_size,
|
|
},
|
|
)
|
|
|
|
|
|
class PddLogisticsClient:
|
|
def __init__(self, api_client: PddApiClient):
|
|
self._api = api_client
|
|
|
|
async def get_trace(self, order_sn: str) -> dict:
|
|
return await self._api.call(
|
|
"pdd.logistics.trace.get",
|
|
{"order_sn": order_sn},
|
|
)
|
|
|
|
|
|
class PddRefundClient:
|
|
def __init__(self, api_client: PddApiClient):
|
|
self._api = api_client
|
|
|
|
async def get_refund_info(self, order_sn: str) -> dict:
|
|
return await self._api.call(
|
|
"pdd.refund.information.get",
|
|
{"order_sn": order_sn},
|
|
)
|
|
|
|
async def get_refund_list(
|
|
self, start_updated_at: str, end_updated_at: str, page: int = 1, page_size: int = 20
|
|
) -> dict:
|
|
return await self._api.call(
|
|
"pdd.refund.list.get",
|
|
{
|
|
"start_updated_at": start_updated_at,
|
|
"end_updated_at": end_updated_at,
|
|
"page": page,
|
|
"page_size": page_size,
|
|
},
|
|
)
|
|
|
|
async def agree_refund(self, order_sn: str, remark: str = "") -> dict:
|
|
return await self._api.call(
|
|
"pdd.refund.status.update",
|
|
{
|
|
"order_sn": order_sn,
|
|
"operate_type": 1,
|
|
"remark": remark,
|
|
},
|
|
)
|
|
|
|
async def reject_refund(self, order_sn: str, reject_reason: str, reject_remark: str = "") -> dict:
|
|
return await self._api.call(
|
|
"pdd.refund.status.update",
|
|
{
|
|
"order_sn": order_sn,
|
|
"operate_type": 2,
|
|
"reject_reason": reject_reason,
|
|
"reject_remark": reject_remark,
|
|
},
|
|
)
|
|
|
|
|
|
class PddSessionClient:
|
|
def __init__(self, api_client: PddApiClient):
|
|
self._api = api_client
|
|
|
|
async def transfer_human(self, session_id: str, mall_id: str, buyer_id: str, reason: str = "") -> dict:
|
|
params: dict = {
|
|
"session_id": session_id,
|
|
"mall_id": mall_id,
|
|
"to_user": buyer_id,
|
|
}
|
|
if reason:
|
|
params["transfer_reason"] = reason
|
|
return await self._api.call(
|
|
"pdd.logistics.cs.session.transfer.human",
|
|
params,
|
|
)
|
|
|
|
async def close_session(self, session_id: str, mall_id: str) -> dict:
|
|
return await self._api.call(
|
|
"pdd.logistics.cs.session.close",
|
|
{
|
|
"session_id": session_id,
|
|
"mall_id": mall_id,
|
|
},
|
|
)
|
|
|
|
|
|
class PddGoodsClient:
|
|
def __init__(self, api_client: PddApiClient):
|
|
self._api = api_client
|
|
|
|
async def get_goods_detail(self, goods_id: int) -> dict:
|
|
return await self._api.call(
|
|
"pdd.goods.detail.get",
|
|
{"goods_id": goods_id},
|
|
)
|
|
|
|
async def get_goods_list(self, page: int = 1, page_size: int = 20) -> dict:
|
|
return await self._api.call(
|
|
"pdd.goods.list.get",
|
|
{"page": page, "page_size": page_size},
|
|
)
|