Files
inquiry_robot/inquiry-agent/agent/channel/wshoto/client.py
T

198 lines
6.9 KiB
Python

"""
微盛 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)]