fix(onebot): base64 统一开关,提取 OneBot 适配器中的通用传输与查找逻辑

- 提取 `_execute_transmission_strategy` 以统一处理 Base64/路径尝试/重试的传输流水线。
- 提取 `_find_item_in_list` 辅助函数,简化群文件夹和相册的名称匹配逻辑。
- 重构 `send_image`、`send_file`、`upload_group_file_to_folder` 和 `upload_group_album` 以减少代码冗余。
- 提升 OneBot 平台适配器的代码可维护性与逻辑一致性。
This commit is contained in:
SXP-Simon
2026-04-15 22:56:24 +08:00
parent cc162b62dd
commit c17371051a
@@ -474,116 +474,92 @@ class OneBotAdapter(PlatformAdapter):
logger.error(f"OneBot 文本发送失败: {e}")
return False
async def _execute_transmission_strategy(
self,
path: str,
worker: Any,
label: str,
format_path_as_url: bool = False,
) -> bool:
"""
通用传输策略执行器。
处理 Base64 优先(开启时)、物理路径尝试、以及 Base64 兜底。
Args:
path: 文件路径或 URL
worker: 执行具体 API 调用的异步函数,接收 (file_val, mode_label) -> Awaitable[None]
label: 业务标签,用于日志
format_path_as_url: 是否将本地路径格式化为 file:/// 形式
"""
try:
use_base64 = self._get_use_base64()
abs_path, is_remote, exists = self._prepare_path(path)
# 1. 优先尝试 Base64 (如果开启)
if use_base64 and not is_remote:
b64 = await self._get_base64_from_file(abs_path)
if b64:
try:
await worker(b64, "Base64 优先")
return True
except Exception:
pass
# 2. 尝试物理路径/远程 URL
if exists:
try:
file_val = abs_path
if not is_remote and format_path_as_url:
file_val = (
f"file://{abs_path}"
if abs_path.startswith("/")
else f"file:///{abs_path}"
)
await worker(file_val, "路径模式")
return True
except Exception as e:
if not use_base64:
logger.error(f"[{label}] 发送失败: {e}")
return False
logger.warning(f"[{label}] 路径发送失败 ({e}),准备 Base64 补救...")
else:
if not use_base64:
logger.error(f"[{label}] 文件不存在且未开启 Base64: {abs_path}")
return False
# 3. 兜底回退
if not is_remote:
b64 = await self._get_base64_from_file(abs_path)
if b64:
await worker(b64, "Base64 补发")
return True
return False
except Exception as e:
logger.error(f"[{label}] 发送异常: {e}")
return False
async def send_image(
self,
group_id: str,
image_path: str,
caption: str = "",
) -> bool:
"""
向群组发送图片消息。
"""向群组发送图片消息。"""
Args:
group_id (str): 目标群号
image_path (str): 图片路径或URL
caption (str): 图片消息的描述文字
Returns:
bool: 是否发送成功
"""
try:
use_base64 = False
plugin: Any = self.config.get("plugin_instance") if self.config else None
if plugin and hasattr(plugin, "config_manager"):
use_base64 = plugin.config_manager.get_enable_base64_image()
base_message = []
async def do_send(file_val: str, label: str):
msg = []
if caption:
base_message.append({"type": "text", "data": {"text": caption}})
# 可选策略:开启时,本地文件优先直接转 Base64 发送
if use_base64 and not image_path.startswith(
("http://", "https://", "base64://")
):
b64_str = await self._get_base64_from_file(image_path)
if b64_str:
message = list(base_message)
message.append({"type": "image", "data": {"file": b64_str}})
await self.bot.call_action(
"send_group_msg",
group_id=int(group_id),
message=message,
)
logger.info(f"OneBot Base64 直传图片成功: 群 {group_id}")
return True
logger.warning("OneBot Base64 直传失败,将回退到默认路径优先策略")
# 默认策略(上游现有行为):
# 1) 优先尝试物理路径;2) 路径失败则回退 Base64
file_str = image_path
if not image_path.startswith(("http://", "https://", "base64://")):
if os.path.isabs(image_path):
# 如果是绝对路径且以 / 开头,只需加 file:// 即可构成 file:///
if image_path.startswith("/"):
file_str = f"file://{image_path}"
else:
file_str = f"file:///{image_path}"
else:
# 如果是相对路径,转为绝对路径
file_str = f"file:///{os.path.abspath(image_path)}"
try:
message = list(base_message)
message.append({"type": "image", "data": {"file": file_str}})
await self.bot.call_action(
"send_group_msg",
group_id=int(group_id),
message=message,
)
return True
except Exception as e:
# 如果是网络图片或 Base64 输入,路径回退无意义,直接失败
if image_path.startswith(("http://", "https://", "base64://")):
raise e
error_str = str(e).lower()
is_potential_success = (
"timeout" in error_str
or "1200" in error_str
or "网络错误" in error_str
)
# 关键修复:对疑似成功的超时错误,不做“立即 Base64 补发”。
# 直接抛到外层统一进入多轮观察,避免同一份报告短时间内连发。
if is_potential_success:
raise e
logger.warning(f"路径发送图片失败 ({e}),尝试 Base64 回退模式...")
b64_str = await self._get_base64_from_file(image_path)
if not b64_str:
logger.error(f"Base64 回退失败:无法读取图片文件 {image_path}")
raise e
message = list(base_message)
message.append({"type": "image", "data": {"file": b64_str}})
await self.bot.call_action(
"send_group_msg",
group_id=int(group_id),
message=message,
)
logger.info(f"Base64 回退模式发送图片成功: 群 {group_id}")
return True
msg.append({"type": "text", "data": {"text": caption}})
msg.append({"type": "image", "data": {"file": file_val}})
await self.bot.call_action(
"send_group_msg",
group_id=int(group_id),
message=message,
"send_group_msg", group_id=int(group_id), message=msg
)
logger.info(f"Base64 回退模式发送图片成功: 群 {group_id}")
return True
logger.debug(f"[OneBot] 图片发送成功 ({label}): 群 {group_id}")
except Exception as e:
logger.error(f"OneBot 图片发送最终失败: {e}")
return False
return await self._execute_transmission_strategy(
image_path, do_send, "OneBot 图片", format_path_as_url=True
)
async def send_file(
self,
@@ -591,49 +567,20 @@ class OneBotAdapter(PlatformAdapter):
file_path: str,
filename: str | None = None,
) -> bool:
"""
通过群文件功能上传并发送文件。
"""通过群文件功能上传并发送文件。"""
Args:
group_id (str): 目标群号
file_path (str): 本地文件绝对路径
filename (str, optional): 显示的文件名,默认为路径尾部
Returns:
bool: 上传任务启动是否成功
"""
try:
# 策略 1: 优先尝试物理路径
try:
await self.bot.call_action(
"upload_group_file",
group_id=int(group_id),
file=file_path,
name=filename or os.path.basename(file_path),
)
return True
except Exception as e:
# 策略 2: 路径报错,回退到 Base64
logger.warning(f"路径发送文件失败 ({e}),尝试 Base64 回退模式...")
file_b64 = await self._get_base64_from_file(file_path)
if not file_b64:
logger.error(f"Base64 回退失败:无法读取文件 {file_path}")
raise e
await self.bot.call_action(
"upload_group_file",
group_id=int(group_id),
file=file_b64,
name=filename or os.path.basename(file_path),
)
# ... 实现省略 ...
logger.info(
f"[OneBot] 文件发送成功(Base64 模式): {filename or file_path}"
async def do_upload(content: str, label: str):
await self.bot.call_action(
"upload_group_file",
group_id=int(group_id),
file=content,
name=filename or os.path.basename(file_path),
)
return True
except Exception as e:
logger.error(f"[OneBot] 文件发送最终失败: {e}")
return False
logger.debug(f"[OneBot] 文件发送成功 ({label}): {filename or file_path}")
return await self._execute_transmission_strategy(
file_path, do_upload, "OneBot 文件"
)
async def send_forward_msg(
self,
@@ -849,52 +796,22 @@ class OneBotAdapter(PlatformAdapter):
filename: str | None = None,
folder_id: str | None = None,
) -> bool:
"""
上传文件到群文件目录的指定子文件夹。
"""上传文件到群文件目录的指定子文件夹。"""
Args:
group_id: 目标群号
file_path: 本地文件绝对路径
filename: 显示的文件名,默认为路径尾部
folder_id: 目标文件夹 ID(由 get_group_file_root_folders 获取)。
为 None 或空字符串时上传到根目录。
Returns:
bool: 上传任务是否成功启动
"""
try:
# 策略 1: 优先使用物理路径
async def do_upload(content: str, label: str):
params = {
"group_id": int(group_id),
"file": file_path,
"file": content,
"name": filename or os.path.basename(file_path),
}
if folder_id:
params["folder"] = folder_id
await self.bot.call_action("upload_group_file", **params)
logger.debug(f"[OneBot] 群文件发送成功 ({label}): {params['name']}")
try:
await self.bot.call_action("upload_group_file", **params)
logger.info(
f"OneBot 群文件上传成功: {params['name']} -> 群 {group_id}"
+ (f" (目录: {folder_id})" if folder_id else " (根目录)")
)
return True
except Exception as e:
# 策略 2: 路径报错,回退到 Base64
logger.warning(f"路径上传群文件失败 ({e}),尝试 Base64 回退模式...")
b64_str = await self._get_base64_from_file(file_path)
if not b64_str:
logger.error(f"Base64 回退失败:无法读取文件 {file_path}")
raise e
params["file"] = b64_str
await self.bot.call_action("upload_group_file", **params)
logger.info(f"Base64 回退模式上传群文件成功: {params['name']}")
return True
except Exception as e:
logger.error(f"OneBot 群文件上传最终失败: {e}")
return False
return await self._execute_transmission_strategy(
file_path, do_upload, "OneBot 群文件"
)
async def create_group_file_folder(
self,
@@ -977,8 +894,6 @@ class OneBotAdapter(PlatformAdapter):
group_id: 目标群号
folder_name: 文件夹名称
Returns:
str | None: folder_id(成功时)或 None(失败时)
"""
if not folder_name:
return None
@@ -1019,124 +934,58 @@ class OneBotAdapter(PlatformAdapter):
album_name: str | None = None,
strict_mode: bool = False,
) -> bool:
"""
上传图片到群相册(NapCat 扩展 API)。
注意:此功能主要由 NapCat 等 OneBot 增强版实现提供。
调用失败时会静默降级,不影响正常发送。
Args:
group_id: 目标群号
image_path: 本地图片文件的绝对路径
album_id: 目标相册 ID
album_name: 目标相册名称(部分 API 需要)
strict_mode: 严格模式。开启后,当指定了 album_name 但找不到对应相册时将直接终止上传
Returns:
bool: 上传是否成功
"""
try:
"""上传图片到群相册(NapCat 扩展 API)。"""
# 严格模式:指定了相册名但未解析到 album_id 时,禁止上传
if strict_mode and album_name and not album_id:
logger.info(
f"[群分析相册] 开始上传流程: 群={group_id}, 图片={image_path}, 目标={album_name or '默认'}"
f"[群分析相册] 严格模式开启:未找到相册 '{album_name}' (群 {group_id}),停止上传。"
)
# 严格模式:指定了相册名但未解析到 album_id 时,禁止回退默认相册
if strict_mode and album_name and not album_id:
logger.info(
f"群分析相册严格模式开启:未找到目标相册 '{album_name}' (群 {group_id}),停止上传。"
)
return False
# 如果没有 album_id,尝试获取该群的第一个相册作为默认目标
if not album_id:
logger.debug("[群分析相册] 未指定 album_id,正在尝试拉取相册列表...")
albums = await self.get_group_album_list(group_id)
if albums:
album_id = str(
albums[0].get("album_id") or albums[0].get("id") or ""
)
if album_id:
logger.debug(f"[群分析相册] 自动选择默认相册 ID: {album_id}")
if not album_id:
logger.info(
f"{group_id} 未找到任何有效相册,且未指定相册名,跳过相册上传以防止后端错误。"
)
return False
# 策略 1: 优先尝试物理路径
try:
# 尝试 upload_image_to_qun_album
params = {
"group_id": int(group_id),
"file": image_path,
"album_id": str(album_id or ""),
}
if album_name:
params["album_name"] = album_name
logger.debug(
f"[群分析相册] 正在调用 upload_image_to_qun_album (路径模式), 参数: {params}"
)
await self.bot.call_action("upload_image_to_qun_album", **params)
logger.info(f"[群分析相册] 路径模式上传成功: 群 {group_id}")
return True
except Exception as e1:
# 策略 2: 路径失败,尝试 Base64 模式
logger.warning(
f"[群分析相册] 路径模式上传失败 ({e1}),尝试 Base64 回退模式..."
)
b64_file = await self._get_base64_from_file(image_path)
if not b64_file:
logger.error(f"Base64 回退失败:无法读取图片文件 {image_path}")
raise e1
# 重新尝试多个可能的 API 名
params = {
"group_id": int(group_id),
"file": b64_file,
"album_id": str(album_id or ""),
}
if album_name:
params["album_name"] = album_name
try:
await self.bot.call_action("upload_image_to_qun_album", **params)
logger.info(
"[群分析相册] Base64 模式 (upload_image_to_qun_album) 上传成功"
)
return True
except Exception as e2:
logger.debug(
f"[群分析相册] Base64 接口 1 (upload_image_to_qun_album) 失败: {e2}"
)
try:
await self.bot.call_action("upload_group_album", **params)
logger.info(
"[群分析相册] Base64 模式 (upload_group_album) 上传成功"
)
return True
except Exception as e3:
logger.debug(
f"[群分析相册] Base64 接口 2 (upload_group_album) 失败: {e3}"
)
await self.bot.call_action("upload_qun_album", **params)
logger.info(
"[群分析相册] Base64 模式 (upload_qun_album) 上传成功"
)
return True
except Exception as e:
error_msg = str(e).lower()
if (
"not found" in error_msg
or "not support" in error_msg
or "不支持" in error_msg
):
logger.debug(f"当前 OneBot 实现不支持群分析相册上传 API: {e}")
else:
logger.warning(f"[群分析相册] 上传流程发生异常: {e}")
return False
# 兜底查询
if not album_id:
albums = await self.get_group_album_list(group_id)
album_id = self._find_item_in_list(
albums, album_name, ["album_id", "id"], ["name", "album_name"]
)
# 如果仍没指定且没搜到特定相册,取第一个
if not album_id and not album_name and albums:
album_id = albums[0].get("album_id") or albums[0].get("id")
if not album_id:
logger.info(
f"[群分析相册] 未能确定目标相册 (群 {group_id}),跳过相册上传。"
)
return False
async def do_upload(content: str, label: str):
params = {
"group_id": int(group_id),
"file": content,
"album_id": str(album_id),
}
if album_name:
params["album_name"] = album_name
for action in [
"upload_image_to_qun_album",
"upload_group_album",
"upload_qun_album",
]:
try:
await self.bot.call_action(action, **params)
logger.debug(
f"[群分析相册] 上传成功 ({label}, {action}): 群 {group_id}"
)
return
except Exception:
continue
raise RuntimeError("所有相册上传 API 均调用失败")
return await self._execute_transmission_strategy(
image_path, do_upload, "OneBot 相册"
)
async def get_group_album_list(
self,
group_id: str,
@@ -1257,3 +1106,44 @@ class OneBotAdapter(PlatformAdapter):
except Exception as e:
logger.debug(f"OneBot set_reaction 失败 (API 可能不支持): {e}")
return False
def _get_use_base64(self) -> bool:
"""从插件配置中获取是否启用 Base64"""
plugin: Any = self.config.get("plugin_instance") if self.config else None
if plugin and hasattr(plugin, "config_manager"):
return plugin.config_manager.get_enable_base64_image()
return False
def _prepare_path(self, path: str) -> tuple[str, bool, bool]:
"""统一路径预处理。返回: (标准化后的绝对路径, 是否为远程/编码路径, 本地文件是否存在)"""
is_remote = path.startswith(("http://", "https://", "base64://"))
if is_remote:
return path, True, True
abs_path = os.path.abspath(path)
exists = os.path.exists(abs_path)
return abs_path, False, exists
def _find_item_in_list(
self,
items: list[dict],
target_name: str | None,
id_keys: list[str],
name_keys: list[str],
) -> str | None:
"""从对象列表中根据名称查找 ID (通用辅助函数)"""
if not target_name:
return None
for item in items:
name = ""
for nk in name_keys:
if item.get(nk):
name = item[nk]
break
if name == target_name:
for ik in id_keys:
if item.get(ik):
return str(item[ik])
return None