Compare commits

...
5 Commits
Author SHA1 Message Date
sansenhoshiandClaude Code 614e1d7e00 chore(bf-bot): 更新 BF 系列通知图
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-23 18:47:17 +08:00
sansenhoshiandClaude Code fc2e542418 chore(deps): 加载 nonebot_plugin_doroending
插件已装进 venv,补进 pyproject 的 plugins 列表才会被 nonebot 加载。
已用 bot.py 同款初始化流程(不含 nonebot.run)验证:全部插件加载成功,
doroending 注册 4 个 matcher。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-23 18:47:17 +08:00
sansenhoshiandClaude Code 023dc1cb5d feat(web): 停止/重启 bot 的等待相位全局化 / 覆盖层收进 BotActionProvider
重启入口有两个 —— 顶栏账号菜单(任何页面可点)和设置页「进程控制」。以前两边
各写一套:设置页只有本页的 fixed 覆盖层,顶栏只弹个 toast。于是从插件页/日志页
点「重启 bot」时界面照常可点,请求全打在已经退出的旧进程上。

- 新增 src/botAction.tsx:Provider 持 phase(null / stop / restart)与 readyCount,
  覆盖层由 AppLayout 在 <Outlet/> 之外统一渲染,任何页面触发都盖得住;
  纯等待逻辑仍在 lib/botAction.ts,两边共用一份
- 设置页删掉本地的 restarting 态与两份覆盖层,只负责发起 + 用 phase 禁用按钮;
  改为消费 readyCount 在回连后重新拉数据(停机期间配置是旧的)
- 未挂载 Provider 时 run() 直接抛错,避免静默无覆盖层

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-23 18:47:15 +08:00
sansenhoshiandClaude Code 574216645c feat(video-analysis): 本地直发成功且群策略要下载链接时补传 S3
本地直发经 hexi/core/outbound_media 钩子改写后已真正可用,此前它因协议端不在本机
恒失败,public_url 因此总有值(每次实际都落到第 2 级 S3)。本地通道通了以后若不补,
public_url 会恒为 None,entry.py 的 `if pub and policy.sends_link` 会让「发送下载
链接」静默失效。

- _local_sent / _public_link_for 把「取链接」与「发送」解耦:只有策略真要链接
  (policy.sends_link)时才补一次 S3 上传,其余情况一概不碰 S3 —— 媒体不再进
  S3 归档是有意的,temp 保留交给 cleanup
