198 lines
6.9 KiB
Python
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)]
|