chore: sync current WechatHookBot workspace

This commit is contained in:
2026-03-09 15:48:45 +08:00
parent 4016c1e6eb
commit 9119e2307d
195 changed files with 24438 additions and 17498 deletions

View File

@@ -1,19 +1,38 @@
"""
WechatHook - 微信 Hook API 封装层
基于个微大客户版 DLL 实现的微信 Hook 接口封装
"""
from .loader import NoveLoader
from .client import WechatHookClient
from .message_types import MessageType, MESSAGE_TYPE_MAP, normalize_message
__all__ = [
'NoveLoader',
'WechatHookClient',
'MessageType',
'MESSAGE_TYPE_MAP',
'normalize_message',
]
__version__ = '1.0.0'
"""
WechatHook - 微信 Hook API 封装层
基于新版 HTTP Hook API 实现的微信接口封装
支持 HTTP 通信,无需 DLL 注入代码
"""
from .client import WechatHookClient
from .http_client import HttpClient
from .http_server import CallbackServer, MessageNormalizer
from .message_types import (
MessageType,
MESSAGE_TYPE_MAP,
WECHAT_MSG_TYPE_MAP,
normalize_message,
normalize_from_callback,
get_internal_msg_type,
)
__all__ = [
# 主客户端
'WechatHookClient',
# HTTP 组件
'HttpClient',
'CallbackServer',
'MessageNormalizer',
# 消息类型
'MessageType',
'MESSAGE_TYPE_MAP',
'WECHAT_MSG_TYPE_MAP',
'normalize_message',
'normalize_from_callback',
'get_internal_msg_type',
]
__version__ = '2.0.0'

File diff suppressed because it is too large Load Diff

974
WechatHook/http_client.py Normal file
View File