- 补传失败只记日志返回 None:发送本身已经成功,不能反过来判失败
- tests/test_video_sender_link.py:覆盖 sends_link 开/关、多文件逐个补传、
  拿不到 public_url、上传抛异常、policy 为 None 五条路径

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-23 18:47:12 +08:00
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
14 changed files with 1266 additions and 84 deletions
+7
View File
@@ -5,6 +5,7 @@ from . import ( # noqa: E402,F401
cooldown,
custom_utils,
message_utils,
outbound_media,
plugin_control,
plugin_manager,
rate_limit,
@@ -16,3 +17,9 @@ async def _startup_plugin_gate() -> None:
"""等所有插件 matcher 注册完成后,把统一 filter 规则注入到 application 插件。"""
n = plugin_control.instrument_plugin_gate()
logger.info(f"插件控制面: 已注入 {n} 条 gateway 规则")
@get_driver().on_startup
async def _startup_outbound_media() -> None:
"""挂载本机媒体服务:协议端据此回拉 bot 生成的本地文件(内部已处理降级)。"""
outbound_media.mount_media_endpoint()
+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:
+487
View File
@@ -0,0 +1,487 @@
"""出站媒体内联化:把本地文件改写为协议端可达的 URL(或 base64 兜底)。
生产环境的 OneBot 协议端不在本机:给 API 传本地路径时,NoneBot 的 `f2s()`
会把 `Path` 转成 `file:///D:/...`(`str` 则原样透传)——而 `file://` 的语义是
「**协议端那台机器上的路径**」,协议端 stat 必然 ENOENT。受影响的不止本仓插件,
还有 pip 装的社区插件(如 nonebot_plugin_doroending),它们的代码改不了,
只能全局拦截。
本模块挂在 `Bot.on_calling_api`(NoneBot 公开钩子,`call_api` 的必经之路,
`bot.send` / alconna `finish` / 直接 `send_group_msg` 全从这里过)上,
在真正调用适配器前改写发送类 API 里的本地文件:
1. **默认改写为 `http://<本机可达IP>:<端口>/media/<token>`**,由协议端主动来拉。
地址取自 `driver.config` 的 host/port + 本机网卡,与 /hub 首页「协议端配对」
展示的是同一来源;反向 WS 部署下协议端本就连得到这个地址(它连的就是它),
所以不需要任何新配置。体积不受 base64 膨胀影响,视频/多媒体转发尤其受益。
2. **拿不到可达地址时回退 `base64://`**:字节由 bot 直接携带,不依赖协议端
反过来拨通本机的任何假设,跨 NAT/隧道也成立。
不改写 `http(s)://` / `base64://` / 不存在的路径——收到的图片 id(如 `{abc}.jpg`)
和协议端自己的缓存路径都靠这条自然放行。任何异常只告警不抛:
拦截层绝不能反过来把发送搞挂。
两类 API 的处理方式不同:
- **消息发送**(`send_*_msg` / `send_*_forward_msg`):媒体在消息段里,递归遍历
后按上面两级改写(URL 优先,base64 兜底)。
- **文件上传**(`upload_group_file` / `upload_private_file`):`file` 在顶层,
且**只做 URL 不做 base64**——文件体积不适合内联。覆盖这条让
`video_analysis` 的群文件「本地直传」兜底和 alconna 的 `$onebot11:file`
一起受益(后者传的是 `Path.as_posix()` 裸路径)。
"""
from __future__ import annotations
import asyncio
import os
import secrets
import socket
import time
from base64 import b64encode
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
from urllib.request import url2pathname
from nonebot.adapters import Bot as BaseBot
# 用 nonebot.log 而非 `from nonebot import logger`:与 rate_limit 保持一致,
# 模块级 import 保持干净,测试才能 importlib 按路径裸加载(不经包 __init__)。
from nonebot.log import logger
try: # NoneBot 内部路径:V11/V12 的 MessageSegment 都继承自它
from nonebot.internal.adapter.message import MessageSegment as BaseMessageSegment
except Exception: # pragma: no cover - 内部路径变更时的兜底,见 _as_segment
BaseMessageSegment = None # type: ignore[assignment,misc]
# 消息段里代表「本地/远端媒体文件」的 type(file 字段语义)
MEDIA_SEG_TYPES = frozenset({"image", "record", "video"})
# OneBot V11 发送类 API(与 picstatus misc_statistics 的清单一致,另加合并转发)。
_SEND_APIS = frozenset(
{
"send_private_msg",
"send_group_msg",
"send_msg",
"send_private_forward_msg",
"send_group_forward_msg",
"send_forward_msg",
}
)
# 文件上传类 API:file 字段在**顶层**而不是消息段里,所以走单独一小段逻辑,
# 且**只做 URL、不做 base64 兜底**——文件动辄几十上百 MB,内联会撑爆内存和
# WS 帧,OneBot 的文件上传语义本来也不是为内联设计的。
_FILE_UPLOAD_APIS = frozenset({"upload_group_file", "upload_private_file"})
# 只有 OneBot 的 file 字段认 file:// / base64:// 语义;bot.py 当前只注册 V11
# (pyproject.toml 虽列了 V12,但代码里没装适配器)。
_ONEBOT_NAMES = frozenset({"OneBot V11"})
# 通配绑定:值本身不可拨号,需要换成具体网卡地址
_WILDCARD_HOSTS = frozenset({"", "0.0.0.0", "::", "*"})
_MAX_DEPTH = 8
_MAX_TOKENS = 256
_DEFAULT_TTL = 300.0
_DEFAULT_MAX_MB = 4.0
# token -> (文件路径, 过期时刻 monotonic)
_tokens: dict[str, tuple[Path, float]] = {}
# 媒体服务是否真的挂上了:没挂上就不能发 URL(否则协议端必然 404)
_mounted = False
# ---------------------------------------------------------------- 环境开关
def _env_flag(name: str, default: bool) -> bool:
raw = os.environ.get(name)
if raw is None:
return default
return raw.strip().lower() not in {"", "0", "false", "no", "off"}
def _enabled() -> bool:
"""总开关(默认开)。关闭的语义是「协议端就在本机」,此时 file:// 是有效的。"""
return _env_flag("HEXI_INLINE_MEDIA", True)
def _url_enabled() -> bool:
"""URL 优先模式(默认开);关掉则一律走 base64。"""
return _env_flag("HEXI_INLINE_MEDIA_URL", True)
def _ttl() -> float:
try:
return float(os.environ.get("HEXI_MEDIA_URL_TTL", "") or _DEFAULT_TTL)
except ValueError:
return _DEFAULT_TTL
def _max_inline_mb() -> float:
try:
return float(os.environ.get("HEXI_INLINE_MEDIA_MAX_MB", "") or _DEFAULT_MAX_MB)
except ValueError:
return _DEFAULT_MAX_MB
# ---------------------------------------------------------------- 路径解析
def local_file_of(value: Any) -> Path | None:
"""把 file 字段值解析成本机真实存在的文件路径;不是本地文件则 None。
- `http(s)://` / `base64://` → None(原样放行)
- `file://` → `url2pathname` 还原
- 其余按原始路径候选(`f2s()` 对 `str` 是原样透传的)
- 不存在 / 非法 → None(收到的图片 id、协议端缓存路径靠这条放行)
"""
if not isinstance(value, str) or not value:
return None
if value.startswith(("http://", "https://", "base64://")):
return None
candidates: list[str] = []
if value.startswith("file://"):
# 关键陷阱:不能直接 unquote(urlparse(uri).path)——Windows 上会得到
# 前导斜杠的 `/D:/...`,is_file() 恒为 False 而**静默不转换**,
# 只在跨机时才暴露。url2pathname 才能正确还原成 `D:\\...`。
try:
uri_path = urlparse(value).path
candidates.append(url2pathname(uri_path))
except Exception:
return None
# 有人手工拼 file:// 且不做百分号转义(video_analysis._file_uri 即如此:
# `"file:///" + path.replace("\\", "/")`)。对已经未转义的串再 unquote,
# 文件名含字面 %XX 时会认错,所以把原始路径也列为候选兜底。
if len(uri_path) > 2 and uri_path[0] == "/" and uri_path[2] == ":":
candidates.append(uri_path[1:]) # /D:/x → D:/x
else:
# 裸路径:f2s() 对 str 原样透传;alconna 的 $onebot11:file 走 as_posix()
candidates.append(value)
for raw in candidates:
try:
path = Path(raw)
if path.is_file():
return path
except (OSError, ValueError):
continue
return None
# ---------------------------------------------------------------- 媒体服务
def _is_private(ip: str) -> bool:
parts = ip.split(".")
if len(parts) != 4:
return False
try:
a, b = int(parts[0]), int(parts[1])
except ValueError:
return False
return a == 10 or (a == 172 and 16 <= b <= 31) or (a == 192 and b == 168)
def _local_ips() -> list[str]:
"""本机可被协议端访问的 IPv4 列表(私有网段优先,排除环回/链路本地)。
与 hexi/web_hub/dashboard.py 的 `_local_ips` 同源逻辑(那边供 /hub 首页
「协议端配对」展示,即用户已经验证过能用的地址);此处刻意不 import,
避免 hexi.core → hexi.web_hub 的反向依赖。
"""
ips: list[str] = []
try:
import psutil
for addrs in psutil.net_if_addrs().values():
for addr in addrs:
if addr.family == socket.AF_INET:
ips.append(str(addr.address or ""))
except Exception: # noqa: BLE001 - psutil 缺失/异常时退回 getaddrinfo
pass
if not ips:
try:
infos = socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET)
ips.extend(info[4][0] for info in infos)
except OSError:
pass
out: list[str] = []
for ip in ips:
ip = ip.strip()
if not ip or ":" in ip or ip.count(".") < 3:
continue
# 环回与链路本地(169.254.x)协议端访问不到,排除
if ip.startswith(("127.", "169.254.")):
continue
if ip not in out:
out.append(ip)
out.sort(key=lambda ip: (not _is_private(ip), ip))
return out
def _listen_addr() -> tuple[str, int] | None:
"""本机媒体服务的对外可达地址(host, port)。"""
try:
from nonebot import get_driver
config = get_driver().config
port = int(getattr(config, "port", 0) or 0)
host = str(getattr(config, "host", "") or "").strip()
except Exception: # noqa: BLE001
return None
if not port:
return None
if host not in _WILDCARD_HOSTS:
# 显式绑定(如 dev 的 127.0.0.1)直接用
return host, port
for ip in _local_ips():
return ip, port
return None
def _base_url() -> str | None:
"""媒体服务基地址;未挂载/无可达地址时 None(调用方据此回退 base64)。"""
if not _mounted:
return None
override = os.environ.get("HEXI_MEDIA_BASE_URL", "").strip()
if override:
return override.rstrip("/")
addr = _listen_addr()
if addr is None:
return None
host, port = addr
return f"http://{host}:{port}"
def _gc() -> None:
now = time.monotonic()
for token, (_, expires) in list(_tokens.items()):
if expires <= now:
del _tokens[token]
while len(_tokens) >= _MAX_TOKENS:
_tokens.pop(next(iter(_tokens)))
def _publish(path: Path) -> str:
"""登记一个一次性 token(值随机不可猜),返回它。"""
_gc()
token = secrets.token_urlsafe(16)
_tokens[token] = (path, time.monotonic() + _ttl())
return token
def mount_media_endpoint() -> None:
"""把媒体路由挂到现有 ASGI app 上(同一个端口,不新开服务)。
免鉴权是**必须**的——协议端登录不了 hub——所以安全模型改为
「路径由 bot 在改写那一刻自己登记,请求方只能出示不可猜 token、
根本无法表达路径」,天然没有目录穿越面,配合短 TTL。
"""
global _mounted
if not _enabled():
logger.info("出站媒体内联化已禁用 (HEXI_INLINE_MEDIA=false),按原值发送")
return
if _mounted:
return
try:
from fastapi import FastAPI
from nonebot import get_app
sub = FastAPI(
title="HeXi Outbound Media",
docs_url=None,
redoc_url=None,
openapi_url=None,
)
@sub.get("/{token}")
async def media(token: str):
from fastapi import HTTPException
from fastapi.responses import FileResponse
entry = _tokens.get(token)
if entry is None:
raise HTTPException(status_code=404, detail="媒体不存在或已过期")
path, expires = entry
if expires <= time.monotonic():
_tokens.pop(token, None)
raise HTTPException(status_code=404, detail="媒体链接已过期")
if not path.is_file():
raise HTTPException(status_code=404, detail="文件已不存在")
# no-store:过期媒体不该被中间层缓存住
return FileResponse(path, headers={"Cache-Control": "no-store"})
get_app().mount("/media", sub)
_mounted = True
logger.info("出站媒体服务已挂载: /media/<token>")
except Exception as e: # noqa: BLE001 - 挂载失败只降级,不影响启动
logger.warning(
f"出站媒体服务挂载失败,将回退 base64: {type(e).__name__}: {e}"
)
# ---------------------------------------------------------------- 改写
async def _to_base64(path: Path) -> str:
# 同步 IO 挪到线程,别在事件循环里读文件
data = await asyncio.to_thread(path.read_bytes)
size_mb = len(data) / 1048576
limit = _max_inline_mb()
if size_mb > limit:
# 超阈值仍要发:不转是 100% 必失败,转了才可能成功
logger.warning(
f"出站媒体内联: {path.name} 体积 {size_mb:.1f}MB 超过 "
f"HEXI_INLINE_MEDIA_MAX_MB={limit:g},仍以 base64 发送"
)
return f"base64://{b64encode(data).decode()}"
async def rewrite_file(value: Any) -> str | None:
"""改写单个 file 字段值;无需改写(或改写失败)返回 None。"""
path = local_file_of(value)
if path is None:
return None
try:
if _url_enabled():
base = _base_url()
if base:
return f"{base}/media/{_publish(path)}"
return await _to_base64(path)
except Exception as e: # noqa: BLE001 - 单个段失败只告警,按原值发送
logger.warning(f"出站媒体改写失败,按原值发送 {path}: {type(e).__name__}: {e}")
return None
async def rewrite_upload_file(value: Any) -> str | None:
"""文件上传类 API 的 file 字段改写;无需改写返回 None。
只做 URL,**不做 base64 兜底**:拿不到可达地址就原样放行,行为与改动前
一致(不保证成功,但绝不会比原来更糟)。`HEXI_INLINE_MEDIA_URL=false`
同样会让这里放行——那个开关的语义是「媒体服务这条路不可用」,文件上传
依赖同一条路,理应一起关掉。
"""
if not _url_enabled():
return None
path = local_file_of(value)
if path is None:
return None
try:
base = _base_url()
if not base:
return None
return f"{base}/media/{_publish(path)}"
except Exception as e: # noqa: BLE001
logger.warning(f"群/私聊文件改写失败,按原值发送 {path}: {type(e).__name__}: {e}")
return None
def _as_segment(node: Any) -> tuple[str, dict] | None:
"""识别消息段对象 → (type, data);不是消息段返回 None。
MessageSegment 不是 dict 子类(但实现了 keys/get),所以单靠 isinstance(dict)
认不出来;这里以 NoneBot 的公共基类为准,并留一条结构兜底以防内部路径变更。
"""
if BaseMessageSegment is not None and isinstance(node, BaseMessageSegment):
seg_type, seg_data = node.type, node.data
else:
seg_type = getattr(node, "type", None)
seg_data = getattr(node, "data", None)
if isinstance(seg_type, str) and isinstance(seg_data, dict):
return seg_type, seg_data
return None
async def _walk_segment(seg_type: str, seg_data: dict, depth: int) -> None:
if seg_type in MEDIA_SEG_TYPES:
rewritten = await rewrite_file(seg_data.get("file"))
if rewritten is not None:
seg_data["file"] = rewritten
# 合并转发节点:content 可能是 str / list[dict] / Message,里面还可能嵌媒体
# (MessageSegment.node_custom 把 Message 原样塞进 data["content"])
for key in ("content", "messages"):
if key in seg_data:
await _walk(seg_data[key], depth + 1)
async def _walk(node: Any, depth: int = 0) -> None:
"""深度受限的通用遍历,原地改写。
形态是散的(Message / 纯 dict 段 / list[dict] / 转发节点的三种 content),
写死形状必漏,所以按结构特征递归而不是枚举。
"""
if node is None or depth > _MAX_DEPTH:
return
# str/bytes 是叶子;数字等标量也直接跳过
if isinstance(node, (str, bytes, int, float, bool)):
return
seg = _as_segment(node)
if seg is not None:
await _walk_segment(seg[0], seg[1], depth)
return
if isinstance(node, (list, tuple)): # 含 Message(list 子类)
for item in node:
await _walk(item, depth + 1)
return
if isinstance(node, dict):
seg_type, seg_data = node.get("type"), node.get("data")
if isinstance(seg_type, str) and isinstance(seg_data, dict):
# 段字典({"type": "image", "data": {...}} / node 字典)
await _walk_segment(seg_type, seg_data, depth)
return
for value in node.values():
await _walk(value, depth + 1)
# ---------------------------------------------------------------- 钩子
@BaseBot.on_calling_api
async def _inline_outbound_media(bot: BaseBot, api: str, data: dict[str, Any]) -> None:
"""call_api 前置钩子:把出站 API 里的本地文件改写成可跨机访问的形式。
钩子拿到的是 `_call_api` 之前**同一个可变 data dict**,且 hook 的 task group
在 `_call_api` 之前 await 完成,所以原地改生效。
"""
if not _enabled():
return
is_send = api in _SEND_APIS
is_upload = api in _FILE_UPLOAD_APIS
if not (is_send or is_upload) or not isinstance(data, dict):
return
try:
if bot.adapter.get_name() not in _ONEBOT_NAMES:
return
except Exception: # noqa: BLE001
return
if is_upload:
# 文件上传的 file 在顶层(不是消息段);name/folder 等参数一律不碰
try:
rewritten = await rewrite_upload_file(data.get("file"))
except Exception as e: # noqa: BLE001 - 绝不反过来搞挂发送
logger.warning(f"出站媒体改写异常(upload.file),按原值发送: {e}")
return
if rewritten is not None:
data["file"] = rewritten
return
for key in ("message", "messages"):
if key in data:
try:
await _walk(data[key])
except Exception as e: # noqa: BLE001 - 绝不反过来搞挂发送
logger.warning(f"出站媒体改写异常({key}),按原值发送: {e}")
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 326 KiB

