- scheduler.py:apscheduler 薄封装(统一 Asia/Shanghai、misfire_grace_time、起止/异常日志) - task_slots.py:长任务任务槽(进度上报 / 同槽去重 / SSE 推送 / 取消), video_analysis temp 清理等长任务接入 - outbound_media:新增 HEXI_MEDIA_MODE 显式发送方式(http/base64/file,/hub 可改), 修复 alconna 对 base64 载荷补 https:// 前缀导致的 fetch failed(repair_mangled_url) - learning_chat 定时任务迁入 hexi.core.scheduler - 测试:新增 test_scheduler / test_task_slots,扩充 test_outbound_media Co-Authored-By: Claude Code <noreply@anthropic.com>
646 lines
25 KiB
Python
646 lines
25 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
|
||
|
||
|
||
# ------------------------------------------------------------ 可达地址选择
|
||
|
||
|
||
def _fake_psutil(monkeypatch, ips_by_iface: dict[str, list[str]]) -> None:
|
||
"""把 psutil.net_if_addrs 换成假网卡表(`_local_ips` 在函数内 import psutil)。"""
|
||
import socket as _socket
|
||
import types
|
||
|
||
fake = types.ModuleType("psutil")
|
||
fake.net_if_addrs = lambda: {
|
||
name: [
|
||
types.SimpleNamespace(family=_socket.AF_INET, address=ip) for ip in ips
|
||
]
|
||
for name, ips in ips_by_iface.items()
|
||
}
|
||
monkeypatch.setitem(sys.modules, "psutil", fake)
|
||
|
||
|
||
def test_route_ip_wins_over_virtual_adapter(om, monkeypatch):
|
||
"""回归 2026-10-08:EasyTier 的 10.0.0.129 按字符串排序赢过真实局域网,
|
||
协议端拉不到媒体(ActionFailed fetch failed)。默认路由网卡必须排最前。"""
|
||
_fake_psutil(
|
||
monkeypatch,
|
||
{
|
||
"EasyTier": ["10.0.0.129"],
|
||
"以太网": ["192.168.2.186"],
|
||
"WLAN": ["169.254.65.98"], # 链路本地,应被过滤
|
||
},
|
||
)
|
||
monkeypatch.setattr(om, "_route_ip", lambda: "192.168.2.186")
|
||
|
||
ips = om._local_ips()
|
||
assert ips[0] == "192.168.2.186"
|
||
assert ips == ["192.168.2.186", "10.0.0.129"]
|
||
|
||
|
||
def test_local_ips_without_route_falls_back_to_private_first(om, monkeypatch):
|
||
"""探不到默认路由(无网络/异常)时退回原排序:私有段优先、其余按字符串。"""
|
||
_fake_psutil(
|
||
monkeypatch,
|
||
{"Ethernet": ["192.168.2.186"], "VPN": ["10.0.0.129"], "eth1": ["203.0.113.9"]},
|
||
)
|
||
monkeypatch.setattr(om, "_route_ip", lambda: None)
|
||
assert om._local_ips() == ["10.0.0.129", "192.168.2.186", "203.0.113.9"]
|
||
|
||
|
||
def test_local_ips_filters_loopback_and_ipv6(om, monkeypatch):
|
||
_fake_psutil(
|
||
monkeypatch,
|
||
{"Loopback": ["127.0.0.1", "::1"], "Ethernet": ["192.168.2.186", "fe80::1"]},
|
||
)
|
||
monkeypatch.setattr(om, "_route_ip", lambda: None)
|
||
assert om._local_ips() == ["192.168.2.186"]
|
||
|
||
|
||
def test_route_ip_absent_from_candidates_is_ignored(om, monkeypatch):
|
||
"""路由出口不在网卡表里(异常环境)时不该崩,也不该把结果搞乱。"""
|
||
_fake_psutil(monkeypatch, {"Ethernet": ["192.168.2.186"]})
|
||
monkeypatch.setattr(om, "_route_ip", lambda: "10.9.9.9")
|
||
assert om._local_ips() == ["192.168.2.186"]
|
||
|
||
|
||
def test_route_ip_smoke(om):
|
||
"""真跑一次 UDP connect(不发包):有默认路由时应给出本机 IPv4。"""
|
||
ip = om._route_ip()
|
||
assert ip is None or (ip.count(".") == 3 and not ip.startswith("127."))
|
||
|
||
|
||
# ------------------------------------------------------------ 发送方式
|
||
|
||
|
||
def _clear_mode_env(monkeypatch) -> None:
|
||
for key in ("HEXI_MEDIA_MODE", "HEXI_INLINE_MEDIA", "HEXI_INLINE_MEDIA_URL"):
|
||
monkeypatch.delenv(key, raising=False)
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("env", "expected"),
|
||
[
|
||
# 默认 http(协议端跨机来拉)
|
||
({}, "http"),
|
||
({"HEXI_MEDIA_MODE": "http"}, "http"),
|
||
({"HEXI_MEDIA_MODE": " BASE64 "}, "base64"), # 大小写/空白容忍
|
||
({"HEXI_MEDIA_MODE": "file"}, "file"),
|
||
# 新键没配时按旧开关推断(老 .env 行为不变)
|
||
({"HEXI_INLINE_MEDIA": "false"}, "file"),
|
||
({"HEXI_INLINE_MEDIA_URL": "0"}, "base64"),
|
||
({"HEXI_INLINE_MEDIA": "false", "HEXI_INLINE_MEDIA_URL": "false"}, "file"),
|
||
# 新键优先于旧开关
|
||
({"HEXI_MEDIA_MODE": "http", "HEXI_INLINE_MEDIA": "false"}, "http"),
|
||
# 非法值退回旧开关/默认
|
||
({"HEXI_MEDIA_MODE": "ftp"}, "http"),
|
||
({"HEXI_MEDIA_MODE": "ftp", "HEXI_INLINE_MEDIA_URL": "false"}, "base64"),
|
||
],
|
||
)
|
||
def test_mode_resolution(om, monkeypatch, env, expected):
|
||
_clear_mode_env(monkeypatch)
|
||
for key, value in env.items():
|
||
monkeypatch.setenv(key, value)
|
||
assert om._mode() == expected
|
||
|
||
|
||
async def test_file_mode_passes_through(om, media_file: Path, monkeypatch):
|
||
"""file 模式(协议端就在本机)完全不改写:消息与文件上传都原样交给适配器。"""
|
||
monkeypatch.setenv("HEXI_MEDIA_MODE", "file")
|
||
monkeypatch.setattr(om, "_mounted", True)
|
||
monkeypatch.setenv("HEXI_MEDIA_BASE_URL", "http://192.168.2.15:39697")
|
||
assert await om.rewrite_file(str(media_file)) is None
|
||
assert await om.rewrite_upload_file(str(media_file)) is None
|
||
|
||
|
||
async def test_base64_mode_skips_url(om, media_file: Path, monkeypatch):
|
||
"""base64 模式即使媒体服务可用也不发 URL(协议端拨不通本机的场合)。"""
|
||
monkeypatch.setenv("HEXI_MEDIA_MODE", "base64")
|
||
monkeypatch.setattr(om, "_mounted", True)
|
||
monkeypatch.setenv("HEXI_MEDIA_BASE_URL", "http://192.168.2.15:39697")
|
||
assert await om.rewrite_file(str(media_file)) == "base64://aGVsbG8="
|
||
# 文件上传不做 base64(太大)→ base64 模式下只能原样放行
|
||
assert await om.rewrite_upload_file(str(media_file)) is None
|
||
|
||
|
||
async def test_http_mode_still_falls_back_to_base64(om, media_file: Path, monkeypatch):
|
||
"""http 模式拿不到可达地址时仍回退 base64(没挂上媒体服务/无网卡)。"""
|
||
monkeypatch.setenv("HEXI_MEDIA_MODE", "http")
|
||
monkeypatch.setattr(om, "_mounted", False)
|
||
assert await om.rewrite_file(str(media_file)) == "base64://aGVsbG8="
|
||
|
||
|
||
# ------------------------------------------------------------ 改写目标
|
||
|
||
|
||
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) # 没被改写
|
||
|
||
|
||
# ------------------------------------------------------------ 伪协议还原
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("value", "expected"),
|
||
[
|
||
# JPEG 的 base64 载荷恒以 /9j/ 开头,alconna 构造 Image(url=...) 时
|
||
# 会因 netloc 为空补上 https:// 前缀(2026-10-10 实测 fetch failed 的根因)
|
||
("https://base64:///9j/4AAQSkZJRg", "base64:///9j/4AAQSkZJRg"),
|
||
("http://base64:///9j/4AAQSkZJRg", "base64:///9j/4AAQSkZJRg"),
|
||
("https://file:///D:/x.jpg", "file:///D:/x.jpg"),
|
||
],
|
||
)
|
||
def test_repair_mangled_pseudo_scheme(om, value: str, expected: str):
|
||
assert om.repair_mangled_url(value) == expected
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"value",
|
||
[
|
||
# 裸域名补 https:// 是 alconna 的本意,正常链接一律不碰
|
||
"https://example.com/a.jpg",
|
||
# 主机名恰好是 base64 的正常链接不能误伤(只有 base64:// 才算伪协议)
|
||
"https://base64.com/a.png",
|
||
# 没被补头的原值无需还原
|
||
"base64:///9j/4AAQSkZJRg",
|
||
"file:///D:/x.jpg",
|
||
None,
|
||
123,
|
||
],
|
||
)
|
||
def test_repair_leaves_normal_values_alone(om, value):
|
||
assert om.repair_mangled_url(value) is None
|
||
|
||
|
||
async def test_walk_repairs_mangled_base64(om, monkeypatch):
|
||
"""补头后的 base64 值进了 file 字段,walk 必须还原回伪协议本身。"""
|
||
monkeypatch.setattr(om, "_mounted", False)
|
||
seg = {"type": "image", "data": {"file": "https://base64:///9j/4AAQSkZJRg"}}
|
||
await om._walk(seg)
|
||
assert seg["data"]["file"] == "base64:///9j/4AAQSkZJRg"
|
||
|
||
|
||
async def test_walk_repairs_real_jpeg_base64(om, tmp_path: Path, monkeypatch):
|
||
"""用真实 JPEG 魔数验证前提:载荷以 /9j/ 开头 → urlparse 解析不出 hostname
|
||
→ 正是 alconna 会补 https:// 的情形;还原后应与原始 base64:// 串一致。"""
|
||
import base64 as _b64
|
||
from urllib.parse import urlparse
|
||
|
||
fp = tmp_path / "a.jpg"
|
||
fp.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 20)
|
||
payload = _b64.b64encode(fp.read_bytes()).decode()
|
||
assert payload.startswith("/9j/") # JPEG 恒如此,补头的前提
|
||
assert urlparse(f"base64://{payload}").hostname is None # netloc 为空才会被补头
|
||
|
||
monkeypatch.setattr(om, "_mounted", False)
|
||
seg = {"type": "image", "data": {"file": f"https://base64://{payload}"}}
|
||
await om._walk(seg)
|
||
assert seg["data"]["file"] == f"base64://{payload}"
|
||
|
||
|
||
# ------------------------------------------------------------ 钩子
|
||
|
||
|
||
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_repairs_mangled_value_even_in_file_mode(
|
||
om, media_file: Path, monkeypatch
|
||
):
|
||
"""file 模式不改写本地文件,但构造期就坏掉的伪协议任何模式都该修。
|
||
|
||
2026-10-10 老婆图报 `ActionFailed ... fetch failed` 发的就是这种值:
|
||
与协议端在哪台机器无关,不还原则必死。
|
||
"""
|
||
monkeypatch.setenv("HEXI_MEDIA_MODE", "file")
|
||
data = {
|
||
"message": Message(
|
||
[
|
||
MessageSegment.image(media_file),
|
||
MessageSegment.image("https://base64:///9j/4AAQSkZJRg"),
|
||
]
|
||
)
|
||
}
|
||
before = data["message"][0].data["file"]
|
||
await om._inline_outbound_media(_FakeBot(), "send_group_msg", data)
|
||
assert data["message"][0].data["file"] == before # 本地文件原样
|
||
assert data["message"][1].data["file"] == "base64:///9j/4AAQSkZJRg"
|
||
|
||
|
||
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
|