@@ -0,0 +1,974 @@
"""
HTTP 客户端模块
用于与新版 Hook API 进行 HTTP 通信
"""
import asyncio
import time
from urllib.parse import urlparse
import httpx
from typing import Optional, Dict, Any, List
from loguru import logger
class HttpClient:
"""
HTTP API 客户端
封装所有与 Hook API 的 HTTP 通信
"""
def __init__(self, base_url: str = "http://127.0.0.1:8888", timeout: float = 30.0):
"""
初始化 HTTP 客户端
Args:
base_url: API 基础 URL
timeout: 请求超时时间(秒)
"""
self.base_url = base_url.rstrip("/")
self.timeout = timeout
self._client: Optional[httpx.AsyncClient] = None
self._last_hook_probe_ts = 0.0
self._last_hook_probe_ok: Optional[bool] = None
self._last_hook_probe_error: str = ""
# 全局串行:所有 Hook API 只允许一个请求在飞行中。
self._hook_request_semaphore = asyncio.Semaphore(1)
self._hook_request_delay = 0.4
# 发送消息专用信号量(串行发送,避免风控)
self._send_semaphore = asyncio.Semaphore(1)
self._send_delay = 0.5 # 发送间隔
async def _get_client(self) -> httpx.AsyncClient:
"""获取或创建 HTTP 客户端"""
if self._client is None or self._client.is_closed:
self._client = httpx.AsyncClient(
base_url=self.base_url,
timeout=httpx.Timeout(self.timeout),
headers={"Content-Type": "application/json"},
trust_env=False
)
return self._client
async def close(self):
"""关闭 HTTP 客户端"""
if self._client and not self._client.is_closed:
await self._client.aclose()
self._client = None
async def _probe_hook_port(self) -> bool:
"""探测 Hook 端口是否可连接(用于定位连接失败原因)"""
now = time.time()
if now - self._last_hook_probe_ts < 2.0:
return self._last_hook_probe_ok is True
self._last_hook_probe_ts = now
parsed = urlparse(self.base_url)
host = parsed.hostname or "127.0.0.1"
port = parsed.port or (443 if parsed.scheme == "https" else 80)
try:
reader, writer = await asyncio.wait_for(
asyncio.open_connection(host, port),
timeout=0.8
)
writer.close()
await writer.wait_closed()
self._last_hook_probe_ok = True
self._last_hook_probe_error = ""
return True
except Exception as e:
self._last_hook_probe_ok = False
self._last_hook_probe_error = str(e)
return False
async def _request(
self,
method: str,
endpoint: str,
data: Optional[Dict[str, Any]] = None,
**kwargs
) -> Optional[Dict[str, Any]]:
"""
发送 HTTP 请求
Args:
method: HTTP 方法 (GET, POST, etc.)
endpoint: API 端点
data: 请求数据
Returns:
响应数据字典,失败返回 None
"""
if self._hook_request_semaphore.locked():
logger.debug("Hook API 排队中,等待串行执行")
async with self._hook_request_semaphore:
max_retries = 2
for attempt in range(max_retries + 1):
try:
if self._hook_request_delay > 0:
await asyncio.sleep(self._hook_request_delay)
client = await self._get_client()
full_url = f"{self.base_url}{endpoint}"
logger.debug(f"[HTTP] {method} {full_url} data={data}")
if method.upper() == "GET":
response = await client.get(endpoint, params=data, **kwargs)
elif method.upper() == "POST":
response = await client.post(endpoint, json=data, **kwargs)
elif method.upper() == "PUT":
response = await client.put(endpoint, json=data, **kwargs)
else:
logger.error(f"不支持的 HTTP 方法: {method}")
return None
logger.debug(f"[HTTP] 响应状态: {response.status_code}")
response.raise_for_status()
result = response.json()
# 群成员列表响应太长,只记录摘要
if isinstance(result, dict) and 'chatRoomMember' in result.get('newChatroomData', {}):
member_count = len(result['newChatroomData']['chatRoomMember'])
logger.debug(f"[HTTP] 响应内容: 群成员列表 (共 {member_count} 人)")
else:
logger.debug(f"[HTTP] 响应内容: {result}")
return result
except httpx.ConnectError as e:
if attempt < max_retries:
wait = 0.2 * (attempt + 1)
logger.warning(f"HTTP 连接失败: {endpoint} -> {e}, {wait:.1f}s 后重试")
await asyncio.sleep(wait)
continue
hook_ok = await self._probe_hook_port()
logger.error(
f"HTTP 请求失败: {endpoint} -> {e} | "
f"hook_port_open={hook_ok} base_url={self.base_url} "
f"probe_error={self._last_hook_probe_error}"
)
return None
except httpx.TimeoutException:
logger.error(f"HTTP 请求超时: {endpoint}")
return None
except httpx.HTTPStatusError as e:
logger.error(f"HTTP 状态错误: {endpoint} -> {e.response.status_code}")
return None
except Exception as e:
logger.error(f"HTTP 请求失败: {endpoint} -> {e}")
return None
async def post(self, endpoint: str, data: Optional[Dict[str, Any]] = None, **kwargs) -> Optional[Dict[str, Any]]:
"""发送 POST 请求"""
return await self._request("POST", endpoint, data, **kwargs)
async def get(self, endpoint: str, params: Optional[Dict[str, Any]] = None, **kwargs) -> Optional[Dict[str, Any]]:
"""发送 GET 请求"""
return await self._request("GET", endpoint, params, **kwargs)
# ==================== 消息发送 API ====================
async def send_text(self, wxid: str, msg: str) -> bool:
"""
发送文本消息
Args:
wxid: 接收者 wxid
msg: 文本内容
Returns:
是否发送成功
"""
async with self._send_semaphore:
if self._send_delay > 0:
await asyncio.sleep(self._send_delay)
data = {"wxid": wxid, "msg": msg}
logger.debug(f"[DEBUG] 发送文本请求: wxid={wxid}, msg长度={len(msg)}")
result = await self.post("/api/send_text_msg", data)
logger.info(f"[DEBUG] 发送文本 API 响应: {result}")
if result is None:
logger.error(f"发送文本失败: {wxid}, API 返回 None (可能连接失败)")
return False
# 检查多种成功响应格式
if result.get("code") == 1:
logger.info(f"发送文本成功: {wxid}")
return True
# 某些 API 使用 baseResponse.ret == 0 表示成功
base_response = result.get("baseResponse", {})
if base_response.get("ret") == 0:
logger.info(f"发送文本成功 (baseResponse): {wxid}")
return True
# 检查 Success 字段
if result.get("Success") is True:
logger.info(f"发送文本成功 (Success): {wxid}")
return True
logger.error(f"发送文本失败: {wxid}, 响应: {result}")
return False
async def send_image(self, wxid: str, image_path: str, timeout: float = 120.0) -> bool:
"""
发送图片消息
Args:
wxid: 接收者 wxid
image_path: 图片文件路径
timeout: 超时时间(秒)
Returns:
是否发送成功
"""
async with self._send_semaphore:
if self._send_delay > 0:
await asyncio.sleep(self._send_delay)
data = {"wxid": wxid, "image_path": image_path}
result = await self.post("/api/send_image_msg", data, timeout=httpx.Timeout(timeout))
if result is None:
logger.error(f"发送图片失败: {wxid}, API 返回 None (可能连接失败)")
return False
if result.get("code") == 1:
logger.info(f"发送图片成功: {wxid}")
return True
base_response = result.get("baseResponse", {})
if base_response.get("ret") == 0:
logger.info(f"发送图片成功 (baseResponse): {wxid}")
return True
if result.get("Success") is True or result.get("errCode") == 1:
logger.info(f"发送图片成功 (Success/errCode): {wxid}")
return True
logger.error(f"发送图片失败: {wxid}, 响应: {result}")
return False
async def send_file(self, wxid: str, file_path: str, timeout: float = 120.0) -> bool:
"""
发送文件消息
Args:
wxid: 接收者 wxid
file_path: 文件路径
timeout: 超时时间(秒)
Returns:
是否发送成功
"""
data = {"wxid": wxid, "full_path": file_path}
result = await self.post("/api/send_file_msg", data, timeout=httpx.Timeout(timeout))
if result is None:
logger.error(f"发送文件失败: {wxid}, API 返回 None (可能连接失败)")
return False
if result.get("code") == 1:
logger.info(f"发送文件成功: {wxid}")
return True
base_response = result.get("baseResponse", {})
if base_response.get("ret") == 0:
logger.info(f"发送文件成功 (baseResponse): {wxid}")
return True
if result.get("Success") is True or result.get("errCode") == 1:
logger.info(f"发送文件成功 (Success/errCode): {wxid}")
return True
logger.error(f"发送文件失败: {wxid}, 响应: {result}")
return False
async def send_at_text(self, room_id: str, msg: str, wxids: str) -> bool:
"""
发送 @ 消息
Args:
room_id: 群聊 ID
msg: 消息内容
wxids: 要 @ 的 wxid多个用逗号分隔notify@all 表示 @所有人
Returns:
是否发送成功
"""
data = {"room_id": room_id, "msg": msg, "wxids": wxids}
result = await self.post("/api/send_at_text", data)
if result and result.get("code") == 1:
logger.info(f"发送 @ 消息成功: {room_id}")
return True
logger.error(f"发送 @ 消息失败: {room_id}")
return False
async def send_card(self, to_wxid: str, card_wxid: str) -> bool:
"""
发送名片消息
Args:
to_wxid: 接收者 wxid
card_wxid: 名片的 wxid
Returns:
是否发送成功
"""
data = {"towxid": to_wxid, "fromwxid": card_wxid}
result = await self.post("/api/send_card_msg", data)
if result:
base_response = result.get("baseResponse", {})
if base_response.get("ret") == 0:
logger.info(f"发送名片成功: {to_wxid}")
return True
logger.error(f"发送名片失败: {to_wxid}")
return False
async def send_voice(self, wxid: str, voice_path: str) -> bool:
"""
发送语音消息
Args:
wxid: 接收者 wxid
voice_path: 语音文件路径silk
Returns:
是否发送成功
"""
def _is_success(resp: Optional[Dict[str, Any]]) -> bool:
if not resp:
return False
if resp.get("code") == 1 or resp.get("Success") is True:
return True
base_response = resp.get("baseResponse", {})
if base_response.get("ret") == 0:
return True
return False
# 新接口: /api/send_voice (toWxid, silkPath)
data_new = {"toWxid": wxid, "silkPath": voice_path}
result = await self.post("/api/send_voice", data_new)
if _is_success(result):
logger.info(f"发送语音成功: {wxid}")
return True
logger.error(f"发送语音失败: {wxid}, 响应: {result}")
return False
async def send_xml(self, wxid: str, xml: str) -> bool:
"""
发送 XML 消息(旧协议,已弃用)
Args:
wxid: 接收者 wxid
xml: XML 内容
Returns:
是否发送成功
"""
data = {"wxid": wxid, "xml": xml}
result = await self.post("/api/send_xml_msg", data)
if result and result.get("code") == 1:
logger.info(f"发送 XML 成功: {wxid}")
return True
logger.error(f"发送 XML 失败: {wxid}")
return False
async def send_app_msg(self, wxid: str, content: str, msg_type: str = "5") -> bool:
"""
发送卡片/XML消息新协议
Args:
wxid: 接收者 wxid
content: appmsg XML 内容(不含外层 <msg> 标签)
msg_type: 消息类型,如 "5" 为链接卡片,"19" 为聊天记录等
Returns:
是否发送成功
"""
data = {"wxid": wxid, "content": content, "type": msg_type}
result = await self.post("/api/send_app_msg", data)
if result is None:
logger.error(f"发送卡片消息失败: {wxid}, API 返回 None")
return False
# 检查多种成功响应格式
if result.get("code") == 1:
logger.info(f"发送卡片消息成功: {wxid}")
return True
base_response = result.get("baseResponse", {})
if base_response.get("ret") == 0:
logger.info(f"发送卡片消息成功 (baseResponse): {wxid}")
return True
if result.get("Success") is True:
logger.info(f"发送卡片消息成功 (Success): {wxid}")
return True
logger.error(f"发送卡片消息失败: {wxid}, 响应: {result}")
return False
async def revoke_message(self, new_msg_id: str) -> bool:
"""
撤回消息
Args:
new_msg_id: 消息 ID (newMsgId)
Returns:
是否撤回成功
"""
data = {"newMsgId": new_msg_id}
result = await self.post("/api/revoke_msg", data)
if result:
logger.info(f"撤回消息成功: {new_msg_id}")
return True
logger.error(f"撤回消息失败: {new_msg_id}")
return False
# ==================== 好友管理 API ====================
async def get_friend_list(self) -> List[Dict]:
"""
获取好友列表
Returns:
好友列表
"""
result = await self.post("/api/get_frien_lists")
if result and "data" in result:
friends = result.get("data", [])
logger.info(f"获取好友列表成功,共 {len(friends)} 个好友")
return friends
# 新接口兜底:先执行初始化再尝试获取
logger.warning("获取好友列表失败,尝试执行微信初始化后重试")
await self.wechat_init()
result = await self.post("/api/get_frien_lists")
if result and "data" in result:
friends = result.get("data", [])
logger.info(f"获取好友列表成功(初始化后),共 {len(friends)} 个好友")
return friends
# 兜底:触发全量更新好友列表接口
logger.warning("获取好友列表仍失败,尝试更新好友列表接口")
result = await self.post("/api/update_all_friend")
if result and "data" in result:
friends = result.get("data", [])
logger.info(f"获取好友列表成功(更新后),共 {len(friends)} 个好友")
return friends
logger.error("获取好友列表失败")
return []
async def get_friend_info(self, wxid: str) -> Optional[Dict]:
"""
获取好友资料(网络获取)
Args:
wxid: 好友 wxid
Returns:
好友资料
"""
data = {"wxid": wxid}
result = await self.post("/api/get_contact_profile", data)
if result:
logger.info(f"获取好友资料成功: {wxid}")
return result
logger.error(f"获取好友资料失败: {wxid}")
return None
async def get_friend_info_cache(self, wxid: str) -> Optional[Dict]:
"""
快速获取好友资料(缓存)
Args:
wxid: 好友 wxid
Returns:
好友资料
"""
data = {"wxid": wxid}
result = await self.post("/api/get_contact_profile_cache", data)
if result:
return result
return None
async def add_friend(self, wxid: str, verify_msg: str = "", scene: int = 3) -> bool:
"""
添加好友
Args:
wxid: 要添加的 wxid
verify_msg: 验证消息
scene: 添加场景
Returns:
是否发送成功
"""
data = {"wxid": wxid, "verify_msg": verify_msg, "scene": scene}
result = await self.post("/api/add_friend", data)
if result:
logger.info(f"发送好友请求成功: {wxid}")
return True
logger.error(f"发送好友请求失败: {wxid}")
return False
async def accept_friend(self, v3: str, v4: str, scene: int) -> bool:
"""
同意好友请求
Args:
v3: 好友请求的 v3 参数
v4: 好友请求的 v4 参数
scene: 场景值
Returns:
是否成功
"""
data = {"v3": v3, "v4": v4, "scene": scene}
result = await self.post("/api/accept_friend", data)
if result:
logger.info("同意好友请求成功")
return True
logger.error("同意好友请求失败")
return False
async def delete_friend(self, wxid: str) -> bool:
"""
删除好友
Args:
wxid: 要删除的好友 wxid
Returns:
是否成功
"""
data = {"wxid": wxid}
result = await self.post("/api/delete_friend", data)
if result:
logger.info(f"删除好友成功: {wxid}")
return True
logger.error(f"删除好友失败: {wxid}")
return False
async def set_friend_remark(self, wxid: str, remark: str) -> bool:
"""
修改好友备注
Args:
wxid: 好友 wxid
remark: 新备注
Returns:
是否成功
"""
data = {"wxid": wxid, "remark": remark}
result = await self.post("/api/set_friend_remark", data)
if result:
logger.info(f"修改备注成功: {wxid} -> {remark}")
return True
logger.error(f"修改备注失败: {wxid}")
return False
async def get_db_handle(self) -> List[Dict]:
"""
获取数据库句柄列表(新接口)
Returns:
数据库句柄列表
"""
result = await self.post("/api/get_db_handle")
if result and isinstance(result.get("data"), list):
return result.get("data", [])
return []
async def sqlite_exec(self, db_name: str, sql_fmt: str) -> List[Dict]:
"""
执行 SQLite 查询(新接口)
Args:
db_name: 数据库名(如 contact.db
sql_fmt: SQL 语句
Returns:
结果行列表,失败返回空列表
"""
data = {"db_name": db_name, "sql_fmt": sql_fmt}
result = await self.post("/api/sqlite3_exec", data)
if isinstance(result, list):
return result
if isinstance(result, dict):
rows = result.get("data")
if isinstance(rows, list):
return rows
logger.error(f"执行数据库查询失败: db={db_name}")
return []
# ==================== 群聊管理 API ====================
async def get_chatroom_members(self, room_id: str) -> List[Dict]:
"""
获取群成员列表
Args:
room_id: 群聊 ID
Returns:
群成员列表
"""
data = {"room_id": room_id}
result = await self.post("/api/get_room_members", data)
if result:
base_response = result.get("baseResponse", {})
if base_response.get("ret") == 0:
chatroom_data = result.get("newChatroomData", {})
members = chatroom_data.get("chatRoomMember", [])
logger.info(f"获取群成员成功: {room_id}, 成员数: {len(members)}")
return members
logger.error(f"获取群成员失败: {room_id}")
return []
async def get_chatroom_info(self, room_id: str) -> Optional[Dict]:
"""
获取群信息
Args:
room_id: 群聊 ID
Returns:
群信息字典
"""
data = {"room_id": room_id}
result = await self.post("/api/get_room_members", data)
if result:
base_response = result.get("baseResponse", {})
if base_response.get("ret") == 0:
return result
return None
async def get_group_member_contact(self, room_id: str, member_wxid: str) -> Optional[Dict]:
"""
查询群成员联系人信息(更详细)
Args:
room_id: 群聊 ID
member_wxid: 成员 wxid
Returns:
成员联系人信息
"""
data = {"roomId": room_id, "wxid": member_wxid}
result = await self.post("/api/get_group_member_contact", data)
if result:
base_response = result.get("baseResponse", {})
if base_response.get("ret") == 0:
contact_list = result.get("contactList", [])
if contact_list:
return contact_list[0]
return None
async def create_chatroom(self, wxid_list: List[str]) -> Optional[str]:
"""
创建群聊
Args:
wxid_list: 成员 wxid 列表至少2人
Returns:
新群聊的 chatroom_id
"""
data = {"wxid_list": ",".join(wxid_list)}
result = await self.post("/api/create_chat_room", data)
if result:
logger.info("创建群聊成功")
return result.get("chatroomUserName")
logger.error("创建群聊失败")
return None
async def invite_to_chatroom(self, room_id: str, wxid_list: List[str]) -> bool:
"""
邀请进群
Args:
room_id: 群聊 ID
wxid_list: 要邀请的 wxid 列表
Returns:
是否成功
"""
data = {"room_id": room_id, "wxid_list": ",".join(wxid_list)}
result = await self.post("/api/invite_member_to_chat_room", data)
if result:
logger.info(f"邀请进群成功: {room_id}")
return True
logger.error(f"邀请进群失败: {room_id}")
return False
async def remove_chatroom_member(self, room_id: str, wxid_list: List[str]) -> bool:
"""
踢出群成员
Args:
room_id: 群聊 ID
wxid_list: 要踢出的 wxid 列表
Returns:
是否成功
"""
data = {"room_id": room_id, "wxid_list": ",".join(wxid_list)}
result = await self.post("/api/del_member_from_chat_room", data)
if result:
logger.info(f"踢出群成员成功: {room_id}")
return True
logger.error(f"踢出群成员失败: {room_id}")
return False
async def quit_chatroom(self, room_id: str) -> bool:
"""
退出群聊
Args:
room_id: 群聊 ID
Returns:
是否成功
"""
data = {"room_id": room_id}
result = await self.post("/api/quit_and_del_chat_room", data)
if result:
logger.info(f"退出群聊成功: {room_id}")
return True
logger.error(f"退出群聊失败: {room_id}")
return False
async def set_chatroom_announcement(self, room_id: str, announcement: str) -> bool:
"""
修改群公告
Args:
room_id: 群聊 ID
announcement: 群公告内容
Returns:
是否成功
"""
data = {"roomId": room_id, "announcement": announcement}
result = await self.post("/api/set_room_announcement_pb", data)
if result:
logger.info(f"修改群公告成功: {room_id}")
return True
logger.error(f"修改群公告失败: {room_id}")
return False
# ==================== 下载 API ====================
async def cdn_download_image(
self,
fileid: str,
aeskey: str,
save_path: str,
img_type: int = 1,
timeout: float = 60.0
) -> Optional[str]:
"""
CDN 下载图片(新接口)
Args:
fileid: 文件ID
aeskey: AES密钥
save_path: 保存路径
img_type: 图片类型 (1=原图, 2=缩略图)
timeout: 超时时间默认60秒
Returns:
保存路径,失败返回 None
"""
data = {
"fileid": fileid,
"asekey": aeskey, # 注意API参数名是 asekey 不是 aeskey
"imgType": img_type,
"out": save_path
}
if self._hook_request_semaphore.locked():
logger.debug("Hook API 排队中,等待串行执行")
async with self._hook_request_semaphore:
# CDN 下载需要更长的超时时间
import httpx
try:
max_retries = 2
for attempt in range(max_retries + 1):
try:
client = await self._get_client()
logger.debug(f"[HTTP] POST /api/cdn_download (timeout={timeout}s)")
response = await client.post(
"/api/cdn_download",
json=data,
timeout=httpx.Timeout(timeout)
)
response.raise_for_status()
result = response.json()
logger.debug(f"CDN下载图片 API 响应: {result}")
if result and result.get("errCode") == 1:
logger.info(f"CDN下载图片成功: {save_path}")
return save_path
logger.error(f"CDN下载图片失败, 响应: {result}")
return None
except httpx.ConnectError as e:
if attempt < max_retries:
wait = 0.2 * (attempt + 1)
logger.warning(f"CDN下载连接失败: {e}, {wait:.1f}s 后重试")
await asyncio.sleep(wait)
continue
logger.error(f"CDN下载图片异常: {e}")
return None
except httpx.TimeoutException:
logger.error(f"CDN下载图片超时 (>{timeout}s): {save_path}")
return None
except Exception as e:
logger.error(f"CDN下载图片异常: {e}")
return None
async def download_image(
self,
to_user: str,
from_user: str,
msg_id: int,
total_len: int,
save_path: str,
start_pos: int = 0,
data_len: int = 0,
compress_type: int = 0
) -> Optional[str]:
"""
下载图片
Args:
to_user: 接收者 wxid
from_user: 发送者 wxid
msg_id: 消息 ID
total_len: 图片总大小
save_path: 保存路径
start_pos: 起始位置
data_len: 数据长度
compress_type: 压缩类型
Returns:
保存路径,失败返回 None
"""
data = {
"to_user": to_user,
"from_user": from_user,
"MsgId": msg_id,
"total_len": total_len,
"data_len": data_len or total_len,
"start_pos": start_pos,
"compress_type": compress_type,
"path": save_path
}
result = await self.post("/api/download_img", data)
logger.debug(f"下载图片 API 响应: {result}")
if result and result.get("status") == "success":
logger.info(f"下载图片成功: {save_path}")
return result.get("path")
# 检查是否文件过期
if result and result.get("status") == "server_error":
server_resp = result.get("serverResp", {})
base_resp = server_resp.get("baseResponse", {})
err_msg = base_resp.get("errMsg", {})
if isinstance(err_msg, dict):
err_msg = err_msg.get("String", "")
logger.warning(f"下载图片服务器错误: {err_msg}")
if "Expired" in str(err_msg):
logger.warning(f"图片已过期无法下载: {msg_id}")
return "expired"
logger.error(f"下载图片失败: {msg_id}, 响应: {result}")
return None
async def download_video(
self,
msg_id: int,
new_msg_id: int,
total_len: int,
save_path: str
) -> Optional[str]:
"""
下载视频
Args:
msg_id: 消息 ID (MsgId)
new_msg_id: 新消息 ID (NewMsgId)
total_len: 视频总长度
save_path: 保存路径
Returns:
保存路径,失败返回 None
"""
data = {
"MsgId": msg_id,
"NewMsgId": new_msg_id,
"total_len": total_len,
"path": save_path
}
result = await self.post("/api/download_video", data)
if result and result.get("status") == "success":
logger.info(f"下载视频成功: {save_path}")
return save_path
logger.error(f"下载视频失败: {msg_id}")
return None
# ==================== 初始化 API ====================
async def wechat_init(self) -> bool:
"""
微信初始化好友列表、群列表
每天需要调用一次,用于刷新好友和群聊缓存
Returns:
是否成功
"""
result = await self.post("/api/wechat_init")
if result:
logger.info("微信初始化成功(好友列表、群列表)")
return True
logger.error("微信初始化失败")
return False
# ==================== 个人信息 API ====================
async def get_self_info(self) -> Optional[Dict]:
"""
获取自己的信息(缓存)
Returns:
个人信息
"""
result = await self.post("/api/get_self_info")
if result:
return result
return None
async def set_nickname(self, nickname: str) -> bool:
"""
修改自己的昵称
Args:
nickname: 新昵称
Returns:
是否成功
"""
data = {"nickname": nickname}
result = await self.post("/api/set_nickname", data)
if result:
logger.info(f"修改昵称成功: {nickname}")
return True
logger.error("修改昵称失败")
return False