After

Width:  |  Height:  |  Size: 199 KiB

@@ -5,6 +5,12 @@
2. 失败 → 上传本地 S3,用预签名链接发送
3. 再失败 → 回退 temp 本地文件再发一次
本地直发要真的通,得让协议端能拿到文件:2026-09-22 起由 `hexi/core/outbound_media`
的全局钩子把本地路径改写成 `/media/<token>` 链接(此前本地那级因协议端不在本机、
`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
+109
View File
@@ -0,0 +1,109 @@
import {
createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode,
} from 'react'
import { Button, Card, Spinner, toast } from '@heroui/react'
import { hubBotAction } from './api/client'
import { watchBotReconnect } from './lib/botAction'
/** 停止/重启 bot 的全局相位(本文件是 Provider;纯等待逻辑在 lib/botAction.ts)
*
* 为什么必须是全局的:重启入口有两个 —— 顶栏账号菜单(任何页面都能点)和
* 设置页的「进程控制」。以前两边各写一套:设置页只有**本页**的 fixed 覆盖层,
* 顶栏只弹个 toast。于是从插件页/日志页点「重启 bot」时界面照常可点,请求全
* 打在已经退出的旧进程上。现在相位收进这一个 provider,覆盖层由 AppLayout
* 统一渲染,任何页面触发都挡得住。
*/
export type BotPhase = null | 'stop' | 'restart'
interface BotActionState {
/** 非 null 表示正处于「已停止」/「等待回连」,全局覆盖层生效 */
phase: BotPhase
/** 成功回连的累计次数:页面据此在回连后重新拉数据 */
readyCount: number
/** 请求停止/重启。进入等待相位;失败原样抛出(由调用方 toast) */
run: (action: 'stop' | 'restart') => Promise<void>
/** 停止后手动收起覆盖层(重启不用,回连或超时会自动收) */
dismiss: () => void
}
const Ctx = createContext<BotActionState>({
phase: null,
readyCount: 0,
run: async () => { throw new Error('BotActionProvider 未挂载') },
dismiss: () => {},
})
export function BotActionProvider({ children }: { children: ReactNode }) {
const [phase, setPhase] = useState<BotPhase>(null)
const [readyCount, setReadyCount] = useState(0)
const reconnectRef = useRef<(() => void) | null>(null)
// 登出/布局卸载时别把等待回连的 SSE 与定时器留在外面
useEffect(() => () => { reconnectRef.current?.() }, [])
const run = useCallback(async (action: 'stop' | 'restart') => {
// 失败直接抛给调用方,这里不改相位(免得把界面锁在一个没发生的操作上)
await hubBotAction(action)
if (action === 'stop') {
// 停了就不会再回连,覆盖层留到用户点「知道了」
setPhase('stop')
return
}
setPhase('restart')
reconnectRef.current?.()
reconnectRef.current = watchBotReconnect({
onReady: () => {
reconnectRef.current = null
setPhase(null)
setReadyCount(n => n + 1)
toast.success('bot 已重启并回连')
},
onTimeout: () => {
reconnectRef.current = null
setPhase(null)
toast.danger('等待回连超时,请检查 bot 是否已启动')
},
})
}, [])
const dismiss = useCallback(() => setPhase(null), [])
return (
<Ctx.Provider value={{ phase, readyCount, run, dismiss }}>{children}</Ctx.Provider>
)
}
export const useBotAction = () => useContext(Ctx)
/** 全局覆盖层:铺满视口(含侧栏与顶栏),等待期间挡掉一切点击。
*
* 刻意用裸 div 而不是 Modal —— Modal 支持 Esc 与点遮罩关闭,而「正在重启」
* 期间放行操作恰恰是这里要防的事。
*/
export function BotActionOverlay() {
const { phase, dismiss } = useBotAction()
if (phase === null) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/60 p-4">
<Card className="border border-slate-200 bg-white">
<Card.Content className="space-y-3 text-center">
{phase === 'stop' ? (
<>
<div className="text-sm font-medium text-slate-800">正在停止 bot…</div>
<p className="text-xs text-slate-400">Web 与 QQ 连接已断开,请在服务器重新启动 bot。</p>
<Button size="sm" variant="tertiary" onPress={dismiss}>知道了</Button>
</>
) : (
<>
<div className="text-sm font-medium text-slate-800">正在重启 bot,等待回连…</div>
<p className="text-xs text-slate-400">期间已禁用所有操作,恢复后自动继续。</p>
<div className="flex justify-center"><Spinner /></div>
</>
)}
</Card.Content>
</Card>
</div>
)
}
+10 -18
View File
@@ -8,10 +8,11 @@ import {
ArrowRotateLeft,
} from '@gravity-ui/icons'
import { useAuth } from '../auth/auth'
import { hubBotAction, hubPlugins } from '../api/client'
import { hubPlugins } from '../api/client'
import { ErrorBoundary } from '../components/ErrorBoundary'
import { useConnection } from '../connection'
import { BOT_ACTION_TEXT, watchBotReconnect } from '../lib/botAction'
import { BOT_ACTION_TEXT } from '../lib/botAction'
import { BotActionOverlay, useBotAction } from '../botAction'
interface NavItem { id: string; label: string; description: string; icon: any }
@@ -221,6 +222,9 @@ export function AppLayout() {
</div>
</main>
</div>
{/* 停止/重启 bot 的全局遮挡:放在 Outlet 之外,任何页面触发都盖得住 */}
<BotActionOverlay />
</div>
)
}
@@ -229,33 +233,21 @@ export function AppLayout() {
/** 顶栏账号菜单:头像 + 用户名点开下拉,重启/关闭 bot 与退出登录都收进去。
*
* 重启/关闭都是不可逆的破坏性操作(关掉之后得去服务器手动拉起),
* 所以先弹确认框再发请求;重启还会复用 `watchBotReconnect` 等回连。
* 所以先弹确认框再发请求;等待回连与全局覆盖层交给 `BotActionProvider`,
* 这样从**任何页面**触发都不会出现「看着没事、点哪都在打已退出的进程」。
*/
function AccountMenu({ username, onLogout }: { username: string; onLogout: () => void }) {
const [pending, setPending] = useState<'stop' | 'restart' | null>(null)
const [busy, setBusy] = useState(false)
const reconnectRef = useRef<(() => void) | null>(null)
// 布局卸载(登出/跳转)时别把等待回连的 SSE 与定时器留在外面
useEffect(() => () => { reconnectRef.current?.() }, [])
const { run } = useBotAction()
const confirm = async () => {
const action = pending
if (!action) return
setBusy(true)
try {
await hubBotAction(action)
await run(action)
setPending(null)
if (action === 'stop') {
toast.warning('已请求关闭 bot;需要时在服务器上重新启动')
return
}
toast.success('已请求重启 bot,等待回连…')
reconnectRef.current?.()
reconnectRef.current = watchBotReconnect({
onReady: () => { reconnectRef.current = null; toast.success('bot 已重启并回连') },
onTimeout: () => { reconnectRef.current = null; toast.danger('等待回连超时,请检查 bot 是否已启动') },
})
} catch (e: any) {
toast.danger(e.message || '操作失败')
} finally { setBusy(false) }
+15 -53
View File
@@ -1,11 +1,11 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import {
Button, Card, Form, Input, Label, ListBox, Modal, Select, Spinner, Switch, TextField, toast, useOverlayState,
Button, Card, Form, Input, Label, ListBox, Modal, Select, Switch, TextField, toast, useOverlayState,
} from '@heroui/react'
import {
hubSettings, hubChangePassword, hubBotAction, hubBotConfig, hubBotConfigSave,
hubSettings, hubChangePassword, hubBotConfig, hubBotConfigSave,
} from '../../api/client'
import { watchBotReconnect } from '../../lib/botAction'
import { useBotAction } from '../../botAction'
interface SettingsData {
ok: boolean
@@ -38,9 +38,10 @@ export default function SettingsPage() {
const [busy, setBusy] = useState(false)
const [botBusy, setBotBusy] = useState(false)
const [pendingAction, setPendingAction] = useState<'stop' | 'restart' | null>(null)
const [restarting, setRestarting] = useState<null | 'stop' | 'restart'>(null)
const botState = useOverlayState()
const restartDisposeRef = useRef<(() => void) | null>(null)
// 停止/重启的等待相位与覆盖层是全局的(见 src/botAction.tsx):本页只负责
// 发起 + 用 phase 禁用自家按钮,覆盖层由 AppLayout 统一渲染。
const { phase: botPhase, readyCount, run: runBotAction } = useBotAction()
const [botCfg, setBotCfg] = useState<BotConfigData | null>(null)
const [cfgSuperusers, setCfgSuperusers] = useState('')
@@ -76,8 +77,9 @@ export default function SettingsPage() {
useEffect(() => { load() }, [load])
// dispose 内部会一并撤掉 SSE 与超时定时器
useEffect(() => () => { restartDisposeRef.current?.() }, [])
// 重启回连后重新拉一遍:停机期间的数据是旧的
// (readyCount 由全局 provider 在回连成功时自增;Suspense 重挂不会误触发)
useEffect(() => { if (readyCount > 0) load() }, [readyCount, load])
const changePassword = async () => {
if (!oldPassword) { toast.warning('请输入原密码'); return }
@@ -127,35 +129,14 @@ export default function SettingsPage() {
botState.open()
}
const pollForReconnect = () => {
// 等待逻辑与顶栏账号菜单共用一份(lib/botAction.ts),别在两边各写一套
restartDisposeRef.current?.()
restartDisposeRef.current = watchBotReconnect({
onReady: () => {
restartDisposeRef.current = null
setRestarting(null)
toast.success('bot 已重启并回连')
load()
},
onTimeout: () => {
restartDisposeRef.current = null
setRestarting(null)
toast.danger('等待回连超时,请检查 bot 是否已启动')
},
})
}
const confirmBotAction = async () => {
if (!pendingAction) return
const action = pendingAction
setBotBusy(true)
try {
const r = await hubBotAction(action)
toast.success((r as any)?.msg || `已请求${action === 'stop' ? '停止' : '重启'} bot`)
await runBotAction(action)
botState.close()
setPendingAction(null)
setRestarting(action)
if (action === 'restart') pollForReconnect()
} catch (e: any) {
toast.danger(e.message || '操作失败')
} finally { setBotBusy(false) }
@@ -172,7 +153,7 @@ export default function SettingsPage() {
<TextField><Label>原密码</Label><Input type="password" value={oldPassword} onChange={e => setOldPassword(e.target.value)} /></TextField>
<TextField><Label>新密码</Label><Input type="password" value={newPassword} onChange={e => setNewPassword(e.target.value)} placeholder="至少 6 位" /></TextField>
<TextField><Label>确认新密码</Label><Input type="password" value={confirm} onChange={e => setConfirm(e.target.value)} /></TextField>
<Button isDisabled={busy || !!restarting} onPress={changePassword}>{busy ? '修改中…' : '保存修改'}</Button>
<Button isDisabled={busy || !!botPhase} onPress={changePassword}>{busy ? '修改中…' : '保存修改'}</Button>
</Form>
<p className="text-xs text-slate-400 mt-3">修改后旧登录态失效,当前会话保持在线。</p>
</Card.Content>
@@ -224,7 +205,7 @@ export default function SettingsPage() {
</TextField>
</div>
<div className="mt-4 flex items-center gap-3">
<Button isDisabled={cfgBusy || !cfgLoaded || !!restarting} onPress={saveBotConfig}>{cfgBusy ? '保存中…' : '保存 BOT 配置'}</Button>
<Button isDisabled={cfgBusy || !cfgLoaded || !!botPhase} onPress={saveBotConfig}>{cfgBusy ? '保存中…' : '保存 BOT 配置'}</Button>
{!cfgLoaded && <span className="text-xs text-slate-400">配置未加载</span>}
{botCfg?.source === 'env' && <span className="text-xs text-slate-400">当前来源:.env</span>}
</div>
@@ -236,8 +217,8 @@ export default function SettingsPage() {
<Card.Content className="space-y-3">
<p className="text-sm text-slate-500">停止 / 重启当前 bot 进程。停止会断开 Web 与 QQ 连接;重启会先拉起新进程再退出旧进程,日志继续写入 _bot_run.log。重启过程中界面会禁用操作并等待回连。</p>
<div className="flex flex-wrap items-center gap-3">
<Button variant="tertiary" className="text-red-500" isDisabled={botBusy || !!restarting} onPress={() => askBotAction('stop')}>停止 bot</Button>
<Button isDisabled={botBusy || !!restarting} onPress={() => askBotAction('restart')}>重启 bot</Button>
<Button variant="tertiary" className="text-red-500" isDisabled={botBusy || !!botPhase} onPress={() => askBotAction('stop')}>停止 bot</Button>
<Button isDisabled={botBusy || !!botPhase} onPress={() => askBotAction('restart')}>重启 bot</Button>
</div>
</Card.Content>
</Card>
@@ -262,25 +243,6 @@ export default function SettingsPage() {
</Modal.Footer>
</Modal.Dialog></Modal.Container></Modal.Backdrop>
</Modal>
{restarting === 'stop' && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/60 p-4">
<Card className="border border-slate-200 bg-white"><Card.Content className="space-y-3 text-center">
<div className="text-sm font-medium text-slate-800">正在停止 bot…</div>
<p className="text-xs text-slate-400">Web 与 QQ 连接已断开,请在服务器重新启动 bot。</p>
<Button size="sm" variant="tertiary" onPress={() => setRestarting(null)}>知道了</Button>
</Card.Content></Card>
</div>
)}
{restarting === 'restart' && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/60 p-4">
<Card className="border border-slate-200 bg-white"><Card.Content className="space-y-3 text-center">
<div className="text-sm font-medium text-slate-800">正在重启 bot,等待回连…</div>
<p className="text-xs text-slate-400">期间已禁用所有操作,恢复后自动继续。</p>
<div className="flex justify-center"><Spinner /></div>
</Card.Content></Card>
</div>
)}
</div>
)
}
+2 -1
View File
@@ -7,6 +7,7 @@ import { AppLayout } from './layout/AppLayout'
import { ErrorBoundary } from './components/ErrorBoundary'
import { ConnectionProvider } from './connection'
import { LogProvider } from './logs'
import { BotActionProvider } from './botAction'
import { pluginModules } from './plugins'
import HomePage from './pages/home'
import PluginsPage from './pages/plugins'
@@ -22,7 +23,7 @@ export function AppRoutes() {
<Routes>
<Route path="/" element={<Navigate to="/hub" replace />} />
<Route path="/hub/login" element={<LoginPage />} />
<Route path="/hub" element={<ConnectionProvider><LogProvider><AppLayout /></LogProvider></ConnectionProvider>}>
<Route path="/hub" element={<ConnectionProvider><LogProvider><BotActionProvider><AppLayout /></BotActionProvider></LogProvider></ConnectionProvider>}>
<Route index element={<HomePage />} />
<Route path="plugins" element={<PluginsPage />} />
<Route path="settings" element={<SettingsPage />} />
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/app.tsx","./src/connection.tsx","./src/logs.tsx","./src/main.tsx","./src/router.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/auth/loginpage.tsx","./src/auth/auth.tsx","./src/components/errorboundary.tsx","./src/components/ui.tsx","./src/layout/applayout.tsx","./src/lib/botaction.ts","./src/lib/format.ts","./src/pages/home/index.tsx","./src/pages/logs/index.tsx","./src/pages/plugins/index.tsx","./src/pages/settings/index.tsx","./src/plugins/index.ts","./src/plugins/galgame_card/index.tsx","./src/plugins/helldivers/index.tsx","./src/plugins/learning_chat/index.tsx","./src/plugins/mc_server_status/index.tsx","./src/plugins/video_analysis/index.tsx"],"version":"5.9.3"}
{"root":["./src/app.tsx","./src/botaction.tsx","./src/connection.tsx","./src/logs.tsx","./src/main.tsx","./src/router.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/auth/loginpage.tsx","./src/auth/auth.tsx","./src/components/errorboundary.tsx","./src/components/ui.tsx","./src/layout/applayout.tsx","./src/lib/botaction.ts","./src/lib/format.ts","./src/pages/home/index.tsx","./src/pages/logs/index.tsx","./src/pages/plugins/index.tsx","./src/pages/settings/index.tsx","./src/plugins/index.ts","./src/plugins/galgame_card/index.tsx","./src/plugins/helldivers/index.tsx","./src/plugins/learning_chat/index.tsx","./src/plugins/mc_server_status/index.tsx","./src/plugins/video_analysis/index.tsx"],"version":"5.9.3"}
+1
View File
@@ -90,6 +90,7 @@ plugins = [
"nonebot_plugin_datastore",
"nonebot_plugin_orm",
"nonebot_plugin_rollpig",
"nonebot_plugin_doroending"
]
[tool.black]
+433
View File
@@ -0,0 +1,433 @@
"""出站媒体内联化单元测试
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
+155
View File
@@ -0,0 +1,155 @@
"""媒体发送成功后「补传 S3 取下载链接」的单元测试
背景:2026-09-22 起 `hexi/core/outbound_media` 的全局钩子把本地路径改写成
`/media/<token>`,本地直发那一级才真正可用。此前它因协议端不在本机、`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 == []