diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index e5d3a2013..5e06c19db 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -151,6 +151,18 @@ CONFIG_METADATA_2 = { "host": "这里填写你的局域网IP或者公网服务器IP", "port": 11451, }, + "weixin_official_account(微信公众平台)": { + "id": "weixin_official_account", + "type": "weixin_official_account", + "enable": False, + "appid": "", + "secret": "", + "token": "", + "encoding_aes_key": "", + "api_base_url": "https://api.weixin.qq.com/cgi-bin/", + "callback_server_host": "0.0.0.0", + "port": 6194, + }, "wecom(企业微信)": { "id": "wecom", "type": "wecom", diff --git a/astrbot/core/platform/manager.py b/astrbot/core/platform/manager.py index 22a06b739..4ac575446 100644 --- a/astrbot/core/platform/manager.py +++ b/astrbot/core/platform/manager.py @@ -72,6 +72,8 @@ class PlatformManager: from .sources.telegram.tg_adapter import TelegramPlatformAdapter # noqa: F401 case "wecom": from .sources.wecom.wecom_adapter import WecomPlatformAdapter # noqa: F401 + case "weixin_official_account": + from .sources.weixin_official_account.weixin_offacc_adapter import WeixinOfficialAccountPlatformAdapter # noqa except (ImportError, ModuleNotFoundError) as e: logger.error( f"加载平台适配器 {platform_config['type']} 失败,原因:{e}。请检查依赖库是否安装。提示:可以在 管理面板->控制台->安装Pip库 中安装依赖库。" diff --git a/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_adapter.py b/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_adapter.py new file mode 100644 index 000000000..d7463d4d1 --- /dev/null +++ b/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_adapter.py @@ -0,0 +1,252 @@ +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.utils import check_signature +from wechatpy.crypto import WeChatCrypto +from wechatpy import WeChatClient +from wechatpy.messages import TextMessage, ImageMessage, VoiceMessage +from wechatpy.exceptions import InvalidSignatureException +from wechatpy import parse_message +from .weixin_offacc_event import WeixinOfficialAccountPlatformEvent + +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.callback_server_host = config.get("callback_server_host", "0.0.0.0") + self.token = config.get("token") + self.encoding_aes_key = config.get("encoding_aes_key") + self.appid = config.get("appid") + 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.crypto = WeChatCrypto(self.token, self.encoding_aes_key, self.appid) + + self.event_queue = event_queue + + self.callback = None + self.shutdown_event = asyncio.Event() + + async def verify(self): + logger.info(f"验证请求有效性: {quart.request.args}") + + args = quart.request.args + if not args.get("signature", None): + logger.error("未知的响应,请检查回调地址是否填写正确。") + return "err" + try: + check_signature( + self.token, + args.get("signature"), + args.get("timestamp"), + args.get("nonce"), + ) + logger.info("验证请求有效性成功。") + return args.get("echostr", "empty") + except InvalidSignatureException: + logger.error("验证请求有效性失败,签名异常,请检查配置。") + return "err" + + 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"将在 {self.callback_server_host}:{self.port} 端口启动 微信公众平台 适配器。" + ) + await self.server.run_task( + host=self.callback_server_host, + port=self.port, + shutdown_trigger=self.shutdown_trigger, + ) + + async def shutdown_trigger(self): + await self.shutdown_event.wait() + + +@register_platform_adapter("weixin_official_account", "微信公众平台 适配器") +class WeixinOfficialAccountPlatformAdapter(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://api.weixin.qq.com/cgi-bin/" + ) + + if not self.api_base_url: + self.api_base_url = "https://api.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 += "/" + + self.server = WecomServer(self._event_queue, self.config) + + self.client = WeChatClient( + self.config["appid"].strip(), + self.config["secret"].strip(), + ) + + async def callback(msg): + try: + await self.convert_message(msg) + except Exception as e: + logger.error(f"转换消息时出现异常: {e}") + + self.server.callback = callback + + @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( + "weixin_official_account", + "微信公众平台 适配器", + ) + + @override + async def run(self): + await self.server.start_polling() + + async def convert_message(self, msg) -> AstrBotMessage | None: + abm = AstrBotMessage() + if isinstance(msg, TextMessage): + abm.message_str = msg.content + abm.self_id = str(msg.target) + 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.target) + 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}。如果没有安装 pydub 和 ffmpeg 请先安装。") + path_wav = path + return + + abm.message_str = "" + abm.self_id = str(msg.target) + 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 + else: + logger.warning(f"暂未实现的事件: {msg.type}") + return + + logger.info(f"abm: {abm}") + await self.handle_msg(abm) + + async def handle_msg(self, message: AstrBotMessage): + message_event = WeixinOfficialAccountPlatformEvent( + 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) + + def get_client(self) -> WeChatClient: + return self.client + + async def terminate(self): + self.server.shutdown_event.set() + try: + await self.server.server.shutdown() + except Exception as _: + pass + logger.info("微信公众平台 适配器已被优雅地关闭") diff --git a/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_event.py b/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_event.py new file mode 100644 index 000000000..9519cd497 --- /dev/null +++ b/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_event.py @@ -0,0 +1,147 @@ +import uuid +import asyncio +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 import WeChatClient + +from astrbot.api import logger + +try: + import pydub +except Exception: + logger.warning( + "检测到 pydub 库未安装,微信公众平台将无法语音收发。如需使用语音,请前往管理面板 -> 控制台 -> 安装 Pip 库安装 pydub。" + ) + pass + + +class WeixinOfficialAccountPlatformEvent(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 split_plain(self, plain: str) -> list[str]: + """将长文本分割成多个小文本, 每个小文本长度不超过 2048 字符 + + Args: + plain (str): 要分割的长文本 + Returns: + list[str]: 分割后的文本列表 + """ + if len(plain) <= 2048: + return [plain] + else: + result = [] + start = 0 + while start < len(plain): + # 剩下的字符串长度<2048时结束 + if start + 2048 >= len(plain): + result.append(plain[start:]) + break + + # 向前搜索分割标点符号 + end = min(start + 2048, len(plain)) + cut_position = end + for i in range(end, start, -1): + if i < len(plain) and plain[i - 1] in [ + "。", + "!", + "?", + ".", + "!", + "?", + "\n", + ";", + ";", + ]: + cut_position = i + break + + # 没找到合适的位置分割, 直接切分 + if cut_position == end and end < len(plain): + cut_position = end + + result.append(plain[start:cut_position]) + start = cut_position + + return result + + async def send(self, message: MessageChain): + message_obj = self.message_obj + for comp in message.chain: + if isinstance(comp, Plain): + # Split long text messages if needed + plain_chunks = await self.split_plain(comp.text) + for chunk in plain_chunks: + self.client.message.send_text(message_obj.sender.user_id, chunk) + await asyncio.sleep(0.5) # Avoid sending too fast + elif isinstance(comp, Image): + img_path = await comp.convert_to_file_path() + + 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.debug(f"微信公众平台上传图片返回: {response}") + self.client.message.send_image( + message_obj.sender.user_id, + response["media_id"], + ) + elif isinstance(comp, Record): + record_path = await comp.convert_to_file_path() + # 转成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.sender.user_id, + response["media_id"], + ) + else: + logger.warning(f"还没实现这个消息类型的发送逻辑: {comp.type}。") + + await super().send(message) + + async def send_streaming(self, generator, use_fallback: bool = False): + buffer = None + async for chain in generator: + if not buffer: + buffer = chain + else: + buffer.chain.extend(chain.chain) + if not buffer: + return + buffer.squash_plain() + await self.send(buffer) + return await super().send_streaming(generator, use_fallback) diff --git a/pyproject.toml b/pyproject.toml index 39ca77062..664740190 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ dependencies = [ "pip>=25.0.1", "psutil>=5.8.0", "pydantic~=2.10.3", + "pydub>=0.25.1", "pyjwt>=2.10.1", "python-telegram-bot>=22.0", "qq-botpy>=1.2.1", diff --git a/uv.lock b/uv.lock index 7cdd6be3e..7f40f2e51 100644 --- a/uv.lock +++ b/uv.lock @@ -192,7 +192,7 @@ wheels = [ [[package]] name = "astrbot" -version = "3.4.39" +version = "3.5.7" source = { editable = "." } dependencies = [ { name = "aiocqhttp" }, @@ -220,6 +220,7 @@ dependencies = [ { name = "pip" }, { name = "psutil" }, { name = "pydantic" }, + { name = "pydub" }, { name = "pyjwt" }, { name = "python-telegram-bot" }, { name = "qq-botpy" }, @@ -257,6 +258,7 @@ requires-dist = [ { name = "pip", specifier = ">=25.0.1" }, { name = "psutil", specifier = ">=5.8.0" }, { name = "pydantic", specifier = "~=2.10.3" }, + { name = "pydub", specifier = ">=0.25.1" }, { name = "pyjwt", specifier = ">=2.10.1" }, { name = "python-telegram-bot", specifier = ">=22.0" }, { name = "qq-botpy", specifier = ">=1.2.1" }, @@ -1658,6 +1660,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b6/5f/d6d641b490fd3ec2c4c13b4244d68deea3a1b970a97be64f34fb5504ff72/pydantic_settings-2.9.1-py3-none-any.whl", hash = "sha256:59b4f431b1defb26fe620c71a7d3968a710d719f5f4cdbbdb7926edeb770f6ef", size = 44356 }, ] +[[package]] +name = "pydub" +version = "0.25.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/9a/e6bca0eed82db26562c73b5076539a4a08d3cffd19c3cc5913a3e61145fd/pydub-0.25.1.tar.gz", hash = "sha256:980a33ce9949cab2a569606b65674d748ecbca4f0796887fd6f46173a7b0d30f", size = 38326 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/53/d78dc063216e62fc55f6b2eebb447f6a4b0a59f55c8406376f76bf959b08/pydub-0.25.1-py2.py3-none-any.whl", hash = "sha256:65617e33033874b59d87db603aa1ed450633288aefead953b30bded59cb599a6", size = 32327 }, +] + [[package]] name = "pyjwt" version = "2.10.1"