新增拼多多(Pinduoduo)渠道扩展,支持在 Yuxi 平台中集成拼多多电商客服渠道。 包含以下功能模块: - client: 拼多多 API 客户端封装 - config: 渠道配置管理 - gateway: SSE/WebSocket 网关接入 - webhook: Webhook 事件处理 - outbound: 外发消息管理 - pairing: 用户配对与绑定 - security: 安全校验 - signature: 请求签名验证 - token: Token 管理 - dedupe: 消息去重 - monitor: 渠道状态监控 - status: 会话状态管理 - tools: Agent 工具集成 - window: 窗口管理 - types: 类型定义
245 lines
8.6 KiB
Python
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
|