chore: sync current WechatHookBot workspace
This commit is contained in:
@@ -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'
|
||||
|
||||
2809
WechatHook/client.py
2809
WechatHook/client.py
File diff suppressed because it is too large
Load Diff
974
WechatHook/http_client.py
Normal file
974
WechatHook/http_client.py
Normal 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
311
WechatHook/http_server.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user