311
WechatHook/http_server.py Normal file
View File

@@ -0,0 +1,311 @@
"""
HTTP 回调服务器模块
接收微信 Hook 推送的消息回调
"""
import asyncio
import json
from typing import Callable, List, Optional, Dict, Any
from loguru import logger
try:
from aiohttp import web
AIOHTTP_AVAILABLE = True
except ImportError:
AIOHTTP_AVAILABLE = False
logger.warning("aiohttp 未安装HTTP 回调服务器将不可用")
class CallbackServer:
"""
HTTP 回调服务器
接收微信 Hook 推送的消息
"""
def __init__(self, host: str = "0.0.0.0", port: int = 9999):
"""
初始化回调服务器
Args:
host: 监听地址
port: 监听端口
"""
self.host = host
self.port = port
self._app: Optional[web.Application] = None
self._runner: Optional[web.AppRunner] = None
self._site: Optional[web.TCPSite] = None
self._message_handlers: List[Callable] = []
self._running = False
def add_message_handler(self, handler: Callable):
"""
添加消息处理器
Args:
handler: 消息处理函数,签名为 async def handler(message_type: str, data: dict)
"""
if handler not in self._message_handlers:
self._message_handlers.append(handler)
logger.debug(f"注册消息处理器: {handler.__name__}")
def remove_message_handler(self, handler: Callable):
"""
移除消息处理器
Args:
handler: 要移除的处理函数
"""
if handler in self._message_handlers:
self._message_handlers.remove(handler)
logger.debug(f"移除消息处理器: {handler.__name__}")
async def _handle_callback(self, request: web.Request) -> web.Response:
"""
处理回调请求
Args:
request: HTTP 请求
Returns:
HTTP 响应
"""
try:
# 读取原始请求体(用于完整日志)
raw_body = await request.text()
# logger.debug(f"[回调原始请求] {raw_body}")
# 解析 JSON 数据
data = json.loads(raw_body) if raw_body else {}
# 判断消息类型
message_type = self._detect_message_type(data)
# 记录原始消息(用于调试)
msg_type_code = str(data.get("msgType", ""))
event_type = data.get("event_type")
event_type_str = str(event_type) if event_type is not None else ""
logger.info(f"[回调] type={message_type}, msgType={msg_type_code}, messageType={data.get('messageType', '')}")
# 如果是系统消息、群信息变化事件或特殊消息,记录原始数据
if msg_type_code in ("10000", "10002") or event_type_str == "1010" or message_type not in ["private_message", "group_message"]:
logger.info(f"[回调原始数据] {json.dumps(data, ensure_ascii=False, indent=2)}")
logger.info(f"[回调详情] 完整数据: {data}")
else:
from_user = data.get("fromUserName", {})
if isinstance(from_user, dict):
from_wxid = from_user.get("String", "")
else:
from_wxid = str(from_user)
logger.debug(f"[回调简要] from={from_wxid}, msgId={data.get('msgId', '')}, newMsgId={data.get('newMsgId', '')}")
# 调用所有处理器
for handler in self._message_handlers:
try:
await handler(message_type, data)
except Exception as e:
logger.error(f"消息处理器异常: {handler.__name__} -> {e}")
return web.json_response({"code": 0, "msg": "success"})
except json.JSONDecodeError:
logger.error("回调数据 JSON 解析失败")
return web.json_response({"code": -1, "msg": "invalid json"}, status=400)
except Exception as e:
logger.error(f"处理回调异常: {e}")
return web.json_response({"code": -1, "msg": str(e)}, status=500)
def _detect_message_type(self, data: dict) -> str:
"""
检测消息类型
Args:
data: 消息数据
Returns:
消息类型字符串
"""
# 优先检查 event_type新接口的事件通知
event_type = data.get("event_type")
if event_type:
# 事件类型映射
event_type_map = {
1008: "chatroom_member_add", # 群成员新增
1009: "chatroom_member_remove", # 群成员删除
1010: "chatroom_info_change", # 群信息变化(猜测)
1012: "chatroom_member_nickname_change", # 群成员昵称修改
}
event_name = event_type_map.get(event_type)
if event_name:
logger.info(f"[事件识别] event_type={event_type} -> {event_name}")
return event_name
# 根据消息字段判断类型
message_type_field = data.get("messageType", "")
if message_type_field == "私聊消息":
return "private_message"
elif message_type_field == "群聊消息":
return "group_message"
elif "snsObject" in data:
return "moments_message"
# 根据 fromUserName 判断
from_user = data.get("fromUserName", {})
if isinstance(from_user, dict):
from_wxid = from_user.get("String", "")
else:
from_wxid = str(from_user)
if from_wxid.endswith("@chatroom"):
return "group_message"
# 默认私聊消息
return "private_message"
async def _health_check(self, request: web.Request) -> web.Response:
"""健康检查端点"""
return web.json_response({"status": "ok", "server": "callback_server"})
async def start(self):
"""启动回调服务器"""
if not AIOHTTP_AVAILABLE:
logger.error("aiohttp 未安装,无法启动回调服务器")
return False
if self._running:
logger.warning("回调服务器已在运行")
return True
try:
self._app = web.Application()
# 注册路由(支持多种路径)
self._app.router.add_route("*", "/", self._handle_callback)
self._app.router.add_route("*", "/callback", self._handle_callback)
self._app.router.add_route("*", "/vxapi", self._handle_callback) # Hook 默认路径
self._app.router.add_route("*", "/api/recvMsg", self._handle_callback) # 新协议路径
self._app.router.add_get("/health", self._health_check)
# 启动服务器
self._runner = web.AppRunner(self._app)
await self._runner.setup()
self._site = web.TCPSite(self._runner, self.host, self.port)
await self._site.start()
self._running = True
logger.success(f"回调服务器已启动: http://{self.host}:{self.port}")
return True
except Exception as e:
logger.error(f"启动回调服务器失败: {e}")
return False
async def stop(self):
"""停止回调服务器"""
if not self._running:
return
try:
if self._site:
await self._site.stop()
if self._runner:
await self._runner.cleanup()
self._running = False
self._app = None
self._runner = None
self._site = None
logger.info("回调服务器已停止")
except Exception as e:
logger.error(f"停止回调服务器失败: {e}")
@property
def is_running(self) -> bool:
"""是否正在运行"""
return self._running
class MessageNormalizer:
"""
消息格式标准化器
将新协议的消息格式转换为内部统一格式
"""
# 微信消息类型映射
MSG_TYPE_MAP = {
"1": "text",
"3": "image",
"34": "voice",
"43": "video",
"47": "emoji",
"48": "location",
"49": "link", # 也可能是小程序、文件等
"42": "card",
"10000": "system",
"10002": "revoke",
}
@classmethod
def normalize(cls, message_type: str, data: dict) -> dict:
"""
标准化消息格式
Args:
message_type: 消息类型 (private_message/group_message)
data: 原始消息数据
Returns:
标准化的消息字典
"""
from .message_types import normalize_from_callback
return normalize_from_callback(message_type, data)
@classmethod
def _extract_string(cls, value) -> str:
"""
提取字符串值
Args:
value: 可能是 dict 或 str
Returns:
字符串值
"""
if isinstance(value, dict):
return value.get("String", "")
return str(value) if value else ""
@classmethod
def _get_internal_type(cls, msg_type_code: str, message_type: str) -> int:
"""
获取内部消息类型码
Args:
msg_type_code: 微信消息类型码
message_type: 消息来源类型
Returns:
内部消息类型码
"""
# 映射到内部类型码(与旧协议兼容)
type_map = {
"1": 11046, # 文本
"3": 11047, # 图片
"34": 11048, # 语音
"43": 11051, # 视频
"47": 11052, # 表情
"48": 11053, # 位置
"49": 11054, # 链接/小程序/文件
"42": 11055, # 名片
"10000": 11058, # 系统消息
"10002": 11057, # 撤回消息
}
return type_map.get(msg_type_code, 11046)

