import json import re import xml.dom.minidom from typing import Optional, Union, List, Tuple, Any, Dict from urllib.parse import quote from app.domain.context import Context, MediaInfo from app.application.messaging.agent import ( matches_channel_admin, register_channel_admin_resolver, resolve_config_principal_ids, ) from app.runtime.log import logger from app.modules._base import _MessageChannelModuleBase from app.adapters.external.wechat_crypt import WXBizMsgCrypt from app.modules.wechat.wechat import WeChat from app.modules.wechat.wechatbot import WeChatBot from app.schemas import NotificationChannel, IncomingMessage, Message from app.schemas.types import ModuleType from app.foundation.dom import DomUtils def _resolve_wechat_admin_ids(config: Optional[dict]) -> set[str]: """解析企业微信管理员及机器人模式下的主用户 ID。""" config_keys = ["WECHAT_ADMINS"] if (config or {}).get("WECHAT_MODE", "app") == "bot": config_keys.append("WECHAT_BOT_CHAT_ID") return resolve_config_principal_ids(config, *config_keys) register_channel_admin_resolver(NotificationChannel.Wechat, _resolve_wechat_admin_ids) class WechatModule(_MessageChannelModuleBase[WeChat]): # 管理员配置键,与渠道 resolver 保持一致 _admin_config_key = "WECHAT_ADMINS" # 命令注册事件源标识固定为 WeChat(get_name 为“企业微信”) _command_origin = "WeChat" def init_module(self) -> None: """ 初始化模块 """ super().init_service(service_name=WeChat.__name__.lower(), service_type=self._create_client) self._channel = NotificationChannel.Wechat @staticmethod def get_name() -> str: return "企业微信" @staticmethod def get_type() -> ModuleType: """ 获取模块类型 """ return ModuleType.Notification @staticmethod def get_subtype() -> NotificationChannel: """ 获取模块的子类型 """ return NotificationChannel.Wechat @staticmethod def get_priority() -> int: """ 获取模块优先级,数字越小优先级越高,只有同一接口下优先级才生效 """ return 1 def stop(self) -> None: """停止模块""" for client in self.get_instances().values(): try: if hasattr(client, "stop"): client.stop() except Exception as err: logger.error(f"停止微信模块实例失败:{err}") @staticmethod def _is_bot_mode(config: dict) -> bool: return (config or {}).get("WECHAT_MODE", "app") == "bot" @classmethod def _create_client(cls, conf): if cls._is_bot_mode(conf.config): return WeChatBot(name=conf.name, **conf.config) return WeChat(name=conf.name, **conf.config) def init_setting(self) -> Tuple[str, Union[str, bool]]: pass def message_parser(self, source: str, body: Any, form: Any, args: Any) -> Optional[IncomingMessage]: """ 解析消息内容,返回字典,注意以下约定值: userid: 用户ID username: 用户名 text: 内容 :param source: 消息来源 :param body: 请求体 :param form: 表单 :param args: 参数 :return: 渠道、消息体 """ try: # 获取服务配置 client_config = self.get_config(source) if not client_config: return None if self._is_bot_mode(client_config.config): return self._parse_bot_message(source=source, body=body, client_config=client_config) client: WeChat = self.get_instance(client_config.name) # URL参数 sVerifyMsgSig = args.get("msg_signature") sVerifyTimeStamp = args.get("timestamp") sVerifyNonce = args.get("nonce") if not sVerifyMsgSig or not sVerifyTimeStamp or not sVerifyNonce: logger.debug(f"微信请求参数错误:{args}") return None # 解密模块 wxcpt = WXBizMsgCrypt(sToken=client_config.config.get('WECHAT_TOKEN'), sEncodingAESKey=client_config.config.get('WECHAT_ENCODING_AESKEY'), sReceiveId=client_config.config.get('WECHAT_CORPID')) # 报文数据 if not body: logger.debug(f"微信请求数据为空") return None logger.debug(f"收到微信请求:{body}") ret, sMsg = wxcpt.DecryptMsg(sPostData=body, sMsgSignature=sVerifyMsgSig, sTimeStamp=sVerifyTimeStamp, sNonce=sVerifyNonce) if ret != 0: logger.error(f"解密微信消息失败 DecryptMsg ret = {ret}") return None # 解析XML报文 """ 1、消息格式: 1348831860 1234567890123456 1 2、事件格式: 1348831860 1 """ dom_tree = xml.dom.minidom.parseString(sMsg.decode('UTF-8')) root_node = dom_tree.documentElement # 消息类型 msg_type = DomUtils.tag_value(root_node, "MsgType") # Event event事件只有click才有效,enter_agent无效 event = DomUtils.tag_value(root_node, "Event") # 用户ID user_id = DomUtils.tag_value(root_node, "FromUserName") # 没的消息类型和用户ID的消息不要 if not msg_type or not user_id: logger.warn(f"解析不到消息类型和用户ID") return None # 解析消息内容 content = None images = None audio_refs = None files = None if msg_type == "event" and event == "click": # 企业微信菜单最终会转成命令文本,需与斜杠命令使用一致的管理员校验。 if self._should_reject_admin_command(client_config.config, user_id): client.send_msg(title="只有管理员才有权限执行此命令", userid=user_id) return None # 根据EventKey执行命令 content = DomUtils.tag_value(root_node, "EventKey") logger.info(f"收到来自 {client_config.name} 的微信事件:userid={user_id}, event={content}") elif msg_type == "text": # 文本消息 content = DomUtils.tag_value(root_node, "Content", default="") logger.info(f"收到来自 {client_config.name} 的微信消息:userid={user_id}, text={content}") elif msg_type == "image": media_id = DomUtils.tag_value(root_node, "MediaId") pic_url = DomUtils.tag_value(root_node, "PicUrl") if media_id: images = [IncomingMessage.MessageImage(ref=f"wxwork://media_id/{media_id}")] elif pic_url: images = [IncomingMessage.MessageImage(ref=pic_url)] logger.info( f"收到来自 {client_config.name} 的微信图片消息:userid={user_id}, images={len(images) if images else 0}" ) elif msg_type == "voice": media_id = DomUtils.tag_value(root_node, "MediaId") recognition = DomUtils.tag_value(root_node, "Recognition", default="") content = (recognition or "").strip() if media_id: audio_refs = [f"wxwork://voice_media_id/{media_id}"] logger.info( f"收到来自 {client_config.name} 的微信语音消息:userid={user_id}, " f"text={content}, audios={len(audio_refs) if audio_refs else 0}" ) elif msg_type == "file": media_id = DomUtils.tag_value(root_node, "MediaId") file_name = DomUtils.tag_value(root_node, "FileName") if media_id: files = [ IncomingMessage.MessageAttachment( ref=f"wxwork://file_media_id/{media_id}", name=file_name, ) ] logger.info( f"收到来自 {client_config.name} 的微信文件消息:userid={user_id}, files={len(files) if files else 0}" ) else: return None if content and content.startswith("/") and self._should_reject_admin_command( client_config.config, user_id ): client.send_msg(title="只有管理员才有权限执行此命令", userid=user_id) return None if content or images or audio_refs or files: # 处理消息内容 return IncomingMessage(channel=NotificationChannel.Wechat, source=client_config.name, userid=user_id, username=user_id, is_channel_admin=matches_channel_admin( NotificationChannel.Wechat, client_config.config, user_id, ), text=content or "", images=images, audio_refs=audio_refs, files=files) except Exception as err: logger.error(f"微信消息处理发生错误:{str(err)}") return None def _parse_bot_message(self, source: str, body: Any, client_config) -> Optional[IncomingMessage]: try: if isinstance(body, bytes): msg_json = json.loads(body) elif isinstance(body, dict): msg_json = body else: msg_json = json.loads(body) while isinstance(msg_json, str): msg_json = json.loads(msg_json) except Exception as err: logger.debug(f"解析企业微信智能机器人消息失败:{err}") return None if not isinstance(msg_json, dict): return None payload_body = msg_json.get("body") or {} sender = ((payload_body.get("from") or {}).get("userid") or "").strip() if not sender: return None if payload_body.get("chattype") == "group": return None text = WeChatBot._extract_text_from_body(payload_body) images = WeChatBot._extract_images_from_body(payload_body) audio_refs = ["wxbot://voice"] if payload_body.get("msgtype") == "voice" else None files = None if payload_body.get("msgtype") == "file": file_payload = payload_body.get("file") or {} download_url = file_payload.get("download_url") if download_url: files = [ IncomingMessage.MessageAttachment( ref=f"wxbot://file/{quote(download_url, safe='')}", name=file_payload.get("name") or file_payload.get("filename"), mime_type=file_payload.get("content_type") or file_payload.get("mime_type"), size=file_payload.get("size"), ) ] if text: text = re.sub(r"@\S+", "", text).strip() if text and text.startswith("/") and self._should_reject_admin_command( client_config.config, sender ): client: WeChatBot = self.get_instance(client_config.name) if client: client.send_msg(title="只有管理员才有权限执行此命令", userid=sender) return None if not text and not images and not audio_refs and not files: return None logger.info( f"收到来自 {client_config.name} 的企业微信智能机器人消息:" f"userid={sender}, text={text}, images={len(images) if images else 0}" ) return IncomingMessage( channel=NotificationChannel.Wechat, source=client_config.name, userid=sender, username=sender, is_channel_admin=matches_channel_admin( NotificationChannel.Wechat, client_config.config, sender, ), text=text or "", images=images, audio_refs=audio_refs, files=files, ) def post_message(self, message: Message, **kwargs) -> None: """ 发送消息 :param message: 消息内容 :return: 成功或失败 """ for conf in self.get_configs().values(): if not self.check_message(message, conf.name): continue targets = message.targets userid = message.userid if not userid and targets is not None: userid = targets.get('wechat_userid') if not userid: logger.warn(f"用户没有指定 微信用户ID,消息无法发送") return client: WeChat = self.get_instance(conf.name) if client: if message.voice_path and hasattr(client, "send_voice"): sent = client.send_voice( voice_path=message.voice_path, userid=userid, ) if not sent: client.send_msg(title=message.title, text=message.text, image=message.image, userid=userid, link=message.link) else: client.send_msg(title=message.title, text=message.text, image=message.image, userid=userid, link=message.link) def download_wechat_image_to_data_url(self, image_ref: str, source: str) -> Optional[str]: """ 下载企业微信渠道图片并转换为 data URL """ if not image_ref: return None client_config = self.get_config(source) if not client_config: return None client = self.get_instance(client_config.name) if not client: return None if image_ref.startswith("wxwork://media_id/") and hasattr(client, "download_media_to_data_url"): media_id = image_ref.replace("wxwork://media_id/", "", 1) return client.download_media_to_data_url(media_id) if image_ref.startswith("wxbot://image/") and hasattr(client, "download_image_to_data_url"): return client.download_image_to_data_url(image_ref) return None def download_wechat_media_bytes(self, media_ref: str, source: str) -> Optional[bytes]: """ 下载企业微信语音媒体并返回原始字节。 """ if not media_ref: return None client_config = self.get_config(source) if not client_config: return None client = self.get_instance(client_config.name) if not client or not hasattr(client, "download_media_bytes"): return None if media_ref.startswith("wxwork://voice_media_id/"): media_id = media_ref.replace("wxwork://voice_media_id/", "", 1) return client.download_media_bytes(media_id) if media_ref.startswith("wxwork://file_media_id/"): media_id = media_ref.replace("wxwork://file_media_id/", "", 1) return client.download_media_bytes(media_id) return None def post_medias_message(self, message: Message, medias: List[MediaInfo]) -> None: """ 发送媒体信息选择列表 :param message: 消息内容 :param medias: 媒体列表 :return: 成功或失败 """ for conf in self.get_configs().values(): if not self.check_message(message, conf.name): continue client: WeChat = self.get_instance(conf.name) if client: # 先发送标题 client.send_msg(title=message.title, userid=message.userid, link=message.link) # 再发送内容 client.send_medias_msg(medias=medias, userid=message.userid) def post_torrents_message(self, message: Message, torrents: List[Context]) -> None: """ 发送种子信息选择列表 :param message: 消息内容 :param torrents: 种子列表 :return: 成功或失败 """ for conf in self.get_configs().values(): if not self.check_message(message, conf.name): continue client: WeChat = self.get_instance(conf.name) if client: client.send_torrents_msg(title=message.title, torrents=torrents, userid=message.userid, link=message.link) def _commands_enabled(self, config: Optional[dict]) -> bool: """ 菜单注册前置条件:智能机器人模式无传统菜单,缺少解密参数时无法调用菜单 API。 """ if self._is_bot_mode(config): logger.debug("智能机器人模式,跳过传统菜单初始化") return False if not config.get("WECHAT_ENCODING_AESKEY") or not config.get("WECHAT_TOKEN"): logger.debug("缺少消息解密参数,跳过菜单初始化") return False return True def _delete_commands(self, client) -> None: """企业微信使用自定义菜单 API 清理命令。""" client.delete_menus() def _apply_commands(self, client, commands: Dict[str, dict]) -> None: """企业微信使用自定义菜单 API 注册命令。""" client.create_menus(commands)