import gewechat_client import typing import asyncio import traceback import time import re import copy import threading import quart import aiohttp from .. import adapter from ...core import app from ..types import message as platform_message from ..types import events as platform_events from ..types import entities as platform_entities from ...utils import image import xml.etree.ElementTree as ET from typing import Optional, Tuple from functools import partial from ..logger import EventLogger class GewechatMessageConverter(adapter.MessageConverter): def __init__(self, config: dict): self.config = config @staticmethod async def yiri2target(message_chain: platform_message.MessageChain) -> list[dict]: content_list = [] for component in message_chain: if isinstance(component, platform_message.At): content_list.append({'type': 'at', 'target': component.target}) elif isinstance(component, platform_message.Plain): content_list.append({'type': 'text', 'content': component.text}) elif isinstance(component, platform_message.Image): if not component.url: pass content_list.append({'type': 'image', 'image': component.url}) elif isinstance(component, platform_message.Voice): content_list.append({'type': 'voice', 'url': component.url, 'length': component.length}) elif isinstance(component, platform_message.Forward): for node in component.node_list: content_list.extend(await GewechatMessageConverter.yiri2target(node.message_chain)) content_list.append({'type': 'image', 'image': component.url}) elif isinstance(component, platform_message.WeChatMiniPrograms): content_list.append( { 'type': 'WeChatMiniPrograms', 'mini_app_id': component.mini_app_id, 'display_name': component.display_name, 'page_path': component.page_path, 'cover_img_url': component.image_url, 'title': component.title, 'user_name': component.user_name, } ) elif isinstance(component, platform_message.WeChatForwardMiniPrograms): content_list.append( { 'type': 'WeChatForwardMiniPrograms', 'xml_data': component.xml_data, 'image_url': component.image_url, } ) elif isinstance(component, platform_message.WeChatEmoji): content_list.append( { 'type': 'WeChatEmoji', 'emoji_md5': component.emoji_md5, 'emoji_size': component.emoji_size, } ) elif isinstance(component, platform_message.WeChatLink): content_list.append( { 'type': 'WeChatLink', 'link_title': component.link_title, 'link_desc': component.link_desc, 'link_thumb_url': component.link_thumb_url, 'link_url': component.link_url, } ) elif isinstance(component, platform_message.WeChatForwardLink): content_list.append({'type': 'WeChatForwardLink', 'xml_data': component.xml_data}) elif isinstance(component, platform_message.Voice): content_list.append({'type': 'voice', 'url': component.url, 'length': component.length}) elif isinstance(component, platform_message.WeChatForwardImage): content_list.append({'type': 'WeChatForwardImage', 'xml_data': component.xml_data}) elif isinstance(component, platform_message.WeChatForwardFile): content_list.append({'type': 'WeChatForwardFile', 'xml_data': component.xml_data}) elif isinstance(component, platform_message.WeChatAppMsg): content_list.append({'type': 'WeChatAppMsg', 'app_msg': component.app_msg}) # 引用消息转发 elif isinstance(component, platform_message.WeChatForwardQuote): content_list.append({'type': 'WeChatAppMsg', 'app_msg': component.app_msg}) elif isinstance(component, platform_message.Forward): for node in component.node_list: if node.message_chain: content_list.extend(await GewechatMessageConverter.yiri2target(node.message_chain)) return content_list async def target2yiri(self, message: dict, bot_account_id: str) -> platform_message.MessageChain: """外部消息转平台消息""" # 数据预处理 message_list = [] ats_bot = False # 是否被@ content = message['Data']['Content']['string'] content_no_preifx = content # 群消息则去掉前缀 is_group_message = self._is_group_message(message) if is_group_message: ats_bot = self._ats_bot(message, bot_account_id) if '@所有人' in content: message_list.append(platform_message.AtAll()) elif ats_bot: message_list.append(platform_message.At(target=bot_account_id)) content_no_preifx, _ = self._extract_content_and_sender(content) msg_type = message['Data']['MsgType'] # 映射消息类型到处理器方法 handler_map = { 1: self._handler_text, 3: self._handler_image, 34: self._handler_voice, 49: self._handler_compound, # 复合类型 } # 分派处理 handler = handler_map.get(msg_type, self._handler_default) handler_result = await handler( message=message, # 原始的message content_no_preifx=content_no_preifx, # 处理后的content ) if handler_result and len(handler_result) > 0: message_list.extend(handler_result) return platform_message.MessageChain(message_list) async def _handler_text(self, message: Optional[dict], content_no_preifx: str) -> platform_message.MessageChain: """处理文本消息 (msg_type=1)""" if message and self._is_group_message(message): pattern = r'@\S{1,20}' content_no_preifx = re.sub(pattern, '', content_no_preifx) return platform_message.MessageChain([platform_message.Plain(content_no_preifx)]) async def _handler_image(self, message: Optional[dict], content_no_preifx: str) -> platform_message.MessageChain: """处理图像消息 (msg_type=3)""" try: image_xml = content_no_preifx if not image_xml: return platform_message.MessageChain([platform_message.Unknown('[图片内容为空]')]) base64_str, image_format = await image.get_gewechat_image_base64( gewechat_url=self.config['gewechat_url'], gewechat_file_url=self.config['gewechat_file_url'], app_id=self.config['app_id'], xml_content=image_xml, token=self.config['token'], image_type=2, ) elements = [ platform_message.Image(base64=f'data:image/{image_format};base64,{base64_str}'), platform_message.WeChatForwardImage(xml_data=image_xml), # 微信消息转发 ] return platform_message.MessageChain(elements) except Exception as e: print(f'处理图片失败: {str(e)}') return platform_message.MessageChain([platform_message.Unknown('[图片处理失败]')]) async def _handler_voice(self, message: Optional[dict], content_no_preifx: str) -> platform_message.MessageChain: """处理语音消息 (msg_type=34)""" message_List = [] try: # 从消息中提取语音数据(需根据实际数据结构调整字段名) audio_base64 = message['Data']['ImgBuf']['buffer'] # 验证语音数据有效性 if not audio_base64: message_List.append(platform_message.Unknown(text='[语音内容为空]')) return platform_message.MessageChain(message_List) # 转换为平台支持的语音格式(如 Silk 格式) voice_element = platform_message.Voice(base64=f'data:audio/silk;base64,{audio_base64}') message_List.append(voice_element) except KeyError as e: print(f'语音数据字段缺失: {str(e)}') message_List.append(platform_message.Unknown(text='[语音数据解析失败]')) except Exception as e: print(f'处理语音消息异常: {str(e)}') message_List.append(platform_message.Unknown(text='[语音处理失败]')) return platform_message.MessageChain(message_List) async def _handler_compound(self, message: Optional[dict], content_no_preifx: str) -> platform_message.MessageChain: """处理复合消息 (msg_type=49),根据子类型分派""" try: xml_data = ET.fromstring(content_no_preifx) appmsg_data = xml_data.find('.//appmsg') if appmsg_data: data_type = appmsg_data.findtext('.//type', '') # 二次分派处理器 sub_handler_map = { '57': self._handler_compound_quote, '5': self._handler_compound_link, '6': self._handler_compound_file, '33': self._handler_compound_mini_program, '36': self._handler_compound_mini_program, '2000': partial(self._handler_compound_unsupported, text='[转账消息]'), '2001': partial(self._handler_compound_unsupported, text='[红包消息]'), '51': partial(self._handler_compound_unsupported, text='[视频号消息]'), } handler = sub_handler_map.get(data_type, self._handler_compound_unsupported) return await handler( message=message, # 原始msg xml_data=xml_data, # xml数据 ) else: return platform_message.MessageChain([platform_message.Unknown(text=content_no_preifx)]) except Exception as e: print(f'解析复合消息失败: {str(e)}') return platform_message.MessageChain([platform_message.Unknown(text=content_no_preifx)]) async def _handler_compound_quote( self, message: Optional[dict], xml_data: ET.Element ) -> platform_message.MessageChain: """处理引用消息 (data_type=57)""" message_list = [] # print("_handler_compound_quote", ET.tostring(xml_data, encoding='unicode')) appmsg_data = xml_data.find('.//appmsg') quote_data = '' # 引用原文 user_data = '' # 用户消息 sender_id = xml_data.findtext('.//fromusername') # 发送方:单聊用户/群member if appmsg_data: user_data = appmsg_data.findtext('.//title') or '' quote_data = appmsg_data.find('.//refermsg').findtext('.//content') message_list.append( platform_message.WeChatForwardQuote(app_msg=ET.tostring(appmsg_data, encoding='unicode')) ) # quote_data原始的消息 if quote_data: quote_data_message_list = platform_message.MessageChain() # 文本消息 try: if '' not in quote_data: quote_data_message_list.append(platform_message.Plain(quote_data)) else: # 引用消息展开 quote_data_xml = ET.fromstring(quote_data) if quote_data_xml.find('img'): quote_data_message_list.extend(await self._handler_image(None, quote_data)) elif quote_data_xml.find('voicemsg'): quote_data_message_list.extend(await self._handler_voice(None, quote_data)) elif quote_data_xml.find('videomsg'): quote_data_message_list.extend(await self._handler_default(None, quote_data)) # 先不处理 else: # appmsg quote_data_message_list.extend(await self._handler_compound(None, quote_data)) except Exception as e: print(f'处理引用消息异常 expcetion:{e}') quote_data_message_list.append(platform_message.Plain(quote_data)) message_list.append( platform_message.Quote( sender_id=sender_id, origin=quote_data_message_list, ) ) if len(user_data) > 0: pattern = r'@\S{1,20}' user_data = re.sub(pattern, '', user_data) message_list.append(platform_message.Plain(user_data)) # for comp in message_list: # if isinstance(comp, platform_message.Quote): # print(f"quote_message_chain len={len(message_list)}") # print(f"quote_message_chain send_id={comp.sender_id}" ) # for quote_item in comp.origin: # print(f"--quote_message_component [msg_type={quote_item.type}][message={quote_item}]" ) # else: # print(f"quote_message_chain plain [msg_type={comp.type}][message={comp.text}]") return platform_message.MessageChain(message_list) async def _handler_compound_file(self, message: dict, xml_data: ET.Element) -> platform_message.MessageChain: """处理文件消息 (data_type=6)""" xml_data_str = ET.tostring(xml_data, encoding='unicode') return platform_message.MessageChain([platform_message.WeChatForwardFile(xml_data=xml_data_str)]) async def _handler_compound_link(self, message: dict, xml_data: ET.Element) -> platform_message.MessageChain: """处理链接消息(如公众号文章、外部网页)""" message_list = [] try: # 解析 XML 中的链接参数 appmsg = xml_data.find('.//appmsg') if appmsg is None: return platform_message.MessageChain() message_list.append( platform_message.WeChatLink( link_title=appmsg.findtext('title', ''), link_desc=appmsg.findtext('des', ''), link_url=appmsg.findtext('url', ''), link_thumb_url=appmsg.findtext('thumburl', ''), # 这个字段拿不到 ) ) # 转发消息 xml_data_str = ET.tostring(xml_data, encoding='unicode') # print(xml_data_str) message_list.append(platform_message.WeChatForwardLink(xml_data=xml_data_str)) except Exception as e: print(f'解析链接消息失败: {str(e)}') return platform_message.MessageChain(message_list) async def _handler_compound_mini_program( self, message: dict, xml_data: ET.Element ) -> platform_message.MessageChain: """处理小程序消息(如小程序卡片、服务通知)""" xml_data_str = ET.tostring(xml_data, encoding='unicode') return platform_message.MessageChain([platform_message.WeChatForwardMiniPrograms(xml_data=xml_data_str)]) async def _handler_default(self, message: Optional[dict], content_no_preifx: str) -> platform_message.MessageChain: """处理未知消息类型""" if message: msg_type = message['Data']['MsgType'] else: msg_type = '' return platform_message.MessageChain([platform_message.Unknown(text=f'[未知消息类型 msg_type:{msg_type}]')]) def _handler_compound_unsupported( self, message: dict, xml_data: str, text: Optional[str] = None ) -> platform_message.MessageChain: """处理未支持复合消息类型(msg_type=49)子类型""" if not text: text = f'[xml_data={xml_data}]' content_list = [] content_list.append(platform_message.Unknown(text=f'[处理未支持复合消息类型[msg_type=49]|{text}')) return platform_message.MessageChain(content_list) # 返回是否被艾特 def _ats_bot(self, message: dict, bot_account_id: str) -> bool: ats_bot = False try: to_user_name = message['Wxid'] # 接收方: 所属微信的wxid raw_content = message['Data']['Content']['string'] # 原始消息内容 content_no_prefix, _ = self._extract_content_and_sender(raw_content) # 直接艾特机器人(这个有bug,当被引用的消息里面有@bot,会套娃 # ats_bot = ats_bot or (f"@{bot_account_id}" in content_no_prefix) # 文本类@bot push_content = message.get('Data', {}).get('PushContent', '') ats_bot = ats_bot or ('在群聊中@了你' in push_content) # 引用别人时@bot msg_source = message.get('Data', {}).get('MsgSource', '') or '' if len(msg_source) > 0: msg_source_data = ET.fromstring(msg_source) at_user_list = msg_source_data.findtext('atuserlist') or '' ats_bot = ats_bot or (to_user_name in at_user_list) # 引用bot if message.get('Data', {}).get('MsgType', 0) == 49: xml_data = ET.fromstring(content_no_prefix) appmsg_data = xml_data.find('.//appmsg') tousername = message['Wxid'] if appmsg_data: # 接收方: 所属微信的wxid quote_id = appmsg_data.find('.//refermsg').findtext('.//chatusr') # 引用消息的原发送者 ats_bot = ats_bot or (quote_id == tousername) except Exception as e: print(f'Error in gewechat _ats_bot: {e}') finally: return ats_bot # 提取一下content前面的sender_id, 和去掉前缀的内容 def _extract_content_and_sender(self, raw_content: str) -> Tuple[str, Optional[str]]: try: # 检查消息开头,如果有 wxid_sbitaz0mt65n22:\n 则删掉 # add: 有些用户的wxid不是上述格式。换成user_name: regex = re.compile(r'^[a-zA-Z0-9_\-]{5,20}:') line_split = raw_content.split('\n') if len(line_split) > 0 and regex.match(line_split[0]): raw_content = '\n'.join(line_split[1:]) sender_id = line_split[0].strip(':') return raw_content, sender_id except Exception as e: print(f'_extract_content_and_sender got except: {e}') finally: return raw_content, None # 是否是群消息 def _is_group_message(self, message: dict) -> bool: from_user_name = message['Data']['FromUserName']['string'] return from_user_name.endswith('@chatroom') class GewechatEventConverter(adapter.EventConverter): def __init__(self, config: dict): self.config = config self.message_converter = GewechatMessageConverter(config) @staticmethod async def yiri2target(event: platform_events.MessageEvent) -> dict: pass async def target2yiri(self, event: dict, bot_account_id: str) -> platform_events.MessageEvent: # print(event) # 排除自己发消息回调回答问题 if event['Wxid'] == event['Data']['FromUserName']['string']: return None # 排除公众号以及微信团队消息 if event['Data']['FromUserName']['string'].startswith('gh_') or event['Data']['FromUserName'][ 'string' ].startswith('weixin'): return None message_chain = await self.message_converter.target2yiri(copy.deepcopy(event), bot_account_id) if not message_chain: return None if '@chatroom' in event['Data']['FromUserName']['string']: # 找出开头的 wxid_ 字符串,以:结尾 sender_wxid = event['Data']['Content']['string'].split(':')[0] return platform_events.GroupMessage( sender=platform_entities.GroupMember( id=sender_wxid, member_name=event['Data']['FromUserName']['string'], permission=platform_entities.Permission.Member, group=platform_entities.Group( id=event['Data']['FromUserName']['string'], name=event['Data']['FromUserName']['string'], permission=platform_entities.Permission.Member, ), special_title='', join_timestamp=0, last_speak_timestamp=0, mute_time_remaining=0, ), message_chain=message_chain, time=event['Data']['CreateTime'], source_platform_object=event, ) else: return platform_events.FriendMessage( sender=platform_entities.Friend( id=event['Data']['FromUserName']['string'], nickname=event['Data']['FromUserName']['string'], remark='', ), message_chain=message_chain, time=event['Data']['CreateTime'], source_platform_object=event, ) class GeWeChatAdapter(adapter.MessagePlatformAdapter): name: str = 'gewechat' # 定义适配器名称 bot: gewechat_client.GewechatClient quart_app: quart.Quart bot_account_id: str config: dict ap: app.Application message_converter: GewechatMessageConverter event_converter: GewechatEventConverter listeners: typing.Dict[ typing.Type[platform_events.Event], typing.Callable[[platform_events.Event, adapter.MessagePlatformAdapter], None], ] = {} def __init__(self, config: dict, ap: app.Application, logger: EventLogger): self.config = config self.ap = ap self.logger = logger self.quart_app = quart.Quart(__name__) self.message_converter = GewechatMessageConverter(config) self.event_converter = GewechatEventConverter(config) @self.quart_app.route('/gewechat/callback', methods=['POST']) async def gewechat_callback(): data = await quart.request.json # print(json.dumps(data, indent=4, ensure_ascii=False)) self.ap.logger.debug(f'Gewechat callback event: {data}') if 'data' in data: data['Data'] = data['data'] if 'type_name' in data: data['TypeName'] = data['type_name'] # print(json.dumps(data, indent=4, ensure_ascii=False)) if 'testMsg' in data: return 'ok' elif 'TypeName' in data and data['TypeName'] == 'AddMsg': try: event = await self.event_converter.target2yiri(data.copy(), self.bot_account_id) except Exception: await self.logger.error(f'Error in gewechat callback: {traceback.format_exc()}') if event.__class__ in self.listeners: await self.listeners[event.__class__](event, self) return 'ok' async def _handle_message(self, message: platform_message.MessageChain, target_id: str): """统一消息处理核心逻辑""" content_list = await self.message_converter.yiri2target(message) at_targets = [item['target'] for item in content_list if item['type'] == 'at'] # 处理@逻辑 at_targets = at_targets or [] member_info = [] if at_targets: member_info = self.bot.get_chatroom_member_detail(self.config['app_id'], target_id, at_targets[::-1])[ 'data' ] # 处理消息组件 for msg in content_list: # 文本消息处理@ if msg['type'] == 'text' and at_targets: for member in member_info: msg['content'] = f'@{member["nickName"]} {msg["content"]}' # 统一消息派发 handler_map = { 'text': lambda msg: self.bot.post_text( app_id=self.config['app_id'], to_wxid=target_id, content=msg['content'], ats=','.join(at_targets), ), 'image': lambda msg: self.bot.post_image( app_id=self.config['app_id'], to_wxid=target_id, img_url=msg['image'], ), 'WeChatForwardMiniPrograms': lambda msg: self.bot.forward_mini_app( app_id=self.config['app_id'], to_wxid=target_id, xml=msg['xml_data'], cover_img_url=msg.get('image_url'), ), 'WeChatEmoji': lambda msg: self.bot.post_emoji( app_id=self.config['app_id'], to_wxid=target_id, emoji_md5=msg['emoji_md5'], emoji_size=msg['emoji_size'], ), 'WeChatLink': lambda msg: self.bot.post_link( app_id=self.config['app_id'], to_wxid=target_id, title=msg['link_title'], desc=msg['link_desc'], link_url=msg['link_url'], thumb_url=msg['link_thumb_url'], ), 'WeChatMiniPrograms': lambda msg: self.bot.post_mini_app( app_id=self.config['app_id'], to_wxid=target_id, mini_app_id=msg['mini_app_id'], display_name=msg['display_name'], page_path=msg['page_path'], cover_img_url=msg['cover_img_url'], title=msg['title'], user_name=msg['user_name'], ), 'WeChatForwardLink': lambda msg: self.bot.forward_url( app_id=self.config['app_id'], to_wxid=target_id, xml=msg['xml_data'] ), 'WeChatForwardImage': lambda msg: self.bot.forward_image( app_id=self.config['app_id'], to_wxid=target_id, xml=msg['xml_data'] ), 'WeChatForwardFile': lambda msg: self.bot.forward_file( app_id=self.config['app_id'], to_wxid=target_id, xml=msg['xml_data'] ), 'voice': lambda msg: self.bot.post_voice( app_id=self.config['app_id'], to_wxid=target_id, voice_url=msg['url'], voice_duration=msg['length'], ), 'WeChatAppMsg': lambda msg: self.bot.post_app_msg( app_id=self.config['app_id'], to_wxid=target_id, appmsg=msg['app_msg'], ), 'at': lambda msg: None, } if handler := handler_map.get(msg['type']): handler(msg) else: self.ap.logger.warning(f'未处理的消息类型: {msg["type"]}') continue async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain): """主动发送消息""" return await self._handle_message(message, target_id) async def reply_message( self, message_source: platform_events.MessageEvent, message: platform_message.MessageChain, quote_origin: bool = False, ): """回复消息""" if message_source.source_platform_object: target_id = message_source.source_platform_object['Data']['FromUserName']['string'] return await self._handle_message(message, target_id) async def is_muted(self, group_id: int) -> bool: pass def register_listener( self, event_type: typing.Type[platform_events.Event], callback: typing.Callable[[platform_events.Event, adapter.MessagePlatformAdapter], None], ): self.listeners[event_type] = callback def unregister_listener( self, event_type: typing.Type[platform_events.Event], callback: typing.Callable[[platform_events.Event, adapter.MessagePlatformAdapter], None], ): pass async def run_async(self): if not self.config['token']: async with aiohttp.ClientSession() as session: async with session.post( f'{self.config["gewechat_url"]}/v2/api/tools/getTokenId', json={'app_id': self.config['app_id']}, ) as response: if response.status != 200: raise Exception(f'获取gewechat token失败: {await response.text()}') self.config['token'] = (await response.json())['data'] self.bot = gewechat_client.GewechatClient(f'{self.config["gewechat_url"]}/v2/api', self.config['token']) def gewechat_login_process(): app_id, error_msg = self.bot.login(self.config['app_id']) if error_msg: raise Exception(f'Gewechat 登录失败: {error_msg}') self.config['app_id'] = app_id self.ap.logger.info(f'Gewechat 登录成功,app_id: {app_id}') self.ap.platform_mgr.write_back_config('gewechat', self, self.config) # 获取 nickname profile = self.bot.get_profile(self.config['app_id']) self.bot_account_id = profile['data']['nickName'] time.sleep(2) try: # gewechat-server容器重启, token会变,但是还会登录成功 # 换新token也会收不到回调,要重新登陆下。 self.bot.set_callback(self.config['token'], self.config['callback_url']) except Exception as e: raise Exception(f'设置 Gewechat 回调失败, token失效: {e}') threading.Thread(target=gewechat_login_process).start() async def shutdown_trigger_placeholder(): while True: await asyncio.sleep(1) await self.quart_app.run_task( host='0.0.0.0', port=self.config['port'], shutdown_trigger=shutdown_trigger_placeholder, ) async def kill(self) -> bool: pass