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