diff --git a/hexi/plugins/nonebot_plugin_video_analysis/handlers/sender.py b/hexi/plugins/nonebot_plugin_video_analysis/handlers/sender.py index 25a5a29..1a76fc3 100644 --- a/hexi/plugins/nonebot_plugin_video_analysis/handlers/sender.py +++ b/hexi/plugins/nonebot_plugin_video_analysis/handlers/sender.py @@ -5,6 +5,12 @@ 2. 失败 → 上传本地 S3,用预签名链接发送 3. 再失败 → 回退 temp 本地文件再发一次 +本地直发要真的通,得让协议端能拿到文件:2026-09-22 起由 `hexi/core/outbound_media` +的全局钩子把本地路径改写成 `/media/` 链接(此前本地那级因协议端不在本机、 +`file://` 必然 ENOENT 而恒失败,实际每次都落到第 2 级 S3)。因此**媒体不再进 S3 +归档是有意的**——只有群策略真要发下载链接(`policy.sends_link`)时才补传一次拿 +public_url,见 `_local_sent`。 + 多媒体(2026-09-08 用户需求): 多图(图集)、图+视频混合、多视频(动图) 等一个作品落地成多个文件时,直接 UniMessage 拼成一个消息发送会报错 (视频段不能与其他段混合,一条消息也放不下多段视频/图集体验差) @@ -17,6 +23,7 @@ temp 下的文件发送成功后也不清理(用户手动处理 data/temp)。 """ +import asyncio from dataclasses import dataclass from pathlib import Path @@ -173,12 +180,42 @@ def _build_s3_msg( return msg, public_url +async def _public_link_for(media: PendingMedia) -> str | None: + """发送已由本地通道完成后,为「发送下载链接」策略补一次 S3 上传。 + + 本地直发不经 S3(最快),但群策略开了链接就需要公网 URL——这里**只取链接**, + 不重建消息。失败只记日志并返回 None:发送本身已经成功了,不能反过来判失败。 + """ + public_url = None + for fp in media.files: + try: + _, pub = await asyncio.to_thread(upload_with_plan, fp, policy=media.policy) + except Exception as e: # noqa: BLE001 + logger.warning(f"补传 S3 取下载链接失败 {fp}: {e}") + return None + if pub: + public_url = pub + return public_url + + +async def _local_sent(media: PendingMedia) -> tuple[bool, str | None]: + """本地通道发送成功后的返回。 + + 只有策略真的要发下载链接(`sends_link` = send_link 且已开公网)时才补 S3; + 其余情况一概不碰 S3 —— 媒体不走 S3 归档是有意为之(temp 保留由 cleanup 管)。 + """ + if media.policy is not None and media.policy.sends_link: + return True, await _public_link_for(media) + return True, None + + async def _send_media_core( media: PendingMedia, event: Event | None = None ) -> tuple[bool, str | None]: """多级发送主体,返回 (是否成功, public_url) - public_url 仅在走 S3 链接发送成功时返回(调用方决定是否发文字)。 + public_url 有两种来源:走 S3 链接发送成功,或本地发送成功但策略要链接时 + 补传 S3(见 `_local_sent`);其余情况为 None(调用方据 `sends_link` 决定是否发文字)。 temp 文件发送成功后保留(用户手动清理 data/temp)。 多媒体作品(多图 / 图+视频 / 多视频)先走合并转发(本地文件 → S3 链接), @@ -200,7 +237,7 @@ async def _send_media_core( user_id=user_id, ): logger.info("媒体发送成功(合并转发,本地文件)") - return True, None + return await _local_sent(media) try: s3_items, public_url = await _build_s3_forward_items(media) if await _send_forward_media( @@ -217,7 +254,7 @@ async def _send_media_core( try: await _build_local_msg(media.files, media.image_post).send() logger.info("媒体发送成功(temp 本地文件直达)") - return True, None + return await _local_sent(media) except Exception as e: logger.warning(f"temp 本地文件发送失败,切换本地 S3 链接: {e}") @@ -234,7 +271,7 @@ async def _send_media_core( try: await _build_local_msg(media.files, media.image_post).send() logger.info("媒体发送成功(回退 temp 本地文件)") - return True, None + return await _local_sent(media) except Exception as e: logger.exception(f"回退发送失败: {e}") return False, None diff --git a/tests/test_video_sender_link.py b/tests/test_video_sender_link.py new file mode 100644 index 0000000..bbd21fc --- /dev/null +++ b/tests/test_video_sender_link.py @@ -0,0 +1,155 @@ +"""媒体发送成功后「补传 S3 取下载链接」的单元测试 + +背景:2026-09-22 起 `hexi/core/outbound_media` 的全局钩子把本地路径改写成 +`/media/`,本地直发那一级才真正可用。此前它因协议端不在本机、`file://` +必然 ENOENT 而**恒失败**,所以每次都落到第 2 级 S3 —— `public_url` 因此总有值。 +本地直发通了以后,如果什么都不做,`public_url` 会恒为 None,`entry.py` 的 +`if pub and policy.sends_link` 就会让「发送下载链接」**静默失效**。 + +`_local_sent` / `_public_link_for` 就是为此把「取链接」与「发送」解耦: +只有策略真要链接(`sends_link`)时才补一次 S3,其余情况一概不碰 S3。 + +sender.py 有插件内相对导入(`..config` / `..policy` / `..services...`), +无法裸加载,故在 sys.modules 里预置这些依赖的替身再按路径加载目标模块。 +""" + +import importlib.util +import sys +import types +from pathlib import Path + +import pytest + +_REPO = Path(__file__).resolve().parents[1] +_PKG = "hexi.plugins.nonebot_plugin_video_analysis" +_SENDER_PATH = ( + _REPO + / "hexi" + / "plugins" + / "nonebot_plugin_video_analysis" + / "handlers" + / "sender.py" +) + +#: 预置的替身模块名 → 该模块要暴露的属性占位(真实函数在测试里替换) +_STUBS = { + _PKG: {}, + f"{_PKG}.handlers": {}, + f"{_PKG}.config": {"group_file_settings": lambda: ([], None)}, + f"{_PKG}.policy": {"Policy": type("Policy", (), {})}, + f"{_PKG}.services": {}, + f"{_PKG}.services.storage": {}, + f"{_PKG}.services.storage.group_file": {"upload_group_files": None}, + f"{_PKG}.services.storage.s3": {"upload_with_plan": None}, + f"{_PKG}.utils": {"media_rel_dir_of": lambda _p: ""}, +} + + +@pytest.fixture(scope="module") +def sender(): + saved = {name: sys.modules.get(name) for name in _STUBS} + for name, attrs in _STUBS.items(): + module = types.ModuleType(name) + for key, value in attrs.items(): + setattr(module, key, value) + sys.modules[name] = module + + target = f"{_PKG}.handlers.sender" + saved[target] = sys.modules.get(target) + spec = importlib.util.spec_from_file_location(target, _SENDER_PATH) + module = importlib.util.module_from_spec(spec) + sys.modules[target] = module + spec.loader.exec_module(module) + + yield module + + for name, previous in saved.items(): + if previous is None: + sys.modules.pop(name, None) + else: + sys.modules[name] = previous + + +class _Policy: + """只带 sends_link 的策略替身(真实 Policy 需要一堆字段/归一化)。""" + + def __init__(self, sends_link: bool): + self.sends_link = sends_link + + +@pytest.fixture +def uploads(monkeypatch, sender): + """记录 upload_with_plan 的调用;返回 (调用列表, 设置返回值的钩子)。""" + calls: list[Path] = [] + result: dict = {"pub": "https://pub.example/x.mp4", "raise": None} + + def fake_upload(file_path, *, policy=None): + calls.append(Path(file_path)) + if result["raise"]: + raise result["raise"] + return "local-url", result["pub"] + + monkeypatch.setattr(sender, "upload_with_plan", fake_upload) + return calls, result + + +@pytest.fixture +def media(sender): + def build(sends_link: bool, n: int = 1, policy=None): + files = [Path(f"D:/temp/f{i}.mp4") for i in range(n)] + return sender.PendingMedia( + files=files, + policy=_Policy(sends_link) if policy is None else policy, + ) + + return build + + +async def test_no_s3_when_link_disabled(sender, media, uploads): + """默认路径:本地发成功就够了,绝不碰 S3(媒体不走 S3 归档是有意的)。""" + calls, _ = uploads + ok, pub = await sender._local_sent(media(sends_link=False)) + assert (ok, pub) == (True, None) + assert calls == [] + + +async def test_s3_fetched_when_link_enabled(sender, media, uploads): + """策略要下载链接时:补传 S3 并把 public_url 交出去(本次修的回归)。""" + calls, _ = uploads + ok, pub = await sender._local_sent(media(sends_link=True)) + assert (ok, pub) == (True, "https://pub.example/x.mp4") + assert len(calls) == 1 + + +async def test_multi_file_uploads_each(sender, media, uploads): + calls, _ = uploads + ok, pub = await sender._local_sent(media(sends_link=True, n=3)) + assert ok is True + assert pub == "https://pub.example/x.mp4" + assert len(calls) == 3 + + +async def test_no_public_url_keeps_none(sender, media, uploads): + """S3 有本地链接但没公网(upload_public 关)→ 不带链接,但也不算失败。""" + calls, result = uploads + result["pub"] = None + ok, pub = await sender._local_sent(media(sends_link=True)) + assert (ok, pub) == (True, None) + assert len(calls) == 1 + + +async def test_upload_failure_never_breaks_send(sender, media, uploads): + """补传失败只记日志:发送本身已经成功了,不能反过来判失败。""" + calls, result = uploads + result["raise"] = RuntimeError("S3 挂了") + ok, pub = await sender._local_sent(media(sends_link=True)) + assert (ok, pub) == (True, None) + assert len(calls) == 1 + + +async def test_policy_none_is_safe(sender, media, uploads): + """无策略(如 Web 面板路径)时不补传也不炸。""" + calls, _ = uploads + ok, pub = await sender._local_sent(media(sends_link=False, policy=None)) + assert (ok, pub) == (True, None) + assert calls == []