新增 Lazada 渠道扩展,支持在 Yuxi 平台中集成 Lazada 电商客服渠道。 包含以下功能模块: - client: Lazada API 客户端封装 - config: 渠道配置管理 - gateway: SSE/WebSocket 网关接入 - webhook: Webhook 事件处理 - outbound: 外发消息管理 - pairing: 用户配对与绑定 - security: 安全校验 - signature: 请求签名验证 - dedupe: 消息去重 - monitor: 渠道状态监控 - status: 会话状态管理 - session: 会话管理 - tools: Agent 工具集成 - tools_config: 工具配置 - types: 类型定义
354 lines
12 KiB
Python
354 lines
12 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import time
|
|
|
|
from .client import LazadaAPIClient, LazadaAPIError
|
|
from .config import LazadaConfig
|
|
from .types import LazadaAccount, TokenInfo
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_gateway: LazadaGateway | None = None
|
|
|
|
|
|
def _get_gateway() -> LazadaGateway | None:
|
|
return _gateway
|
|
|
|
|
|
def _set_gateway(gw: LazadaGateway) -> None:
|
|
global _gateway
|
|
_gateway = gw
|
|
|
|
|
|
class LazadaGateway:
|
|
def __init__(self):
|
|
self._account: LazadaAccount | None = None
|
|
self._client: LazadaAPIClient | None = None
|
|
self._running = False
|
|
self._cancel_event = asyncio.Event()
|
|
self._polling_task: asyncio.Task | None = None
|
|
self._refresh_task: asyncio.Task | None = None
|
|
self._last_message_times: dict[str, float] = {}
|
|
self._token_lock = asyncio.Lock()
|
|
self._last_poll_time: float = 0.0
|
|
self._last_message_ids: dict[str, str] = {}
|
|
self._plugin = None
|
|
|
|
@property
|
|
def account(self) -> LazadaAccount | None:
|
|
return self._account
|
|
|
|
@property
|
|
def client(self) -> LazadaAPIClient:
|
|
if self._client is None:
|
|
raise RuntimeError("Gateway 未启动")
|
|
return self._client
|
|
|
|
async def start(self, ctx) -> object:
|
|
global _gateway
|
|
|
|
config = LazadaConfig()
|
|
account = await config.resolve_account(ctx.account_id if hasattr(ctx, "account_id") else "default")
|
|
|
|
if not account.is_configured():
|
|
logger.warning("Lazada account not configured, skipping start")
|
|
return {"running": False, "reason": "not-configured"}
|
|
|
|
if not account.is_authorized():
|
|
logger.warning("Lazada account not authorized, skipping start")
|
|
return {"running": False, "reason": "not-authorized"}
|
|
|
|
self._account = account
|
|
self._cancel_event.clear()
|
|
|
|
self._client = LazadaAPIClient(
|
|
app_key=account.app_key,
|
|
app_secret=account.app_secret,
|
|
site_code=account.site_code,
|
|
timeout=account.http_timeout_ms / 1000,
|
|
)
|
|
|
|
self._client.set_token(
|
|
TokenInfo(
|
|
access_token=account.access_token,
|
|
refresh_token=account.refresh_token,
|
|
expires_at=account.token_expires_at,
|
|
refresh_expires_at=account.refresh_expires_at,
|
|
)
|
|
)
|
|
|
|
try:
|
|
await self._client.call("/im/session/list", params={"page_size": "1"})
|
|
except LazadaAPIError as e:
|
|
if e.code in ("InvalidAccessToken", "41", "AccessTokenExpired"):
|
|
try:
|
|
token_info = await self._client.refresh_token()
|
|
self._account.access_token = token_info.access_token
|
|
self._account.refresh_token = token_info.refresh_token
|
|
self._account.token_expires_at = token_info.expires_at
|
|
self._account.refresh_expires_at = token_info.refresh_expires_at
|
|
except Exception:
|
|
logger.exception("Lazada token refresh failed on start")
|
|
await self.stop(ctx)
|
|
return {"running": False, "reason": "token-expired-and-refresh-failed"}
|
|
else:
|
|
logger.error("Lazada API error on start: %s", e)
|
|
await self.stop(ctx)
|
|
return {"running": False, "reason": f"api-error: {e.code}"}
|
|
|
|
self._running = True
|
|
_gateway = self
|
|
|
|
self._polling_task = asyncio.create_task(self._polling_loop())
|
|
self._refresh_task = asyncio.create_task(self._token_refresh_loop())
|
|
|
|
logger.info(
|
|
"Lazada gateway started: account=%s site=%s seller=%s",
|
|
account.account_id,
|
|
account.site_code,
|
|
account.seller_id,
|
|
)
|
|
return {
|
|
"running": True,
|
|
"account_id": account.account_id,
|
|
"site_code": account.site_code,
|
|
"seller_id": account.seller_id,
|
|
}
|
|
|
|
async def stop(self, ctx) -> None:
|
|
global _gateway
|
|
self._running = False
|
|
self._cancel_event.set()
|
|
|
|
for task in [self._polling_task, self._refresh_task]:
|
|
if task and not task.done():
|
|
task.cancel()
|
|
try:
|
|
await task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
|
|
if self._client:
|
|
await self._client.close()
|
|
self._client = None
|
|
|
|
_gateway = None
|
|
self._plugin = None
|
|
logger.info("Lazada gateway stopped")
|
|
|
|
def get_token(self, account_id: str = "default") -> str | None:
|
|
if self._client:
|
|
return self._client.access_token
|
|
return None
|
|
|
|
async def poll_once(self) -> list[dict]:
|
|
if not self._client or not self._plugin:
|
|
return []
|
|
|
|
from .webhook import parse_inbound_message
|
|
|
|
try:
|
|
sessions_resp = await self._client.call(
|
|
"/im/session/list",
|
|
params={"page_size": "20"},
|
|
)
|
|
sessions = sessions_resp.get("data", {}).get("sessions", [])
|
|
except Exception:
|
|
logger.exception("长轮询获取会话列表失败")
|
|
return []
|
|
|
|
start_time = str(int((self._last_poll_time or time.time() - 300) * 1000))
|
|
results = []
|
|
|
|
for s in sessions:
|
|
session_id = s.get("session_id", "")
|
|
if not session_id:
|
|
continue
|
|
|
|
params: dict[str, str] = {
|
|
"session_id": session_id,
|
|
"page_size": "10",
|
|
"start_time": start_time,
|
|
}
|
|
last_msg_id = self._last_message_ids.get(session_id)
|
|
if last_msg_id:
|
|
params["last_message_id"] = last_msg_id
|
|
|
|
try:
|
|
resp = await self._client.call("/im/message/list", params=params)
|
|
messages = resp.get("data", {}).get("messages", [])
|
|
except Exception:
|
|
continue
|
|
|
|
for msg_data in messages:
|
|
payload = {"message_type": 2, "data": msg_data}
|
|
inbound = parse_inbound_message(payload)
|
|
if inbound is None:
|
|
continue
|
|
|
|
self._last_message_ids[session_id] = inbound.message_id
|
|
results.append(payload)
|
|
|
|
self._last_poll_time = time.time()
|
|
return results
|
|
|
|
async def _polling_loop(self) -> None:
|
|
await asyncio.sleep(5)
|
|
while self._running:
|
|
try:
|
|
await self._dispatch_polled_messages()
|
|
except Exception:
|
|
logger.exception("Lazada 长轮询异常")
|
|
interval = self._account.polling_interval_ms / 1000 if self._account else 5
|
|
await asyncio.sleep(interval)
|
|
|
|
async def _dispatch_polled_messages(self) -> None:
|
|
if not self._client or not self._plugin:
|
|
return
|
|
|
|
from .webhook import parse_inbound_message
|
|
|
|
try:
|
|
sessions_resp = await self._client.call(
|
|
"/im/session/list",
|
|
params={"page_size": "20"},
|
|
)
|
|
sessions = sessions_resp.get("data", {}).get("sessions", [])
|
|
except Exception:
|
|
logger.exception("长轮询获取会话列表失败")
|
|
return
|
|
|
|
start_time = str(int((self._last_poll_time or time.time() - 300) * 1000))
|
|
|
|
for s in sessions:
|
|
session_id = s.get("session_id", "")
|
|
if not session_id:
|
|
continue
|
|
|
|
params: dict[str, str] = {
|
|
"session_id": session_id,
|
|
"page_size": "10",
|
|
"start_time": start_time,
|
|
}
|
|
last_msg_id = self._last_message_ids.get(session_id)
|
|
if last_msg_id:
|
|
params["last_message_id"] = last_msg_id
|
|
|
|
try:
|
|
resp = await self._client.call("/im/message/list", params=params)
|
|
messages = resp.get("data", {}).get("messages", [])
|
|
except Exception:
|
|
continue
|
|
|
|
for msg_data in messages:
|
|
payload = {"message_type": 2, "data": msg_data}
|
|
inbound = parse_inbound_message(payload)
|
|
if inbound is None:
|
|
continue
|
|
|
|
self._last_message_ids[session_id] = inbound.message_id
|
|
|
|
if self._plugin.is_duplicate(inbound.message_id):
|
|
continue
|
|
|
|
if self._account is None:
|
|
continue
|
|
|
|
dm_policy = self._plugin.resolve_dm_policy()
|
|
if dm_policy.get("mode") == "disabled":
|
|
continue
|
|
|
|
if dm_policy.get("mode") in ("pairing", "allowlist"):
|
|
if not await self._plugin.check_allowlist(inbound.from_account_id, "lazada"):
|
|
continue
|
|
|
|
unified_raw_event = {
|
|
"message_type": 2,
|
|
"data": {
|
|
"message_id": inbound.message_id,
|
|
"session_id": inbound.session_id,
|
|
"from_account_id": inbound.from_account_id,
|
|
"from_account_type": inbound.from_account_type,
|
|
"to_account_id": inbound.to_account_id,
|
|
"to_account_type": inbound.to_account_type,
|
|
"template_id": inbound.template_id,
|
|
"content": inbound.content,
|
|
"send_time": inbound.send_time,
|
|
"site_id": inbound.site_id,
|
|
"type": inbound.msg_type,
|
|
"auto_reply": inbound.auto_reply,
|
|
"status": inbound.status,
|
|
},
|
|
}
|
|
from .format import strip_html
|
|
|
|
unified_raw_event["data"]["content"] = strip_html(inbound.content)
|
|
|
|
unified = self._plugin.parse_to_unified(unified_raw_event, "default")
|
|
if unified is None:
|
|
continue
|
|
|
|
from yuxi.channel.runtime.manager import gateway as channel_gateway
|
|
|
|
processor = getattr(channel_gateway, "_processor", None)
|
|
if processor:
|
|
asyncio.create_task(
|
|
self._dispatch_to_agent(processor, unified),
|
|
name=f"lazada-poll-dispatch-{inbound.from_account_id}",
|
|
)
|
|
|
|
self._last_poll_time = time.time()
|
|
|
|
async def _dispatch_to_agent(self, processor, msg) -> None:
|
|
try:
|
|
await asyncio.wait_for(processor.process(msg), timeout=120.0)
|
|
except TimeoutError:
|
|
logger.error("Agent response timeout for Lazada user %s", msg.sender.id)
|
|
except Exception:
|
|
logger.exception("Failed to process Lazada message for user %s", msg.sender.id)
|
|
|
|
async def _token_refresh_loop(self) -> None:
|
|
while self._running:
|
|
await asyncio.sleep(86400)
|
|
if not self._running:
|
|
return
|
|
try:
|
|
async with self._token_lock:
|
|
token_info = await self._client.refresh_token()
|
|
self._account.access_token = token_info.access_token
|
|
self._account.refresh_token = token_info.refresh_token
|
|
self._account.token_expires_at = token_info.expires_at
|
|
self._account.refresh_expires_at = token_info.refresh_expires_at
|
|
logger.info("Lazada access_token 刷新成功")
|
|
except Exception:
|
|
logger.exception("Lazada access_token 刷新失败")
|
|
|
|
async def probe(self) -> bool:
|
|
if not self._client:
|
|
return False
|
|
try:
|
|
await self._client.call("/im/session/list", params={"page_size": "1"})
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
def track_buyer_reply(self, session_id: str) -> None:
|
|
self._last_message_times[session_id] = time.time()
|
|
|
|
def is_session_active(self, session_id: str) -> bool:
|
|
last_time = self._last_message_times.get(session_id, 0)
|
|
return (time.time() - last_time) < 30 * 60
|
|
|
|
def get_daily_message_limit(self, session_id: str) -> int:
|
|
last_time = self._last_message_times.get(session_id, 0)
|
|
elapsed_days = (time.time() - last_time) / 86400
|
|
if elapsed_days > 30:
|
|
return 0
|
|
if elapsed_days > 2:
|
|
return 5
|
|
if elapsed_days * 24 > 0.5:
|
|
return 10
|
|
return 9999
|