View File

@@ -1,215 +1,431 @@
"""
消息类型定义和映射
定义个微 API 的消息类型常量,以及到内部事件的映射关系
定义新协议 API 的消息类型常量,以及到内部事件的映射关系
"""
import json
import xml.etree.ElementTree as ET
class MessageType:
"""消息类型常量(基于新协议)"""
# 内部消息类型码(与旧协议兼容)
MT_DEBUG_LOG = 11024 # 调试日志
MT_USER_LOGIN = 11025 # 用户登录
MT_USER_LOGOUT = 11026 # 用户登出
MT_GET_LOGIN_INFO = 11028 # 获取登录信息
# 消息通知类型
MT_TEXT = 11046 # 文本消息
MT_IMAGE = 11047 # 图片消息
MT_VOICE = 11048 # 语音消息
MT_VIDEO = 11051 # 视频消息
MT_EMOJI = 11052 # 表情消息
MT_REVOKE = 11057 # 撤回消息
MT_SYSTEM = 11058 # 系统消息
MT_QUOTE = 11061 # 引用消息
MT_FRIEND_REQUEST = 11056 # 好友请求
MT_LOCATION = 11053 # 位置消息
MT_LINK = 11054 # 链接消息
MT_FILE = 11055 # 文件消息
class MessageType:
"""消息类型常量(基于实际测试)"""
# 系统消息类型
MT_DEBUG_LOG = 11024 # 调试日志
MT_USER_LOGIN = 11025 # 用户登录
MT_USER_LOGOUT = 11026 # 用户登出
MT_GET_LOGIN_INFO = 11028 # 获取登录信息
# 消息通知类型(基于实际测试修正)
MT_TEXT = 11046 # 文本消息
MT_IMAGE = 11047 # 图片消息
MT_VOICE = 11048 # 语音消息
MT_VIDEO = 11051 # 视频消息
MT_EMOJI = 11052 # 表情消息
MT_REVOKE = 11057 # 撤回消息
MT_SYSTEM = 11058 # 系统消息
MT_QUOTE = 11061 # 引用消息
MT_FRIEND_REQUEST = 11056 # 好友请求
# 实际测试得出的正确映射
MT_LOCATION = 11053 # 位置消息(实际)
MT_LINK = 11054 # 链接消息(实际)
MT_FILE = 11055 # 文件消息(实际)
# 兼容性定义
MT_CARD = 11055 # 名片消息(临时兼容,实际可能是文件类型)
MT_MINIAPP = 11054 # 小程序消息(临时兼容,实际可能是链接类型)
MT_CARD = 11062 # 名片消息
MT_MINIAPP = 11063 # 小程序消息
MT_UNKNOWN = 11999 # 未知消息
# 群聊通知类型
MT_CHATROOM_MEMBER_ADD = 11098 # 群成员新增
MT_CHATROOM_MEMBER_REMOVE = 11099 # 群成员删除
MT_CHATROOM_INFO_CHANGE = 11100 # 群信息变化(成员数量变化等)
# 发送消息类型
MT_SEND_TEXT = 11036 # 发送文本
# 消息类型到事件名称的映射
MESSAGE_TYPE_MAP = {
MessageType.MT_TEXT: "text_message",
MessageType.MT_IMAGE: "image_message",
MessageType.MT_VOICE: "voice_message",
MessageType.MT_VIDEO: "video_message",
MessageType.MT_EMOJI: "emoji_message",
MessageType.MT_REVOKE: "revoke_message",
MessageType.MT_SYSTEM: "system_message",
MessageType.MT_FRIEND_REQUEST: "friend_request",
MessageType.MT_QUOTE: "quote_message",
MT_CHATROOM_INFO_CHANGE = 11100 # 群信息变化
MT_CHATROOM_MEMBER_NICKNAME_CHANGE = 11101 # 群成员昵称修改
# 发送消息类型
MT_SEND_TEXT = 11036 # 发送文本
# 微信原始消息类型到内部类型的映射
WECHAT_MSG_TYPE_MAP = {
"1": MessageType.MT_TEXT, # 文本
"3": MessageType.MT_IMAGE, # 图片
"34": MessageType.MT_VOICE, # 语音
"37": MessageType.MT_FRIEND_REQUEST, # 好友请求
"42": MessageType.MT_CARD, # 名片
"43": MessageType.MT_VIDEO, # 视频
"47": MessageType.MT_EMOJI, # 表情
"48": MessageType.MT_LOCATION, # 位置
"49": MessageType.MT_LINK, # AppMsg需结合 XML 细分
"10000": MessageType.MT_SYSTEM, # 系统消息
"10002": MessageType.MT_REVOKE, # 撤回消息
}
# 消息类型到事件名称的映射
MESSAGE_TYPE_MAP = {
MessageType.MT_TEXT: "text_message",
MessageType.MT_IMAGE: "image_message",
MessageType.MT_VOICE: "voice_message",
MessageType.MT_VIDEO: "video_message",
MessageType.MT_EMOJI: "emoji_message",
MessageType.MT_REVOKE: "revoke_message",
MessageType.MT_SYSTEM: "system_message",
MessageType.MT_FRIEND_REQUEST: "friend_request",
MessageType.MT_QUOTE: "quote_message",
MessageType.MT_CHATROOM_MEMBER_ADD: "chatroom_member_add",
MessageType.MT_CHATROOM_MEMBER_REMOVE: "chatroom_member_remove",
MessageType.MT_CHATROOM_INFO_CHANGE: "chatroom_info_change",
# 修正后的映射(基于实际测试)
MessageType.MT_LOCATION: "location_message", # 11053 -> 位置消息
MessageType.MT_LINK: "link_message", # 11054 -> 链接消息
MessageType.MT_FILE: "file_message", # 11055 -> 文件消息
MessageType.MT_CHATROOM_MEMBER_NICKNAME_CHANGE: "chatroom_member_nickname_change",
MessageType.MT_LOCATION: "location_message",
MessageType.MT_LINK: "link_message",
MessageType.MT_FILE: "file_message",
MessageType.MT_CARD: "card_message",
MessageType.MT_MINIAPP: "miniapp_message",
MessageType.MT_UNKNOWN: "other_message",
}
def _extract_string(value) -> str:
"""
提取字符串值
Args:
value: 可能是 dict 或 str
Returns:
字符串值
"""
if isinstance(value, dict):
return value.get("String", "")
return str(value) if value else ""
def _ensure_dict(value) -> dict:
"""确保返回 dict兼容字符串/空值)"""
if isinstance(value, dict):
return value
if isinstance(value, str):
text = value.strip()
if text.startswith("{") or text.startswith("["):
try:
parsed = json.loads(text)
if isinstance(parsed, dict):
return parsed
except Exception:
pass
return {}
def _strip_group_prefix(content: str) -> str:
"""去掉群聊消息里可能带的 `wxid:\n` 前缀。"""
if not content or not isinstance(content, str):
return ""
xml_start = content.find("<?xml")
if xml_start == -1:
xml_start = content.find("<msg")
if xml_start == -1:
xml_start = content.find("<appmsg")
if xml_start > 0:
return content[xml_start:]
return content
def _parse_appmsg_meta(content) -> dict:
"""解析 `msgType=49` 的 XML提取 appmsg 元信息。"""
xml_content = _strip_group_prefix(_extract_string(content))
if not xml_content or "<" not in xml_content:
return {}
try:
root = ET.fromstring(xml_content)
except Exception:
return {}
appmsg = root.find(".//appmsg")
if appmsg is None and root.tag == "appmsg":
appmsg = root
if appmsg is None:
return {}
appattach = appmsg.find("appattach")
title = (appmsg.findtext("title", "") or "").strip()
desc = (appmsg.findtext("des", "") or appmsg.findtext("description", "") or "").strip()
url = (appmsg.findtext("url", "") or "").strip()
thumb_url = (appmsg.findtext("thumburl", "") or "").strip()
appmsg_type = (appmsg.findtext("type", "") or "").strip()
file_name = ""
file_ext = ""
if appattach is not None:
file_name = (appattach.findtext("filename", "") or title).strip()
file_ext = (appattach.findtext("fileext", "") or "").strip()
return {
"xml": xml_content,
"appmsg_type": appmsg_type,
"title": title,
"desc": desc,
"url": url,
"thumb_url": thumb_url,
"has_weappinfo": appmsg.find("weappinfo") is not None,
"has_appattach": appattach is not None,
"file_name": file_name,
"file_ext": file_ext,
}
def _resolve_appmsg_internal_type(appmsg_meta: dict) -> int:
"""根据 appmsg 元信息判断 49 消息的真实内部类型。"""
appmsg_type = str(appmsg_meta.get("appmsg_type", "")).strip()
xml_content = appmsg_meta.get("xml", "")
if appmsg_type == "57":
return MessageType.MT_QUOTE
if appmsg_type in {"33", "36"} or appmsg_meta.get("has_weappinfo"):
return MessageType.MT_MINIAPP
if appmsg_type == "6" or appmsg_meta.get("has_appattach"):
return MessageType.MT_FILE
if appmsg_type in {"5", "19"}:
return MessageType.MT_LINK
if "<appattach" in xml_content:
return MessageType.MT_FILE
if "<weappinfo" in xml_content:
return MessageType.MT_MINIAPP
return MessageType.MT_LINK
def normalize_message(msg_type: int, data: dict) -> dict:
"""
个微 API 的消息格式转换为统一的内部格式(兼容 XYBot
Args:
msg_type: 消息类型
data: 原始消息数据
Returns:
标准化的消息字典
"""
# 基础消息结构
"""
新协议的消息格式转换为统一的内部格式
Args:
msg_type: 内部消息类型
data: 原始消息数据(来自 HTTP 回调)
Returns:
标准化的消息字典
"""
# 判断消息来源类型
message_type_field = data.get("messageType", "")
is_group = message_type_field == "群聊消息"
# 如果没有 messageType 字段,根据 fromUserName 判断
if not message_type_field:
from_user = _extract_string(data.get("fromUserName", {}))
is_group = from_user.endswith("@chatroom")
# 提取基础字段
from_user = _extract_string(data.get("fromUserName", {}))
to_user = _extract_string(data.get("toUserName", {}))
content = _extract_string(data.get("content", {}))
appmsg_meta = _parse_appmsg_meta(content) if str(data.get("msgType", "")) == "49" else {}
# 群聊消息的真实内容
real_content = data.get("real_content", "")
if is_group and not real_content:
# 从 content 中提取格式wxid:\n实际内容
if ":\n" in content:
parts = content.split(":\n", 1)
if len(parts) == 2:
real_content = parts[1]
else:
real_content = content
# 构建标准消息
message = {
"MsgType": msg_type,
# 消息唯一ID用于去重/撤回等)。个微 API 通常为 msgid 字段。
"MsgId": data.get("msgid") or data.get("msg_id") or data.get("id") or "",
"FromWxid": data.get("from_wxid", ""),
"ToWxid": data.get("to_wxid", ""),
"Content": data.get("msg", data.get("content", data.get("raw_msg", ""))), # 系统消息使用 raw_msg
"CreateTime": data.get("timestamp", data.get("create_time", 0)),
"IsGroup": False,
"SenderWxid": data.get("from_wxid", ""),
"MsgId": data.get("newMsgId", "") or str(data.get("msgId", "")),
"FromWxid": from_user,
"ToWxid": to_user,
"Content": real_content if is_group else content,
"CreateTime": int(data.get("createTime", 0)),
"IsGroup": is_group,
"RawMsgType": str(data.get("msgType", "1")),
}
# 判断是否是群聊消息room_wxid 不为空)
room_wxid = data.get("room_wxid", "")
if room_wxid:
message["IsGroup"] = True
message["FromWxid"] = room_wxid
message["SenderWxid"] = data.get("from_wxid", "")
# @ 消息处理
if "at_user_list" in data:
message["Ats"] = data["at_user_list"]
elif "at_list" in data:
message["Ats"] = data["at_list"]
# 图片消息
if msg_type == MessageType.MT_IMAGE:
message["ImagePath"] = data.get("image_path", "")
# 文件消息实际类型11055
if appmsg_meta:
message["AppMsgType"] = appmsg_meta.get("appmsg_type", "")
# 群聊消息处理
if is_group:
message["RoomWxid"] = from_user
# 提取发送者信息 - 优先使用 room_sender_by 字段
room_sender_by = _extract_string(data.get("room_sender_by", ""))
member_info = _ensure_dict(data.get("member_info"))
if room_sender_by:
message["SenderWxid"] = room_sender_by
elif member_info:
message["SenderWxid"] = member_info.get("userName", "")
else:
# 从 content 中提取发送者 wxid
if ":\n" in content:
sender_wxid = content.split(":\n")[0]
message["SenderWxid"] = sender_wxid
else:
message["SenderWxid"] = ""
# 发送者昵称 - 从 newChatroomData 中查找
sender_profile = _ensure_dict(data.get("sender_profile"))
new_chatroom_data = _ensure_dict(sender_profile.get("newChatroomData"))
chatroom_members = new_chatroom_data.get("chatRoomMember") or []
if not isinstance(chatroom_members, list):
chatroom_members = []
sender_wxid = message.get("SenderWxid", "")
for member in chatroom_members:
if member.get("userName") == sender_wxid:
message["SenderNickname"] = member.get("nickName", "")
break
else:
if member_info:
message["SenderNickname"] = member_info.get("nickName", "")
else:
message["SenderNickname"] = ""
message["SenderAvatar"] = member_info.get("bigHeadImgUrl", "") if member_info else ""
else:
# 私聊消息
message["SenderWxid"] = from_user
message["RoomWxid"] = ""
# 提取发送者信息
sender_profile = _ensure_dict(data.get("sender_profile"))
if sender_profile:
message["SenderNickname"] = _extract_string(sender_profile.get("nickName", {}))
message["SenderAvatar"] = sender_profile.get("bigHeadImgUrl", "")
else:
message["SenderNickname"] = data.get("sender_nick", "")
message["SenderAvatar"] = ""
# @ 消息处理
content_to_check = real_content if is_group else content
if "@" in content_to_check:
message["IsAtMessage"] = True
# 解析 @ 列表(简单实现)
# TODO: 从 msgSource XML 中解析完整的 @ 列表
# 图片消息
if msg_type == MessageType.MT_IMAGE:
message["ImgStatus"] = data.get("imgStatus", 0)
img_buf = data.get("imgBuf", {})
if img_buf:
message["ImgLen"] = img_buf.get("iLen", 0)
# 文件消息
if msg_type == MessageType.MT_FILE:
message["Filename"] = data.get("filename", "")
message["FileExtend"] = data.get("file_extend", "")
message["File"] = data.get("file_data", "")
# 语音消息
if msg_type == MessageType.MT_VOICE:
message["ImgBuf"] = {"buffer": data.get("voice_data", "")}
# 视频消息
if msg_type == MessageType.MT_VIDEO:
message["Video"] = data.get("video_data", "")
# 引用消息
if "quote" in data:
message["Quote"] = data["quote"]
# 引用消息的 @ 提取(从 XML 中解析)
message["Filename"] = (
appmsg_meta.get("file_name")
or _extract_string(data.get("filename", ""))
or appmsg_meta.get("title", "")
)
message["FileExtend"] = appmsg_meta.get("file_ext") or _extract_string(data.get("file_extend", ""))
# 语音消息
if msg_type == MessageType.MT_VOICE:
message["VoiceLength"] = data.get("voiceLength", 0)
# 视频消息
if msg_type == MessageType.MT_VIDEO:
message["VideoLength"] = data.get("playLength", 0)
# 引用消息
if msg_type == MessageType.MT_QUOTE:
try:
import xml.etree.ElementTree as ET
content = message.get("Content", "")
if content:
root = ET.fromstring(content)
title = root.find(".//title")
if title is not None and title.text:
title_text = title.text
# 检查 title 中是否包含 @
if "@" in title_text:
# 从 main_config.toml 读取机器人昵称
import tomllib
from pathlib import Path
config_path = Path("main_config.toml")
if config_path.exists():
with open(config_path, "rb") as f:
main_config = tomllib.load(f)
bot_nickname = main_config.get("Bot", {}).get("nickname", "")
bot_wxid = main_config.get("Bot", {}).get("wxid", "")
# 检查是否 @ 了机器人
if bot_nickname and f"@{bot_nickname}" in title_text:
message["Ats"] = [bot_wxid] if bot_wxid else []
# 尝试解析引用内容
try:
import xml.etree.ElementTree as ET
if content:
root = ET.fromstring(content)
title = root.find(".//title")
if title is not None and title.text:
message["QuoteTitle"] = title.text
refermsg = root.find(".//refermsg")
if refermsg is not None:
message["QuoteContent"] = refermsg.findtext("content", "")
message["QuoteSender"] = refermsg.findtext("fromusr", "")
except Exception:
pass # 解析失败则忽略
pass
# 位置消息实际类型11053
if msg_type == MessageType.MT_LOCATION:
message["Latitude"] = data.get("latitude", 0)
message["Longitude"] = data.get("longitude", 0)
message["LocationTitle"] = data.get("title", "")
message["LocationAddress"] = data.get("address", "")
if msg_type == MessageType.MT_CARD:
message["CardWxid"] = _extract_string(data.get("recommend_wxid", ""))
message["CardNickname"] = (
_extract_string(data.get("recommend_nickname", ""))
or _extract_string(data.get("title", ""))
)
# 链接消息实际类型11054
# 位置消息
if msg_type == MessageType.MT_LOCATION:
message["Latitude"] = data.get("latitude", 0)
message["Longitude"] = data.get("longitude", 0)
message["LocationTitle"] = data.get("title", "")
message["LocationAddress"] = data.get("address", "")
# 链接消息
if msg_type == MessageType.MT_LINK:
message["LinkTitle"] = data.get("title", "")
message["LinkDesc"] = data.get("desc", "")
message["LinkUrl"] = data.get("url", "")
message["LinkThumb"] = data.get("thumb_url", "")
message["MiniappPagePath"] = data.get("page_path", "")
message["LinkTitle"] = appmsg_meta.get("title") or _extract_string(data.get("title", ""))
message["LinkDesc"] = appmsg_meta.get("desc") or _extract_string(data.get("desc", ""))
message["LinkUrl"] = appmsg_meta.get("url") or _extract_string(data.get("url", ""))
message["LinkThumb"] = appmsg_meta.get("thumb_url") or _extract_string(data.get("thumb_url", ""))
# 好友请求
if msg_type == MessageType.MT_FRIEND_REQUEST:
message["V3"] = data.get("v3", "")
message["V4"] = data.get("v4", "")
message["Scene"] = data.get("scene", 0)
if msg_type == MessageType.MT_MINIAPP:
message["MiniAppTitle"] = appmsg_meta.get("title") or _extract_string(data.get("title", ""))
message["MiniAppDesc"] = appmsg_meta.get("desc") or _extract_string(data.get("desc", ""))
message["MiniAppUrl"] = appmsg_meta.get("url") or _extract_string(data.get("url", ""))
message["MiniAppThumb"] = appmsg_meta.get("thumb_url") or _extract_string(data.get("thumb_url", ""))
# 好友请求
if msg_type == MessageType.MT_FRIEND_REQUEST:
message["V3"] = data.get("v3", "")
message["V4"] = data.get("v4", "")
message["Scene"] = data.get("scene", 0)
# 系统消息
if msg_type == MessageType.MT_SYSTEM:
message["Content"] = content
# 保留原始数据
message["_raw"] = data
return message
def get_internal_msg_type(wechat_msg_type: str, data: dict = None) -> int:
"""
将微信消息类型转换为内部消息类型
Args:
wechat_msg_type: 微信消息类型(字符串)
Returns:
内部消息类型码
"""
wechat_msg_type = str(wechat_msg_type)
# 群成员新增 (type=11098)
if msg_type == MessageType.MT_CHATROOM_MEMBER_ADD:
message["FromWxid"] = data.get("room_wxid", "")
message["IsGroup"] = True
message["RoomWxid"] = data.get("room_wxid", "")
message["RoomNickname"] = data.get("nickname", "")
message["MemberList"] = data.get("member_list", [])
message["TotalMember"] = data.get("total_member", 0)
message["ManagerWxid"] = data.get("manager_wxid", "")
if wechat_msg_type == "49" and data is not None:
appmsg_meta = _parse_appmsg_meta(data.get("content", ""))
if appmsg_meta:
return _resolve_appmsg_internal_type(appmsg_meta)
return MessageType.MT_LINK
# 群成员删除 (type=11099)
if msg_type == MessageType.MT_CHATROOM_MEMBER_REMOVE:
message["FromWxid"] = data.get("room_wxid", "")
message["IsGroup"] = True
message["RoomWxid"] = data.get("room_wxid", "")
message["RoomNickname"] = data.get("nickname", "")
message["MemberList"] = data.get("member_list", [])
message["TotalMember"] = data.get("total_member", 0)
message["ManagerWxid"] = data.get("manager_wxid", "")
# 系统消息 (type=11058)
if msg_type == MessageType.MT_SYSTEM:
# 系统消息的内容在 raw_msg 字段
message["Content"] = data.get("raw_msg", "")
# 系统消息也可能是群聊消息
if room_wxid:
message["IsGroup"] = True
message["FromWxid"] = room_wxid
# 群信息变化 (type=11100)
if msg_type == MessageType.MT_CHATROOM_INFO_CHANGE:
message["FromWxid"] = data.get("room_wxid", "")
message["IsGroup"] = True
message["RoomWxid"] = data.get("room_wxid", "")
message["RoomNickname"] = data.get("nickname", "")
message["TotalMember"] = data.get("total_member", 0)
message["ManagerWxid"] = data.get("manager_wxid", "")
# member_list 可能存在也可能不存在
message["MemberList"] = data.get("member_list", [])
return message
return WECHAT_MSG_TYPE_MAP.get(wechat_msg_type, MessageType.MT_UNKNOWN)
def normalize_from_callback(message_type: str, data: dict) -> dict:
"""
从 HTTP 回调数据标准化消息
Args:
message_type: 消息来源类型 (private_message/group_message)
data: 原始回调数据
Returns:
标准化的消息字典
"""
# 获取微信消息类型
wechat_msg_type = str(data.get("msgType", "1"))
# 转换为内部类型
internal_type = get_internal_msg_type(wechat_msg_type, data)
# 调用通用标准化函数
return normalize_message(internal_type, data)