2026-09-23 18:47:09 +08:00
|
|
|
|
"""出站媒体内联化:把本地文件改写为协议端可达的 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 里的本地文件:
|
|
|
|
|
|
|
2026-10-13 17:53:21 +08:00
|
|
|
|
发送方式由 `HEXI_MEDIA_MODE` 显式配置(`http` / `base64` / `file`,见 `_mode()`,
|
|
|
|
|
|
/hub 全局设置页可改):默认 `http`,即下面的两级改写;`base64` 跳过第 1 级;
|
|
|
|
|
|
`file` 整体不改写(协议端就在本机时才用)。
|
|
|
|
|
|
|
2026-09-23 18:47:09 +08:00
|
|
|
|
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()` 裸路径)。
|
2026-10-13 17:53:21 +08:00
|
|
|
|
|
|
|
|
|
|
另有一类「必死值」需要顺手还原:alconna 的 `Media.__post_init__` 会给解析不出
|
|
|
|
|
|
hostname 的 url 补 `https://` 前缀(本意是补全裸域名),而 JPEG 的 base64 载荷
|
|
|
|
|
|
恒以 `/9j/` 开头——`urlparse("base64:///9j/...")` 的 netloc 为空,于是被补成
|
|
|
|
|
|
`https://base64:///9j/...`,协议端拿 `base64` 当域名去 fetch,必报
|
|
|
|
|
|
`ActionFailed ... fetch failed`(2026-10-10 实测:group_tools 的老婆图)。
|
|
|
|
|
|
见 `repair_mangled_url`;它修的是**构造期就已损坏的值**,与发送方式无关,
|
|
|
|
|
|
file 模式下同样执行。
|
2026-09-23 18:47:09 +08:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
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"}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-13 17:53:21 +08:00
|
|
|
|
#: 资源发送方式(显式配置,仿 HoshinoBot 的 RES_PROTOCOL)
|
|
|
|
|
|
MEDIA_MODES: tuple[str, ...] = ("http", "base64", "file")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _mode() -> str:
|
|
|
|
|
|
"""当前发送方式:`http`(默认)/ `base64` / `file`。
|
|
|
|
|
|
|
|
|
|
|
|
- `http` 改写为 `http://<可达地址>:<端口>/media/<token>`,协议端来拉;
|
|
|
|
|
|
拿不到可达地址时回退 base64(媒体服务没挂上 / 无网卡)
|
|
|
|
|
|
- `base64` 一律内联,字节由 bot 自己携带,不依赖协议端回拨
|
|
|
|
|
|
- `file` 完全不改写,直接把本地路径交给适配器 —— 只有**协议端就在本机**
|
|
|
|
|
|
时才是对的(跨机必 ENOENT,见模块文档)
|
|
|
|
|
|
|
|
|
|
|
|
配置键 `HEXI_MEDIA_MODE` 优先;没配时按旧的 `HEXI_INLINE_MEDIA` /
|
|
|
|
|
|
`HEXI_INLINE_MEDIA_URL` 两个布尔开关推断,老 .env 行为不变。
|
|
|
|
|
|
"""
|
|
|
|
|
|
raw = (os.environ.get("HEXI_MEDIA_MODE") or "").strip().lower()
|
|
|
|
|
|
if raw in MEDIA_MODES:
|
|
|
|
|
|
return raw
|
|
|
|
|
|
if raw:
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
f"HEXI_MEDIA_MODE={raw!r} 不是 {'/'.join(MEDIA_MODES)} 之一,按默认 http 处理"
|
|
|
|
|
|
)
|
|
|
|
|
|
if not _env_flag("HEXI_INLINE_MEDIA", True):
|
|
|
|
|
|
return "file"
|
|
|
|
|
|
if not _env_flag("HEXI_INLINE_MEDIA_URL", True):
|
|
|
|
|
|
return "base64"
|
|
|
|
|
|
return "http"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-23 18:47:09 +08:00
|
|
|
|
def _enabled() -> bool:
|
2026-10-13 17:53:21 +08:00
|
|
|
|
"""是否启用改写。`file` 模式=「协议端就在本机」,此时 file:// 本来就是对的。"""
|
|
|
|
|
|
return _mode() != "file"
|
2026-09-23 18:47:09 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _url_enabled() -> bool:
|
2026-10-13 17:53:21 +08:00
|
|
|
|
"""是否用 URL(`/media/<token>`):只有 http 模式用,其余走 base64 / 原样。"""
|
|
|
|
|
|
return _mode() == "http"
|
2026-09-23 18:47:09 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-13 17:53:21 +08:00
|
|
|
|
def _route_ip() -> str | None:
|
|
|
|
|
|
"""默认路由出口 IP —— 连外网时内核会挑的那张网卡。
|
|
|
|
|
|
|
|
|
|
|
|
多网卡机器只看网卡列表挑不出「协议端该往哪连」:VPN / 虚拟机 / WSL /
|
|
|
|
|
|
EasyTier / Tailscale 的地址**同样是私有段**,排序时纯按字符串比大小,
|
|
|
|
|
|
`10.0.0.129`(EasyTier 虚拟网卡)就会赢过真实局域网的 `192.168.2.186`,
|
|
|
|
|
|
协议端拉不到 → `ActionFailed ... fetch failed`(2026-10-08 实测)。
|
|
|
|
|
|
UDP connect 不发任何包,只是让内核回答"出口在哪";无网络时返回 None。
|
|
|
|
|
|
"""
|
|
|
|
|
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
|
|
|
|
try:
|
|
|
|
|
|
sock.connect(("8.8.8.8", 80))
|
|
|
|
|
|
return str(sock.getsockname()[0])
|
|
|
|
|
|
except OSError:
|
|
|
|
|
|
return None
|
|
|
|
|
|
finally:
|
|
|
|
|
|
sock.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-23 18:47:09 +08:00
|
|
|
|
def _local_ips() -> list[str]:
|
2026-10-13 17:53:21 +08:00
|
|
|
|
"""本机可被协议端访问的 IPv4 列表(默认路由网卡最前,其次私有网段优先)。
|
2026-09-23 18:47:09 +08:00
|
|
|
|
|
|
|
|
|
|
与 hexi/web_hub/dashboard.py 的 `_local_ips` 同源逻辑(那边供 /hub 首页
|
|
|
|
|
|
「协议端配对」展示,即用户已经验证过能用的地址);此处刻意不 import,
|
2026-10-13 17:53:21 +08:00
|
|
|
|
避免 hexi.core → hexi.web_hub 的反向依赖。改这里记得同步那边。
|
2026-09-23 18:47:09 +08:00
|
|
|
|
"""
|
|
|
|
|
|
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))
|
2026-10-13 17:53:21 +08:00
|
|
|
|
route = _route_ip()
|
|
|
|
|
|
if route in out:
|
|
|
|
|
|
# 默认路由那张网卡排最前:它才是协议端大概率连得到的地址(见 _route_ip)
|
|
|
|
|
|
out.remove(route)
|
|
|
|
|
|
out.insert(0, route)
|
2026-09-23 18:47:09 +08:00
|
|
|
|
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():
|
2026-10-13 17:53:21 +08:00
|
|
|
|
logger.info(
|
|
|
|
|
|
f"出站媒体内联化已禁用 (HEXI_MEDIA_MODE={_mode()}),按原值发送"
|
|
|
|
|
|
)
|
2026-09-23 18:47:09 +08:00
|
|
|
|
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。"""
|
2026-10-13 17:53:21 +08:00
|
|
|
|
if not _enabled():
|
|
|
|
|
|
return None # file 模式:路径原样交给适配器
|
2026-09-23 18:47:09 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-13 17:53:21 +08:00
|
|
|
|
# alconna 的 Media.__post_init__ 会给解析不出 hostname 的 url 补 `https://`
|
|
|
|
|
|
# 前缀(本意是补全 example.com/a.png 这类裸域名),而 OneBot file 字段认的
|
|
|
|
|
|
# 两个伪协议都可能解析不出 hostname:
|
|
|
|
|
|
# - `base64://` + JPEG 载荷(恒以 "/9j/" 开头)→ netloc 为空 → 变成
|
|
|
|
|
|
# `https://base64:///9j/...`,协议端只会把 "base64" 当域名去 fetch,必死
|
|
|
|
|
|
# - `file:///D:/x.jpg`(netloc 也为空)同理被补头
|
|
|
|
|
|
# 带上 http(s) 外衣后这两个值不再有任何一方能解释,还原回伪协议本身。
|
|
|
|
|
|
_MANGLED_SCHEMES = ("base64://", "file://")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def repair_mangled_url(value: Any) -> str | None:
|
|
|
|
|
|
"""还原被 alconna 补了 http(s):// 头的伪协议值;无需还原返回 None。
|
|
|
|
|
|
|
|
|
|
|
|
只认「http(s):// 前缀 + 伪协议」这种必然非法的组合——裸域名补成
|
|
|
|
|
|
https:// 是 alconna 的正常行为,不受影响。
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not isinstance(value, str):
|
|
|
|
|
|
return None
|
|
|
|
|
|
for prefix in ("https://", "http://"):
|
|
|
|
|
|
if value.startswith(prefix):
|
|
|
|
|
|
rest = value[len(prefix) :]
|
|
|
|
|
|
if rest.startswith(_MANGLED_SCHEMES):
|
|
|
|
|
|
return rest
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-23 18:47:09 +08:00
|
|
|
|
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:
|
2026-10-13 17:53:21 +08:00
|
|
|
|
value = seg_data.get("file")
|
|
|
|
|
|
# 先还原构造期被 alconna 补坏的伪协议,再按需改写本地文件
|
|
|
|
|
|
repaired = repair_mangled_url(value)
|
|
|
|
|
|
if repaired is not None:
|
|
|
|
|
|
value = seg_data["file"] = repaired
|
|
|
|
|
|
rewritten = await rewrite_file(value)
|
2026-09-23 18:47:09 +08:00
|
|
|
|
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 完成,所以原地改生效。
|
2026-10-13 17:53:21 +08:00
|
|
|
|
|
|
|
|
|
|
这里不按 `_enabled()` 提前退出:两个改写函数各自按发送方式把关(file 模式
|
|
|
|
|
|
原样放行),而 `repair_mangled_url` 修的是构造期就坏掉的值,任何模式都该修。
|
2026-09-23 18:47:09 +08:00
|
|
|
|
"""
|
|
|
|
|
|
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}")
|