Files
abot/wechat_ipad/providers/server_864/message.py
liuwei 539bebd58b 补齐864消息接口适配并记录联调分层结论
- 修正 send_voice 到真实 SendVoice 路由

- 为 864 补上名片发送与视频发送的初版适配入口

- 更新路线图,记录消息接口在未建立连接对象时返回该链接不存在的联调结论
2026-05-07 12:16:11 +08:00

336 lines
13 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",
# 864 的语音发送真实路由是 `/message/SendVoice`
# 1. controller 方法名虽然叫 `UploadVoiceRequestApi`
# 2. 但 router.go 暴露给外部的路径是 `SendVoice`
# 3. 这里按真实注册路由适配,避免后续一接登录态就踩 404。
"/message/SendVoice",
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):
"""发送视频消息。
当前实现说明:
1. 864 走的是 `CdnUploadVideo` 路由,而不是 855 的 `SendVideo`
2. body 里的 `VideoData` 在 JSON 中要传整数数组,因此这里会把视频字节展开成 `list[int]`
3. 返回结构和旧项目三元组并不完全一致,所以仍复用本地兜底消息回执。
"""
return await self._queue_message(self._send_video_message, wxid, video, image)
async def _send_video_message(self, wxid: str, video: Union[str, bytes, os.PathLike], image=None):
if isinstance(video, str):
video_bytes = base64.b64decode(video.split(",", 1)[1] if "," in video else video)
elif isinstance(video, bytes):
video_bytes = video
elif isinstance(video, os.PathLike):
async with aiofiles.open(video, "rb") as f:
video_bytes = await f.read()
else:
raise ValueError("video should be str, bytes, or path")
thumb_base64 = ""
if image is not None:
thumb_base64 = await self._read_base64_payload(image)
data = await self._request_data(
"post",
"/message/CdnUploadVideo",
json_body={
"ToUserName": wxid,
"VideoData": list(video_bytes),
"ThumbData": thumb_base64,
},
timeout=120,
)
return self._extract_send_message_result(data, fallback_target=wxid)
async def send_card_message(self, wxid: str, card_wxid: str, card_nickname: str, card_alias: str = ""):
"""发送名片消息。"""
return await self._queue_message(self._send_card_message, wxid, card_wxid, card_nickname, card_alias)
async def _send_card_message(self, wxid: str, card_wxid: str, card_nickname: str, card_alias: str = ""):
data = await self._request_data(
"post",
"/message/ShareCardMessage",
json_body={
"ToUserName": wxid,
"CardWxId": card_wxid,
"CardNickName": card_nickname,
"CardAlias": card_alias,
"CardFlag": 0,
},
timeout=20,
)
return self._extract_send_message_result(data, fallback_target=wxid)
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}