feat(core): 出站媒体内联化全局钩子 / 本地文件改写为协议端可达 URL

协议端不在本机,而 NoneBot 的 f2s() 会把 Path 转成 file:///D:/...(file:// 的
语义是「协议端那台机器上的路径」),必然 ENOENT。受影响的不止本仓调用点,还有
装不了改不动的 pip 社区插件(本次报错来自 nonebot_plugin_doroending),只能全局拦截。

- 新增 hexi/core/outbound_media.py:挂 Bot.on_calling_api(公开钩子、适配器不覆盖),
  在真正调用适配器前原地改写发送类 API 的 data:默认改写成
  http://<本机可达IP>:<端口>/media/<token> 由协议端主动回拉(地址与 /hub 首页
  「协议端配对」同源,零新配置),拿不到可达地址时回退 base64://
- 文件上传类(upload_group_file / upload_private_file)单独处理:file 在顶层且
  只做 URL 不做 base64(几十上百 MB 不适合内联),覆盖 video_analysis 群文件
  「本地直传」兜底与 alconna 的 $onebot11:file
- 媒体路由 get_app().mount("/media", sub) 复用现有端口不新开服务;免鉴权是必须的
  (协议端登录不了 hub),安全模型改为「路径由 bot 改写那一刻自己登记、请求方只能
  出示不可猜 token」,配短 TTL;_mounted 为 False 时绝不发 URL
- 钩子内每个段独立 try/except,任何异常只告警不抛,绝不反过来把发送搞挂
- message_utils.common_proc_reply 占位图不再自己转 base64,改传 path= 交给钩子,
  避免两处各有一套策略
- tests/test_outbound_media.py:用 tmp_path 造真实文件而非 mock 文件系统,重点覆盖
  file:// 解析(空格/中文编码、手工未转义的字面 %XX)与 walker 各消息形态

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-09-23 18:47:09 +08:00
co-authored by Claude Code
parent 4b5e8b8b2e
commit ed75a66982
4 changed files with 932 additions and 7 deletions
+5 -7
View File
@@ -1,4 +1,3 @@
import mimetypes
from pathlib import Path
from nonebot import get_bot
@@ -148,15 +147,14 @@ async def common_proc_reply(
def build() -> UniMessage:
# 每次重建:alconna 的 reply_to 会把引用段插入消息自身,降级重发需干净副本
# 本地文件转 base64 内联发送:后端(NapCat)读的是它自己 CWD 下的路径,
# file:/// 形式不可靠;base64:// 由 bot 直接携带字节,OneBot 实现通用支持
# 本地文件统一交给 hexi.core.outbound_media 的 call_api 钩子改写
# (URL 优先/base64 兜底),这里不再自己转 base64——占位图本就是发图,
# 走同一套策略才不会出现「一处能发一处不能发」。关掉 HEXI_INLINE_MEDIA
# 的语义是「协议端就在本机」,此时 file:// 同样是有效的。
if isinstance(image, str) and image.startswith(("http://", "https://")):
img = UniMessage.image(url=image)
else:
img = UniMessage.image(
raw=Path(image).read_bytes(),
mimetype=mimetypes.guess_type(str(image))[0],
)
img = UniMessage.image(path=Path(image))
return UniMessage.text(f"{text} ") + img if text else img
if message_id: