import asyncio import logging import time from collections import defaultdict import httpx logger = logging.getLogger(__name__) PROBE_URL = "https://api.weibo.com/2/users/show.json" MAX_QUEUE_SIZE = 1000 _WINDOW_SECS = 72 * 3600 _MAX_REPLIES = 3 class WeiboGateway: def __init__(self): self._queue = asyncio.Queue(maxsize=MAX_QUEUE_SIZE) self._account = None self._http: httpx.AsyncClient | None = None self._running = False self._cancel_event = asyncio.Event() self._watchdog_task = None self._reply_windows: dict[str, list[float]] = defaultdict(list) self._reply_counts: dict[str, int] = defaultdict(int) @property def webhook_queue(self) -> asyncio.Queue: return self._queue @property def access_token(self) -> str | None: return self._account.access_token if self._account else None async def start(self, ctx) -> object: account = self._resolve_account(ctx) if not account.is_configured(): logger.warning("Weibo account not configured, skipping start") return {"running": False, "reason": "not-configured"} self._account = account self._cancel_event.clear() self._http = httpx.AsyncClient(timeout=15.0) await self._verify_token() self._running = True self._watchdog_task = asyncio.create_task(self._token_watchdog()) logger.info("Weibo gateway started for account %s", account.account_id) return {"running": True, "account_id": account.account_id} async def stop(self, ctx) -> None: self._running = False self._cancel_event.set() if self._watchdog_task: self._watchdog_task.cancel() try: await self._watchdog_task except asyncio.CancelledError: pass self._watchdog_task = None if self._http: await self._http.aclose() self._http = None logger.info("Weibo gateway stopped") def record_inbound(self, sender_id: str) -> None: now = time.time() self._reply_windows[sender_id].append(now) self._reply_counts[sender_id] = 0 self._reply_windows[sender_id] = [t for t in self._reply_windows[sender_id] if now - t < _WINDOW_SECS] def can_reply(self, sender_id: str) -> bool: now = time.time() timestamps = self._reply_windows.get(sender_id, []) valid = [t for t in timestamps if now - t < _WINDOW_SECS] self._reply_windows[sender_id] = valid if not valid: return False total_allowed = len(valid) * _MAX_REPLIES return self._reply_counts.get(sender_id, 0) < total_allowed def record_outbound(self, sender_id: str) -> None: self._reply_counts[sender_id] = self._reply_counts.get(sender_id, 0) + 1 async def _token_watchdog(self) -> None: TOKEN_CHECK_INTERVAL = 86400 while self._running: try: await asyncio.sleep(TOKEN_CHECK_INTERVAL) if not self._running: break await self._verify_token() except ConnectionError: logger.error("Weibo access_token expired! Notifying admin...") self._running = False break except asyncio.CancelledError: raise except Exception: logger.exception("Token watchdog error") async def _verify_token(self) -> None: token = self._account.access_token if not token: raise ConnectionError("Weibo access_token is empty") resp = await self._http.get(f"{PROBE_URL}?access_token={token}&uid={self._account.app_key}") data = resp.json() if "error_code" in data: error_code = data.get("error_code") error_msg = data.get("error", "") logger.error("Weibo token invalid: error_code=%s, error=%s", error_code, error_msg) raise ConnectionError(f"Weibo access_token invalid: {error_msg}") logger.info("Weibo access_token verified successfully") @staticmethod def _resolve_account(ctx): from yuxi.channel.extensions.weibo.config import WeiboConfig config = WeiboConfig() return config.resolve_account()