diff --git a/.gitignore b/.gitignore index a3b2aad90..5d4ef6c22 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ __pycache__ botpy.log .vscode +.venv* +.idea data_v2.db data_v3.db configs/session diff --git a/astrbot/core/message/components.py b/astrbot/core/message/components.py index 64c324a9e..7a6284a83 100644 --- a/astrbot/core/message/components.py +++ b/astrbot/core/message/components.py @@ -61,6 +61,8 @@ class ComponentType(Enum): TTS = "TTS" Unknown = "Unknown" + WechatEmoji = "WechatEmoji" # Wechat 下的 emoji 表情包 + class BaseMessageComponent(BaseModel): type: ComponentType @@ -412,6 +414,8 @@ class Reply(BaseMessageComponent): """引用的消息发送时间""" message_str: T.Optional[str] = "" """解析后的纯文本消息字符串""" + sender_str: T.Optional[str] = "" + """被引用的消息纯文本""" text: T.Optional[str] = "" """deprecated""" @@ -559,6 +563,16 @@ class File(BaseMessageComponent): super().__init__(name=name, file=file) +class WechatEmoji(BaseMessageComponent): + type: ComponentType = "WechatEmoji" + md5: T.Optional[str] = "" + md5_len: T.Optional[int] = 0 + cdnurl: T.Optional[str] = "" + + def __init__(self, **_): + super().__init__(**_) + + ComponentTypes = { "plain": Plain, "text": Plain, @@ -587,4 +601,5 @@ ComponentTypes = { "tts": TTS, "unknown": Unknown, "file": File, + "WechatEmoji": WechatEmoji, } diff --git a/astrbot/core/platform/sources/gewechat/client.py b/astrbot/core/platform/sources/gewechat/client.py index 0cd6cbe40..abab1790b 100644 --- a/astrbot/core/platform/sources/gewechat/client.py +++ b/astrbot/core/platform/sources/gewechat/client.py @@ -10,11 +10,18 @@ import anyio import quart from astrbot.api import logger, sp -from astrbot.api.message_components import Plain, Image, At, Record +from astrbot.api.message_components import Plain, Image, At, Record, Video from astrbot.api.platform import AstrBotMessage, MessageMember, MessageType from astrbot.core.utils.io import download_image_by_url from .downloader import GeweDownloader +try: + from .xml_data_parser import GeweDataParser +except (ImportError, ModuleNotFoundError) as e: + logger.warning( + f"警告: 可能未安装 defusedxml 依赖库,将导致无法解析微信的 表情包、引用 类型的消息: {str(e)}" + ) + class SimpleGewechatClient: """针对 Gewechat 的简单实现。 @@ -217,15 +224,10 @@ class SimpleGewechatClient: case 34: # 语音消息 - # data = await self.multimedia_downloader.download_voice( - # self.appid, - # content, - # abm.message_id - # ) - # print(data) if "ImgBuf" in d and "buffer" in d["ImgBuf"]: voice_data = base64.b64decode(d["ImgBuf"]["buffer"]) file_path = f"data/temp/gewe_voice_{abm.message_id}.silk" + async with await anyio.open_file(file_path, "wb") as f: await f.write(voice_data) abm.message.append(Record(file=file_path, url=file_path)) @@ -236,15 +238,19 @@ class SimpleGewechatClient: case 42: # 名片 logger.info("消息类型(42):名片") case 43: # 视频 - logger.info("消息类型(43):视频") + video = Video(file="", cover=content) + abm.message.append(video) case 47: # emoji - logger.info("消息类型(47):emoji") + data_parser = GeweDataParser(content, abm.group_id == "") + emoji = data_parser.parse_emoji() + abm.message.append(emoji) case 48: # 地理位置 logger.info("消息类型(48):地理位置") case 49: # 公众号/文件/小程序/引用/转账/红包/视频号/群聊邀请 - logger.info( - "消息类型(49):公众号/文件/小程序/引用/转账/红包/视频号/群聊邀请" - ) + data_parser = GeweDataParser(content, abm.group_id == "") + abm_data = data_parser.parse_mutil_49() + if abm_data: + abm.message.append(abm_data) case 51: # 帐号消息同步? logger.info("消息类型(51):帐号消息同步?") case 10000: # 被踢出群聊/更换群主/修改群名称 @@ -508,6 +514,34 @@ class SimpleGewechatClient: json_blob = await resp.json() logger.debug(f"发送图片结果: {json_blob}") + async def post_emoji(self, to_wxid, emoji_md5, emoji_size, cdnurl=""): + """发送emoji消息""" + payload = { + "appId": self.appid, + "toWxid": to_wxid, + "emojiMd5": emoji_md5, + "emojiSize": emoji_size, + } + + # 优先表情包,若拿不到表情包的md5,就用当作图片发 + try: + if emoji_md5 != "" and emoji_size != "": + async with aiohttp.ClientSession() as session: + async with session.post( + f"{self.base_url}/message/postEmoji", + headers=self.headers, + json=payload, + ) as resp: + json_blob = await resp.json() + logger.info( + f"发送emoji消息结果: {json_blob.get('msg', '操作失败')}" + ) + else: + await self.post_image(to_wxid, cdnurl) + + except Exception as e: + logger.error(e) + async def post_video( self, to_wxid, video_url: str, thumb_url: str, video_duration: int ): @@ -525,6 +559,27 @@ class SimpleGewechatClient: json_blob = await resp.json() logger.debug(f"发送视频结果: {json_blob}") + async def forward_video(self, to_wxid, cnd_xml: str): + """转发视频 + + Args: + to_wxid (str): 发送给谁 + cnd_xml (str): 视频消息的cdn信息 + """ + payload = { + "appId": self.appid, + "toWxid": to_wxid, + "xml": cnd_xml, + } + async with aiohttp.ClientSession() as session: + async with session.post( + f"{self.base_url}/message/forwardVideo", + headers=self.headers, + json=payload, + ) as resp: + json_blob = await resp.json() + logger.debug(f"转发视频结果: {json_blob}") + async def post_voice(self, to_wxid, voice_url: str, voice_duration: int): """发送语音信息 @@ -546,7 +601,7 @@ class SimpleGewechatClient: f"{self.base_url}/message/postVoice", headers=self.headers, json=payload ) as resp: json_blob = await resp.json() - logger.debug(f"发送语音结果: {json_blob}") + logger.info(f"发送语音结果: {json_blob.get('msg', '操作失败')}") async def post_file(self, to_wxid, file_url: str, file_name: str): """发送文件 diff --git a/astrbot/core/platform/sources/gewechat/downloader.py b/astrbot/core/platform/sources/gewechat/downloader.py index d2227e75f..01c89fd28 100644 --- a/astrbot/core/platform/sources/gewechat/downloader.py +++ b/astrbot/core/platform/sources/gewechat/downloader.py @@ -39,3 +39,17 @@ class GeweDownloader: continue raise Exception("无法下载图片") + + async def download_emoji_md5(self, app_id, emoji_md5): + """下载emoji""" + try: + payload = {"appId": app_id, "emojiMd5": emoji_md5} + + # gewe 计划中的接口,暂时没有实现。返回代码404 + data = await self._post_json( + self.base_url, "/message/downloadEmojiMd5", payload + ) + json_blob = json.loads(data) + return json_blob + except BaseException as e: + logger.error(f"gewe download emoji: {e}") diff --git a/astrbot/core/platform/sources/gewechat/gewechat_event.py b/astrbot/core/platform/sources/gewechat/gewechat_event.py index 5b74a63eb..78902a4c5 100644 --- a/astrbot/core/platform/sources/gewechat/gewechat_event.py +++ b/astrbot/core/platform/sources/gewechat/gewechat_event.py @@ -8,7 +8,15 @@ from astrbot.core.utils.tencent_record_helper import wav_to_tencent_silk from astrbot.api import logger from astrbot.api.event import AstrMessageEvent, MessageChain from astrbot.api.platform import AstrBotMessage, PlatformMetadata, Group, MessageMember -from astrbot.api.message_components import Plain, Image, Record, At, File, Video +from astrbot.api.message_components import ( + Plain, + Image, + Record, + At, + File, + Video, + WechatEmoji as Emoji, +) from .client import SimpleGewechatClient @@ -84,55 +92,60 @@ class GewechatPlatformEvent(AstrMessageEvent): logger.debug(f"gewe callback img url: {img_url}") await client.post_image(to_wxid, img_url) elif isinstance(comp, Video): - try: - from pyffmpeg import FFmpeg - except (ImportError, ModuleNotFoundError): - logger.error( - "需要安装 pyffmpeg 库才能发送视频: pip install pyffmpeg" - ) - raise ModuleNotFoundError( - "需要安装 pyffmpeg 库才能发送视频: pip install pyffmpeg" + if comp.cover != "": + await client.forward_video(to_wxid, comp.cover) + else: + try: + from pyffmpeg import FFmpeg + except (ImportError, ModuleNotFoundError): + logger.error( + "需要安装 pyffmpeg 库才能发送视频: pip install pyffmpeg" + ) + raise ModuleNotFoundError( + "需要安装 pyffmpeg 库才能发送视频: pip install pyffmpeg" + ) + + video_url = comp.file + # 根据 url 下载视频 + video_filename = f"{uuid.uuid4()}.mp4" + video_path = f"data/temp/{video_filename}" + await download_file(video_url, video_path) + + # 获取视频第一帧 + thumb_path = f"data/temp/{uuid.uuid4()}.jpg" + try: + ff = FFmpeg() + command = f'-i "{video_path}" -ss 0 -vframes 1 "{thumb_path}"' + ff.options(command) + thumb_file_id = os.path.basename(thumb_path) + thumb_url = f"{client.file_server_url}/{thumb_file_id}" + except Exception as e: + logger.error(f"获取视频第一帧失败: {e}") + # 获取视频时长 + try: + from pyffmpeg import FFprobe + + # 创建 FFprobe 实例 + ffprobe = FFprobe(video_url) + # 获取时长字符串 + duration_str = ffprobe.duration + # 处理时长字符串 + video_duration = float(duration_str.replace(":", "")) + except Exception as e: + logger.error(f"获取时长失败: {e}") + video_duration = 10 + + file_id = os.path.basename(video_path) + video_url = f"{client.file_server_url}/{file_id}" + await client.post_video( + to_wxid, video_url, thumb_url, video_duration ) - video_url = comp.file - # 根据 url 下载视频 - video_filename = f"{uuid.uuid4()}.mp4" - video_path = f"data/temp/{video_filename}" - await download_file(video_url, video_path) - - # 获取视频第一帧 - thumb_path = f"data/temp/{uuid.uuid4()}.jpg" - try: - ff = FFmpeg() - command = f'-i "{video_path}" -ss 0 -vframes 1 "{thumb_path}"' - ff.options(command) - thumb_file_id = os.path.basename(thumb_path) - thumb_url = f"{client.file_server_url}/{thumb_file_id}" - except Exception as e: - logger.error(f"获取视频第一帧失败: {e}") - # 获取视频时长 - try: - from pyffmpeg import FFprobe - - # 创建 FFprobe 实例 - ffprobe = FFprobe(video_url) - # 获取时长字符串 - duration_str = ffprobe.duration - # 处理时长字符串 - video_duration = float(duration_str.replace(":", "")) - except Exception as e: - logger.error(f"获取时长失败: {e}") - video_duration = 10 - - file_id = os.path.basename(video_path) - video_url = f"{client.file_server_url}/{file_id}" - await client.post_video(to_wxid, video_url, thumb_url, video_duration) - - # 删除临时视频和缩略图文件 - if os.path.exists(video_path): - os.remove(video_path) - if os.path.exists(thumb_path): - os.remove(thumb_path) + # 删除临时视频和缩略图文件 + if os.path.exists(video_path): + os.remove(video_path) + if os.path.exists(thumb_path): + os.remove(thumb_path) elif isinstance(comp, Record): # 默认已经存在 data/temp 中 record_url = comp.file @@ -165,6 +178,8 @@ class GewechatPlatformEvent(AstrMessageEvent): file_url = f"{client.file_server_url}/{file_id}" logger.debug(f"gewe callback file url: {file_url}") await client.post_file(to_wxid, file_url, file_id) + elif isinstance(comp, Emoji): + await client.post_emoji(to_wxid, comp.md5, comp.md5_len, comp.cdnurl) elif isinstance(comp, At): pass else: diff --git a/astrbot/core/platform/sources/gewechat/xml_data_parser.py b/astrbot/core/platform/sources/gewechat/xml_data_parser.py new file mode 100644 index 000000000..476c37644 --- /dev/null +++ b/astrbot/core/platform/sources/gewechat/xml_data_parser.py @@ -0,0 +1,78 @@ +from defusedxml import ElementTree as eT +from astrbot.api import logger +from astrbot.api.message_components import WechatEmoji as Emoji, Reply, Plain + + +class GeweDataParser: + def __init__(self, data, is_private_chat): + self.data = data + self.is_private_chat = is_private_chat + + def _format_to_xml(self): + return eT.fromstring(self.data) + + def parse_mutil_49(self): + appmsg_type = self._format_to_xml().find(".//appmsg/type") + if appmsg_type is None: + return + + match appmsg_type.text: + case "57": + return self.parse_reply() + + def parse_emoji(self) -> Emoji | None: + try: + emoji_element = self._format_to_xml().find(".//emoji") + # 提取 md5 和 len 属性 + if emoji_element is not None: + md5_value = emoji_element.get("md5") + emoji_size = emoji_element.get("len") + cdnurl = emoji_element.get("cdnurl") + + return Emoji(md5=md5_value, md5_len=emoji_size, cdnurl=cdnurl) + + except Exception as e: + logger.error(f"gewechat: parse_emoji failed, {e}") + + def parse_reply(self) -> Reply | None: + try: + replied_id = -1 + replied_uid = 0 + replied_nickname = "" + replied_content = "" + content = "" + + root = self._format_to_xml() + refermsg = root.find(".//refermsg") + if refermsg is not None: + # 被引用的信息 + svrid = refermsg.find("svrid") + fromusr = refermsg.find("fromusr") + displayname = refermsg.find("displayname") + refermsg_content = refermsg.find("content") + if svrid is not None: + replied_id = svrid.text + if fromusr is not None: + replied_uid = fromusr.text + if displayname is not None: + replied_nickname = displayname.text + if refermsg_content is not None: + replied_content = refermsg_content.text + + # 提取引用者说的内容 + title = root.find(".//appmsg/title") + if title is not None: + content = title.text + + r = Reply( + id=replied_id, + chain=[Plain(content)], + sender_id=replied_uid, + sender_nickname=replied_nickname, + sender_str=replied_content, + message_str=content, + ) + return r + + except Exception as e: + logger.error(f"gewechat: parse_reply failed, {e}") diff --git a/requirements.txt b/requirements.txt index 8045fa151..437e0807b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -25,6 +25,7 @@ dashscope python-telegram-bot wechatpy dingtalk-stream +defusedxml mcp certifi pip \ No newline at end of file