From 19b492bdbc2569946b6de0f8fb3187b6ce018f95 Mon Sep 17 00:00:00 2001 From: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Date: Mon, 23 Mar 2026 19:27:13 +0800 Subject: [PATCH 1/2] perf: avoid unnecessary base64 conversion for aiocqhttp image/record sending MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NapCat and other OneBot protocol endpoints natively support file://, http(s)://, and base64:// URIs. Previously _from_segment_to_dict always called convert_to_base64() which forces downloading HTTP images and reading local files into memory just to encode them — wasting CPU, memory, and bandwidth. Now we check the source URI first and pass it through directly when the protocol endpoint can handle it. Only truly unknown formats fall back to base64 encoding. Closes #6717 --- .../aiocqhttp/aiocqhttp_message_event.py | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py b/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py index 4b642d8ce5..fb498bf0bb 100644 --- a/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py +++ b/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py @@ -1,4 +1,5 @@ import asyncio +import os import re from collections.abc import AsyncGenerator @@ -31,11 +32,41 @@ def __init__( super().__init__(message_str, message_obj, platform_meta, session_id) self.bot = bot + @staticmethod + def _resolve_file_uri(segment: Image | Record) -> str | None: + """尝试从 Image/Record 中提取可以直接透传给协议端的 file URI。 + + NapCat 等 OneBot 协议端支持 file://、http(s)://、base64:// 三种格式, + 如果输入本身就是这些格式则无需做 base64 转换,直接透传即可。 + 返回 None 表示需要走 base64 兜底。 + """ + raw = segment.url or segment.file if isinstance(segment, Image) else segment.file + if not raw: + return None + + # 协议端能直接处理的格式,原样透传 + if raw.startswith(("file:///", "http://", "https://", "base64://")): + return raw + + # 裸路径,转成 file:// URI 让协议端自己读 + if os.path.isabs(raw) and os.path.exists(raw): + import pathlib + return pathlib.Path(raw).as_uri() + + return None + @staticmethod async def _from_segment_to_dict(segment: BaseMessageComponent) -> dict: """修复部分字段""" if isinstance(segment, Image | Record): - # For Image and Record segments, we convert them to base64 + # 优先透传 file/http URL,避免不必要的 base64 转换 + file_uri = AiocqhttpMessageEvent._resolve_file_uri(segment) + if file_uri is not None: + return { + "type": segment.type.lower(), + "data": {"file": file_uri}, + } + # 兜底:无法直接透传时才走 base64 bs64 = await segment.convert_to_base64() return { "type": segment.type.lower(), From 3759a0bc2272878b6a0384e659a2bb7b937ed208 Mon Sep 17 00:00:00 2001 From: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Date: Tue, 24 Mar 2026 00:36:17 +0800 Subject: [PATCH 2/2] refactor: add prefer_base64 config, fix cross-server & sync IO issues - Add `prefer_base64` config option (default: true) for aiocqhttp adapter. When enabled, always use base64 encoding for media to ensure cross-server compatibility. When disabled, try file:// and http:// passthrough first. - Replace sync `os.path.exists` with `aiofiles.os.path.exists` to avoid blocking the event loop on concurrent requests. - Fix operator precedence ambiguity in _resolve_file_uri by splitting the Image/Record branching into explicit if/else. - Move `pathlib` import to module level (PEP 8). Addresses review feedback from @FlanChanXwO and @sourcery-ai. --- astrbot/core/config/default.py | 6 ++ .../aiocqhttp/aiocqhttp_message_event.py | 73 ++++++++++++------- .../aiocqhttp/aiocqhttp_platform_adapter.py | 3 + 3 files changed, 54 insertions(+), 28 deletions(-) diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 0f43dbd06d..d3b01c6490 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -348,6 +348,7 @@ class ChatProviderTemplate(TypedDict): "ws_reverse_host": "0.0.0.0", "ws_reverse_port": 6199, "ws_reverse_token": "", + "prefer_base64": True, }, "微信公众平台": { "id": "weixin_official_account", @@ -788,6 +789,11 @@ class ChatProviderTemplate(TypedDict): "type": "string", "hint": "反向 Websocket Token。未设置则不启用 Token 验证。", }, + "prefer_base64": { + "description": "优先使用 Base64 发送媒体", + "type": "bool", + "hint": "开启后,图片、语音等媒体文件将统一使用 Base64 编码发送,确保跨服务器兼容。关闭后优先使用本地文件路径或网络 URL 透传,可提升发送性能。仅当 AstrBot 与协议端部署在同一台机器时建议关闭。", + }, "wecom_ai_bot_name": { "description": "企业微信智能机器人的名字", "type": "string", diff --git a/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py b/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py index fb498bf0bb..52606f8269 100644 --- a/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py +++ b/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py @@ -1,8 +1,9 @@ import asyncio -import os +import pathlib import re from collections.abc import AsyncGenerator +import aiofiles.os from aiocqhttp import CQHttp, Event from astrbot.api.event import AstrMessageEvent, MessageChain @@ -28,19 +29,30 @@ def __init__( platform_meta, session_id, bot: CQHttp, + prefer_base64: bool = True, ) -> None: super().__init__(message_str, message_obj, platform_meta, session_id) self.bot = bot + self.prefer_base64 = prefer_base64 @staticmethod - def _resolve_file_uri(segment: Image | Record) -> str | None: + async def _resolve_file_uri( + segment: Image | Record, + prefer_base64: bool = True, + ) -> str | None: """尝试从 Image/Record 中提取可以直接透传给协议端的 file URI。 - NapCat 等 OneBot 协议端支持 file://、http(s)://、base64:// 三种格式, - 如果输入本身就是这些格式则无需做 base64 转换,直接透传即可。 - 返回 None 表示需要走 base64 兜底。 + 当 prefer_base64 为 True 时直接返回 None,走 base64 兜底, + 保证跨服务器部署时的兼容性。 """ - raw = segment.url or segment.file if isinstance(segment, Image) else segment.file + if prefer_base64: + return None + + # 取原始值:Image 优先用 url,没有再取 file;Record 只有 file + if isinstance(segment, Image): + raw = segment.url or segment.file + else: + raw = segment.file if not raw: return None @@ -48,25 +60,30 @@ def _resolve_file_uri(segment: Image | Record) -> str | None: if raw.startswith(("file:///", "http://", "https://", "base64://")): return raw - # 裸路径,转成 file:// URI 让协议端自己读 - if os.path.isabs(raw) and os.path.exists(raw): - import pathlib - return pathlib.Path(raw).as_uri() + # 裸路径 → file:// URI(异步检查,不阻塞事件循环) + p = pathlib.Path(raw) + if p.is_absolute() and await aiofiles.os.path.exists(raw): + return p.as_uri() return None @staticmethod - async def _from_segment_to_dict(segment: BaseMessageComponent) -> dict: + async def _from_segment_to_dict( + segment: BaseMessageComponent, + prefer_base64: bool = True, + ) -> dict: """修复部分字段""" if isinstance(segment, Image | Record): - # 优先透传 file/http URL,避免不必要的 base64 转换 - file_uri = AiocqhttpMessageEvent._resolve_file_uri(segment) + # 根据 prefer_base64 配置决定是否尝试透传 + file_uri = await AiocqhttpMessageEvent._resolve_file_uri( + segment, prefer_base64=prefer_base64, + ) if file_uri is not None: return { "type": segment.type.lower(), "data": {"file": file_uri}, } - # 兜底:无法直接透传时才走 base64 + # 兜底:走 base64 bs64 = await segment.convert_to_base64() return { "type": segment.type.lower(), @@ -75,20 +92,14 @@ async def _from_segment_to_dict(segment: BaseMessageComponent) -> dict: }, } if isinstance(segment, File): - # For File segments, we need to handle the file differently d = await segment.to_dict() file_val = d.get("data", {}).get("file", "") if file_val: - import pathlib - try: - # 使用 pathlib 处理路径,能更好地处理 Windows/Linux 差异 path_obj = pathlib.Path(file_val) - # 如果是绝对路径且不包含协议头 (://),则转换为标准的 file: URI if path_obj.is_absolute() and "://" not in file_val: d["data"]["file"] = path_obj.as_uri() except Exception: - # 如果不是合法路径(例如已经是特定的特殊字符串),则跳过转换 pass return d if isinstance(segment, Video): @@ -98,22 +109,25 @@ async def _from_segment_to_dict(segment: BaseMessageComponent) -> dict: return segment.toDict() @staticmethod - async def _parse_onebot_json(message_chain: MessageChain): + async def _parse_onebot_json( + message_chain: MessageChain, + prefer_base64: bool = True, + ): """解析成 OneBot json 格式""" ret = [] for segment in message_chain.chain: if isinstance(segment, At): # At 组件后插入一个空格,避免与后续文本粘连 - d = await AiocqhttpMessageEvent._from_segment_to_dict(segment) + d = await AiocqhttpMessageEvent._from_segment_to_dict(segment, prefer_base64=prefer_base64) ret.append(d) ret.append({"type": "text", "data": {"text": " "}}) elif isinstance(segment, Plain): if not segment.text.strip(): continue - d = await AiocqhttpMessageEvent._from_segment_to_dict(segment) + d = await AiocqhttpMessageEvent._from_segment_to_dict(segment, prefer_base64=prefer_base64) ret.append(d) else: - d = await AiocqhttpMessageEvent._from_segment_to_dict(segment) + d = await AiocqhttpMessageEvent._from_segment_to_dict(segment, prefer_base64=prefer_base64) ret.append(d) return ret @@ -150,6 +164,7 @@ async def send_message( event: Event | None = None, is_group: bool = False, session_id: str | None = None, + prefer_base64: bool = True, ) -> None: """发送消息至 QQ 协议端(aiocqhttp)。 @@ -158,7 +173,8 @@ async def send_message( message_chain (MessageChain): 要发送的消息链 event (Event | None, optional): aiocqhttp 事件对象. is_group (bool, optional): 是否为群消息. - session_id (str | None, optional): 会话 ID(群号或 QQ 号 + session_id (str | None, optional): 会话 ID(群号或 QQ 号) + prefer_base64 (bool, optional): 是否优先 base64 编码发送媒体. """ # 转发消息、文件消息不能和普通消息混在一起发送 @@ -166,7 +182,7 @@ async def send_message( isinstance(seg, Node | Nodes | File) for seg in message_chain.chain ) if not send_one_by_one: - ret = await cls._parse_onebot_json(message_chain) + ret = await cls._parse_onebot_json(message_chain, prefer_base64=prefer_base64) if not ret: return await cls._dispatch_send(bot, event, is_group, session_id, ret) @@ -187,10 +203,10 @@ async def send_message( payload["user_id"] = session_id await bot.call_action("send_private_forward_msg", **payload) elif isinstance(seg, File): - d = await cls._from_segment_to_dict(seg) + d = await cls._from_segment_to_dict(seg, prefer_base64=prefer_base64) await cls._dispatch_send(bot, event, is_group, session_id, [d]) else: - messages = await cls._parse_onebot_json(MessageChain([seg])) + messages = await cls._parse_onebot_json(MessageChain([seg]), prefer_base64=prefer_base64) if not messages: continue await cls._dispatch_send(bot, event, is_group, session_id, messages) @@ -209,6 +225,7 @@ async def send(self, message: MessageChain) -> None: event=event, # 不强制要求一定是 Event is_group=is_group, session_id=session_id, + prefer_base64=self.prefer_base64, ) await super().send(message) diff --git a/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py b/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py index 7110199afb..77904f0c14 100644 --- a/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py +++ b/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py @@ -44,6 +44,7 @@ def __init__( self.settings = platform_settings self.host = platform_config["ws_reverse_host"] self.port = platform_config["ws_reverse_port"] + self.prefer_base64 = platform_config.get("prefer_base64", True) self.metadata = PlatformMetadata( name="aiocqhttp", @@ -122,6 +123,7 @@ async def send_by_session( event=None, # 这里不需要 event,因为是通过 session 发送的 is_group=is_group, session_id=session_id, + prefer_base64=self.prefer_base64, ) await super().send_by_session(session, message_chain) @@ -488,6 +490,7 @@ async def handle_msg(self, message: AstrBotMessage) -> None: platform_meta=self.meta(), session_id=message.session_id, bot=self.bot, + prefer_base64=self.prefer_base64, ) self.commit_event(message_event)