From 0959d5986bf05496707a1a65a2c1133bb276178d Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Mon, 24 Feb 2025 22:43:43 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20=E5=B0=86=20astrbot=5Fplugi?= =?UTF-8?q?n=5Fwecom=20=E9=9B=86=E6=88=90=E8=87=B3=20astrbot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/config/default.py | 2 +- astrbot/core/log.py | 2 +- .../aiocqhttp/aiocqhttp_platform_adapter.py | 2 +- .../platform/sources/wecom/wecom_adapter.py | 237 ++++++++++++++++++ .../platform/sources/wecom/wecom_event.py | 103 ++++++++ requirements.txt | 4 +- 6 files changed, 345 insertions(+), 5 deletions(-) create mode 100644 astrbot/core/platform/sources/wecom/wecom_adapter.py create mode 100644 astrbot/core/platform/sources/wecom/wecom_event.py diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 3970b8d55..63830d3b0 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -124,7 +124,7 @@ CONFIG_METADATA_2 = { "secret": "", "port": 6196 }, - "aiocqhtp(QQ)": { + "aiocqhttp(OneBotv11)": { "id": "default", "type": "aiocqhttp", "enable": False, diff --git a/astrbot/core/log.py b/astrbot/core/log.py index 1f1537f85..0ab5fe852 100644 --- a/astrbot/core/log.py +++ b/astrbot/core/log.py @@ -7,7 +7,7 @@ from typing import List CACHED_SIZE = 200 log_color_config = { - 'DEBUG': 'bold_blue', 'INFO': 'bold_cyan', + 'DEBUG': 'green', 'INFO': 'bold_cyan', 'WARNING': 'bold_yellow', 'ERROR': 'red', 'CRITICAL': 'bold_red', 'RESET': 'reset', 'asctime': 'green' diff --git a/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py b/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py index 6ef1c3e56..94b10e3d2 100644 --- a/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py +++ b/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py @@ -231,7 +231,7 @@ class AiocqhttpAdapter(Platform): @self.bot.on_websocket_connection def on_websocket_connection(_): - logger.info("aiocqhttp 适配器已连接。") + logger.info("aiocqhttp(OneBot v11) 适配器已连接。") bot = self.bot.run_task(host=self.host, port=int(self.port), shutdown_trigger=self.shutdown_trigger_placeholder) diff --git a/astrbot/core/platform/sources/wecom/wecom_adapter.py b/astrbot/core/platform/sources/wecom/wecom_adapter.py new file mode 100644 index 000000000..0d47a8b6e --- /dev/null +++ b/astrbot/core/platform/sources/wecom/wecom_adapter.py @@ -0,0 +1,237 @@ +import sys +import uuid +import asyncio +import quart + +from astrbot.api.platform import Platform, AstrBotMessage, MessageMember, PlatformMetadata, MessageType +from astrbot.api.event import MessageChain +from astrbot.api.message_components import Plain, Image, Record +from astrbot.core.platform.astr_message_event import MessageSesion +from astrbot.api.platform import register_platform_adapter +from astrbot.core import logger +from requests import Response + +from wechatpy.enterprise.crypto import WeChatCrypto +from wechatpy.enterprise import WeChatClient +from wechatpy.enterprise.messages import TextMessage, ImageMessage, VoiceMessage +from wechatpy.exceptions import InvalidSignatureException +from wechatpy.enterprise import parse_message +from .wecom_event import WecomPlatformEvent + +if sys.version_info >= (3, 12): + from typing import override +else: + from typing_extensions import override + +class WecomServer(): + def __init__( + self, + event_queue: asyncio.Queue, + config: dict + ): + self.server = quart.Quart(__name__) + self.port = int(config.get("port")) + self.server.add_url_rule('/callback/command', view_func=self.verify, methods=['GET']) + self.server.add_url_rule('/callback/command', view_func=self.callback_command, methods=['POST']) + self.event_queue = event_queue + + self.crypto = WeChatCrypto( + config['token'], + config['encoding_aes_key'], + config['corpid'] + ) + + self.callback = None + + async def verify(self): + logger.info(f"验证请求有效性: {quart.request.args}") + args = quart.request.args + try: + echo_str = self.crypto.check_signature( + args.get('msg_signature'), + args.get('timestamp'), + args.get('nonce'), + args.get('echostr') + ) + logger.info("验证请求有效性成功。") + return echo_str + except InvalidSignatureException: + logger.error("验证请求有效性失败,签名异常,请检查配置。") + raise + + async def callback_command(self): + data = await quart.request.get_data() + msg_signature = quart.request.args.get('msg_signature') + timestamp = quart.request.args.get('timestamp') + nonce = quart.request.args.get('nonce') + try: + xml = self.crypto.decrypt_message( + data, + msg_signature, + timestamp, + nonce + ) + except InvalidSignatureException: + logger.error("解密失败,签名异常,请检查配置。") + raise + else: + msg = parse_message(xml) + logger.info(f"解析成功: {msg}") + + if self.callback: + await self.callback(msg) + + return "success" + + async def start_polling(self): + logger.info(f"将在 0.0.0.0:{self.port} 端口启动 企业微信 适配器。") + await self.server.run_task( + host='0.0.0.0', + port=self.port, + shutdown_trigger=self.shutdown_trigger_placeholder + ) + + async def shutdown_trigger_placeholder(self): + while not self.event_queue.closed: + await asyncio.sleep(1) + logger.info("企业微信 适配器已关闭。") + + +@register_platform_adapter("wecom", "wecom 适配器", default_config_tmpl={ + "corpid": "", + "secret": "", + "port": 6195, + "token": "", + "encoding_aes_key": "", + "api_base_url": "https://qyapi.weixin.qq.com/cgi-bin/", +}) +class WecomPlatformAdapter(Platform): + def __init__(self, platform_config: dict, platform_settings: dict, event_queue: asyncio.Queue) -> None: + super().__init__(event_queue) + self.config = platform_config + self.settingss = platform_settings + self.client_self_id = uuid.uuid4().hex[:8] + self.api_base_url = platform_config.get("api_base_url", "https://qyapi.weixin.qq.com/cgi-bin/") + + if not self.api_base_url: + self.api_base_url = "https://qyapi.weixin.qq.com/cgi-bin/" + + if self.api_base_url.endswith("/"): + self.api_base_url = self.api_base_url[:-1] + if not self.api_base_url.endswith("/cgi-bin"): + self.api_base_url += "/cgi-bin" + + if not self.api_base_url.endswith("/"): + self.api_base_url += "/" + + @override + async def send_by_session(self, session: MessageSesion, message_chain: MessageChain): + await super().send_by_session(session, message_chain) + + @override + def meta(self) -> PlatformMetadata: + return PlatformMetadata( + "wecom", + "wecom 适配器", + ) + + @override + async def run(self): + self.server = WecomServer( + self._event_queue, + self.config + ) + + self.client = WeChatClient( + self.config['corpid'], + self.config['secret'], + ) + self.client.API_BASE_URL = self.api_base_url + + async def callback(msg): + await self.convert_message(msg) + + self.server.callback = callback + + await self.server.start_polling() + + async def convert_message(self, msg): + abm = AstrBotMessage() + if msg.type == 'text': + assert isinstance(msg, TextMessage) + abm.message_str = msg.content + abm.self_id = str(msg.agent) + abm.message = [Plain(msg.content)] + abm.type = MessageType.FRIEND_MESSAGE + abm.sender = MessageMember( + msg.source, + msg.source, + ) + abm.message_id = msg.id + abm.timestamp = msg.time + abm.session_id = abm.sender.user_id + abm.raw_message = msg + elif msg.type == 'image': + assert isinstance(msg, ImageMessage) + abm.message_str = "[图片]" + abm.self_id = str(msg.agent) + abm.message = [Image(file=msg.image, url=msg.image)] + abm.type = MessageType.FRIEND_MESSAGE + abm.sender = MessageMember( + msg.source, + msg.source, + ) + abm.message_id = msg.id + abm.timestamp = msg.time + abm.session_id = abm.sender.user_id + abm.raw_message = msg + elif msg.type == 'voice': + assert isinstance(msg, VoiceMessage) + + resp: Response = await asyncio.get_event_loop().run_in_executor( + None, + self.client.media.download, + msg.media_id + ) + path = f"data/temp/wecom_{msg.media_id}.amr" + with open(path, 'wb') as f: + f.write(resp.content) + + try: + from pydub import AudioSegment + + path_wav = f"data/temp/wecom_{msg.media_id}.wav" + audio = AudioSegment.from_file(path) + audio.export(path_wav, format="wav") + except Exception as e: + logger.error(f"转换音频失败: {e}。如果没有安装 ffmpeg 请先安装。") + path_wav = path + return + + abm.message_str = "" + abm.self_id = str(msg.agent) + abm.message = [Record(file=path_wav, url=path_wav)] + abm.type = MessageType.FRIEND_MESSAGE + abm.sender = MessageMember( + msg.source, + msg.source, + ) + abm.message_id = msg.id + abm.timestamp = msg.time + abm.session_id = abm.sender.user_id + abm.raw_message = msg + + + + logger.info(f"abm: {abm}") + await self.handle_msg(abm) + + async def handle_msg(self, message: AstrBotMessage): + message_event = WecomPlatformEvent( + message_str=message.message_str, + message_obj=message, + platform_meta=self.meta(), + session_id=message.session_id, + client=self.client + ) + self.commit_event(message_event) \ No newline at end of file diff --git a/astrbot/core/platform/sources/wecom/wecom_event.py b/astrbot/core/platform/sources/wecom/wecom_event.py new file mode 100644 index 000000000..83e99b5c4 --- /dev/null +++ b/astrbot/core/platform/sources/wecom/wecom_event.py @@ -0,0 +1,103 @@ +import uuid +from astrbot.api.event import AstrMessageEvent, MessageChain +from astrbot.api.platform import AstrBotMessage, PlatformMetadata +from astrbot.api.message_components import Plain, Image, Record +from wechatpy.enterprise import WeChatClient +from astrbot.core.utils.io import download_image_by_url, download_file + +from astrbot.api import logger + +try: + import pydub +except Exception: + logger.warning( + "检测到 pydub 库未安装,企业微信将无法语音收发。如需使用语音,请前往管理面板 -> 控制台 -> 安装 Pip 库安装 pydub。" + ) + pass + + +class WecomPlatformEvent(AstrMessageEvent): + def __init__( + self, + message_str: str, + message_obj: AstrBotMessage, + platform_meta: PlatformMetadata, + session_id: str, + client: WeChatClient, + ): + super().__init__(message_str, message_obj, platform_meta, session_id) + self.client = client + + @staticmethod + async def send_with_client( + client: WeChatClient, message: MessageChain, user_name: str + ): + pass + + async def send(self, message: MessageChain): + message_obj = self.message_obj + + for comp in message.chain: + if isinstance(comp, Plain): + self.client.message.send_text( + message_obj.self_id, message_obj.session_id, comp.text + ) + elif isinstance(comp, Image): + img_url = comp.file + img_path = "" + if img_url.startswith("file:///"): + img_path = img_url[8:] + elif comp.file and comp.file.startswith("http"): + img_path = await download_image_by_url(comp.file) + else: + img_path = img_url + + with open(img_path, "rb") as f: + try: + response = self.client.media.upload("image", f) + except Exception as e: + logger.error(f"企业微信上传图片失败: {e}") + await self.send( + MessageChain().message(f"企业微信上传图片失败: {e}") + ) + return + logger.info(f"企业微信上传图片返回: {response}") + self.client.message.send_image( + message_obj.self_id, + message_obj.session_id, + response["media_id"], + ) + elif isinstance(comp, Record): + record_url = comp.file + record_path = "" + + if record_url.startswith("file:///"): + record_path = record_url[8:] + elif record_url.startswith("http"): + await download_file(record_url, f"data/temp/{uuid.uuid4()}.wav") + else: + record_path = record_url + + # 转成amr + record_path_amr = f"data/temp/{uuid.uuid4()}.amr" + pydub.AudioSegment.from_wav(record_path).export( + record_path_amr, format="amr" + ) + + with open(record_path_amr, "rb") as f: + try: + response = self.client.media.upload("voice", f) + except Exception as e: + logger.error(f"企业微信上传语音失败: {e}") + await self.send( + MessageChain().message(f"企业微信上传语音失败: {e}") + ) + return + logger.info(f"企业微信上传语音返回: {response}") + self.client.message.send_voice( + message_obj.self_id, + message_obj.session_id, + response["media_id"], + ) + + await super().send(message) diff --git a/requirements.txt b/requirements.txt index 7290f6291..9384b3bc7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,9 +18,9 @@ docstring_parser aiodocker silk-python psutil>=5.8.0 - lark-oapi ormsgpack cryptography dashscope -python-telegram-bot \ No newline at end of file +python-telegram-bot +wechatpy \ No newline at end of file