""" 微盛 OpenAPI 客户端:换票 + 拉员工群消息。 本文件职责:带超时的 HTTPS GET;token 缓存在本前缀 Redis。 禁止:在回调线程调用;禁止把 access_token / secret 打进日志;禁止动现网官方 seq。 """ from __future__ import annotations import logging from typing import Any, Optional import httpx from agent.config import Settings, get_settings from agent.redis_coord import get_redis_runtime from agent.redis_coord.client import RedisClient from agent.redis_coord.token_cache import get_wshoto_token, set_wshoto_token logger = logging.getLogger(__name__) _TOKEN_PATH = "/openapi/access_token" _LIST_PATH = "/openapi/chat/message/list" _HTTP_TIMEOUT = 8.0 class WshotoError(RuntimeError): """管家接口业务失败(code!=0)。message 不得含密钥。""" def __init__(self, code: int, msg: str) -> None: self.code = int(code) self.msg = str(msg or "") super().__init__(f"wshoto code={self.code} msg={self.msg}") def wshoto_ready(settings: Optional[Settings] = None) -> bool: """有 AppId/Secret 且开关未关,才允许 Worker 去拉。""" cfg = settings or get_settings() if not bool(getattr(cfg, "wshoto_poll_enabled", True)): return False return bool((cfg.wshoto_app_id or "").strip() and (cfg.wshoto_app_secret or "").strip()) def _is_token_error(code: int, msg: str) -> bool: text = (msg or "").lower() return code in {40001, 40014, 42001} or "token" in text or "过期" in (msg or "") class WshotoOpenClient: """ 微盛查询客户端。 调用时机:Worker HTTP 槽。失败抛 WshotoError 或返回空列表由调用方决定。 """ def __init__( self, settings: Optional[Settings] = None, *, redis: Optional[RedisClient] = None, ) -> None: self.settings = settings or get_settings() self._redis = redis def _redis_client(self) -> Optional[RedisClient]: if self._redis is not None: return self._redis try: return get_redis_runtime().client except Exception: logger.exception("微盛 token 缓存:Redis 不可用,本次直打换票") return None def _base(self) -> str: return (self.settings.wshoto_base_url or "https://open.wshoto.com").rstrip("/") def fetch_token(self, *, force: bool = False) -> str: """ 取 access_token:先读本前缀缓存,没有再换票。 副作用:可能写 Redis token:wshoto。不会推进任何消息游标。 """ app_id = (self.settings.wshoto_app_id or "").strip() secret = (self.settings.wshoto_app_secret or "").strip() if not app_id or not secret: raise WshotoError(0, "missing_app_credentials") cache = self._redis_client() if cache is not None and not force: cached = get_wshoto_token(cache, app_id=app_id) if cached: return cached url = f"{self._base()}{_TOKEN_PATH}" try: resp = httpx.get( url, params={"appid": app_id, "app_secret": secret}, timeout=_HTTP_TIMEOUT, ) resp.raise_for_status() body = resp.json() except Exception as exc: # noqa: BLE001 raise WshotoError(0, f"token_http:{type(exc).__name__}") from exc code = int(body.get("code") if body.get("code") is not None else -1) if code != 0: raise WshotoError(code, str(body.get("msg") or "token_failed")) data = body.get("data") if isinstance(body.get("data"), dict) else {} token = str(data.get("access_token") or "").strip() if not token: raise WshotoError(code, "empty_access_token") expires = int(data.get("expires_in") or 7200) if cache is not None: set_wshoto_token(cache, app_id=app_id, token=token, ttl_seconds=max(60, expires - 120)) logger.info("微盛换票成功 expires_in=%s", expires) return token def list_group_messages( self, *, userid: str, target_id: str = "", last_seq: int = 0, page_size: int = 50, msg_time_start: int = 0, msg_time_end: int = 0, ) -> list[dict[str, Any]]: """ 拉指定员工的群聊消息(type=1)。 last_seq 只是本次查询过滤,不是官方 SDK 消费确认。 首次不要传 last_seq=0 翻全量,应配合时间窗。 """ token = self.fetch_token() return self._list_once( token=token, userid=userid, target_id=target_id, last_seq=last_seq, page_size=page_size, msg_time_start=msg_time_start, msg_time_end=msg_time_end, retry_token=True, ) def _list_once( self, *, token: str, userid: str, target_id: str, last_seq: int, page_size: int, msg_time_start: int, msg_time_end: int, retry_token: bool, ) -> list[dict[str, Any]]: params: dict[str, Any] = { "access_token": token, "type": 1, "page_size": max(1, min(int(page_size or 50), 100)), } if userid: params["userid"] = userid if target_id: params["target_id"] = target_id if last_seq > 0: params["last_seq"] = int(last_seq) if msg_time_start > 0 and msg_time_end > 0: params["msg_time_start"] = int(msg_time_start) params["msg_time_end"] = int(msg_time_end) url = f"{self._base()}{_LIST_PATH}" try: resp = httpx.get(url, params=params, timeout=_HTTP_TIMEOUT) resp.raise_for_status() body = resp.json() except Exception as exc: # noqa: BLE001 raise WshotoError(0, f"list_http:{type(exc).__name__}") from exc code = int(body.get("code") if body.get("code") is not None else -1) msg = str(body.get("msg") or "") if code != 0: if retry_token and _is_token_error(code, msg): fresh = self.fetch_token(force=True) return self._list_once( token=fresh, userid=userid, target_id=target_id, last_seq=last_seq, page_size=page_size, msg_time_start=msg_time_start, msg_time_end=msg_time_end, retry_token=False, ) raise WshotoError(code, msg or "list_failed") data = body.get("data") if isinstance(body.get("data"), dict) else {} records = data.get("records") if isinstance(data, dict) else None if not isinstance(records, list): return [] return [dict(x) for x in records if isinstance(x, dict)]