新增864 provider并打通server_key配置

- 新增 server_864 独立 provider 目录,接入登录、消息轮询、联系人、群资料、用户资料与朋友圈基础能力

- 扩展 gateway、robot 与配置归一化逻辑,支持 server_864/864 别名和 WECHAT_SERVER_KEY

- 更新配置示例与多版本适配路线图,明确 864 第一版接入范围和后续待补项
This commit is contained in:
liuwei
2026-05-07 11:24:33 +08:00
parent 86f8d57874
commit ff33edb0d1
18 changed files with 1174 additions and 7 deletions

View File

@@ -0,0 +1,119 @@
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:
payload = await response.json(content_type=None)
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()
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