855 协议版本-调整完毕内容
This commit is contained in:
@@ -8,6 +8,8 @@ import wechat_ipad
|
||||
# 明确导入需要的类
|
||||
from loguru import logger
|
||||
|
||||
from wechat_ipad.models.message import WxMessage
|
||||
|
||||
|
||||
async def bot_core():
|
||||
# 读取 config.toml 文件
|
||||
@@ -140,7 +142,11 @@ async def bot_core():
|
||||
data = data.get("AddMsgs")
|
||||
if data:
|
||||
for message in data:
|
||||
logger.info("message: {}".format(message))
|
||||
# 获取原始JSON数据
|
||||
# 创建消息对象
|
||||
msg = WxMessage.from_json(message)
|
||||
logger.info("source message: {}".format(message))
|
||||
logger.info("parse msg: {}".format(msg))
|
||||
# 使用异步睡眠替代忙等待循环
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
|
||||
@@ -34,10 +34,11 @@ class WechatAPIClient(LoginMixin, MessageMixin, FriendMixin, ChatroomMixin, User
|
||||
raise UserLoggedOut("请先登录")
|
||||
|
||||
output = ""
|
||||
for id in at:
|
||||
nickname = await self.get_nickname(id)
|
||||
for at_id in at:
|
||||
nickname = await self.get_chatroom_nickname(at_id, wxid)
|
||||
output += f"@{nickname}\u2005"
|
||||
|
||||
output += "\n"
|
||||
output += content
|
||||
|
||||
return await self.send_text_message(wxid, output, at)
|
||||
|
||||
BIN
wechat_ipad/client/fallback.png
Normal file
BIN
wechat_ipad/client/fallback.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1001 KiB |
@@ -59,7 +59,7 @@ class FriendMixin(WechatAPIClientBase):
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
json_param = {"Wxid": self.wxid, "RequestWxids": wxid}
|
||||
response = await session.post(f'http://{self.ip}:{self.port}/GetContact', json=json_param)
|
||||
response = await session.post(f'http://{self.ip}:{self.port}/api/Friend/GetContractDetail', json=json_param)
|
||||
json_resp = await response.json()
|
||||
|
||||
if json_resp.get("Success"):
|
||||
@@ -90,7 +90,7 @@ class FriendMixin(WechatAPIClientBase):
|
||||
wxid = ",".join(wxid)
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
json_param = {"Wxid": self.wxid, "RequestWxids": wxid, "Chatroom": chatroom}
|
||||
json_param = {"Wxid": self.wxid, "Towxids": wxid, "Chatroom": chatroom}
|
||||
response = await session.post(f'http://{self.ip}:{self.port}/api/Friend/GetContractDetail', json=json_param)
|
||||
json_resp = await response.json()
|
||||
|
||||
|
||||
@@ -43,8 +43,8 @@ class ChatroomMixin(WechatAPIClientBase):
|
||||
raise UserLoggedOut("请先登录")
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
json_param = {"Wxid": self.wxid, "Chatroom": chatroom}
|
||||
response = await session.post(f'http://{self.ip}:{self.port}/GetChatroomInfo', json=json_param)
|
||||
json_param = {"Wxid": self.wxid, "QID": chatroom}
|
||||
response = await session.post(f'http://{self.ip}:{self.port}/api/Group/GetChatRoomInfoDetail', json=json_param)
|
||||
json_resp = await response.json()
|
||||
|
||||
if json_resp.get("Success"):
|
||||
@@ -67,8 +67,8 @@ class ChatroomMixin(WechatAPIClientBase):
|
||||
raise UserLoggedOut("请先登录")
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
json_param = {"Wxid": self.wxid, "Chatroom": chatroom}
|
||||
response = await session.post(f'http://{self.ip}:{self.port}/GetChatroomInfoNoAnnounce', json=json_param)
|
||||
json_param = {"Wxid": self.wxid, "QID": chatroom}
|
||||
response = await session.post(f'http://{self.ip}:{self.port}/api/Group/GetChatRoomInfo', json=json_param)
|
||||
json_resp = await response.json()
|
||||
|
||||
if json_resp.get("Success"):
|
||||
@@ -89,8 +89,8 @@ class ChatroomMixin(WechatAPIClientBase):
|
||||
raise UserLoggedOut("请先登录")
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
json_param = {"Wxid": self.wxid, "Chatroom": chatroom}
|
||||
response = await session.post(f'http://{self.ip}:{self.port}/GetChatroomMemberDetail', json=json_param)
|
||||
json_param = {"Wxid": self.wxid, "QID": chatroom}
|
||||
response = await session.post(f'http://{self.ip}:{self.port}/api/Group/GetChatRoomMemberDetail', json=json_param)
|
||||
json_resp = await response.json()
|
||||
|
||||
if json_resp.get("Success"):
|
||||
@@ -138,11 +138,46 @@ class ChatroomMixin(WechatAPIClientBase):
|
||||
wxid = ",".join(wxid)
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
json_param = {"Wxid": self.wxid, "Chatroom": chatroom, "InviteWxids": wxid}
|
||||
response = await session.post(f'http://{self.ip}:{self.port}/InviteChatroomMember', json=json_param)
|
||||
json_param = {"Wxid": self.wxid, "ChatRoomName": chatroom, "ToWxids": wxid}
|
||||
response = await session.post(f'http://{self.ip}:{self.port}/api/Group/InviteChatRoomMember',
|
||||
json=json_param)
|
||||
json_resp = await response.json()
|
||||
|
||||
if json_resp.get("Success"):
|
||||
return True
|
||||
else:
|
||||
self.error_handler(json_resp)
|
||||
|
||||
async def get_chatroom_nickname(self, wxid: Union[str, list[str]], chatroom: str) -> Union[str, list[str]]:
|
||||
"""获取用户昵称
|
||||
|
||||
Args:
|
||||
wxid: 用户wxid,可以是单个wxid或最多20个wxid的列表
|
||||
chatroom: 群聊id
|
||||
|
||||
Returns:
|
||||
Union[str, list[str]]: 如果输入单个wxid返回str,如果输入wxid列表则返回对应的昵称列表
|
||||
"""
|
||||
data = await self.get_chatroom_member_list(chatroom)
|
||||
|
||||
if isinstance(wxid, str):
|
||||
# 单个wxid的情况
|
||||
for member in data:
|
||||
if member.get("UserName") == wxid:
|
||||
# 优先返回DisplayName,如果不存在则返回NickName
|
||||
return member.get("DisplayName") or member.get("NickName") or ""
|
||||
return "" # 如果没找到对应的成员,返回空字符串
|
||||
else:
|
||||
# wxid列表的情况
|
||||
result = []
|
||||
for single_wxid in wxid:
|
||||
found = False
|
||||
for member in data:
|
||||
if member.get("UserName") == single_wxid:
|
||||
# 优先返回DisplayName,如果不存在则返回NickName
|
||||
result.append(member.get("DisplayName") or member.get("NickName") or "")
|
||||
found = True
|
||||
break
|
||||
if not found:
|
||||
result.append("") # 如果没找到对应的成员,添加空字符串
|
||||
return result
|
||||
|
||||
@@ -97,8 +97,7 @@ class LoginMixin(WechatAPIClientBase):
|
||||
raise UserLoggedOut("请先登录")
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
json_param = {"Wxid": self.wxid}
|
||||
response = await session.post(f'http://{self.ip}:{self.port}/Logout', json=json_param)
|
||||
response = await session.post(f'http://{self.ip}:{self.port}/api/Login/LogOut?wxid={self.wxid}')
|
||||
json_resp = await response.json()
|
||||
|
||||
if json_resp.get("Success"):
|
||||
|
||||
@@ -8,7 +8,7 @@ from pathlib import Path
|
||||
from typing import Union
|
||||
|
||||
import aiohttp
|
||||
import logging
|
||||
from loguru import logger
|
||||
from pymediainfo import MediaInfo
|
||||
|
||||
import pysilk
|
||||
@@ -24,6 +24,7 @@ class MessageMixin(WechatAPIClientBase):
|
||||
super().__init__(ip, port)
|
||||
self._message_queue = Queue()
|
||||
self._is_processing = False
|
||||
self.logging = logger
|
||||
|
||||
async def _process_message_queue(self):
|
||||
"""
|
||||
@@ -62,7 +63,13 @@ class MessageMixin(WechatAPIClientBase):
|
||||
|
||||
async def revoke_message(self, wxid: str, client_msg_id: int, create_time: int, new_msg_id: int) -> bool:
|
||||
"""撤回消息。
|
||||
|
||||
{
|
||||
"ClientMsgId": 0,
|
||||
"CreateTime": 0,
|
||||
"NewMsgId": 0,
|
||||
"ToUserName": "string",
|
||||
"Wxid": "string"
|
||||
}
|
||||
Args:
|
||||
wxid (str): 接收人wxid
|
||||
client_msg_id (int): 发送消息的返回值
|
||||
@@ -81,16 +88,17 @@ class MessageMixin(WechatAPIClientBase):
|
||||
raise UserLoggedOut("请先登录")
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
json_param = {"Wxid": self.wxid, "ToWxid": wxid, "ClientMsgId": client_msg_id, "CreateTime": create_time,
|
||||
json_param = {"Wxid": self.wxid, "ToUserName": wxid, "ClientMsgId": client_msg_id,
|
||||
"CreateTime": create_time,
|
||||
"NewMsgId": new_msg_id}
|
||||
response = await session.post(f'http://{self.ip}:{self.port}/RevokeMsg', json=json_param)
|
||||
response = await session.post(f'http://{self.ip}:{self.port}/api/Msg/Revoke', json=json_param)
|
||||
json_resp = await response.json()
|
||||
|
||||
if json_resp.get("Success"):
|
||||
logging.info("消息撤回成功: 对方wxid:{} ClientMsgId:{} CreateTime:{} NewMsgId:{}",
|
||||
wxid,
|
||||
client_msg_id,
|
||||
new_msg_id)
|
||||
self.logging.info("消息撤回成功: 对方wxid:{} ClientMsgId:{} CreateTime:{} NewMsgId:{}",
|
||||
wxid,
|
||||
client_msg_id,
|
||||
new_msg_id)
|
||||
return True
|
||||
else:
|
||||
self.error_handler(json_resp)
|
||||
@@ -131,10 +139,10 @@ class MessageMixin(WechatAPIClientBase):
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
json_param = {"Wxid": self.wxid, "ToWxid": wxid, "Content": content, "Type": 1, "At": at_str}
|
||||
response = await session.post(f'http://{self.ip}:{self.port}/SendTextMsg', json=json_param)
|
||||
response = await session.post(f'http://{self.ip}:{self.port}/api/Msg/SendTxt', json=json_param)
|
||||
json_resp = await response.json()
|
||||
if json_resp.get("Success"):
|
||||
logging.info("发送文字消息: 对方wxid:{} at:{} 内容:{}", wxid, at, content)
|
||||
self.logging.info("发送文字消息: 对方wxid:{} at:{} 内容:{}", wxid, at, content)
|
||||
data = json_resp.get("Data")
|
||||
return data.get("List")[0].get("ClientMsgid"), data.get("List")[0].get("Createtime"), data.get("List")[
|
||||
0].get("NewMsgId")
|
||||
@@ -163,7 +171,6 @@ class MessageMixin(WechatAPIClientBase):
|
||||
int, int, int]:
|
||||
if not self.wxid:
|
||||
raise UserLoggedOut("请先登录")
|
||||
|
||||
|
||||
if isinstance(image, str):
|
||||
pass
|
||||
@@ -177,12 +184,12 @@ class MessageMixin(WechatAPIClientBase):
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
json_param = {"Wxid": self.wxid, "ToWxid": wxid, "Base64": image}
|
||||
response = await session.post(f'http://{self.ip}:{self.port}/SendImageMsg', json=json_param)
|
||||
response = await session.post(f'http://{self.ip}:{self.port}/api/Msg/UploadImg', json=json_param)
|
||||
json_resp = await response.json()
|
||||
|
||||
if json_resp.get("Success"):
|
||||
json_param.pop('Base64')
|
||||
logging.info("发送图片消息: 对方wxid:{} 图片base64略", wxid)
|
||||
self.logging.info("发送图片消息: 对方wxid:{} 图片base64略", wxid)
|
||||
data = json_resp.get("Data")
|
||||
return data.get("ClientImgId").get("string"), data.get("CreateTime"), data.get("Newmsgid")
|
||||
else:
|
||||
@@ -240,18 +247,18 @@ class MessageMixin(WechatAPIClientBase):
|
||||
|
||||
# 打印预估时间,300KB/s
|
||||
predict_time = int(file_len / 1024 / 300)
|
||||
logging.info("开始发送视频: 对方wxid:{} 视频base64略 图片base64略 预计耗时:{}秒", wxid, predict_time)
|
||||
self.logging.info("开始发送视频: 对方wxid:{} 视频base64略 图片base64略 预计耗时:{}秒", wxid, predict_time)
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
json_param = {"Wxid": self.wxid, "ToWxid": wxid, "Base64": vid_base64, "ImageBase64": image_base64,
|
||||
"PlayLength": duration}
|
||||
async with session.post(f'http://{self.ip}:{self.port}/SendVideoMsg', json=json_param) as resp:
|
||||
async with session.post(f'http://{self.ip}:{self.port}/api/Msg/SendVideo', json=json_param) as resp:
|
||||
json_resp = await resp.json()
|
||||
|
||||
if json_resp.get("Success"):
|
||||
json_param.pop('Base64')
|
||||
json_param.pop('ImageBase64')
|
||||
logging.info("发送视频成功: 对方wxid:{} 时长:{} 视频base64略 图片base64略", wxid, duration)
|
||||
self.logging.info("发送视频成功: 对方wxid:{} 时长:{} 视频base64略 图片base64略", wxid, duration)
|
||||
data = json_resp.get("Data")
|
||||
return data.get("clientMsgId"), data.get("newMsgId")
|
||||
else:
|
||||
@@ -277,12 +284,12 @@ class MessageMixin(WechatAPIClientBase):
|
||||
"""
|
||||
return await self._queue_message(self._send_voice_message, wxid, voice, format)
|
||||
|
||||
async def _send_voice_message(self, wxid: str, voice: Union[str, bytes, os.PathLike], format: str = "amr") -> \
|
||||
async def _send_voice_message(self, wxid: str, voice: Union[str, bytes, os.PathLike], format: str = "AMR") -> \
|
||||
tuple[int, int, int]:
|
||||
if not self.wxid:
|
||||
raise UserLoggedOut("请先登录")
|
||||
|
||||
elif format not in ["amr", "wav", "mp3"]:
|
||||
|
||||
elif format not in ["AMR", "WAVE", "MP3", "SILK", "SPEEX"]:
|
||||
raise ValueError("format must be one of amr, wav, mp3")
|
||||
|
||||
# read voice to byte
|
||||
@@ -297,15 +304,15 @@ class MessageMixin(WechatAPIClientBase):
|
||||
raise ValueError("voice should be str, bytes, or path")
|
||||
|
||||
# get voice duration and b64
|
||||
if format.lower() == "amr":
|
||||
if format.lower() == "AMR":
|
||||
audio = AudioSegment.from_file(BytesIO(voice_byte), format="amr")
|
||||
voice_base64 = base64.b64encode(voice_byte).decode()
|
||||
elif format.lower() == "wav":
|
||||
elif format.lower() == "WAVE":
|
||||
audio = AudioSegment.from_file(BytesIO(voice_byte), format="wav").set_channels(1)
|
||||
audio = audio.set_frame_rate(self._get_closest_frame_rate(audio.frame_rate))
|
||||
voice_base64 = base64.b64encode(
|
||||
await pysilk.async_encode(audio.raw_data, sample_rate=audio.frame_rate)).decode()
|
||||
elif format.lower() == "mp3":
|
||||
elif format.lower() == "MP3":
|
||||
audio = AudioSegment.from_file(BytesIO(voice_byte), format="mp3").set_channels(1)
|
||||
audio = audio.set_frame_rate(self._get_closest_frame_rate(audio.frame_rate))
|
||||
voice_base64 = base64.b64encode(
|
||||
@@ -314,18 +321,24 @@ class MessageMixin(WechatAPIClientBase):
|
||||
raise ValueError("format must be one of amr, wav, mp3")
|
||||
|
||||
duration = len(audio)
|
||||
|
||||
format_dict = {"amr": 0, "wav": 4, "mp3": 4}
|
||||
|
||||
# Type: AMR = 0, MP3 = 2, SILK = 4, SPEEX = 1, WAVE = 3 VoiceTime :音频长度 1000为一秒
|
||||
format_dict = {"AMR": 0, "WAVE": 3, "MP3": 2, "SILK": 4, "SPEEX": 1}
|
||||
# {
|
||||
# "Base64": "string",
|
||||
# "ToWxid": "string",
|
||||
# "Type": 0,
|
||||
# "VoiceTime": 0,
|
||||
# "Wxid": "string"
|
||||
# }
|
||||
async with aiohttp.ClientSession() as session:
|
||||
json_param = {"Wxid": self.wxid, "ToWxid": wxid, "Base64": voice_base64, "VoiceTime": duration,
|
||||
"Type": format_dict[format]}
|
||||
response = await session.post(f'http://{self.ip}:{self.port}/SendVoiceMsg', json=json_param)
|
||||
response = await session.post(f'http://{self.ip}:{self.port}/api/Msg/SendVoice', json=json_param)
|
||||
json_resp = await response.json()
|
||||
|
||||
if json_resp.get("Success"):
|
||||
json_param.pop('Base64')
|
||||
logging.info("发送语音消息: 对方wxid:{} 时长:{} 格式:{} 音频base64略", wxid, duration, format)
|
||||
self.logging.info("发送语音消息: 对方wxid:{} 时长:{} 格式:{} 音频base64略", wxid, duration, format)
|
||||
data = json_resp.get("Data")
|
||||
return int(data.get("ClientMsgId")), data.get("CreateTime"), data.get("NewMsgId")
|
||||
else:
|
||||
@@ -344,10 +357,38 @@ class MessageMixin(WechatAPIClientBase):
|
||||
|
||||
return closest_rate
|
||||
|
||||
async def send_link_xml_message(self, xml: str, towxid: str) -> tuple[str, int, int]:
|
||||
"""发送链接消息。
|
||||
{
|
||||
"ToWxid": "string",
|
||||
"Type": 0,
|
||||
"Wxid": "string",
|
||||
"Xml": "string"
|
||||
}
|
||||
Args:
|
||||
xml (str): 发送的内容
|
||||
towxid (str):接收人
|
||||
|
||||
Returns:
|
||||
tuple[str, int, int]: 返回(ClientMsgid, CreateTime, NewMsgId)
|
||||
|
||||
Raises:
|
||||
UserLoggedOut: 未登录时调用
|
||||
BanProtection: 登录新设备后4小时内操作
|
||||
根据error_handler处理错误
|
||||
"""
|
||||
|
||||
return await self._queue_message(self._send_link_xml_message, xml, towxid)
|
||||
|
||||
async def send_link_message(self, wxid: str, url: str, title: str = "", description: str = "",
|
||||
thumb_url: str = "") -> tuple[str, int, int]:
|
||||
"""发送链接消息。
|
||||
|
||||
{
|
||||
"ToWxid": "string",
|
||||
"Type": 0,
|
||||
"Wxid": "string",
|
||||
"Xml": "string"
|
||||
}
|
||||
Args:
|
||||
wxid (str): 接收人wxid
|
||||
url (str): 跳转链接
|
||||
@@ -363,8 +404,25 @@ class MessageMixin(WechatAPIClientBase):
|
||||
BanProtection: 登录新设备后4小时内操作
|
||||
根据error_handler处理错误
|
||||
"""
|
||||
|
||||
return await self._queue_message(self._send_link_message, wxid, url, title, description, thumb_url)
|
||||
|
||||
async def _send_link_xml_message(self, xml: str, towxid: str) -> tuple[int, int, int]:
|
||||
if not self.wxid:
|
||||
raise UserLoggedOut("请先登录")
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
json_param = {"Wxid": self.wxid, "ToWxid": towxid, "Xml": xml, "Type": 0}
|
||||
|
||||
response = await session.post(f'http://{self.ip}:{self.port}/api/Msg/ShareLink', json=json_param)
|
||||
json_resp = await response.json()
|
||||
logger.info(f"_send_link_xml_message resp:{json_resp}")
|
||||
if json_resp.get("Success"):
|
||||
data = json_resp.get("Data")
|
||||
return data.get("clientMsgId"), data.get("createTime"), data.get("newMsgId")
|
||||
else:
|
||||
self.error_handler(json_resp)
|
||||
|
||||
async def _send_link_message(self, wxid: str, url: str, title: str = "", description: str = "",
|
||||
thumb_url: str = "") -> tuple[int, int, int]:
|
||||
if not self.wxid:
|
||||
@@ -373,16 +431,17 @@ class MessageMixin(WechatAPIClientBase):
|
||||
async with aiohttp.ClientSession() as session:
|
||||
json_param = {"Wxid": self.wxid, "ToWxid": wxid, "Url": url, "Title": title, "Desc": description,
|
||||
"ThumbUrl": thumb_url}
|
||||
response = await session.post(f'http://{self.ip}:{self.port}/SendShareLink', json=json_param)
|
||||
|
||||
response = await session.post(f'http://{self.ip}:{self.port}/api/Msg/ShareLink', json=json_param)
|
||||
json_resp = await response.json()
|
||||
|
||||
if json_resp.get("Success"):
|
||||
logging.info("发送链接消息: 对方wxid:{} 链接:{} 标题:{} 描述:{} 缩略图链接:{}",
|
||||
wxid,
|
||||
url,
|
||||
title,
|
||||
description,
|
||||
thumb_url)
|
||||
self.logging.info("发送链接消息: 对方wxid:{} 链接:{} 标题:{} 描述:{} 缩略图链接:{}",
|
||||
wxid,
|
||||
url,
|
||||
title,
|
||||
description,
|
||||
thumb_url)
|
||||
data = json_resp.get("Data")
|
||||
return data.get("clientMsgId"), data.get("createTime"), data.get("newMsgId")
|
||||
else:
|
||||
@@ -409,7 +468,6 @@ class MessageMixin(WechatAPIClientBase):
|
||||
async def _send_emoji_message(self, wxid: str, md5: str, total_length: int) -> tuple[int, int, int]:
|
||||
if not self.wxid:
|
||||
raise UserLoggedOut("请先登录")
|
||||
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
json_param = {"Wxid": self.wxid, "ToWxid": wxid, "Md5": md5, "TotalLen": total_length}
|
||||
@@ -417,7 +475,7 @@ class MessageMixin(WechatAPIClientBase):
|
||||
json_resp = await response.json()
|
||||
|
||||
if json_resp.get("Success"):
|
||||
logging.info("发送表情消息: 对方wxid:{} md5:{} 总长度:{}", wxid, md5, total_length)
|
||||
self.logging.info("发送表情消息: 对方wxid:{} md5:{} 总长度:{}", wxid, md5, total_length)
|
||||
return json_resp.get("Data").get("emojiItem")
|
||||
else:
|
||||
self.error_handler(json_resp)
|
||||
@@ -447,7 +505,6 @@ class MessageMixin(WechatAPIClientBase):
|
||||
if not self.wxid:
|
||||
raise UserLoggedOut("请先登录")
|
||||
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
json_param = {"Wxid": self.wxid, "ToWxid": wxid, "CardWxid": card_wxid, "CardAlias": card_alias,
|
||||
"CardNickname": card_nickname}
|
||||
@@ -455,10 +512,10 @@ class MessageMixin(WechatAPIClientBase):
|
||||
json_resp = await response.json()
|
||||
|
||||
if json_resp.get("Success"):
|
||||
logging.info("发送名片消息: 对方wxid:{} 名片wxid:{} 名片备注:{} 名片昵称:{}", wxid,
|
||||
card_wxid,
|
||||
card_alias,
|
||||
card_nickname)
|
||||
self.logging.info("发送名片消息: 对方wxid:{} 名片wxid:{} 名片备注:{} 名片昵称:{}", wxid,
|
||||
card_wxid,
|
||||
card_alias,
|
||||
card_nickname)
|
||||
data = json_resp.get("Data")
|
||||
return data.get("List")[0].get("ClientMsgid"), data.get("List")[0].get("Createtime"), data.get("List")[
|
||||
0].get("NewMsgId")
|
||||
@@ -486,16 +543,20 @@ class MessageMixin(WechatAPIClientBase):
|
||||
async def _send_app_message(self, wxid: str, xml: str, type: int) -> tuple[int, int, int]:
|
||||
if not self.wxid:
|
||||
raise UserLoggedOut("请先登录")
|
||||
|
||||
|
||||
# {
|
||||
# "ToWxid": "string",
|
||||
# "Type": 0,
|
||||
# "Wxid": "string",
|
||||
# "Xml": "string"
|
||||
# }
|
||||
async with aiohttp.ClientSession() as session:
|
||||
json_param = {"Wxid": self.wxid, "ToWxid": wxid, "Xml": xml, "Type": type}
|
||||
response = await session.post(f'http://{self.ip}:{self.port}/SendAppMsg', json=json_param)
|
||||
response = await session.post(f'http://{self.ip}:{self.port}/api/Msg/SendApp', json=json_param)
|
||||
json_resp = await response.json()
|
||||
|
||||
logger.info(f"json_resp: {json_resp}")
|
||||
if json_resp.get("Success"):
|
||||
json_param["Xml"] = json_param["Xml"].replace("\n", "")
|
||||
logging.info("发送app消息: 对方wxid:{} 类型:{} xml:{}", wxid, type, json_param["Xml"])
|
||||
self.logging.info("发送app消息: 对方wxid:{} 类型:{} xml:{}", wxid, type, json_param["Xml"])
|
||||
return json_resp.get("Data").get("clientMsgId"), json_resp.get("Data").get(
|
||||
"createTime"), json_resp.get("Data").get("newMsgId")
|
||||
else:
|
||||
@@ -522,14 +583,13 @@ class MessageMixin(WechatAPIClientBase):
|
||||
if not self.wxid:
|
||||
raise UserLoggedOut("请先登录")
|
||||
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
json_param = {"Wxid": self.wxid, "ToWxid": wxid, "Content": xml}
|
||||
response = await session.post(f'http://{self.ip}:{self.port}/SendCDNFileMsg', json=json_param)
|
||||
json_resp = await response.json()
|
||||
|
||||
if json_resp.get("Success"):
|
||||
logging.info("转发文件消息: 对方wxid:{} xml:{}", wxid, xml)
|
||||
self.logging.info("转发文件消息: 对方wxid:{} xml:{}", wxid, xml)
|
||||
data = json_resp.get("Data")
|
||||
return data.get("clientMsgId"), data.get("createTime"), data.get("newMsgId")
|
||||
else:
|
||||
@@ -556,14 +616,13 @@ class MessageMixin(WechatAPIClientBase):
|
||||
if not self.wxid:
|
||||
raise UserLoggedOut("请先登录")
|
||||
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
json_param = {"Wxid": self.wxid, "ToWxid": wxid, "Content": xml}
|
||||
response = await session.post(f'http://{self.ip}:{self.port}/SendCDNImgMsg', json=json_param)
|
||||
json_resp = await response.json()
|
||||
|
||||
if json_resp.get("Success"):
|
||||
logging.info("转发图片消息: 对方wxid:{} xml:{}", wxid, xml)
|
||||
self.logging.info("转发图片消息: 对方wxid:{} xml:{}", wxid, xml)
|
||||
data = json_resp.get("Data")
|
||||
return data.get("ClientImgId").get("string"), data.get("CreateTime"), data.get("Newmsgid")
|
||||
else:
|
||||
@@ -590,14 +649,13 @@ class MessageMixin(WechatAPIClientBase):
|
||||
if not self.wxid:
|
||||
raise UserLoggedOut("请先登录")
|
||||
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
json_param = {"Wxid": self.wxid, "ToWxid": wxid, "Content": xml}
|
||||
response = await session.post(f'http://{self.ip}:{self.port}/SendCDNVideoMsg', json=json_param)
|
||||
json_resp = await response.json()
|
||||
|
||||
if json_resp.get("Success"):
|
||||
logging.info("转发视频消息: 对方wxid:{} xml:{}", wxid, xml)
|
||||
self.logging.info("转发视频消息: 对方wxid:{} xml:{}", wxid, xml)
|
||||
data = json_resp.get("Data")
|
||||
return data.get("clientMsgId"), data.get("newMsgId")
|
||||
else:
|
||||
|
||||
@@ -13,7 +13,11 @@ from wechat_ipad.client.base import WechatAPIClientBase, Proxy
|
||||
class ToolMixin(WechatAPIClientBase):
|
||||
async def download_image(self, aeskey: str, cdnmidimgurl: str) -> str:
|
||||
"""CDN下载高清图片。
|
||||
|
||||
{
|
||||
"Wxid": "string",
|
||||
"FileNo": "string",
|
||||
"FileAesKey": "string"
|
||||
}
|
||||
Args:
|
||||
aeskey (str): 图片的AES密钥
|
||||
cdnmidimgurl (str): 图片的CDN URL
|
||||
@@ -30,7 +34,7 @@ class ToolMixin(WechatAPIClientBase):
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
json_param = {"Wxid": self.wxid, "AesKey": aeskey, "Cdnmidimgurl": cdnmidimgurl}
|
||||
response = await session.post(f'http://{self.ip}:{self.port}/CdnDownloadImg', json=json_param)
|
||||
response = await session.post(f'http://{self.ip}:{self.port}/api/Tools/CdnDownloadImage', json=json_param)
|
||||
json_resp = await response.json()
|
||||
|
||||
if json_resp.get("Success"):
|
||||
|
||||
399
wechat_ipad/models/message.py
Normal file
399
wechat_ipad/models/message.py
Normal file
@@ -0,0 +1,399 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Dict, Any
|
||||
from enum import Enum
|
||||
import xml.etree.ElementTree as ET
|
||||
import re
|
||||
|
||||
|
||||
class MessageType(Enum):
|
||||
"""消息类型枚举"""
|
||||
UNKNOWN = 0 # 未知类型
|
||||
TEXT = 1 # 文本消息
|
||||
IMAGE = 3 # 图片消息
|
||||
VOICE = 34 # 语音消息
|
||||
VERIFY_MSG = 37 # 好友确认消息
|
||||
POSSIBLE_FRIEND_MSG = 40 # 好友推荐消息
|
||||
SHARE_CARD = 42 # 名片消息
|
||||
VIDEO = 43 # 视频消息
|
||||
EMOTICON = 47 # 动画表情
|
||||
LOCATION = 48 # 位置消息
|
||||
APP = 49 # 应用消息(链接、音乐、小程序等)
|
||||
VOIP_MSG = 50 # VOIP消息
|
||||
STATUS_NOTIFY = 51 # 状态通知
|
||||
SYSTEM = 10000 # 系统消息
|
||||
SYSTEM_NOTIFY = 10002 # 系统通知
|
||||
RECALLED = 10002 # 撤回消息
|
||||
EMOJI = 1090519089 # 大表情
|
||||
|
||||
|
||||
class AppMessageType(Enum):
|
||||
"""应用消息类型枚举"""
|
||||
UNKNOWN = 0 # 未知类型
|
||||
TEXT = 1 # 文本
|
||||
IMG = 2 # 图片
|
||||
AUDIO = 3 # 音频
|
||||
VIDEO = 4 # 视频
|
||||
LINK = 5 # 链接消息
|
||||
FILE = 6 # 文件
|
||||
QUOTE = 57 # 引用
|
||||
EMOJI = 8 # 表情
|
||||
LOCATION = 17 # 位置
|
||||
APP_MSG = 33 # APP消息
|
||||
MINIPROGRAM = 36 # 小程序
|
||||
TRANSFER = 2000 # 转账
|
||||
RED_PACKET = 2001 # 红包
|
||||
CARD_TICKET = 2002 # 卡券
|
||||
REAL_TIME_LOCATION_START = 17 # 实时位置共享开始
|
||||
REAL_TIME_LOCATION_STOP = 18 # 实时位置共享结束
|
||||
CARD = 42 # 名片
|
||||
VOICE_REMIND = 43 # 语音提醒
|
||||
FILE_NOTICE = 74 # 文件通知
|
||||
CHANNELS = 51 # 视频号消息
|
||||
|
||||
|
||||
@dataclass
|
||||
class MessageContent:
|
||||
"""消息内容"""
|
||||
raw_content: str # 原始内容
|
||||
xml_content: str = "" # XML内容(如果有)
|
||||
clean_content: str = "" # 清理后的内容(去除发信人信息)
|
||||
sender: str = "" # 发信人wxid
|
||||
|
||||
def __post_init__(self):
|
||||
"""处理XML内容和清理发信人信息"""
|
||||
# 清理发信人信息
|
||||
self.clean_content = self.clean_sender_info(self.raw_content)
|
||||
|
||||
# 处理XML内容
|
||||
if self.clean_content and (self.clean_content.startswith('<?xml') or self.clean_content.startswith('<msg')):
|
||||
try:
|
||||
self.xml_content = self.clean_content
|
||||
except ET.ParseError:
|
||||
pass
|
||||
|
||||
def clean_sender_info(self, content: str) -> str:
|
||||
"""清理内容中的发信人信息"""
|
||||
if not content:
|
||||
return ""
|
||||
|
||||
# 如果有发信人信息,优先使用发信人信息进行清理
|
||||
if self.sender:
|
||||
# 尝试移除发信人前缀(包括昵称和wxid两种情况)
|
||||
patterns = [
|
||||
f"^{re.escape(self.sender)}[::]\\s*\\n", # wxid格式
|
||||
f"^[^\\n]+?\\({re.escape(self.sender)}\\)[::]\\s*\\n", # 昵称(wxid)格式
|
||||
f"^[^\\n]+?<{re.escape(self.sender)}>[::]\\s*\\n", # 昵称<wxid>格式
|
||||
]
|
||||
for pattern in patterns:
|
||||
content = re.sub(pattern, '', content)
|
||||
|
||||
# 通用清理规则(用于处理其他可能的格式)
|
||||
patterns = [
|
||||
r'^wxid_[a-zA-Z0-9_]+[::]\s*\n', # wxid格式
|
||||
r'^[^::\n]+\([^)]+\)[::]\s*\n', # 昵称(wxid)格式
|
||||
r'^[^::\n]+<[^>]+>[::]\s*\n', # 昵称<wxid>格式
|
||||
r'^[^::\n]+[::]\s*\n', # 其他格式
|
||||
]
|
||||
|
||||
for pattern in patterns:
|
||||
content = re.sub(pattern, '', content)
|
||||
|
||||
return content.strip()
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImageContent:
|
||||
"""图片消息特定内容"""
|
||||
aes_key: str
|
||||
url: str
|
||||
length: int
|
||||
md5: str
|
||||
thumb_base64: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class VoiceContent:
|
||||
"""语音消息特定内容"""
|
||||
voice_length: int
|
||||
aes_key: str
|
||||
url: str
|
||||
voice_base64: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class VideoContent:
|
||||
"""视频消息特定内容"""
|
||||
aes_key: str
|
||||
video_url: str
|
||||
thumb_url: str
|
||||
length: int
|
||||
play_length: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class LocationContent:
|
||||
"""地理位置特定内容"""
|
||||
x: float # 纬度
|
||||
y: float # 经度
|
||||
label: str # 地址标签
|
||||
poi_name: Optional[str] = None # 地点名称
|
||||
|
||||
|
||||
@dataclass
|
||||
class WxMessage:
|
||||
"""消息基础类"""
|
||||
type_name: str
|
||||
appid: str
|
||||
wxid: str
|
||||
msg_id: int
|
||||
sender: str
|
||||
to_user: str
|
||||
roomid: str # 群聊ID
|
||||
msg_type: MessageType
|
||||
content: MessageContent
|
||||
create_time: int
|
||||
push_content: Optional[str]
|
||||
new_msg_id: int
|
||||
msg_seq: int
|
||||
msg_source: str
|
||||
raw_data: Dict[str, Any] # 原始JSON数据
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, data: Dict[str, Any]) -> 'WxMessage':
|
||||
"""从JSON数据创建消息对象"""
|
||||
to_user = data.get("ToUserName", {}).get("string", "")
|
||||
from_user = data.get("FromUserName", {}).get("string", "")
|
||||
|
||||
# 获取原始内容
|
||||
content_str = data.get("Content", {}).get("string", "")
|
||||
|
||||
# 判断是否是群聊消息
|
||||
is_group_chat = from_user.endswith("@chatroom")
|
||||
|
||||
# 如果是群聊消息,需要调整发送者和接收者
|
||||
actual_sender = from_user
|
||||
if is_group_chat and content_str:
|
||||
# 从消息内容中提取真正的发送人
|
||||
parts = content_str.split(':', 1) # 只分割第一个冒号
|
||||
if len(parts) > 1:
|
||||
# 提取发送人ID(冒号前的部分)
|
||||
potential_sender = parts[0].strip()
|
||||
if potential_sender: # 确保发送人ID不为空
|
||||
actual_sender = potential_sender
|
||||
# 群聊消息中,接收者是群ID
|
||||
to_user = from_user
|
||||
|
||||
# 创建MessageContent对象时传入发信人信息
|
||||
message_content = MessageContent(content_str, sender=actual_sender)
|
||||
|
||||
return cls(
|
||||
type_name=data.get("TypeName", ""),
|
||||
appid=data.get("Appid", ""),
|
||||
wxid=data.get("Wxid", ""),
|
||||
msg_id=data.get("MsgId", 0),
|
||||
sender=actual_sender, # 使用提取出的实际发送人
|
||||
to_user=to_user, # 群聊时,接收者为群ID
|
||||
roomid=from_user if is_group_chat else "", # 如果是群聊,roomid就是from_user
|
||||
msg_type=MessageType(data.get("MsgType", 0)),
|
||||
content=message_content,
|
||||
create_time=data.get("CreateTime", 0),
|
||||
push_content=data.get("PushContent"),
|
||||
new_msg_id=data.get("NewMsgId", 0),
|
||||
msg_seq=data.get("MsgSeq", 0),
|
||||
msg_source=data.get("MsgSource", ""),
|
||||
raw_data=data
|
||||
)
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""返回消息的字符串表示,用于打印和日志"""
|
||||
# 获取消息类型的名称
|
||||
msg_type_name = self.msg_type.name if self.msg_type else "UNKNOWN"
|
||||
|
||||
# 处理不同类型的消息内容
|
||||
content_str = ""
|
||||
if self.msg_type == MessageType.TEXT:
|
||||
# 文本消息直接显示清理后的内容
|
||||
content_str = self.content.clean_content
|
||||
elif self.msg_type == MessageType.IMAGE:
|
||||
# 图片消息显示图片信息
|
||||
img_content = self.get_image_content()
|
||||
if img_content:
|
||||
content_str = f"[图片] 大小: {img_content.length}字节, MD5: {img_content.md5}"
|
||||
else:
|
||||
content_str = "[图片]"
|
||||
elif self.msg_type == MessageType.VOICE:
|
||||
# 语音消息显示语音信息
|
||||
voice_content = self.get_voice_content()
|
||||
if voice_content:
|
||||
content_str = f"[语音] 长度: {voice_content.voice_length}ms"
|
||||
else:
|
||||
content_str = "[语音]"
|
||||
elif self.msg_type == MessageType.VIDEO:
|
||||
# 视频消息显示视频信息
|
||||
video_content = self.get_video_content()
|
||||
if video_content:
|
||||
content_str = f"[视频] 长度: {video_content.play_length}ms, 大小: {video_content.length}字节"
|
||||
else:
|
||||
content_str = "[视频]"
|
||||
elif self.msg_type == MessageType.LOCATION:
|
||||
# 位置消息显示位置信息
|
||||
location_content = self.get_location_content()
|
||||
if location_content:
|
||||
content_str = f"[位置] {location_content.label}"
|
||||
else:
|
||||
content_str = "[位置]"
|
||||
elif self.msg_type == MessageType.APP:
|
||||
# 应用消息显示应用类型
|
||||
app_type = self.get_app_message_type()
|
||||
if app_type:
|
||||
content_str = f"[应用消息] 类型: {app_type.name}"
|
||||
else:
|
||||
content_str = "[应用消息]"
|
||||
elif self.msg_type == MessageType.EMOJI:
|
||||
content_str = "[表情]"
|
||||
elif self.msg_type == MessageType.SYSTEM:
|
||||
content_str = f"[系统消息] {self.content.raw_content}"
|
||||
elif self.msg_type == MessageType.SYSTEM_NOTIFY:
|
||||
content_str = f"[系统通知] {self.content.raw_content}"
|
||||
else:
|
||||
# 其他类型消息
|
||||
content_str = f"[未知类型消息] {self.content.raw_content[:30]}..."
|
||||
|
||||
# 限制内容长度,避免过长
|
||||
if len(content_str) > 100:
|
||||
content_str = content_str[:97] + "..."
|
||||
|
||||
# 构建基本信息
|
||||
from_info = f"发送者: {self.sender}"
|
||||
to_info = f"接收者: {self.to_user}"
|
||||
|
||||
# 如果是群消息,添加群信息
|
||||
group_info = ""
|
||||
if self.from_group():
|
||||
group_info = f"群聊: {self.roomid}, "
|
||||
|
||||
# 构建完整的消息字符串
|
||||
return (f"WxMessage[ID: {self.msg_id}, 类型: {msg_type_name}, "
|
||||
f"{group_info}{from_info}, {to_info}, "
|
||||
f"内容: {content_str}]")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""返回消息的详细表示,用于调试"""
|
||||
return self.__str__()
|
||||
|
||||
def from_self(self) -> bool:
|
||||
"""判断是否是自己发送的消息"""
|
||||
return self.sender == self.wxid
|
||||
|
||||
def from_group(self) -> bool:
|
||||
"""判断是否是群聊消息"""
|
||||
return self.to_user.endswith("@chatroom")
|
||||
|
||||
def is_at(self, wxid) -> bool:
|
||||
"""是否被 @:群消息,在 @ 名单里,并且不是 @ 所有人"""
|
||||
if not self.from_group():
|
||||
return False # 只有群消息才能 @
|
||||
|
||||
if not re.findall(f"<atuserlist>[\s|\S]*({wxid})[\s|\S]*</atuserlist>", self.msg_source):
|
||||
return False # 不在 @ 清单里
|
||||
|
||||
if re.findall(r"@(?:所有人|all|All)", self.content.clean_content):
|
||||
return False # 排除 @ 所有人
|
||||
|
||||
return True
|
||||
|
||||
def get_app_message_type(self) -> Optional[AppMessageType]:
|
||||
"""获取应用消息类型"""
|
||||
if self.msg_type != MessageType.APP or not self.content.xml_content:
|
||||
return None
|
||||
|
||||
try:
|
||||
appmsg = ET.fromstring(self.content.xml_content).find('.//appmsg')
|
||||
if appmsg is not None:
|
||||
type_value = int(appmsg.find('type').text)
|
||||
return AppMessageType(type_value)
|
||||
except (AttributeError, ValueError, ET.ParseError):
|
||||
pass
|
||||
return None
|
||||
|
||||
def get_image_content(self) -> Optional[ImageContent]:
|
||||
"""获取图片消息内容"""
|
||||
if self.msg_type != MessageType.IMAGE or not self.content.xml_content:
|
||||
return None
|
||||
|
||||
try:
|
||||
img = ET.fromstring(self.content.xml_content).find('img')
|
||||
if img is not None:
|
||||
return ImageContent(
|
||||
aes_key=img.get('aeskey', ''),
|
||||
url=img.get('cdnthumburl', ''),
|
||||
length=int(img.get('length', 0)),
|
||||
md5=img.get('md5', ''),
|
||||
thumb_base64=self.raw_data.get("Data", {}).get("ImgBuf", {}).get("buffer")
|
||||
)
|
||||
except (AttributeError, ValueError, ET.ParseError):
|
||||
pass
|
||||
return None
|
||||
|
||||
def get_voice_content(self) -> Optional[VoiceContent]:
|
||||
"""获取语音消息内容"""
|
||||
if self.msg_type != MessageType.VOICE or not self.content.xml_content:
|
||||
return None
|
||||
|
||||
try:
|
||||
voice = ET.fromstring(self.content.xml_content).find('.//voicemsg')
|
||||
if voice is not None:
|
||||
return VoiceContent(
|
||||
voice_length=int(voice.get('voicelength', 0)),
|
||||
aes_key=voice.get('aeskey', ''),
|
||||
url=voice.get('voiceurl', ''),
|
||||
voice_base64=self.raw_data.get("Data", {}).get("ImgBuf", {}).get("buffer")
|
||||
)
|
||||
except (AttributeError, ValueError, ET.ParseError):
|
||||
pass
|
||||
return None
|
||||
|
||||
def get_video_content(self) -> Optional[VideoContent]:
|
||||
"""获取视频消息内容"""
|
||||
if self.msg_type != MessageType.VIDEO or not self.content.xml_content:
|
||||
return None
|
||||
|
||||
try:
|
||||
video = ET.fromstring(self.content.xml_content).find('.//videomsg')
|
||||
if video is not None:
|
||||
return VideoContent(
|
||||
aes_key=video.get('aeskey', ''),
|
||||
video_url=video.get('cdnvideourl', ''),
|
||||
thumb_url=video.get('cdnthumburl', ''),
|
||||
length=int(video.get('length', 0)),
|
||||
play_length=int(video.get('playlength', 0))
|
||||
)
|
||||
except (AttributeError, ValueError, ET.ParseError):
|
||||
pass
|
||||
return None
|
||||
|
||||
def get_location_content(self) -> Optional[LocationContent]:
|
||||
"""获取地理位置内容"""
|
||||
if self.msg_type != MessageType.LOCATION or not self.content.xml_content:
|
||||
return None
|
||||
|
||||
try:
|
||||
location = ET.fromstring(self.content.xml_content).find('location')
|
||||
if location is not None:
|
||||
return LocationContent(
|
||||
x=float(location.get('x', 0)),
|
||||
y=float(location.get('y', 0)),
|
||||
label=location.get('label', ''),
|
||||
poi_name=location.get('poiname')
|
||||
)
|
||||
except (AttributeError, ValueError, ET.ParseError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
content_str = """wxid_g6vc38ifs1an22:\n1"""
|
||||
content = MessageContent(content_str, sender="Jyunere")
|
||||
print(content.raw_content)
|
||||
print(content.xml_content)
|
||||
print(content.clean_content)
|
||||
Reference in New Issue
Block a user