Files
abot/wechat_ipad/providers/server_864/message.py
liuwei ff33edb0d1 新增864 provider并打通server_key配置
- 新增 server_864 独立 provider 目录,接入登录、消息轮询、联系人、群资料、用户资料与朋友圈基础能力

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

- 更新配置示例与多版本适配路线图,明确 864 第一版接入范围和后续待补项
2026-05-07 11:24:33 +08:00

285 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import asyncio
import base64
import os
import time
from asyncio import Future, Queue, sleep
from typing import Union
import aiofiles
from utils.trace_context import format_trace_prefix
from wechat_ipad.providers.server_864.base import Server864APIClientBase
class MessageMixin(Server864APIClientBase):
"""864 消息发送与消息同步接口。"""
def __init__(self):
# 这里不能再走 `super().__init__()`
# 1. provider 显式构造时已经先初始化过 `Server864APIClientBase`
# 2. 若继续沿 MRO 调 `super()`,会再次命中基类构造并要求重复传 ip/port
# 3. 因此消息模块只初始化自己独有的发送队列状态即可。
self._message_queue = Queue()
self._is_processing = False
async def _process_message_queue(self):
"""串行处理消息发送,保持现有项目的节流语义。"""
if self._is_processing:
return
self._is_processing = True
while True:
if self._message_queue.empty():
self._is_processing = False
break
func, args, kwargs, future = await self._message_queue.get()
try:
result = await func(*args, **kwargs)
future.set_result(result)
except Exception as e:
future.set_exception(e)
finally:
self._message_queue.task_done()
await sleep(1)
async def _queue_message(self, func, *args, **kwargs):
"""将发送动作排入本地发送队列。"""
future = Future()
await self._message_queue.put((func, args, kwargs, future))
if not self._is_processing:
asyncio.create_task(self._process_message_queue())
return await future
@staticmethod
def _normalize_base64_payload(data: Union[str, bytes, os.PathLike]) -> str:
"""把图片/语音等输入统一转成 base64 字符串。"""
if isinstance(data, str):
return data.split(",", 1)[1] if "," in data else data
if isinstance(data, bytes):
return base64.b64encode(data).decode()
raise ValueError("需要先读取文件路径后再处理")
async def _read_base64_payload(self, data: Union[str, bytes, os.PathLike]) -> str:
"""兼容路径/字节/字符串三种输入。"""
if isinstance(data, os.PathLike):
async with aiofiles.open(data, "rb") as f:
return base64.b64encode(await f.read()).decode()
return self._normalize_base64_payload(data)
def _extract_send_message_result(self, data, *, fallback_target: str = "") -> tuple[int, int, int]:
"""把 864 的发送回执尽量归一化成旧项目习惯的三元组。"""
if isinstance(data, list) and data:
item = dict(data[0] or {})
elif isinstance(data, dict):
item = dict(data)
else:
item = {}
resp = item.get("resp") or {}
base_response = resp.get("BaseResponse") or resp.get("baseResponse") or {}
client_msg_id = (
item.get("ClientMsgId")
or item.get("clientMsgId")
or resp.get("ClientMsgId")
or resp.get("clientMsgId")
or resp.get("MsgId")
or resp.get("msgId")
)
create_time = item.get("CreateTime") or item.get("createTime") or int(time.time())
new_msg_id = (
item.get("NewMsgId")
or item.get("newMsgId")
or resp.get("NewMsgId")
or resp.get("newMsgId")
or resp.get("MsgSvrId")
or resp.get("msgSvrId")
)
if client_msg_id is None and base_response.get("Ret") == 0:
return self._next_fallback_message_ids()
if client_msg_id is None:
client_msg_id, fallback_create_time, fallback_new_msg_id = self._next_fallback_message_ids()
return client_msg_id, fallback_create_time, fallback_new_msg_id
return int(client_msg_id), int(create_time or 0), int(new_msg_id or 0)
async def send_text_message(self, wxid: str, content: str, at: Union[list, str] = "") -> tuple[int, int, int]:
return await self._queue_message(self._send_text_message, wxid, content, at)
async def _send_text_message(self, wxid: str, content: str, at: Union[list, str] = "") -> tuple[int, int, int]:
at_list = [item for item in (at if isinstance(at, list) else str(at or "").split(",")) if item]
data = await self._request_data(
"post",
"/message/SendTextMessage",
json_body={"MsgItem": [{
"ToUserName": wxid,
"TextContent": content,
"ImageContent": "",
"MsgType": 1,
"AtWxIDList": at_list,
}]},
timeout=20,
)
return self._extract_send_message_result(data, fallback_target=wxid)
async def send_image_message(self, wxid: str, image: Union[str, bytes, os.PathLike]) -> tuple[int, int, int]:
return await self._queue_message(self._send_image_message, wxid, image)
async def _send_image_message(self, wxid: str, image: Union[str, bytes, os.PathLike]) -> tuple[int, int, int]:
image_base64 = await self._read_base64_payload(image)
data = await self._request_data(
"post",
"/message/SendImageMessage",
json_body={"MsgItem": [{
"ToUserName": wxid,
"TextContent": "",
"ImageContent": image_base64,
"MsgType": 2,
"AtWxIDList": [],
}]},
timeout=60,
)
return self._extract_send_message_result(data, fallback_target=wxid)
async def send_voice_message(
self,
wxid: str,
voice: Union[str, bytes, os.PathLike],
format: str = "silk",
) -> tuple[int, int, int]:
return await self._queue_message(self._send_voice_message, wxid, voice, format)
async def _send_voice_message(
self,
wxid: str,
voice: Union[str, bytes, os.PathLike],
format: str = "silk",
) -> tuple[int, int, int]:
voice_base64 = await self._read_base64_payload(voice)
data = await self._request_data(
"post",
"/message/UploadVoiceRequest",
json_body={
"ToUserName": wxid,
"VoiceData": voice_base64,
"VoiceSecond": 1,
"VoiceFormat": 4 if str(format).lower() in {"silk", "wav", "mp3"} else 0,
},
timeout=60,
)
return self._extract_send_message_result(data, fallback_target=wxid)
async def send_link_xml_message(self, xml: str, towxid: str) -> tuple[int, int, int]:
return await self._queue_message(self._send_link_xml_message, xml, towxid)
async def _send_link_xml_message(self, xml: str, towxid: str) -> tuple[int, int, int]:
data = await self._request_data(
"post",
"/message/SendAppMessage",
json_body={"AppList": [{
"ToUserName": towxid,
"ContentXML": xml,
"ContentType": 5,
}]},
timeout=20,
)
return self._extract_send_message_result(data, fallback_target=towxid)
async def send_link_message(
self,
wxid: str,
url: str,
title: str = "",
description: str = "",
thumb_url: str = "",
) -> tuple[int, int, int]:
xml = (
f"<msg><appmsg><title>{title}</title><des>{description}</des>"
f"<url>{url}</url><thumburl>{thumb_url}</thumburl></appmsg></msg>"
)
return await self.send_link_xml_message(xml, wxid)
async def send_emoji_message(self, wxid: str, md5: str, total_length: int):
return await self._queue_message(self._send_emoji_message, wxid, md5, total_length)
async def _send_emoji_message(self, wxid: str, md5: str, total_length: int):
return await self._request_data(
"post",
"/message/SendEmojiMessage",
json_body={"EmojiList": [{
"ToUserName": wxid,
"EmojiMd5": md5,
"EmojiSize": int(total_length),
}]},
timeout=20,
)
async def revoke_message(self, wxid: str, client_msg_id: int, create_time: int, new_msg_id: int) -> bool:
del create_time
await self._request_data(
"post",
"/message/RevokeMsgNew",
json_body={
"NewMsgId": str(new_msg_id),
"ClientMsgId": int(client_msg_id),
"CreateTime": int(time.time()),
"ToUserName": wxid,
},
timeout=20,
)
return True
async def send_video_message(self, wxid: str, video: Union[str, bytes, os.PathLike], image=None):
"""864 首版暂未做视频主动发送,先明确抛错避免静默失败。"""
del wxid, video, image
raise NotImplementedError("server_864 第一版暂未实现 send_video_message可后续补 CDN 视频发送适配")
async def send_card_message(self, wxid: str, card_wxid: str, card_nickname: str, card_alias: str = ""):
del wxid, card_wxid, card_nickname, card_alias
raise NotImplementedError("server_864 第一版暂未实现 send_card_message")
async def send_app_message(self, wxid: str, xml: str, type: int):
data = await self._request_data(
"post",
"/message/SendAppMessage",
json_body={"AppList": [{
"ToUserName": wxid,
"ContentXML": xml,
"ContentType": int(type),
}]},
timeout=20,
)
return self._extract_send_message_result(data, fallback_target=wxid)
async def sync_message(self, count: int = 0) -> dict:
"""轮询 864 的 HTTP 同步消息接口,并归一化成 `AddMsgs` 结构。"""
data = await self._request_data(
"post",
"/message/HttpSyncMsg",
json_body={"Count": int(count)},
timeout=25,
)
if isinstance(data, dict) and "AddMsgs" in data:
return data
if isinstance(data, list):
return {"AddMsgs": data, "raw": data}
if isinstance(data, dict):
add_msgs = (
data.get("AddMsgs")
or data.get("Msgs")
or data.get("MsgList")
or data.get("msgList")
or data.get("Messages")
or data.get("messages")
or []
)
return {"AddMsgs": list(add_msgs or []), "raw": data}
return {"AddMsgs": [], "raw": data}