Compare commits
5
Commits
4b5e8b8b2e
...
614e1d7e00
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
614e1d7e00 | ||
|
|
fc2e542418 | ||
|
|
023dc1cb5d | ||
|
|
574216645c | ||
|
|
ed75a66982 |
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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) }
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 @@
|
||||
{"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"}
|
||||
@@ -90,6 +90,7 @@ plugins = [
|
||||
"nonebot_plugin_datastore",
|
||||
"nonebot_plugin_orm",
|
||||
"nonebot_plugin_rollpig",
|
||||
"nonebot_plugin_doroending"
|
||||
]
|
||||
|
||||
[tool.black]
|
||||
|
||||
@@ -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
|
||||
@@ -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 == []
|
||||
Reference in New Issue
Block a user