ForcePilot/backend/package/yuxi/channel/extensions/pinduoduo/gateway.py
Kris 1476e82ef0 feat(channel): 添加拼多多渠道扩展
新增拼多多(Pinduoduo)渠道扩展,支持在 Yuxi 平台中集成拼多多电商客服渠道。

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

245 lines
8.6 KiB
Python

from __future__ import annotations
import asyncio
import logging
from datetime import datetime, timedelta
from yuxi.channel.context import ChannelContext
from yuxi.channel.extensions.pinduoduo.client import PddApiClient, PddCSClient
from yuxi.channel.extensions.pinduoduo.config import PinduoduoConfig
from yuxi.channel.extensions.pinduoduo.dedupe import PddMessageDeduplicator
from yuxi.channel.extensions.pinduoduo.monitor import PinduoduoMonitor
from yuxi.channel.extensions.pinduoduo.outbound import PinduoduoOutbound
from yuxi.channel.extensions.pinduoduo.pairing import PinduoduoPairing
from yuxi.channel.extensions.pinduoduo.security import PinduoduoSecurity
from yuxi.channel.extensions.pinduoduo.status import PinduoduoStatus
from yuxi.channel.extensions.pinduoduo.token import PddTokenManager
from yuxi.channel.extensions.pinduoduo.types import PinduoduoAccount
from yuxi.channel.extensions.pinduoduo.window import PinduoduoWindowTracker
logger = logging.getLogger(__name__)
class PinduoduoGateway:
def __init__(
self,
account: PinduoduoAccount,
config_adapter: PinduoduoConfig,
):
self._account = account
self._config_adapter = config_adapter
self._token_mgr: PddTokenManager | None = None
self._api_client: PddApiClient | None = None
self._cs_client: PddCSClient | None = None
self._outbound: PinduoduoOutbound | None = None
self._monitor: PinduoduoMonitor | None = None
self._security: PinduoduoSecurity | None = None
self._pairing: PinduoduoPairing | None = None
self._deduplicator: PddMessageDeduplicator | None = None
self._status: PinduoduoStatus | None = None
self._window_tracker: PinduoduoWindowTracker | None = None
self._poll_task: asyncio.Task | None = None
self._running = False
self._last_poll_time: datetime | None = None
async def start(self, ctx: ChannelContext) -> None:
mall_id = self._account.mall_id
account_id = self._account.account_id
self._status = PinduoduoStatus(account_id=account_id, mall_id=mall_id)
self._token_mgr = PddTokenManager(
client_id=self._account.client_id,
client_secret=self._account.client_secret,
mall_id=mall_id,
refresh_token=self._account.refresh_token,
access_token=self._account.access_token,
)
await self._token_mgr.start_background_refresh()
self._api_client = PddApiClient(self._token_mgr, self._account.sign_method)
self._cs_client = PddCSClient(self._api_client)
self._outbound = PinduoduoOutbound(
cs_client=self._cs_client,
account=self._account,
window_tracker=self._window_tracker,
)
self._monitor = PinduoduoMonitor(mall_id, account_id)
self._security = PinduoduoSecurity(self._account)
self._pairing = PinduoduoPairing()
self._deduplicator = PddMessageDeduplicator()
self._window_tracker = PinduoduoWindowTracker()
self._running = True
self._status.running = True
self._status.connected = True
self._last_poll_time = datetime.now()
interval = self._account.poll_interval_sec
self._poll_task = asyncio.create_task(
self._poll_loop(ctx, interval),
name=f"pdd-poll-{account_id}",
)
logger.info(
"Pinduoduo gateway started: account=%s, mall_id=%s, interval=%ds",
account_id,
mall_id,
interval,
)
async def stop(self, ctx: ChannelContext) -> None:
self._running = False
if self._poll_task and not self._poll_task.done():
self._poll_task.cancel()
try:
await self._poll_task
except asyncio.CancelledError:
pass
self._poll_task = None
if self._token_mgr:
await self._token_mgr.close()
if self._api_client:
await self._api_client.close()
if self._status:
self._status.connected = False
self._status.running = False
logger.info(
"Pinduoduo gateway stopped: account=%s",
self._account.account_id,
)
async def _poll_loop(self, ctx: ChannelContext, interval: int) -> None:
while self._running:
try:
await self._poll_once(ctx)
except asyncio.CancelledError:
break
except Exception:
logger.exception("Poll error for account=%s", self._account.account_id)
await asyncio.sleep(interval)
async def _poll_once(self, ctx: ChannelContext) -> None:
if not self._cs_client or not self._monitor:
return
now = datetime.now()
start = self._last_poll_time or (now - timedelta(seconds=60))
end = now
start_str = start.strftime("%Y-%m-%d %H:%M:%S")
end_str = end.strftime("%Y-%m-%d %H:%M:%S")
data = await self._cs_client.poll_messages(
mall_id=self._account.mall_id,
start_modified=start_str,
end_modified=end_str,
page_size=100,
page_num=1,
)
self._last_poll_time = now
messages = self._monitor.parse_poll_response(data, self._account.mall_id)
if not messages:
return
if self._status:
self._status.poll_count += len(messages)
for msg in messages:
await self._handle_message(msg, ctx)
async def _handle_message(self, msg, ctx: ChannelContext) -> None:
if self._deduplicator and self._deduplicator.is_duplicate(msg.msg_id):
return
if self._security:
allowed, reason = self._security.check_dm_access(msg.buyer_id)
if not allowed and reason:
if "#pair" in (msg.content or "").lower() and self._pairing:
parts = msg.content.strip().split()
if len(parts) >= 2:
code = parts[-1]
if self._pairing.verify_code(msg.buyer_id, code):
self._security.mark_paired(msg.buyer_id)
if self._outbound and msg.session_id:
await self._outbound.send_text(
msg.session_id,
msg.buyer_id,
"✅ 配对成功!有什么可以帮您的?",
)
else:
if self._outbound and msg.session_id:
await self._outbound.send_text(
msg.session_id,
msg.buyer_id,
"❌ 配对码无效或已过期,请重新获取",
)
return
code = self._pairing.generate_code(msg.buyer_id)
if code and self._outbound and msg.session_id:
await self._outbound.send_text(
msg.session_id,
msg.buyer_id,
f"🔑 您的配对码: {code}\n请在 5 分钟内输入 #pair {code} 完成配对",
)
return
return
if self._monitor:
if self._window_tracker and msg.buyer_id:
self._window_tracker.record(msg.buyer_id)
unified = self._monitor.to_unified_message(msg)
if ctx.queue:
await ctx.queue.put(unified)
logger.debug(
"Message queued: msg_id=%s, buyer=%s",
msg.msg_id,
msg.buyer_id,
)
@property
def status(self) -> PinduoduoStatus | None:
return self._status
@property
def outbound(self) -> PinduoduoOutbound | None:
return self._outbound
@property
def security(self) -> PinduoduoSecurity | None:
return self._security
@property
def pairing(self) -> PinduoduoPairing | None:
return self._pairing
@property
def token_mgr(self) -> PddTokenManager | None:
return self._token_mgr
@property
def api_client(self) -> PddApiClient | None:
return self._api_client
@property
def monitor(self) -> PinduoduoMonitor | None:
return self._monitor
@property
def deduplicator(self) -> PddMessageDeduplicator | None:
return self._deduplicator
@property
def window_tracker(self) -> PinduoduoWindowTracker | None:
return self._window_tracker