"""出站媒体内联化:把本地文件改写为协议端可达的 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/`**,由协议端主动来拉。 地址取自 `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/") 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}")