Files
abot/wechat_ipad/client/tools.py
liuwei 2a54650a6f 完善表情资产后台能力并补充群总结落库
- 新增表情资产表,支持表情文件落盘后的资产沉淀、查询与发送时间回写
- 将表情下载从消息主链路中移出,改为后台定时批处理,降低同步入库阻塞风险
- 抽取通用 CDN 下载与 base64 落盘能力,统一图片与表情文件处理方式
- 在后台通讯录聊天窗口增加表情资产面板,支持查看资产并直接选择发送表情
- 新增后台表情资产接口,支持按群过滤最近表情素材
- 优化消息列表中的表情消息展示,支持在后台直接预览表情图片
- 启动时不再同步补偿历史表情,统一交由定时任务处理,避免影响系统稳定性
- 新增群总结落库表,支持将每日总结写入数据库,便于后续知识库提取与复用
- 将定时总结结果写入数据库,保留总结文本、周期信息、消息数量和元数据
2026-04-02 17:52:17 +08:00

444 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import base64
import io
import os
import imghdr
import aiofiles
import aiohttp
import pysilk
from pydub import AudioSegment
from wechat_ipad import UserLoggedOut
from wechat_ipad.client.base import WechatAPIClientBase, Proxy
class ToolMixin(WechatAPIClientBase):
async def download_cdn_file(self, aeskey: str, file_url: str) -> str:
"""通用 CDN 文件下载。
{
"Wxid": "string",
"FileNo": "string",
"FileAesKey": "string"
}
Args:
aeskey (str): 文件的AES密钥
file_url (str): 文件的CDN URL
Returns:
str: 文件的base64编码字符串
Raises:
UserLoggedOut: 未登录时调用
根据error_handler处理错误
"""
if not self.wxid:
raise UserLoggedOut("请先登录")
async with aiohttp.ClientSession() as session:
json_param = {"Wxid": self.wxid, "FileAesKey": aeskey, "FileNo": file_url}
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"):
return json_resp.get("Data").get("Image")
else:
self.error_handler(json_resp)
async def download_image(self, aeskey: str, cdnmidimgurl: str) -> str:
"""CDN下载高清图片。"""
return await self.download_cdn_file(aeskey, cdnmidimgurl)
async def download_voice(self, msg_id: str, voiceurl: str, length: int) -> str:
"""下载语音文件。
Args:
msg_id (str): 消息的msgid
voiceurl (str): 语音的url从xml获取
length (int): 语音长度从xml获取
Returns:
str: 语音的base64编码字符串
Raises:
UserLoggedOut: 未登录时调用
根据error_handler处理错误
"""
if not self.wxid:
raise UserLoggedOut("请先登录")
async with aiohttp.ClientSession() as session:
json_param = {"Wxid": self.wxid, "MsgId": msg_id, "Voiceurl": voiceurl, "Length": length}
response = await session.post(f'http://{self.ip}:{self.port}/DownloadVoice', json=json_param)
json_resp = await response.json()
if json_resp.get("Success"):
return json_resp.get("Data").get("data").get("buffer")
else:
self.error_handler(json_resp)
async def download_attach_xml(self, xml_str: str) -> str:
# 读取消息信息,进行处理
import xml.etree.ElementTree as ET
root = ET.fromstring(xml_str)
appmsg = root.find("appmsg")
appid = appmsg.attrib.get("appid", "")
appattach = appmsg.find("appattach")
attach_id = appattach.findtext("attachid", "")
datalen = int(appattach.findtext("totallen", "0"))
username = root.findtext("fromusername", "")
return self.download_attach(attach_id, datalen, username, appid)
async def download_attach(self, attach_id: str, datalen: int, username: str, appid: str) -> str:
"""下载附件。
{
"AppID": "wx6618f1cfc6c132f8",
"AttachId": "@cdn_3057020100044b304902010002042d0c366c02032df7950204d35d06af0204681af942042438363966373134342d663961352d343065612d623038662d3062643730663335343731370204052400050201000405004c54a100_c57ad24ba4e9ceeb3c5e10e33361028d_1",
"DataLen": 1160,
"Section": {
"DataLen": 1160,
"StartPos": 0
},
"UserName": "Jyunere",
"Wxid": "wxid_ts8v7yk4g5c522"
}
Args:
attach_id (str): 附件ID
Returns:
dict: 附件数据
Raises:
UserLoggedOut: 未登录时调用
根据error_handler处理错误
"""
if not self.wxid:
raise UserLoggedOut("请先登录")
async with aiohttp.ClientSession() as session:
json_param = {"Wxid": self.wxid, "AttachId": attach_id, "DataLen": datalen,
"Section": {"DataLen": datalen, "StartPos": 0}, "UserName": username, "AppID": appid}
response = await session.post(f'http://{self.ip}:{self.port}/api/Tools/DownloadFile', json=json_param)
json_resp = await response.json()
if json_resp.get("Success"):
return json_resp.get("Data").get("data").get("buffer")
else:
self.error_handler(json_resp)
async def download_video(self, msg_id) -> str:
"""下载视频。
Args:
msg_id (str): 消息的msg_id
Returns:
str: 视频的base64编码字符串
Raises:
UserLoggedOut: 未登录时调用
根据error_handler处理错误
"""
if not self.wxid:
raise UserLoggedOut("请先登录")
async with aiohttp.ClientSession() as session:
json_param = {"Wxid": self.wxid, "MsgId": msg_id}
response = await session.post(f'http://{self.ip}:{self.port}/DownloadVideo', json=json_param)
json_resp = await response.json()
if json_resp.get("Success"):
return json_resp.get("Data").get("data").get("buffer")
else:
self.error_handler(json_resp)
async def friend_circle_upload(self, base64: str) -> str:
# / FriendCircle / Upload
if not self.wxid:
raise UserLoggedOut("请先登录")
async with aiohttp.ClientSession() as session:
json_param = {"Wxid": self.wxid, "Base64": base64}
response = await session.post(f'http://{self.ip}:{self.port}/api/FriendCircle/Upload', json=json_param)
json_resp = await response.json()
if json_resp.get("Success"):
return json_resp.get("Data").get("ThumbUrls")[0].get("Url")
else:
self.error_handler(json_resp)
async def set_step(self, count: int) -> bool:
"""设置步数。
Args:
count (int): 要设置的步数
Returns:
bool: 成功返回True失败返回False
Raises:
UserLoggedOut: 未登录时调用
BanProtection: 风控保护: 新设备登录后4小时内请挂机
根据error_handler处理错误
"""
if not self.wxid:
raise UserLoggedOut("请先登录")
async with aiohttp.ClientSession() as session:
json_param = {"Wxid": self.wxid, "StepCount": count}
response = await session.post(f'http://{self.ip}:{self.port}/SetStep', json=json_param)
json_resp = await response.json()
if json_resp.get("Success"):
return True
else:
self.error_handler(json_resp)
async def set_proxy(self, proxy: Proxy) -> bool:
"""设置代理。
Args:
proxy (Proxy): 代理配置对象
Returns:
bool: 成功返回True失败返回False
Raises:
UserLoggedOut: 未登录时调用
根据error_handler处理错误
"""
if not self.wxid:
raise UserLoggedOut("请先登录")
async with aiohttp.ClientSession() as session:
json_param = {"Wxid": self.wxid,
"Proxy": {"ProxyIp": f"{proxy.ip}:{proxy.port}",
"ProxyUser": proxy.username,
"ProxyPassword": proxy.password}}
response = await session.post(f'http://{self.ip}:{self.port}/SetProxy', json=json_param)
json_resp = await response.json()
if json_resp.get("Success"):
return True
else:
self.error_handler(json_resp)
async def check_database(self) -> bool:
"""检查数据库状态。
Returns:
bool: 数据库正常返回True否则返回False
"""
async with aiohttp.ClientSession() as session:
response = await session.get(f'http://{self.ip}:{self.port}/CheckDatabaseOK')
json_resp = await response.json()
if json_resp.get("Running"):
return True
else:
return False
@staticmethod
async def base64_to_file(base64_str: str, file_name: str, file_path: str) -> bool:
"""将base64字符串转换为文件并保存。
Args:
base64_str (str): base64编码的字符串
file_name (str): 要保存的文件名
file_path (str): 文件保存路径
Returns:
bool: 转换成功返回True失败返回False
"""
try:
os.makedirs(file_path, exist_ok=True)
# 拼接完整的文件路径
full_path = os.path.join(file_path, file_name)
# 移除可能存在的 base64 头部信息
if ',' in base64_str:
base64_str = base64_str.split(',')[1]
# 解码 base64 并写入文件
async with aiofiles.open(full_path, 'wb') as f:
await f.write(base64.b64decode(base64_str))
return True
except Exception as e:
return False
@staticmethod
def guess_file_extension(file_bytes: bytes, default_ext: str = ".bin") -> str:
"""根据文件头猜测扩展名。"""
if not file_bytes:
return default_ext
if file_bytes.startswith(b"GIF87a") or file_bytes.startswith(b"GIF89a"):
return ".gif"
if file_bytes.startswith(b"\x89PNG\r\n\x1a\n"):
return ".png"
if file_bytes.startswith(b"RIFF") and file_bytes[8:12] == b"WEBP":
return ".webp"
if file_bytes.startswith(b"\xff\xd8\xff"):
return ".jpg"
detected = imghdr.what(None, h=file_bytes)
if detected:
return f".{detected}"
return default_ext
@staticmethod
async def base64_to_file_autoext(base64_str: str, file_stem: str, file_path: str,
default_ext: str = ".bin") -> str:
"""将base64写入文件并自动识别扩展名。"""
os.makedirs(file_path, exist_ok=True)
if ',' in base64_str:
base64_str = base64_str.split(',')[1]
file_bytes = base64.b64decode(base64_str)
ext = ToolMixin.guess_file_extension(file_bytes, default_ext=default_ext)
full_path = os.path.join(file_path, f"{file_stem}{ext}")
async with aiofiles.open(full_path, 'wb') as f:
await f.write(file_bytes)
return full_path
@staticmethod
async def file_to_base64(file_path: str) -> str:
"""将文件转换为base64字符串。
Args:
file_path (str): 文件路径
Returns:
str: base64编码的字符串
"""
async with aiofiles.open(file_path, 'rb') as f:
return base64.b64encode(await f.read()).decode()
@staticmethod
def base64_to_byte(base64_str: str) -> bytes:
"""将base64字符串转换为bytes。
Args:
base64_str (str): base64编码的字符串
Returns:
bytes: 解码后的字节数据
"""
# 移除可能存在的 base64 头部信息
if ',' in base64_str:
base64_str = base64_str.split(',')[1]
return base64.b64decode(base64_str)
@staticmethod
def byte_to_base64(byte: bytes) -> str:
"""将bytes转换为base64字符串。
Args:
byte (bytes): 字节数据
Returns:
str: base64编码的字符串
"""
return base64.b64encode(byte).decode("utf-8")
@staticmethod
async def silk_byte_to_byte_wav_byte(silk_byte: bytes) -> bytes:
"""将silk字节转换为wav字节。
Args:
silk_byte (bytes): silk格式的字节数据
Returns:
bytes: wav格式的字节数据
"""
return await pysilk.async_decode(silk_byte, to_wav=True)
@staticmethod
def wav_byte_to_amr_byte(wav_byte: bytes) -> bytes:
"""将WAV字节数据转换为AMR格式。
Args:
wav_byte (bytes): WAV格式的字节数据
Returns:
bytes: AMR格式的字节数据
Raises:
Exception: 转换失败时抛出异常
"""
try:
# 从字节数据创建 AudioSegment 对象
audio = AudioSegment.from_wav(io.BytesIO(wav_byte))
# 设置 AMR 编码的标准参数
audio = audio.set_frame_rate(8000).set_channels(1)
# 创建一个字节缓冲区来存储 AMR 数据
output = io.BytesIO()
# 导出为 AMR 格式
audio.export(output, format="amr")
# 获取字节数据
return output.getvalue()
except Exception as e:
raise Exception(f"转换WAV到AMR失败: {str(e)}")
@staticmethod
def wav_byte_to_amr_base64(wav_byte: bytes) -> str:
"""将WAV字节数据转换为AMR格式的base64字符串。
Args:
wav_byte (bytes): WAV格式的字节数据
Returns:
str: AMR格式的base64编码字符串
"""
return base64.b64encode(ToolMixin.wav_byte_to_amr_byte(wav_byte)).decode()
@staticmethod
async def wav_byte_to_silk_byte(wav_byte: bytes) -> bytes:
"""将WAV字节数据转换为silk格式。
Args:
wav_byte (bytes): WAV格式的字节数据
Returns:
bytes: silk格式的字节数据
"""
# get pcm data
audio = AudioSegment.from_wav(io.BytesIO(wav_byte))
pcm = audio.raw_data
return await pysilk.async_encode(pcm, data_rate=audio.frame_rate, sample_rate=audio.frame_rate)
@staticmethod
async def wav_byte_to_silk_base64(wav_byte: bytes) -> str:
"""将WAV字节数据转换为silk格式的base64字符串。
Args:
wav_byte (bytes): WAV格式的字节数据
Returns:
str: silk格式的base64编码字符串
"""
return base64.b64encode(await ToolMixin.wav_byte_to_silk_byte(wav_byte)).decode()
@staticmethod
async def silk_base64_to_wav_byte(silk_base64: str) -> bytes:
"""将silk格式的base64字符串转换为WAV字节数据。
Args:
silk_base64 (str): silk格式的base64编码字符串
Returns:
bytes: WAV格式的字节数据
"""
return await ToolMixin.silk_byte_to_byte_wav_byte(base64.b64decode(silk_base64))