Files
HeXi/tests/test_outbound_media.py
sansenhoshiandClaude Code ed75a66982 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>
2026-09-23 18:47:09 +08:00

434 lines
16 KiB
Python

"""出站媒体内联化单元测试
hexi/core 包 __init__ 会触发 NoneBot 运行时初始化(get_driver),
无法裸导入,故用 importlib 以真实模块名加载 outbound_media 并注册进
sys.modules(game: 与 test_rate_limit / test_cooldown 同一套路)。
文件一律用 tmp_path 造真实文件,**不 mock 文件系统**——这类转换的坑
恰恰在真实路径语义上(file:// 的百分号编码、Windows 盘符、相对路径),
mock 掉就等于把要验的东西验没了。
"""
import importlib.util
import sys
from pathlib import Path
import pytest
from nonebot.adapters.onebot.v11 import Message, MessageSegment
_BASE = Path(__file__).resolve().parents[1] / "hexi" / "core"
_MOD_NAME = "hexi.core.outbound_media"
def _load_module(name: str, path: Path):
spec = importlib.util.spec_from_file_location(name, path)
module = importlib.util.module_from_spec(spec)
sys.modules[name] = module
spec.loader.exec_module(module)
return module
@pytest.fixture(scope="module")
def om():
module = _load_module(_MOD_NAME, _BASE / "outbound_media.py")
yield module
sys.modules.pop(_MOD_NAME, None)
@pytest.fixture
def media_file(tmp_path: Path) -> Path:
"""带空格与中文的真实文件:同时覆盖 URL 编码与多字节路径两种坑。"""
fp = tmp_path / "有 空格 的文件.jpg"
fp.write_bytes(b"hello")
return fp
class _FakeAdapter:
def __init__(self, name: str = "OneBot V11"):
self._name = name
def get_name(self) -> str:
return self._name
class _FakeBot:
def __init__(self, name: str = "OneBot V11"):
self.adapter = _FakeAdapter(name)
# ------------------------------------------------------------ local_file_of
def test_file_uri_with_spaces_and_unicode(om, media_file: Path):
"""file:// URI(带 %20 与中文百分号编码)必须能还原成本机真实路径。
这是整个模块最容易静默失效的一处:天真的
`Path(unquote(urlparse(uri).path))` 在 Windows 上会得到前导斜杠的
`/D:/...`,is_file() 恒为 False 而**不报错**,只在跨机时才暴露。
"""
uri = media_file.as_uri()
assert "%" in uri # 确认确实带百分号编码,否则这条测试没验到东西
assert om.local_file_of(uri) == media_file
def test_plain_absolute_path(om, media_file: Path):
"""f2s() 对 str 是原样透传的,所以裸路径也得认。"""
assert om.local_file_of(str(media_file)) == media_file
def test_relative_path(om, media_file: Path, monkeypatch):
monkeypatch.chdir(media_file.parent)
assert om.local_file_of(media_file.name) == Path(media_file.name)
@pytest.mark.parametrize(
"value",
[
"http://example.com/a.jpg",
"https://example.com/a.jpg",
"base64://aGVsbG8=",
],
)
def test_remote_and_inline_values_pass_through(om, value: str):
assert om.local_file_of(value) is None
@pytest.mark.parametrize(
"value",
[
"D:/nope/does_not_exist.jpg",
"file:///D:/nope/does_not_exist.jpg",
# 协议端回传的图片 id / 它自己的缓存路径:不是本机文件,必须自然放行
"{12345678-1234-1234-1234-123456789abc}.jpg",
"C:/NapCat/cache/abcdef.image",
"",
],
)
def test_non_local_values_are_left_alone(om, value: str):
assert om.local_file_of(value) is None
@pytest.mark.parametrize("value", [None, 123, b"bytes", ["a.jpg"]])
def test_non_string_values_are_left_alone(om, value):
assert om.local_file_of(value) is None
# ------------------------------------------------------------ 改写目标
async def test_falls_back_to_base64_when_no_url(om, media_file: Path, monkeypatch):
"""拿不到可达地址(未挂载)→ base64 兜底,字节由 bot 自己携带。"""
monkeypatch.setattr(om, "_mounted", False)
assert await om.rewrite_file(str(media_file)) == "base64://aGVsbG8="
async def test_url_mode_when_mounted(om, media_file: Path, monkeypatch):
monkeypatch.setattr(om, "_mounted", True)
monkeypatch.setenv("HEXI_MEDIA_BASE_URL", "http://192.168.2.15:39697/")
url = await om.rewrite_file(str(media_file))
assert url.startswith("http://192.168.2.15:39697/media/")
# 基地址结尾斜杠不该被拼成 //media
assert "//media" not in url
token = url.rsplit("/", 1)[1]
assert om._tokens[token][0] == media_file
async def test_url_mode_can_be_disabled(om, media_file: Path, monkeypatch):
"""URL 模式关掉后一律 base64,即使媒体服务挂着。"""
monkeypatch.setattr(om, "_mounted", True)
monkeypatch.setenv("HEXI_MEDIA_BASE_URL", "http://192.168.2.15:39697")
monkeypatch.setenv("HEXI_INLINE_MEDIA_URL", "false")
assert await om.rewrite_file(str(media_file)) == "base64://aGVsbG8="
async def test_rewrite_is_idempotent(om, media_file: Path, monkeypatch):
"""重复执行必须无副作用:第二次看到 base64:// 应直接放行。"""
monkeypatch.setattr(om, "_mounted", False)
once = await om.rewrite_file(str(media_file))
assert await om.rewrite_file(once) is None
async def test_rewrite_returns_none_for_remote(om):
assert await om.rewrite_file("http://example.com/a.jpg") is None
# ------------------------------------------------------------ 遍历形态
async def test_walk_rewrites_message_segments(om, media_file: Path, monkeypatch):
monkeypatch.setattr(om, "_mounted", False)
msg = Message(
[
MessageSegment.text("看图"),
MessageSegment.image(media_file),
MessageSegment.face(1),
]
)
await om._walk(msg)
assert msg[0].data["text"] == "看图"
assert msg[1].data["file"] == "base64://aGVsbG8="
assert msg[2].type == "face" # 非媒体段不动
@pytest.mark.parametrize("seg_type", ["image", "record", "video"])
async def test_walk_rewrites_all_media_types(
om, media_file: Path, monkeypatch, seg_type
):
monkeypatch.setattr(om, "_mounted", False)
seg = {"type": seg_type, "data": {"file": str(media_file)}}
await om._walk(seg)
assert seg["data"]["file"] == "base64://aGVsbG8="
async def test_walk_handles_bare_dict_segment(om, media_file: Path, monkeypatch):
"""group_daily_analysis 那种纯 dict 段(不是 Message 也不是 MessageSegment)。"""
monkeypatch.setattr(om, "_mounted", False)
payload = {"type": "image", "data": {"file": str(media_file)}}
await om._walk(payload)
assert payload["data"]["file"] == "base64://aGVsbG8="
async def test_walk_handles_list_of_dict_segments(om, media_file: Path, monkeypatch):
monkeypatch.setattr(om, "_mounted", False)
payload = [
{"type": "text", "data": {"text": "hi"}},
{"type": "image", "data": {"file": str(media_file)}},
]
await om._walk(payload)
assert payload[1]["data"]["file"] == "base64://aGVsbG8="
async def test_walk_forward_node_with_str_content(om, media_file: Path, monkeypatch):
"""message_utils.build_forward_nodes: content 是 str(不含媒体,应保持原样)。"""
monkeypatch.setattr(om, "_mounted", False)
nodes = [
{"type": "node", "data": {"name": "x", "uin": "1", "content": "纯文本"}},
]
await om._walk(nodes)
assert nodes[0]["data"]["content"] == "纯文本"
async def test_walk_forward_node_with_dict_list_content(
om, media_file: Path, monkeypatch
):
"""video_analysis._build_forward_nodes: content 是段字典数组。"""
monkeypatch.setattr(om, "_mounted", False)
nodes = [
{
"type": "node",
"data": {
"name": "x",
"uin": "1",
"content": [{"type": "video", "data": {"file": str(media_file)}}],
},
}
]
await om._walk(nodes)
assert nodes[0]["data"]["content"][0]["data"]["file"] == "base64://aGVsbG8="
async def test_walk_forward_node_with_message_content(
om, media_file: Path, monkeypatch
):
"""MessageSegment.node_custom 会把 Message 原样塞进 data["content"]。"""
monkeypatch.setattr(om, "_mounted", False)
node = MessageSegment.node_custom(
1, "n", Message([MessageSegment.image(media_file)])
)
await om._walk([node])
inner = node.data["content"][0]
assert isinstance(inner, MessageSegment)
assert inner.data["file"] == "base64://aGVsbG8="
async def test_walk_recurses_messages_key(om, media_file: Path, monkeypatch):
"""转发的 messages 还可能是 Message 对象(alconna 的 include() 返回同类型)。"""
monkeypatch.setattr(om, "_mounted", False)
data = {
"messages": Message(
[
MessageSegment.node_custom(
1, "n", Message([MessageSegment.image(media_file)])
)
]
)
}
await om._walk(data["messages"])
assert data["messages"][0].data["content"][0].data["file"] == "base64://aGVsbG8="
async def test_walk_leaves_remote_media_alone(om, media_file: Path, monkeypatch):
monkeypatch.setattr(om, "_mounted", False)
seg = {"type": "image", "data": {"file": "https://example.com/a.jpg"}}
await om._walk(seg)
assert seg["data"]["file"] == "https://example.com/a.jpg"
async def test_walk_respects_depth_limit(om, media_file: Path, monkeypatch):
"""超深嵌套不再下钻:拦截层不该在畸形结构上无界递归。"""
monkeypatch.setattr(om, "_mounted", False)
node: dict = {"type": "image", "data": {"file": str(media_file)}}
for _ in range(20):
node = {"type": "node", "data": {"content": [node]}}
await om._walk(node)
deepest = node
while isinstance(deepest, dict) and "content" in deepest.get("data", {}):
deepest = deepest["data"]["content"][0]
assert deepest["data"]["file"] == str(media_file) # 没被改写
# ------------------------------------------------------------ 钩子
async def test_hook_rewrites_send_api(om, media_file: Path, monkeypatch):
monkeypatch.setattr(om, "_mounted", False)
data = {"group_id": 1, "message": Message([MessageSegment.image(media_file)])}
await om._inline_outbound_media(_FakeBot(), "send_group_msg", data)
assert data["message"][0].data["file"] == "base64://aGVsbG8="
async def test_hook_ignores_unrelated_api(om, media_file: Path, monkeypatch):
"""既非发送也非文件上传的 API 一律不碰。"""
monkeypatch.setattr(om, "_mounted", True)
monkeypatch.setenv("HEXI_MEDIA_BASE_URL", "http://192.168.2.186:39697")
data = {"group_id": 1, "file": str(media_file)}
await om._inline_outbound_media(_FakeBot(), "get_group_info", data)
assert data["file"] == str(media_file)
async def test_hook_ignores_other_adapters(om, media_file: Path, monkeypatch):
monkeypatch.setattr(om, "_mounted", False)
data = {"message": Message([MessageSegment.image(media_file)])}
before = data["message"][0].data["file"]
await om._inline_outbound_media(_FakeBot("Telegram"), "send_group_msg", data)
assert data["message"][0].data["file"] == before
async def test_hook_disabled_by_env(om, media_file: Path, monkeypatch):
"""关掉开关 = 声明「协议端就在本机」,此时原样发送才是对的。"""
monkeypatch.setenv("HEXI_INLINE_MEDIA", "false")
data = {"message": Message([MessageSegment.image(media_file)])}
before = data["message"][0].data["file"]
await om._inline_outbound_media(_FakeBot(), "send_group_msg", data)
assert data["message"][0].data["file"] == before
# f2s() 对 Path 产出 file:// URI,正是本模块要拦的那种值
assert before.startswith("file://")
async def test_hook_handles_forward_messages_key(om, media_file: Path, monkeypatch):
monkeypatch.setattr(om, "_mounted", False)
data = {
"group_id": 1,
"messages": [
{
"type": "node",
"data": {
"name": "n",
"uin": "1",
"content": [{"type": "image", "data": {"file": str(media_file)}}],
},
}
],
}
await om._inline_outbound_media(_FakeBot(), "send_group_forward_msg", data)
seg = data["messages"][0]["data"]["content"][0]
assert seg["data"]["file"] == "base64://aGVsbG8="
async def test_hook_never_raises_on_broken_segment(om, monkeypatch):
"""拦截层绝不能反过来把发送搞挂:畸形段只告警。"""
monkeypatch.setattr(om, "_mounted", False)
data = {"message": [{"type": "image", "data": {"file": object()}}]}
await om._inline_outbound_media(_FakeBot(), "send_group_msg", data)
# ------------------------------------------------------------ 文件上传
def _unescaped_uri(path: Path) -> str:
"""video_analysis._file_uri 的手工拼法:正斜杠、不转义。"""
return "file:///" + str(path.resolve()).replace("\\", "/")
async def test_upload_file_url_when_mounted(om, media_file: Path, monkeypatch):
monkeypatch.setattr(om, "_mounted", True)
monkeypatch.setenv("HEXI_MEDIA_BASE_URL", "http://192.168.2.186:39697")
data = {"group_id": 1, "file": str(media_file), "name": "显示名.zip"}
await om._inline_outbound_media(_FakeBot(), "upload_group_file", data)
assert data["file"].startswith("http://192.168.2.186:39697/media/")
assert data["name"] == "显示名.zip" # 其它参数一律不碰
assert om._tokens[data["file"].rsplit("/", 1)[1]][0] == media_file
@pytest.mark.parametrize(
"builder",
[
str, # 裸路径(alconna $onebot11:file 的 as_posix() 形态)
lambda p: p.as_uri(), # Path.as_uri():带百分号转义
_unescaped_uri, # 手工拼:不转义
],
ids=["plain", "as_uri", "unescaped"],
)
async def test_upload_file_accepts_all_uri_forms(
om, media_file: Path, monkeypatch, builder
):
monkeypatch.setattr(om, "_mounted", True)
monkeypatch.setenv("HEXI_MEDIA_BASE_URL", "http://192.168.2.186:39697")
data = {"file": builder(media_file)}
await om._inline_outbound_media(_FakeBot(), "upload_private_file", data)
assert data["file"].startswith("http://192.168.2.186:39697/media/")
async def test_upload_file_has_no_base64_fallback(om, media_file: Path, monkeypatch):
"""文件上传不做 base64 兜底:没 URL 就原样放行(不能比改动前更糟)。"""
monkeypatch.setattr(om, "_mounted", False)
data = {"group_id": 1, "file": str(media_file)}
await om._inline_outbound_media(_FakeBot(), "upload_group_file", data)
assert data["file"] == str(media_file)
async def test_upload_file_respects_url_toggle(om, media_file: Path, monkeypatch):
monkeypatch.setattr(om, "_mounted", True)
monkeypatch.setenv("HEXI_MEDIA_BASE_URL", "http://192.168.2.186:39697")
monkeypatch.setenv("HEXI_INLINE_MEDIA_URL", "false")
data = {"group_id": 1, "file": str(media_file)}
await om._inline_outbound_media(_FakeBot(), "upload_group_file", data)
assert data["file"] == str(media_file)
async def test_upload_file_ignores_s3_url(om, media_file: Path, monkeypatch):
"""S3 预签名链接是群文件的首选路径,不能被碰。"""
monkeypatch.setattr(om, "_mounted", True)
monkeypatch.setenv("HEXI_MEDIA_BASE_URL", "http://192.168.2.186:39697")
url = "http://192.168.2.15:5246/PLANA/x.zip?X-Amz-Signature=abc"
data = {"group_id": 1, "file": url}
await om._inline_outbound_media(_FakeBot(), "upload_group_file", data)
assert data["file"] == url
async def test_local_file_of_handles_literal_percent(tmp_path: Path, om):
"""文件名含字面 %XX 时,未转义的手拼 URI 不能被 unquote 带偏。
先按解码结果找(会解析成错误路径),再退回原始路径 —— 这条兜底的意义就在此。
"""
fp = tmp_path / "100%20off.zip"
fp.write_bytes(b"zip")
assert om.local_file_of(_unescaped_uri(fp)) == fp
async def test_media_endpoint_roundtrip(om, media_file: Path, monkeypatch):
"""token → 路径 的登记与过期语义(不依赖 FastAPI,直接验注册表)。"""
monkeypatch.setattr(om, "_mounted", True)
monkeypatch.setenv("HEXI_MEDIA_BASE_URL", "http://127.0.0.1:11011")
monkeypatch.setenv("HEXI_MEDIA_URL_TTL", "60")
url = await om.rewrite_file(str(media_file))
token = url.rsplit("/", 1)[1]
path, expires = om._tokens[token]
assert path == media_file
assert expires > om.time.monotonic()
# token 不可猜:长度足够且不含路径信息
assert len(token) >= 20
assert "空格" not in token