import time from typing import Any import aiohttp from wechat_ipad.errors import UserLoggedOut class Server864APIClientBase: """864 provider 的基础 HTTP 访问封装。 设计说明: 1. 864 与 855 一样仍然是 HTTP 驱动,但核心鉴权从 `wxid` 切到了固定 `key`; 2. 这里把请求拼装、错误转换、常见返回结构解析集中收口,避免每个 mixin 重复写样板代码; 3. 对外仍尽量返回项目当前可直接消费的 dict / list,保持切换成本低。 """ def __init__(self, ip: str, port: int, server_key: str = "", **kwargs): del kwargs self.ip = ip self.port = port self.server_key = str(server_key or "").strip() self.wxid = "" self.nickname = "" self.alias = "" self.phone = "" self.signature = "" # 864 的发送接口与 855 一样,很多业务链路仍会依赖这些消息回执字段: # 1. 但 864 有些接口返回的是 protobuf JSON,而不是统一 msg id 三元组; # 2. 因此这里准备一份本地递增 client id,在响应缺字段时作为兼容兜底; # 3. 这样可以保证上层至少拿到稳定结构,而不会因为个别 server 少字段直接崩掉。 self._fallback_client_msg_id = int(time.time() * 1000) super().__init__() @property def base_url(self) -> str: return f"http://{self.ip}:{self.port}" def _ensure_server_key(self) -> str: """确保 864 固定鉴权 key 已配置。""" if not self.server_key: raise ValueError("server_864 缺少 server_key,请在 .env 中配置 WECHAT_SERVER_KEY") return self.server_key async def _request_payload( self, method: str, path: str, *, params: dict[str, Any] | None = None, json_body: dict[str, Any] | None = None, timeout: int = 20, ) -> dict[str, Any]: """向 864 server 发送请求,并保留原始 payload 便于上层按需解析。""" merged_params = dict(params or {}) merged_params["key"] = self._ensure_server_key() request_timeout = aiohttp.ClientTimeout(total=timeout) async with aiohttp.ClientSession(timeout=request_timeout) as session: async with session.request( method.upper(), f"{self.base_url}{path}", params=merged_params, json=json_body, ) as response: try: payload = await response.json(content_type=None) except Exception: raw_text = await response.text() # 864 少数接口会直接返回纯文本而不是标准 DTO: # 1. 当前已在 `LogOut` 链路里碰到这种情况,旧逻辑会先炸 JSON 解析,再把真正状态信息吞掉; # 2. 对 2xx 响应来说,这通常只是“接口风格不统一”,不应该直接视为致命协议错误; # 3. 因此这里统一包成兼容 DTO,保留原始文本给上层按需处理。 payload = { "Code": 200 if response.status < 400 else response.status, "Data": None, "Text": str(raw_text or "").strip(), } return self._validate_payload(payload) async def _request_data( self, method: str, path: str, *, params: dict[str, Any] | None = None, json_body: dict[str, Any] | None = None, timeout: int = 20, ) -> Any: """获取成功返回中的 Data 字段。""" payload = await self._request_payload( method, path, params=params, json_body=json_body, timeout=timeout, ) return payload.get("Data") def _validate_payload(self, payload: Any) -> dict[str, Any]: """把 864 的 DTO 返回统一转换成 Python 异常或 dict。""" if not isinstance(payload, dict): raise ValueError(f"server_864 返回了无法识别的响应结构: {payload!r}") code = payload.get("Code") if code == 200: return payload message = str(payload.get("Text") or payload.get("Message") or "server_864 请求失败").strip() # 864 某些登录接口会用非 200 编码表达“当前 key 已经在线”: # 1. 对二维码申请链路来说,这更接近一种状态回执,而不是硬失败; # 2. 如果这里直接抛异常,上层就会把“已经在线”误判成致命错误并退出线程; # 3. 因此先保留 payload 原样放行,让 runtime 再决定是复用现有登录态还是继续走扫码。 if "该链接已绑定微信号" in message and "在线状态良好" in message: return payload lowered_message = message.lower() if any(keyword in lowered_message for keyword in ("重新登录", "已退出登录", "离线", "账号需要重新登录")): raise UserLoggedOut(message) raise Exception(message) def _next_fallback_message_ids(self) -> tuple[int, int, int]: """生成一组兼容旧调用面的消息回执兜底值。""" self._fallback_client_msg_id += 1 client_msg_id = self._fallback_client_msg_id create_time = int(time.time()) return client_msg_id, create_time, 0 @staticmethod def _pick_first(data: Any, *keys: str) -> Any: """从 dict 中按优先级取第一个存在的字段。""" if not isinstance(data, dict): return None for key in keys: if key in data and data.get(key) is not None: return data.get(key) return None