diff --git a/AGENTS.md b/AGENTS.md
index 5189c95b..599e6826 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -31,6 +31,9 @@ The tool supports a `brief` boolean parameter (`default: false`). When `brief: t
### `group.get_avatar` — fetch user avatar
`group.get_avatar` accepts `user_id` (required) and optional `size` (40, 100, 140, 640, default 100). It downloads the QQ avatar and registers it as an attachment, returning an `` tag that can be embedded in messages.
+### OneBot local file transport
+`[onebot].file_send_mode` selects `local` (default for compatibility, including older configs without this field or an environment override), `url`, or `stream`; URL and Stream require explicit selection. `file_send_host` defaults to `127.0.0.1` and is used only for URL delivery. Both hot reload per logical delivery snapshot. Keep local source paths in business tools, attachment registration and history; `OneBotClient` prepares a separate wire request, including nested forward media. URL mode uses the running Runtime port and per-file 16-minute tokens/copies; Stream requires the NapCat extension, uses 64 KiB chunks and a separate completion request with SHA-256 verification. Stream/URL preparation plus send/fallback share 8 minutes excluding the Stream queue. Preparation errors must not mark delivery or trigger file-segment fallback; uncertain delivery must not be retried. Never log chunk data/tokens, re-interpret completed NapCat paths on the Bot, or call global `clean_stream_temp_file`. The upstream merge and existing attachment registration may still buffer whole files. See [deployment](docs/deployment.md) and [configuration](docs/configuration.md).
+
### Unified attachment tag
Use `` for both images and files. The legacy `` tag is still supported for backward compatibility but `attachment` is the recommended unified syntax. The system distinguishes image vs file based on the UID prefix (`pic_`/`file_`).
Remote attachments are cached only up to `[attachments].remote_download_max_size_mb`; larger items, or all remote items when the value is `0`, are registered as URL references with `source_ref` instead of downloaded file content.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3a634c56..43a52bb5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,17 @@
+## v3.14.0 OneBot 本地文件三模式传输
+
+本版本为 Bot 本地文件新增统一传输层,支持 `local`、`url`、`stream` 三种发送方式并按投递快照热更新;默认保持 `local` 兼容旧部署,跨文件系统发送可显式启用 Runtime 临时链接或 NapCat Stream 分块上传。
+
+- 新增 `[onebot].file_send_mode`(`local` / `url` / `stream`)与 `[onebot].file_send_host`,环境变量为 `ONEBOT_FILE_SEND_MODE` / `ONEBOT_FILE_SEND_HOST`,WebUI 配置表单提供三种模式的下拉选择。两项均支持热更新,每次逻辑投递开始时取得独立快照,排队中与进行中的投递保持旧值。默认 `local`,旧配置及未显式指定模式的环境保持原有发送行为,`url` 和 `stream` 必须显式启用;`file_send_host` 仅用于 URL 模式,接受 IPv4、IPv6 或域名。
+- QQ 本地图片、语音、视频、文件与嵌套合并转发中的媒体统一经过新传输层,覆盖 CQ 字符串与消息段数组,文件消息段缺少 `name` 时自动补全文件名;已有 HTTP/HTTPS URL、Base64 和协议端资源标识原样通过,展示文件名、附件 UID 与历史来源保持不变。
+- `url` 模式复用 Runtime HTTP 监听,将本地文件复制为独立临时副本,新增 `GET` / `HEAD` `/api/v1/onebot/files/{file_id}?token=...` 路由:每个文件独立随机令牌、有效期 16 分钟、支持 Range 与重复读取;令牌只授权该文件,不能替代 `X-Undefined-API-Key` 调用其他 Runtime 接口;访问日志不记录查询串,正常停止清理本实例缓存,启动只回收本模块命名且已过期的遗留副本。删除源文件或切换发送模式不会提前使有效链接失效。
+- `stream` 模式通过既有 OneBot WebSocket 按 64 KiB 分块上传并逐块等待确认,完成后独立请求并校验协议端路径、大小与 SHA-256;同一 Bot 的 Stream 文件投递串行,纯文本不等待上传锁;失败只重置未完成的 Stream,不调用清空临时目录的接口;不支持零字节文件。协议端不支持扩展时明确报错并提示改用 `local` 或 `url`,不会静默回退。
+- 文件准备、实际发送与明确失败后的文件消息段回退共用 8 分钟预算,排队不计时;文件准备失败不计作已发送、不触发回退,也不会自动切换模式或重试上传。投递已发出但等待超时、被取消或连接中断时按结果未确认处理,禁止自动重发;`send_forward_msg` / `send_private_forward_msg` 纳入防重投递动作。
+- 文件上传失败的回退收敛到统一调用入口:仅协议端明确拒绝时才改用文件消息段,且回退请求同样经过传输层准备;`FileTransferError` 不再被私聊与群临时会话回退、附件派发和 Bilibili 发送链路吞掉,消息类工具把包含模式与阶段的可展示说明直接反馈给模型。
+- 加固日志与连接脱敏。WebSocket 客户端不再输出未经脱敏的 DEBUG 握手与原始帧,连接错误与 API 失败信息经脱敏后记录,新增 `chunk_data` 脱敏规则并修复脱敏替换未正确回填捕获组的问题;无法解析的消息只记录长度,接收循环停止时统一让挂起的请求失败退出,不再悬挂。
+
+---
+
## v3.13.3 回复精简、群活跃度统计与图片预览修复
本版本减少对话中的重复解释,明确群成员活跃度统计的含义与数据范围,并修复 Chat 图片预览的键盘和焦点交互。
diff --git a/apps/undefined-chat/package-lock.json b/apps/undefined-chat/package-lock.json
index 45ab3f02..4c938832 100644
--- a/apps/undefined-chat/package-lock.json
+++ b/apps/undefined-chat/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "undefined-chat",
- "version": "3.13.3",
+ "version": "3.14.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "undefined-chat",
- "version": "3.13.3",
+ "version": "3.14.0",
"dependencies": {
"@tauri-apps/api": "^2.3.0",
"@tauri-apps/plugin-dialog": "^2.7.1",
diff --git a/apps/undefined-chat/package.json b/apps/undefined-chat/package.json
index 5208cf7f..d5cef4e4 100644
--- a/apps/undefined-chat/package.json
+++ b/apps/undefined-chat/package.json
@@ -1,7 +1,7 @@
{
"name": "undefined-chat",
"private": true,
- "version": "3.13.3",
+ "version": "3.14.0",
"type": "module",
"scripts": {
"tauri": "tauri",
diff --git a/apps/undefined-chat/src-tauri/Cargo.lock b/apps/undefined-chat/src-tauri/Cargo.lock
index 67bb276f..4d67f414 100644
--- a/apps/undefined-chat/src-tauri/Cargo.lock
+++ b/apps/undefined-chat/src-tauri/Cargo.lock
@@ -5431,7 +5431,7 @@ dependencies = [
[[package]]
name = "undefined_chat"
-version = "3.13.3"
+version = "3.14.0"
dependencies = [
"futures-util",
"keyring",
diff --git a/apps/undefined-chat/src-tauri/Cargo.toml b/apps/undefined-chat/src-tauri/Cargo.toml
index 1723522f..d71d32d4 100644
--- a/apps/undefined-chat/src-tauri/Cargo.toml
+++ b/apps/undefined-chat/src-tauri/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "undefined_chat"
-version = "3.13.3"
+version = "3.14.0"
description = "Undefined native chat client"
authors = ["Undefined contributors"]
license = "MIT"
diff --git a/apps/undefined-chat/src-tauri/tauri.conf.json b/apps/undefined-chat/src-tauri/tauri.conf.json
index b989c307..780ce634 100644
--- a/apps/undefined-chat/src-tauri/tauri.conf.json
+++ b/apps/undefined-chat/src-tauri/tauri.conf.json
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Undefined Chat",
- "version": "3.13.3",
+ "version": "3.14.0",
"identifier": "com.undefined.chat",
"build": {
"beforeDevCommand": "npm run dev",
diff --git a/apps/undefined-console/package-lock.json b/apps/undefined-console/package-lock.json
index 7a918f34..d87ed904 100644
--- a/apps/undefined-console/package-lock.json
+++ b/apps/undefined-console/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "undefined-console",
- "version": "3.13.3",
+ "version": "3.14.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "undefined-console",
- "version": "3.13.3",
+ "version": "3.14.0",
"dependencies": {
"@tauri-apps/api": "^2.3.0",
"@tauri-apps/plugin-http": "^2.3.0"
diff --git a/apps/undefined-console/package.json b/apps/undefined-console/package.json
index 4e2b6a46..177a760a 100644
--- a/apps/undefined-console/package.json
+++ b/apps/undefined-console/package.json
@@ -1,7 +1,7 @@
{
"name": "undefined-console",
"private": true,
- "version": "3.13.3",
+ "version": "3.14.0",
"type": "module",
"scripts": {
"tauri": "tauri",
diff --git a/apps/undefined-console/src-tauri/Cargo.lock b/apps/undefined-console/src-tauri/Cargo.lock
index 28b25b98..20cac311 100644
--- a/apps/undefined-console/src-tauri/Cargo.lock
+++ b/apps/undefined-console/src-tauri/Cargo.lock
@@ -4063,7 +4063,7 @@ checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "undefined_console"
-version = "3.13.3"
+version = "3.14.0"
dependencies = [
"serde",
"serde_json",
diff --git a/apps/undefined-console/src-tauri/Cargo.toml b/apps/undefined-console/src-tauri/Cargo.toml
index 5d6733d9..22a09387 100644
--- a/apps/undefined-console/src-tauri/Cargo.toml
+++ b/apps/undefined-console/src-tauri/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "undefined_console"
-version = "3.13.3"
+version = "3.14.0"
description = "Undefined cross-platform management console"
authors = ["Undefined contributors"]
license = "MIT"
diff --git a/apps/undefined-console/src-tauri/tauri.conf.json b/apps/undefined-console/src-tauri/tauri.conf.json
index 7d36e5d2..7209eed8 100644
--- a/apps/undefined-console/src-tauri/tauri.conf.json
+++ b/apps/undefined-console/src-tauri/tauri.conf.json
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Undefined Console",
- "version": "3.13.3",
+ "version": "3.14.0",
"identifier": "com.undefined.console",
"build": {
"beforeDevCommand": "npm run dev",
diff --git a/config.toml.example b/config.toml.example
index 15f027a9..df7e9ff5 100644
--- a/config.toml.example
+++ b/config.toml.example
@@ -73,6 +73,13 @@ ws_url = "ws://127.0.0.1:3001"
# en: Access token (optional).
token = ""
+# zh: 本地文件发送方式:local(共享路径,默认,兼容旧部署)/ url(Runtime 临时链接)/ stream(NapCat Stream API)。支持热更新。
+# en: Local file transport: local (shared paths, default for compatibility), url (temporary Runtime links), or stream (NapCat Stream API). Hot reload supported.
+file_send_mode = "local"
+# zh: 仅 URL 模式使用:OneBot 可访问的 Runtime 主机地址,支持 IPv4、IPv6 或域名,不包含协议、端口或路径;端口使用 Runtime 实际监听端口。
+# en: URL mode only: Runtime host reachable by OneBot, as IPv4, IPv6 or a domain without scheme, port or path. Uses the actual Runtime listening port.
+file_send_host = "127.0.0.1"
+
[models]
# zh: 对话模型配置(主模型,处理每一条消息)。
# en: Chat model config (the main model, processing each message).
diff --git a/docs/configuration.md b/docs/configuration.md
index 70211f58..43c90a43 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -223,8 +223,20 @@ model_name = "gpt-4o-mini"
|---|---:|---|---|
| `ws_url` | `""` | OneBot WebSocket 地址 | 模板示例通常写 `ws://127.0.0.1:3001`;严格模式必填 |
| `token` | `""` | OneBot token | 同时用于 URL 参数与 `Authorization` 头 |
+| `file_send_mode` | `"local"` | Bot 本地文件发送方式:`local` / `url` / `stream` | 缺省或空值用默认值,兼容旧部署;去除首尾空白并转小写;非法非空值报配置错误 |
+| `file_send_host` | `"127.0.0.1"` | URL 模式传给 OneBot 的 Runtime 下载主机 | IPv4、IPv6 或域名,不包含协议、端口或路径;缺省或空值用默认值 |
-`onebot.*` 变更需要重启进程才能生效。
+`onebot.ws_url` / `onebot.token` 变更需要重启进程。`file_send_mode` / `file_send_host` 支持热更新:每次逻辑投递开始时取得独立快照,排队及进行中的投递保持旧值,后续投递使用新值。环境变量为 `ONEBOT_FILE_SEND_MODE` / `ONEBOT_FILE_SEND_HOST`,沿用 TOML 优先、环境变量补缺的规则。
+
+- `local`(默认):保留原有路径或 `file://` 格式,协议端必须能读取该路径。
+- `url`:复用 Runtime HTTP 监听,将本地文件复制为临时下载资源。URL 使用 `file_send_host` 与 **实际生效的监听端口**,不会使用尚未重启生效的新 `api.port`。需要 `[api].enabled = true` 且协议端能访问该监听;默认 `127.0.0.1` 指协议端自身的回环地址,跨容器时应填写其可达的 Bot 主机或域名,并配置可达的 `[api].host`。
+- `stream`:通过 NapCat `upload_file_stream` 扩展按 64 KiB 分块上传,校验完成后使用协议端路径发送。协议端不支持时明确报错,需手动选择其他模式。零字节文件不支持此模式。
+
+这些选项只影响 Bot 本地文件;已有 HTTP/HTTPS URL、Base64 和协议端资源标识保持原样,展示文件名、附件 UID 和历史来源不变。旧配置未包含新字段且未通过环境变量指定模式时继续采用 `local`,保持原有发送行为;`url` 和 `stream` 需要显式启用。不能假定所有 OneBot 实现或 Lagrange.Core 都支持 NapCat 扩展。
+
+Stream 本地文件投递在同一 Bot 内串行,纯文本不等待上传锁。Stream/URL 文件准备、发送与明确失败后的文件消息段回退共用 8 分钟预算,排队不计时;临时资源保留 16 分钟。URL 副本在源文件删除或切换模式后仍可下载,到期拒绝新请求,已有下载允许完成。文件准备失败不会触发文件消息段回退或标记已发送;投递发出后无法确认结果时禁止自动重发。不会自动切换模式、自动重试上传或启动 Runtime。
+
+传输过程使用分块 IO;现有附件登记与 NapCat 的分块合并仍可能读取完整文件,不保证整个链路固定内存占用。参见 [三模式部署要求](deployment.md#napcat--lagrangecore-部署要求) 与 [临时文件接口](openapi.md#onebot-临时文件下载)。
---
@@ -1332,6 +1344,7 @@ api_key = "replace-with-your-key"
- `naga.*`(`enabled/api_url/api_key/use_proxy/moderation_enabled/mode/allowed_group_ids/blocked_group_ids/allowed_private_ids/blocked_private_ids`)
### 5.3 明确“会执行热应用”的字段
+- `onebot.file_send_mode` / `onebot.file_send_host`(新投递读取快照;进行中投递及旧 URL 生命周期不变)
- 模型发车间隔 / 模型名 / 模型池变更(队列间隔刷新)
- `models.grok.model_name` / `models.grok.queue_interval_seconds`(队列间隔刷新)
- `models.summary` / `models.historian` / `models.grok` 的非队列字段会刷新 AI 运行时配置,但不会重建聊天、视觉或 Agent 模型客户端;其中 `models.summary` 热更新会重建摘要服务,`/summary`/`/sum`、SummaryService(如 `/bugfix`)会立即使用专用 summary 模型配置;主 AI 调用的 `summary_agent` 始终走 `models.agent`(及 agent 模型池)。
@@ -1709,6 +1722,8 @@ api_key = "replace-with-your-key"
| TOML 路径 | 环境变量 |
|-----------|----------|
| `onebot.token` | `ONEBOT_TOKEN` |
+| `onebot.file_send_mode` | `ONEBOT_FILE_SEND_MODE` |
+| `onebot.file_send_host` | `ONEBOT_FILE_SEND_HOST` |
| `onebot.ws_url` | `ONEBOT_WS_URL` |
#### `render`
diff --git a/docs/deployment.md b/docs/deployment.md
index 23b57e4b..c01c2b44 100644
--- a/docs/deployment.md
+++ b/docs/deployment.md
@@ -241,34 +241,35 @@ python -c "from Undefined.utils.resources import read_text_resource; print(len(r
## NapCat / Lagrange.Core 部署要求
-**NapCat(或 Lagrange.Core)必须与 Bot 进程共享同一文件系统,不能将 NapCat 单独放在无法访问 Bot 数据目录的 Docker 容器内。**
+Bot 本地文件支持三种发送方式,默认 `local`,保持旧部署的发送行为。**是否需要共享文件系统取决于模式**:
-### 原因
+| 模式 | 共享文件系统 | 协议端要求 | Runtime 文件监听 |
+|---|---|---|---|
+| `local`(默认) | 必须按发送路径可见 | 能读取 Bot 给出的路径/`file://` URI | 不需要 |
+| `url` | 不需要 | 对相应消息/文件接口支持 HTTP URL,且能访问 Runtime | 需要 |
+| `stream` | 不需要 | 支持 NapCat `upload_file_stream` 扩展 | 不需要 |
-Bot 发送本地文件(图片、音频、压缩包等)时,统一使用 `file:///path/to/file` URI,例如:
-
-```
-[CQ:image,file=file:///home/pyl/Undefined/data/cache/render/stats_line_chart.png]
+```toml
+[onebot]
+file_send_mode = "local"
+file_send_host = "127.0.0.1" # 仅 URL 模式使用,不包含协议、端口或路径
```
-NapCat 收到后会在**自身所在的文件系统**上按路径读取文件。若 NapCat 在独立容器中,宿主机路径不可见,会报:
+`local` 适用于同一宿主机、同一容器,或共享 volume 且内部路径一致的不同容器。协议端会在**自己的文件系统**中读取 URI;路径未挂载仍会报 `ENOENT`。
-```
-ENOENT: no such file or directory, copyfile '/home/pyl/...' -> '/app/.config/QQ/NapCat/temp/...'
-```
+`url` 模式复用 `[api]` Runtime HTTP 服务,无需额外端口。`file_send_host` 填写协议端实际可达的 IPv4、IPv6 或域名;IPv6 会正确生成带方括号的 URL。端口取实际监听值,修改 `api.port` 而尚未重启时仍使用旧端口。默认 `127.0.0.1` 仅适用于协议端与 Bot 共用网络空间的情况,独立容器中的回环地址指向容器自身;需要同时保证 `[api].host` 的绑定允许协议端访问。Runtime 关闭或未就绪时准备阶段报错,不会自动启动服务。
+
+URL 使用单文件独立令牌,有效期 16 分钟,支持 HEAD、Range 和重复读取。下载读取的是 Bot 保存的独立副本,业务删除源文件或切换模式不会影响有效链接。到期拒绝新请求,正在读取的请求可以完成,然后清理副本。不要在反向代理访问日志中记录文件 URL 查询串。
-### 支持的部署方式
+`stream` 通过已有 OneBot WebSocket 按 64 KiB 分块上传,每块单独等待确认,最后独立请求完成并校验路径、大小和 SHA-256,再发 QQ 消息。一个 Bot 的 Stream 文件投递串行,多文件顺序准备,文本消息不受上传锁影响。文件准备、发送和明确失败后的回退共用 8 分钟预算,排队等待不计时;协议端文件显式保留 16 分钟。未完成 Stream 失败时仅尝试重置该 Stream,已完成文件依靠保留期回收,不调用清空临时目录的接口。不支持零字节文件,不自动重试上传或跨重启续传。
-| 场景 | 是否支持 |
-|---|---|
-| Bot 和 NapCat 都在宿主机 | ✅ |
-| Bot 在宿主机,NapCat 在 Docker(路径未挂载) | ❌ |
-| Bot 和 NapCat 在同一个 Docker 容器 | ✅ |
-| Bot 和 NapCat 在不同容器,共享同一 volume 且路径一致 | ✅ |
+**旧配置缺少新增字段且未通过环境变量指定模式时继续使用 `local`。** 需要跨文件系统发送时,可显式设置 `onebot.file_send_mode = "url"` 或 `"stream"`。选择 Stream 后,协议端明确不支持扩展时会提示切换配置,不会静默回退。NapCat 扩展不能视为所有 OneBot 实现的共同能力;使用 Lagrange.Core 等实现时应按其实际能力选 `local`,或核对所用消息与普通文件上传接口的 URL 支持后选择 `url`。
+
+实现参考固定版本的 [NapCat 上传示例](https://github.com/NapNeko/NapCatQQ/blob/109d0c1dff755875f3b79795e99cee6115289fbb/packages/napcat-onebot/action/stream/test_upload_stream.py) 与 [UploadFileStream](https://github.com/NapNeko/NapCatQQ/blob/109d0c1dff755875f3b79795e99cee6115289fbb/packages/napcat-onebot/action/stream/UploadFileStream.ts)。Bot 新传输层使用分块 IO,但该上游在合并磁盘分块时仍构造完整内存缓冲区,现有附件登记也可能读取完整文件;**不承诺整个链路固定内存占用**。
### 受影响的功能
-以下功能均依赖本地文件路径:
+以下功能的本地来源统一经过该传输层,保留原始附件 UID、展示文件名与历史语义:
- `/stats` 统计图表
- `render.render_markdown` / `render.render_latex` 渲染图片
@@ -276,3 +277,5 @@ ENOENT: no such file or directory, copyfile '/home/pyl/...' -> '/app/.config/QQ/
- `code_delivery_agent` 代码交付压缩包
- `messages.send_text_file` / `messages.send_url_file`
- Bilibili 视频下载发送
+
+同时覆盖语音、视频缩略图和嵌套合并转发中的媒体,支持 CQ 字符串及消息段数组。已有 HTTP/HTTPS URL、Base64 或协议端资源标识原样通过。两项配置支持按投递快照热更新,见 [配置说明](configuration.md#43-onebot-协议端连接)。
diff --git a/docs/openapi.md b/docs/openapi.md
index cf0621a1..46d635e9 100644
--- a/docs/openapi.md
+++ b/docs/openapi.md
@@ -59,7 +59,7 @@ tool_invoke_callback_timeout = 10
## 2. 鉴权规则
-- 除 `/api/v1/naga/*` 外,所有 `/api/*` 路由都要求请求头:
+- 除 `/api/v1/naga/*` 及下述使用单文件令牌的 GET/HEAD 下载路由外,所有 `/api/*` 路由都要求请求头:
```http
X-Undefined-API-Key:
@@ -80,6 +80,19 @@ curl http://127.0.0.1:8788/openapi.json
## 4. 主要接口
+### OneBot 临时文件下载
+
+```text
+GET /api/v1/onebot/files/{file_id}?token=...
+HEAD /api/v1/onebot/files/{file_id}?token=...
+```
+
+仅 `[onebot].file_send_mode = "url"` 在进程内部登记本地文件时产生下载链接,无公共上传或任意本地路径参数。每个 ID 对应独立随机令牌,权限仅限该文件;`X-Undefined-API-Key` 不能替代此令牌,该令牌也不能调用其他 Runtime API。此路由不借用或扩大 WebChat 附件作用域。
+
+支持 GET、HEAD、Range(`206`)和有效期内重复读取。缺少或错误令牌为 `401`,文件不存在或过期为 `404`,无效 Range 为 `416`。链接与独立副本保留 16 分钟,过期拒绝新读取,已有下载结束后删除副本。业务清理源文件及切换发送模式不会提前撤销链接。正常停止清理本实例缓存;启动只回收 `data/cache/onebot_files` 中本模块命名且已过期的遗留缓存。
+
+服务复用 Runtime 实际监听端口。`onebot.file_send_host` 仅用于生成协议端可达的 URL,不能改变绑定地址;绑定仍由 `[api].host` 控制。文件 URL 令牌和 Stream 分块内容不会写入 Bot 请求日志及 Runtime 访问日志,反向代理也应隐藏查询串。
+
### 健康检查
- `GET /health`
diff --git a/docs/python-api.md b/docs/python-api.md
index f88582fb..4b243f20 100644
--- a/docs/python-api.md
+++ b/docs/python-api.md
@@ -148,6 +148,18 @@ print(cfg.chat_model.model_name) # gpt-4o-mini
`strict=True` 时缺失必填项(如 `onebot.ws_url`、各模型 `api_url` 等)会抛出异常;行为与 CLI 严格模式一致。
+### `OneBotClient` 文件传输依赖
+
+`OneBotClient(ws_url, token="", *, config_getter=None, file_transport=None)` 保留原有位置参数和发送返回值。未注入配置时本地文件默认使用 `local`,保持旧的本地路径发送行为;库嵌入需要 Stream 上传时可通过 `config_getter` 显式返回 `FileSendSettings("stream")`,运行中需要热更新则注入返回当前 `Config` 的函数:
+
+```python
+from Undefined.onebot import OneBotClient
+
+client = OneBotClient(cfg.onebot_ws_url, cfg.onebot_token, config_getter=lambda: cfg)
+```
+
+`OneBotFileTransport` 的 `chunk_size`、`timeout` 和 `store` 可用于测试注入,不是额外业务配置。URL 模式需要将同一客户端传入 `RuntimeAPIContext.onebot`,并显式启动 `RuntimeAPIServer`;服务会绑定客户端的临时文件存储与实际监听端口,OneBot 发送方法不会自动启动 Runtime。准备错误为 `FileTransferError`,含 `mode`、`stage`、`user_message` 与 `file_transfer_error` 标记;投递发出后结果未确认仍为 `OneBotDeliveryUncertainError`。详细模式与部署要求见 [配置说明](configuration.md#43-onebot-协议端连接)。
+
### `Config.builder`
链式构建器,适合在 base mapping 上覆盖少量字段:
diff --git a/pyproject.toml b/pyproject.toml
index 5c900c90..64a25c87 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "Undefined-bot"
-version = "3.13.3"
+version = "3.14.0"
description = "QQ bot platform with cognitive memory architecture and multi-agent Skills, via OneBot V11."
readme = "README.md"
authors = [
diff --git a/src/Undefined/__init__.py b/src/Undefined/__init__.py
index 8264c69f..86925bb5 100644
--- a/src/Undefined/__init__.py
+++ b/src/Undefined/__init__.py
@@ -24,7 +24,7 @@
from .skills.registry import BaseRegistry as BaseRegistry
from .skills.tools import ToolRegistry as ToolRegistry
-__version__: str = "3.13.3"
+__version__: str = "3.14.0"
# symbol -> (module_path, attribute_name);首次访问时才 importlib 加载
_LAZY_IMPORTS: dict[str, tuple[str, str]] = {
diff --git a/src/Undefined/api/_openapi.py b/src/Undefined/api/_openapi.py
index 1f15b230..798717ba 100644
--- a/src/Undefined/api/_openapi.py
+++ b/src/Undefined/api/_openapi.py
@@ -18,6 +18,38 @@ def _build_openapi_spec(ctx: RuntimeAPIContext, request: web.Request) -> dict[st
cfg = ctx.config_getter()
naga_routes_enabled = _naga_routes_enabled(cfg, ctx.naga_store)
paths: dict[str, Any] = {
+ "/api/v1/onebot/files/{file_id}": {
+ method: {
+ "summary": "Download a temporary OneBot file"
+ if method == "get"
+ else "Inspect a temporary OneBot file",
+ "description": "Process-internal registration only. A token authorizes one file for 16 minutes. Supports Range and repeated reads; does not authorize other Runtime APIs.",
+ "security": [{"OneBotFileToken": []}],
+ "parameters": [
+ {
+ "name": "file_id",
+ "in": "path",
+ "required": True,
+ "schema": {"type": "string"},
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "File",
+ "content": {
+ "application/octet-stream": {
+ "schema": {"type": "string", "format": "binary"}
+ }
+ },
+ },
+ "206": {"description": "Partial file"},
+ "401": {"description": "Missing or invalid file token"},
+ "404": {"description": "File missing or expired"},
+ "416": {"description": "Invalid byte range"},
+ },
+ }
+ for method in ("get", "head")
+ },
"/health": {
"get": {
"summary": "Health check",
@@ -323,6 +355,7 @@ def _build_openapi_spec(ctx: RuntimeAPIContext, request: web.Request) -> dict[st
],
"components": {
"securitySchemes": {
+ "OneBotFileToken": {"type": "apiKey", "in": "query", "name": "token"},
"ApiKeyAuth": {
"type": "apiKey",
"in": "header",
diff --git a/src/Undefined/api/app.py b/src/Undefined/api/app.py
index c7aa4146..ea47393b 100644
--- a/src/Undefined/api/app.py
+++ b/src/Undefined/api/app.py
@@ -15,6 +15,10 @@
from aiohttp import web
from aiohttp.web_response import Response
+from Undefined.onebot.file_store import FILE_ROUTE, FILE_ROUTE_NAME, OneBotFileStore
+from Undefined.onebot.file_transport import OneBotFileTransport
+from .routes import onebot_files
+
from ._context import RuntimeAPIContext
from ._helpers import (
_apply_cors_headers,
@@ -47,6 +51,8 @@ def __init__(
context: RuntimeAPIContext,
host: str,
port: int,
+ *,
+ file_store: OneBotFileStore | None = None,
) -> None:
self._context = context
self._host = host
@@ -56,21 +62,41 @@ def __init__(
self._background_tasks: set[asyncio.Task[Any]] = set()
self._naga_state = NagaState()
self._chat_job_manager = chat.ChatJobManager(context)
+ transport = getattr(context.onebot, "file_transport", None)
+ self._file_store = (
+ file_store
+ if file_store is not None
+ else (
+ transport.store if isinstance(transport, OneBotFileTransport) else None
+ )
+ )
async def start(self) -> None:
from Undefined.config.models import resolve_bind_hosts
app = self._create_app()
- self._runner = web.AppRunner(app)
- await self._runner.setup()
- for h in resolve_bind_hosts(self._host):
- site = web.TCPSite(self._runner, host=h, port=self._port)
- await site.start()
- self._sites.append(site)
+ self._runner = web.AppRunner(
+ app, access_log_class=onebot_files.FileAccessLogger
+ )
+ try:
+ await self._runner.setup()
+ port = self._port
+ for h in resolve_bind_hosts(self._host):
+ site = web.TCPSite(self._runner, host=h, port=port)
+ await site.start()
+ self._sites.append(site)
+ port = int(self._runner.addresses[0][1])
+ if self._file_store is not None:
+ await self._file_store.start(port)
+ except BaseException:
+ await self.stop()
+ raise
cfg = self._context.config_getter()
logger.info("[RuntimeAPI] 已启动: %s", cfg.api.display_url)
async def stop(self) -> None:
+ if self._file_store is not None:
+ self._file_store.unavailable()
await self._chat_job_manager.stop()
for task in self._background_tasks:
@@ -83,7 +109,12 @@ async def stop(self) -> None:
await self._runner.cleanup()
logger.info("[RuntimeAPI] 已停止")
self._runner = None
- self._site = None
+ self._sites.clear()
+ if self._file_store is not None:
+ await self._file_store.stop()
+
+ async def _onebot_file_handler(self, request: web.Request) -> web.StreamResponse:
+ return await onebot_files.download(request, self._file_store)
def _create_app(self) -> web.Application:
@web.middleware
@@ -99,7 +130,9 @@ async def _auth_middleware(
if request.path.startswith("/api/"):
cfg = self._context.config_getter()
is_naga_path = request.path.startswith("/api/v1/naga/")
- skip_auth = is_naga_path and _naga_runtime_enabled(cfg)
+ skip_auth = (
+ is_naga_path and _naga_runtime_enabled(cfg)
+ ) or onebot_files.is_file_request(request)
if not skip_auth:
expected = str(cfg.api.auth_key or "")
provided = request.headers.get(_AUTH_HEADER, "")
@@ -117,6 +150,7 @@ async def _auth_middleware(
[
web.get("/health", self._health_handler),
web.get("/openapi.json", self._openapi_handler),
+ web.get(FILE_ROUTE, self._onebot_file_handler, name=FILE_ROUTE_NAME),
web.get("/api/v1/probes/internal", self._internal_probe_handler),
web.get("/api/v1/probes/external", self._external_probe_handler),
web.get("/api/v1/memory", self._memory_handler),
diff --git a/src/Undefined/api/routes/onebot_files.py b/src/Undefined/api/routes/onebot_files.py
new file mode 100644
index 00000000..4a1476c0
--- /dev/null
+++ b/src/Undefined/api/routes/onebot_files.py
@@ -0,0 +1,64 @@
+"""使用独立单文件令牌的 Runtime 下载路由。"""
+
+from urllib.parse import quote
+
+from aiohttp import web
+from aiohttp.web_log import AccessLogger
+
+from Undefined.onebot.file_store import (
+ FILE_ROUTE_NAME,
+ FileAuthorizationError,
+ OneBotFileStore,
+)
+
+
+class FileAccessLogger(AccessLogger):
+ """保留普通访问日志格式,专用文件路由只记录不带查询串的路径。"""
+
+ def log(
+ self, request: web.BaseRequest, response: web.StreamResponse, time: float
+ ) -> None:
+ if request.path.startswith("/api/v1/onebot/files/") or "token" in request.query:
+ self.logger.info(
+ "%s %s status=%d elapsed=%.3fs",
+ request.method,
+ request.path,
+ response.status,
+ time,
+ )
+ else:
+ super().log(request, response, time)
+
+
+def is_file_request(request: web.Request) -> bool:
+ return request.match_info.route.name == FILE_ROUTE_NAME and request.method in {
+ "GET",
+ "HEAD",
+ }
+
+
+async def download(
+ request: web.Request, store: OneBotFileStore | None
+) -> web.StreamResponse:
+ if store is None:
+ return web.Response(status=404)
+ try:
+ async with store.acquire(
+ request.match_info["file_id"], request.query.get("token", "")
+ ) as entry:
+ response = web.FileResponse(
+ entry.path,
+ headers={
+ "Content-Type": entry.content_type,
+ "Content-Disposition": f"attachment; filename*=UTF-8''{quote(entry.name, safe='')}",
+ "Cache-Control": "private, no-store",
+ "Referrer-Policy": "no-referrer",
+ "X-Content-Type-Options": "nosniff",
+ },
+ )
+ # FileResponse 在 prepare 中发送文件,不能在仅构造响应后释放读者租约。
+ await response.prepare(request)
+ await response.write_eof()
+ return response
+ except FileAuthorizationError as exc:
+ return web.Response(status=exc.status)
diff --git a/src/Undefined/attachments/render.py b/src/Undefined/attachments/render.py
index 15a820ed..102f7809 100644
--- a/src/Undefined/attachments/render.py
+++ b/src/Undefined/attachments/render.py
@@ -301,6 +301,10 @@ async def dispatch_pending_file_sends(
continue
dispatched_count += 1
except Exception as exc:
+ if bool(getattr(exc, "file_transfer_error", False)):
+ # 携带失败前已成功派发数量,供调用方按部分成功处理,避免整批重发。
+ setattr(exc, "dispatched_file_count", dispatched_count)
+ raise
if bool(getattr(exc, "delivery_uncertain", False)):
logger.warning(
"[文件发送] 投递结果未确认,停止继续派发以避免重复发送 "
diff --git a/src/Undefined/bilibili/sender.py b/src/Undefined/bilibili/sender.py
index d6cef331..ea1ec134 100644
--- a/src/Undefined/bilibili/sender.py
+++ b/src/Undefined/bilibili/sender.py
@@ -398,6 +398,10 @@ async def send_bilibili_video(
except Exception as exc:
logger.exception("[Bilibili] 处理视频失败: %s", bvid)
+ if getattr(exc, "delivery_uncertain", False) or getattr(
+ exc, "file_transfer_error", False
+ ):
+ raise
try:
if video_info is None:
video_info = await get_video_info(bvid, cookie=cookie)
diff --git a/src/Undefined/config/config_class.py b/src/Undefined/config/config_class.py
index c6b1115a..47ffd6be 100644
--- a/src/Undefined/config/config_class.py
+++ b/src/Undefined/config/config_class.py
@@ -30,6 +30,7 @@
WeixinConfig,
)
from .toml_io import _load_env, load_toml_data
+from .onebot import FileSendMode
@dataclass
@@ -65,6 +66,8 @@ class Config:
nagaagent_mode_enabled: bool
onebot_ws_url: str
onebot_token: str
+ onebot_file_send_mode: FileSendMode
+ onebot_file_send_host: str
chat_model: ChatModelConfig
vision_model: VisionModelConfig
security_model_enabled: bool
diff --git a/src/Undefined/config/env_registry.py b/src/Undefined/config/env_registry.py
index e2f952d0..1b377598 100644
--- a/src/Undefined/config/env_registry.py
+++ b/src/Undefined/config/env_registry.py
@@ -128,6 +128,8 @@
("messages", "use_proxy"): "MESSAGES_USE_PROXY",
("naga", "use_proxy"): "NAGA_USE_PROXY",
("onebot", "token"): "ONEBOT_TOKEN",
+ ("onebot", "file_send_mode"): "ONEBOT_FILE_SEND_MODE",
+ ("onebot", "file_send_host"): "ONEBOT_FILE_SEND_HOST",
("onebot", "ws_url"): "ONEBOT_WS_URL",
("render", "browser_executable_path"): "RENDER_BROWSER_EXECUTABLE_PATH",
("render", "long_image_default_padding"): "RENDER_LONG_IMAGE_DEFAULT_PADDING",
diff --git a/src/Undefined/config/load_sections/core.py b/src/Undefined/config/load_sections/core.py
index 28a8f832..b87628c6 100644
--- a/src/Undefined/config/load_sections/core.py
+++ b/src/Undefined/config/load_sections/core.py
@@ -16,6 +16,8 @@
_get_value,
)
+from ..onebot import parse_file_send_host, parse_file_send_mode
+
logger = logging.getLogger(__name__)
@@ -180,4 +182,10 @@ def load_core(
"nagaagent_mode_enabled": nagaagent_mode_enabled,
"onebot_ws_url": onebot_ws_url,
"onebot_token": onebot_token,
+ "onebot_file_send_mode": parse_file_send_mode(
+ _get_value(data, ("onebot", "file_send_mode"), "ONEBOT_FILE_SEND_MODE")
+ ),
+ "onebot_file_send_host": parse_file_send_host(
+ _get_value(data, ("onebot", "file_send_host"), "ONEBOT_FILE_SEND_HOST")
+ ),
}
diff --git a/src/Undefined/config/onebot.py b/src/Undefined/config/onebot.py
new file mode 100644
index 00000000..63c3b377
--- /dev/null
+++ b/src/Undefined/config/onebot.py
@@ -0,0 +1,63 @@
+"""OneBot 本地文件传输配置与单次投递快照。"""
+
+from dataclasses import dataclass
+from ipaddress import ip_address
+import re
+from typing import Any, Literal, cast
+
+FileSendMode = Literal["local", "url", "stream"]
+DEFAULT_FILE_SEND_MODE: FileSendMode = "local"
+DEFAULT_FILE_SEND_HOST = "127.0.0.1"
+
+
+def parse_file_send_mode(value: Any) -> FileSendMode:
+ mode = (
+ str(value if value is not None else "").strip().lower()
+ or DEFAULT_FILE_SEND_MODE
+ )
+ if mode not in {"local", "url", "stream"}:
+ raise ValueError("onebot.file_send_mode 必须为 local、url 或 stream")
+ return cast(FileSendMode, mode)
+
+
+def parse_file_send_host(value: Any) -> str:
+ host = str(value or "").strip() or DEFAULT_FILE_SEND_HOST
+ if any(char in host for char in "/\\?#@%") or any(char.isspace() for char in host):
+ raise ValueError(
+ "onebot.file_send_host 必须为不带协议、端口、路径或作用域标识的主机地址"
+ )
+ if host.startswith("[") and host.endswith("]") and ":" in host:
+ host = host[1:-1]
+ try:
+ return str(ip_address(host))
+ except ValueError:
+ pass
+ try:
+ ascii_host = host.rstrip(".").encode("idna").decode("ascii")
+ except UnicodeError:
+ ascii_host = ""
+ if (
+ not ascii_host
+ or len(ascii_host) > 253
+ or any(
+ not re.fullmatch(r"[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?", label)
+ for label in ascii_host.split(".")
+ )
+ ):
+ raise ValueError(
+ "onebot.file_send_host 必须为 IPv4、IPv6 或域名,不包含协议、端口或路径"
+ )
+ return ascii_host
+
+
+@dataclass(frozen=True)
+class FileSendSettings:
+ mode: FileSendMode = DEFAULT_FILE_SEND_MODE
+ host: str = DEFAULT_FILE_SEND_HOST
+
+ @classmethod
+ def from_config(cls, config: Any) -> "FileSendSettings":
+ return cls(
+ parse_file_send_mode(getattr(config, "onebot_file_send_mode", None)),
+ parse_file_send_host(getattr(config, "onebot_file_send_host", None)),
+ )
diff --git a/src/Undefined/main.py b/src/Undefined/main.py
index f4e4c354..5d7e37c6 100644
--- a/src/Undefined/main.py
+++ b/src/Undefined/main.py
@@ -183,7 +183,11 @@ async def main() -> None:
_reranker: Any = None
try:
init_start = time.perf_counter()
- onebot = OneBotClient(config.onebot_ws_url, config.onebot_token)
+ onebot = OneBotClient(
+ config.onebot_ws_url,
+ config.onebot_token,
+ config_getter=lambda: get_config(strict=False),
+ )
memory_storage = MemoryStorage(max_memories=100)
end_summary_storage = EndSummaryStorage()
ai = AIClient(
diff --git a/src/Undefined/onebot/client.py b/src/Undefined/onebot/client.py
index 0120d12c..52993435 100644
--- a/src/Undefined/onebot/client.py
+++ b/src/Undefined/onebot/client.py
@@ -1,6 +1,7 @@
"""OneBot v11 WebSocket 客户端实现。"""
import asyncio
+from copy import deepcopy
import hashlib
import json
import logging
@@ -11,6 +12,12 @@
from websockets.asyncio.client import ClientConnection
from Undefined.context import RequestContext
+from Undefined.onebot.file_errors import FileTransferError, OneBotAPIError
+from Undefined.onebot.file_transport import OneBotFileTransport
+from Undefined.onebot.file_store import DELIVERY_TIMEOUT
+from Undefined.onebot.file_references import local_file_path
+from Undefined.utils import io
+from Undefined.attachments.segments import display_name_from_source
from Undefined.utils.logging import log_debug_json, redact_string, sanitize_data
logger = logging.getLogger(__name__)
@@ -21,6 +28,8 @@
"send_private_msg",
"upload_group_file",
"upload_private_file",
+ "send_forward_msg",
+ "send_private_forward_msg",
}
)
_DELIVERY_TIMEOUT_MARKERS = ("timeout", "timed out", "超时")
@@ -100,7 +109,14 @@ def _mark_message_sent_this_turn() -> None:
class OneBotClient:
"""OneBot v11 WebSocket 客户端"""
- def __init__(self, ws_url: str, token: str = ""):
+ def __init__(
+ self,
+ ws_url: str,
+ token: str = "",
+ *,
+ config_getter: Callable[[], Any] | None = None,
+ file_transport: OneBotFileTransport | None = None,
+ ) -> None:
self.ws_url = ws_url
self.token = token
self.ws: ClientConnection | None = None
@@ -110,6 +126,11 @@ def __init__(self, ws_url: str, token: str = ""):
Callable[[dict[str, Any]], Coroutine[Any, Any, None]] | None
) = None
self._running = False
+ self.file_transport = (
+ file_transport
+ if file_transport is not None
+ else OneBotFileTransport(self._call_api_raw, config_getter=config_getter)
+ )
def set_message_handler(
self, handler: Callable[[dict[str, Any]], Coroutine[Any, Any, None]]
@@ -150,17 +171,21 @@ async def connect(self) -> None:
if self.token:
extra_headers["Authorization"] = f"Bearer {self.token}"
+ # websockets 的 DEBUG 会记录未脱敏的握手和原始帧;请求细节由本模块脱敏后记录。
+ wire_logger = logging.getLogger(f"{__name__}.wire")
+ wire_logger.setLevel(logging.INFO)
try:
self.ws = await websockets.connect(
url,
ping_interval=20,
- ping_timeout=480,
+ ping_timeout=DELIVERY_TIMEOUT,
max_size=100 * 1024 * 1024, # 100MB,支持大量历史消息
additional_headers=extra_headers if extra_headers else None,
+ logger=wire_logger,
)
logger.info("[bold green][WebSocket][/bold green] 连接成功")
except Exception as e:
- logger.error(f"[WebSocket] 连接失败: {e}")
+ logger.error("[WebSocket] 连接失败: %s", redact_string(str(e)))
raise
async def disconnect(self) -> None:
@@ -179,26 +204,81 @@ async def _call_api(
*,
suppress_error_retcodes: set[int] | None = None,
mark_sent: bool = True,
+ fallback: tuple[str, dict[str, Any]] | None = None,
) -> dict[str, Any]:
- """调用 OneBot API"""
- if not self.ws:
- raise RuntimeError("WebSocket 未连接")
-
- request_params = params or {}
- if action in _DELIVERY_ACTIONS and _was_delivery_uncertain(
- action, request_params
- ):
- logger.warning(
- "[投递防重] 同一请求内相同投递此前结果未确认,拒绝自动重试: action=%s",
- action,
+ original = deepcopy(params) if params is not None else {}
+ if fallback is not None:
+ fallback = (fallback[0], deepcopy(fallback[1]))
+ if action not in _DELIVERY_ACTIONS:
+ return await self._call_api_raw(
+ action, original, suppress_error_retcodes=suppress_error_retcodes
)
+ if _was_delivery_uncertain(action, original):
if mark_sent:
_mark_message_sent_this_turn()
raise OneBotDeliveryUncertainError(
- action,
- "同一请求内相同投递此前结果未确认,已阻止重复发送",
+ action, "同一请求内相同投递此前结果未确认,已阻止重复发送"
)
+ try:
+ async with self.file_transport.prepare(action, original) as files:
+ try:
+ result = await self._call_api_raw(
+ action,
+ files.apply(action, original),
+ suppress_error_retcodes=suppress_error_retcodes,
+ )
+ except OneBotAPIError:
+ if fallback is None:
+ raise
+ fallback_action, fallback_params = fallback
+ logger.warning("[文件上传] %s 明确失败,尝试文件消息段回退", action)
+ result = await self._call_api_raw(
+ fallback_action, files.apply(fallback_action, fallback_params)
+ )
+ if mark_sent:
+ _mark_message_sent_this_turn()
+ return result
+ except FileTransferError as exc:
+ if exc.stage != "send":
+ raise
+ # 总预算在发送阶段耗尽:请求可能已发出,按结果未确认处理。
+ _remember_uncertain_delivery(action, original)
+ if fallback is not None:
+ _remember_uncertain_delivery(*fallback)
+ if mark_sent:
+ _mark_message_sent_this_turn()
+ raise OneBotDeliveryUncertainError(
+ action, "文件传输超过本次投递总时间预算,投递结果未确认"
+ ) from exc
+ except OneBotDeliveryUncertainError:
+ _remember_uncertain_delivery(action, original)
+ if fallback is not None:
+ _remember_uncertain_delivery(*fallback)
+ if mark_sent:
+ _mark_message_sent_this_turn()
+ raise
+ except asyncio.CancelledError as exc:
+ # 取消可能发生在请求发出之后:登记未确认投递,但不改写取消语义。
+ if getattr(exc, "onebot_delivery_uncertain", False):
+ _remember_uncertain_delivery(action, original)
+ if fallback is not None:
+ _remember_uncertain_delivery(*fallback)
+ if mark_sent:
+ _mark_message_sent_this_turn()
+ raise
+ async def _call_api_raw(
+ self,
+ action: str,
+ params: dict[str, Any] | None = None,
+ *,
+ suppress_error_retcodes: set[int] | None = None,
+ ) -> dict[str, Any]:
+ """调用 OneBot API"""
+ if not self.ws:
+ raise RuntimeError("WebSocket 未连接")
+
+ request_params = params or {}
self._message_id += 1
echo = str(self._message_id) # 使用字符串类型
@@ -223,13 +303,13 @@ async def _call_api(
try:
await self.ws.send(json.dumps(request))
# 等待响应,超时 8 分钟
- response = await asyncio.wait_for(future, timeout=480.0)
+ response = await asyncio.wait_for(future, timeout=DELIVERY_TIMEOUT)
duration = time.perf_counter() - start_time
status = response.get("status")
if status == "failed":
retcode = response.get("retcode", -1)
- msg = response.get("message", "未知错误")
+ msg = redact_string(str(response.get("message", "未知错误")))
if suppress_error_retcodes and retcode in suppress_error_retcodes:
logger.warning(
f"[bold yellow][API预期失败][/bold yellow] [green]{action}[/green] (ID=[magenta]{echo}[/magenta]) | 耗时=[magenta]{duration:.2f}s[/magenta] | retcode=[yellow]{retcode}[/yellow] | message={msg}"
@@ -239,15 +319,12 @@ async def _call_api(
f"[bold red][API失败][/bold red] [green]{action}[/green] (ID=[magenta]{echo}[/magenta]) | 耗时=[magenta]{duration:.2f}s[/magenta] | retcode=[red]{retcode}[/red] | message={msg}"
)
if _is_delivery_timeout(action, msg):
- _remember_uncertain_delivery(action, request_params)
- if mark_sent:
- _mark_message_sent_this_turn()
raise OneBotDeliveryUncertainError(
action,
str(msg),
retcode=retcode,
)
- raise RuntimeError(f"API 调用失败: {msg} (retcode={retcode})")
+ raise OneBotAPIError(msg, retcode)
logger.info(
f"[bold green][API成功][/bold green] [green]{action}[/green] (ID=[magenta]{echo}[/magenta]) | 耗时=[magenta]{duration:.2f}s[/magenta]"
@@ -255,17 +332,28 @@ async def _call_api(
if logger.isEnabledFor(logging.DEBUG):
log_debug_json(logger, "[OneBot响应体]", response)
return response
- except asyncio.TimeoutError as exc:
+ except asyncio.CancelledError as exc:
+ duration = time.perf_counter() - start_time
+ logger.error(f"[API超时] {action} (ID={echo}) | 耗时={duration:.2f}s")
+ if action in _DELIVERY_ACTIONS:
+ # 请求可能已发出:标记后由 _call_api 登记未确认投递,取消原样传播。
+ setattr(exc, "onebot_delivery_uncertain", True)
+ raise
+ except (
+ TimeoutError,
+ websockets.exceptions.ConnectionClosed,
+ ConnectionError,
+ OSError,
+ ) as exc:
duration = time.perf_counter() - start_time
logger.error(f"[API超时] {action} (ID={echo}) | 耗时={duration:.2f}s")
if action in _DELIVERY_ACTIONS:
- _remember_uncertain_delivery(action, request_params)
- if mark_sent:
- _mark_message_sent_this_turn()
raise OneBotDeliveryUncertainError(
action,
- "等待 OneBot 投递响应超时",
+ "投递请求发出后等待响应超时或连接中断",
) from exc
+ if isinstance(exc, websockets.exceptions.ConnectionClosed):
+ raise ConnectionError("OneBot WebSocket 连接中断") from exc
raise
finally:
self._pending_responses.pop(echo, None)
@@ -286,8 +374,6 @@ async def send_group_message(
},
mark_sent=mark_sent,
)
- if mark_sent:
- _mark_message_sent_this_turn()
return result
async def send_private_message(
@@ -318,8 +404,6 @@ async def send_private_message(
params,
mark_sent=mark_sent,
)
- if mark_sent:
- _mark_message_sent_this_turn()
return result
async def get_group_msg_history(
@@ -730,41 +814,20 @@ async def upload_group_file(
file_path: 本地文件绝对路径
name: 文件名(可选,默认使用原文件名)
"""
- from pathlib import Path as _Path
-
- file_name = name or _Path(file_path).name
- file_uri = _Path(file_path).resolve().as_uri()
- try:
- return await self._call_api(
- "upload_group_file",
+ file_uri, file_name = await self._file_upload_source(file_path, name)
+ return await self._call_api(
+ "upload_group_file",
+ {"group_id": group_id, "file": file_uri, "name": file_name},
+ fallback=(
+ "send_group_msg",
{
"group_id": group_id,
- "file": file_uri,
- "name": file_name,
+ "message": [
+ {"type": "file", "data": {"file": file_uri, "name": file_name}}
+ ],
},
- )
- except OneBotDeliveryUncertainError:
- logger.warning(
- "[文件上传] upload_group_file 投递结果未确认,"
- "不执行文件消息段回退: group=%s",
- group_id,
- )
- raise
- except RuntimeError:
- # 回退:尝试用文件消息段发送
- logger.warning(
- "[文件上传] upload_group_file 失败,尝试文件消息段回退: group=%s",
- group_id,
- )
- return await self.send_group_message(
- group_id,
- [
- {
- "type": "file",
- "data": {"file": file_uri, "name": file_name},
- }
- ],
- )
+ ),
+ )
async def upload_private_file(
self,
@@ -779,40 +842,20 @@ async def upload_private_file(
file_path: 本地文件绝对路径
name: 文件名(可选,默认使用原文件名)
"""
- from pathlib import Path as _Path
-
- file_name = name or _Path(file_path).name
- file_uri = _Path(file_path).resolve().as_uri()
- try:
- return await self._call_api(
- "upload_private_file",
+ file_uri, file_name = await self._file_upload_source(file_path, name)
+ return await self._call_api(
+ "upload_private_file",
+ {"user_id": user_id, "file": file_uri, "name": file_name},
+ fallback=(
+ "send_private_msg",
{
"user_id": user_id,
- "file": file_uri,
- "name": file_name,
+ "message": [
+ {"type": "file", "data": {"file": file_uri, "name": file_name}}
+ ],
},
- )
- except OneBotDeliveryUncertainError:
- logger.warning(
- "[文件上传] upload_private_file 投递结果未确认,"
- "不执行文件消息段回退: user=%s",
- user_id,
- )
- raise
- except RuntimeError:
- logger.warning(
- "[文件上传] upload_private_file 失败,尝试文件消息段回退: user=%s",
- user_id,
- )
- return await self.send_private_message(
- user_id,
- [
- {
- "type": "file",
- "data": {"file": file_uri, "name": file_name},
- }
- ],
- )
+ ),
+ )
async def send_group_sign(self, group_id: int) -> dict[str, Any]:
"""执行群打卡
@@ -825,6 +868,20 @@ async def send_group_sign(self, group_id: int) -> dict[str, Any]:
"""
return await self._call_api("send_group_sign", {"group_id": group_id})
+ @staticmethod
+ async def _file_upload_source(source: str, name: str | None) -> tuple[str, str]:
+ from pathlib import Path
+
+ path = local_file_path(source)
+ if path is None and "://" not in source and await io.is_file(Path(source)):
+ path = Path(source)
+ filename = name or display_name_from_source(source, "file")
+ if path is not None:
+ filename = name or path.name
+ if not source.startswith("file://"):
+ source = (await io.resolve_path(path)).as_uri()
+ return source, filename
+
async def _get_group_notices(self, group_id: int) -> list[dict[str, Any]]:
"""获取群公告列表(非标准 API,依赖具体实现)
@@ -875,7 +932,9 @@ async def run(self) -> None:
await self._dispatch_message(data)
except json.JSONDecodeError as e:
logger.error(
- f"[WebSocket] 无法解析 JSON 消息: {raw_message!r}, 错误: {e}"
+ "[WebSocket] 无法解析 JSON 消息: size=%d, 错误: %s",
+ len(raw_message),
+ e,
)
except websockets.ConnectionClosed:
logger.warning("[WebSocket] 连接已关闭,接收循环结束")
@@ -884,6 +943,11 @@ async def run(self) -> None:
logger.exception(f"[WebSocket] 接收消息时发生异常: {e}")
finally:
self._running = False
+ for future in self._pending_responses.values():
+ if not future.done():
+ future.set_exception(
+ ConnectionError("OneBot WebSocket 接收循环已停止")
+ )
# 等待所有后台任务完成
if self._tasks:
logger.debug(
@@ -902,7 +966,9 @@ async def _dispatch_message(self, data: dict[str, Any]) -> None:
echo_str = str(echo)
if echo_str in self._pending_responses:
logger.debug(f"收到 API 响应: echo={echo_str}")
- self._pending_responses[echo_str].set_result(data)
+ future = self._pending_responses[echo_str]
+ if not future.done():
+ future.set_result(data)
return
else:
logger.debug(
diff --git a/src/Undefined/onebot/file_errors.py b/src/Undefined/onebot/file_errors.py
new file mode 100644
index 00000000..c698888a
--- /dev/null
+++ b/src/Undefined/onebot/file_errors.py
@@ -0,0 +1,22 @@
+"""文件准备错误与明确的 OneBot API 拒绝。"""
+
+from typing import Any
+
+
+class FileTransferError(RuntimeError):
+ file_transfer_error = True
+
+ def __init__(self, mode: str, message: str, *, stage: str = "prepare") -> None:
+ self.mode = mode
+ self.stage = stage
+ self.user_message = f"本地文件传输失败({mode},{stage}):{message}"
+ super().__init__(self.user_message)
+
+
+class OneBotAPIError(RuntimeError):
+ """协议端明确返回失败;区别于断连、超时及文件准备失败。"""
+
+ def __init__(self, message: str, retcode: Any) -> None:
+ self.message = message
+ self.retcode = retcode
+ super().__init__(f"API 调用失败: {message} (retcode={retcode})")
diff --git a/src/Undefined/onebot/file_references.py b/src/Undefined/onebot/file_references.py
new file mode 100644
index 00000000..fc2cc729
--- /dev/null
+++ b/src/Undefined/onebot/file_references.py
@@ -0,0 +1,107 @@
+"""只转换发送副本中的本地媒体字段,保留原始 CQ 文本和未知消息段。"""
+
+from copy import deepcopy
+from pathlib import Path
+from typing import Any, Callable
+from urllib.parse import urlsplit
+from urllib.request import url2pathname
+
+from Undefined.attachments.segments import is_localish_path
+from Undefined.attachments.render import _escape_cq_component
+from Undefined.utils.common import CQ_PATTERN
+
+_MEDIA_FIELDS = {
+ "image": ("file",),
+ "record": ("file",),
+ "video": ("file", "thumb"),
+ "file": ("file",),
+}
+
+
+def local_file_path(value: str) -> Path | None:
+ if not is_localish_path(value):
+ return None
+ if value.startswith("file://"):
+ uri = urlsplit(value)
+ path = url2pathname(uri.path)
+ if uri.netloc and uri.netloc != "localhost":
+ path = f"//{uri.netloc}{path}"
+ return Path(path)
+ return Path(value)
+
+
+def _unescape_cq(value: str) -> str:
+ return (
+ value.replace("[", "[")
+ .replace("]", "]")
+ .replace(",", ",")
+ .replace("&", "&")
+ )
+
+
+def map_file_references(
+ action: str, params: dict[str, Any], replace: Callable[[str], str]
+) -> dict[str, Any]:
+ """转换已知媒体字段,replace 可仅收集来源;永不修改调用者输入。"""
+
+ def message(value: Any) -> Any:
+ if isinstance(value, str):
+
+ def cq(match: Any) -> str:
+ kind, args = match.group(1), match.group(2)
+ fields = _MEDIA_FIELDS.get(kind)
+ if fields is None:
+ return str(match.group(0))
+ parts = args.split(",")
+ changed = False
+ filename: str | None = None
+ for index, part in enumerate(parts):
+ key, sep, raw = part.partition("=")
+ if sep and key in fields:
+ original = _unescape_cq(raw)
+ updated = replace(original)
+ if updated != original:
+ changed = True
+ parts[index] = f"{key}={_escape_cq_component(updated)}"
+ path = local_file_path(original)
+ if kind == "file" and key == "file" and path is not None:
+ filename = path.name
+ if not changed:
+ return str(match.group(0))
+ if filename and not any(part.startswith("name=") for part in parts):
+ parts.append(f"name={_escape_cq_component(filename)}")
+ return f"[CQ:{kind},{','.join(parts)}]"
+
+ return CQ_PATTERN.sub(cq, value)
+ if isinstance(value, list):
+ return [message(item) for item in value]
+ if isinstance(value, dict):
+ result = deepcopy(value)
+ kind = result.get("type")
+ data = result.get("data")
+ if isinstance(data, dict):
+ for field in _MEDIA_FIELDS.get(str(kind), ()):
+ if isinstance(data.get(field), str):
+ original = data[field]
+ data[field] = replace(original)
+ if (
+ kind == "file"
+ and data[field] != original
+ and "name" not in data
+ ):
+ path = local_file_path(original)
+ if path is not None:
+ data["name"] = path.name
+ if kind == "node" and "content" in data:
+ data["content"] = message(data["content"])
+ return result
+ return value
+
+ result = deepcopy(params)
+ if action in {"upload_group_file", "upload_private_file"}:
+ if isinstance(result.get("file"), str):
+ result["file"] = replace(result["file"])
+ for field in ("message", "messages"):
+ if field in result:
+ result[field] = message(result[field])
+ return result
diff --git a/src/Undefined/onebot/file_store.py b/src/Undefined/onebot/file_store.py
new file mode 100644
index 00000000..cefb9daa
--- /dev/null
+++ b/src/Undefined/onebot/file_store.py
@@ -0,0 +1,154 @@
+"""Runtime 专用的单文件临时授权及副本生命周期。"""
+
+import asyncio
+from contextlib import asynccontextmanager
+from dataclasses import dataclass
+import logging
+import mimetypes
+from pathlib import Path
+import re
+import secrets
+import time
+from collections.abc import AsyncIterator, Callable
+from uuid import uuid4
+
+from Undefined.config.models import format_netloc
+from Undefined.onebot.file_errors import FileTransferError
+from Undefined.utils import io
+from Undefined.utils.paths import ONEBOT_FILE_CACHE_DIR
+
+logger = logging.getLogger(__name__)
+DELIVERY_TIMEOUT = 480.0
+FILE_RETENTION = DELIVERY_TIMEOUT * 2
+FILE_CHUNK_SIZE = 64 * 1024
+FILE_ROUTE = "/api/v1/onebot/files/{file_id}"
+FILE_ROUTE_NAME = "onebot-file-download"
+_CACHE_NAME = re.compile(r"^onebot-(\d+)-[0-9a-f]{32}$")
+
+
+@dataclass
+class PublishedFile:
+ path: Path
+ token: str
+ name: str
+ content_type: str
+ expires_at: float
+ readers: int = 0
+
+
+class FileAuthorizationError(Exception):
+ def __init__(self, status: int) -> None:
+ self.status = status
+
+
+class OneBotFileStore:
+ def __init__(
+ self,
+ cache_dir: Path = ONEBOT_FILE_CACHE_DIR,
+ *,
+ retention: float = FILE_RETENTION,
+ chunk_size: int = FILE_CHUNK_SIZE,
+ clock: Callable[[], float] = time.time,
+ ) -> None:
+ if retention <= 0 or chunk_size <= 0:
+ raise ValueError("URL 文件保留期和复制分块大小必须大于零")
+ self.cache_dir = cache_dir
+ self.retention = retention
+ self.chunk_size = chunk_size
+ self.clock = clock
+ self.port: int | None = None
+ self._files: dict[str, PublishedFile] = {}
+ self._cleanup_task: asyncio.Task[None] | None = None
+
+ async def start(self, port: int) -> None:
+ await io.ensure_dir(self.cache_dir)
+ # 只回收本模块命名且已过期的遗留副本,不能清空整个缓存根目录。
+ for path, is_dir in await io.list_directory_entries(self.cache_dir):
+ match = _CACHE_NAME.fullmatch(path.name)
+ if is_dir and match and int(match[1]) <= self.clock() * 1000:
+ await io.delete_tree(path)
+ self.port = port
+ self._cleanup_task = asyncio.create_task(self._cleanup_loop())
+
+ def unavailable(self) -> None:
+ self.port = None
+
+ async def stop(self) -> None:
+ self.unavailable()
+ if self._cleanup_task is not None:
+ self._cleanup_task.cancel()
+ await asyncio.gather(self._cleanup_task, return_exceptions=True)
+ self._cleanup_task = None
+ await self.cleanup(all_files=True)
+
+ async def publish(self, source: Path, host: str) -> str:
+ port = self.port
+ if port is None:
+ raise FileTransferError(
+ "url", "Runtime 文件服务未就绪,请启动 Runtime 并确认监听设置"
+ )
+ file_id = uuid4().hex
+ pending_expiry = self.clock() + self.retention + DELIVERY_TIMEOUT
+ directory = self.cache_dir / f"onebot-{int(pending_expiry * 1000)}-{file_id}"
+ try:
+ before = await io.file_fingerprint(source)
+ await io.copy_file_atomic(source, directory / "content", self.chunk_size)
+ if await io.file_fingerprint(source) != before:
+ raise FileTransferError(
+ "url", "制作下载副本期间源文件发生变化,请重新生成后发送"
+ )
+ if self.port is None:
+ raise FileTransferError("url", "Runtime 文件服务已经停止")
+ expiry = self.clock() + self.retention
+ final = self.cache_dir / f"onebot-{int(expiry * 1000)}-{file_id}"
+ await io.move_path(directory, final)
+ directory = final
+ if self.port != port:
+ raise FileTransferError("url", "Runtime 文件服务已经停止或重新绑定")
+ token = secrets.token_urlsafe(32)
+ self._files[file_id] = PublishedFile(
+ path=final / "content",
+ token=token,
+ name=source.name,
+ content_type=mimetypes.guess_type(source.name)[0]
+ or "application/octet-stream",
+ expires_at=expiry,
+ )
+ return f"http://{format_netloc(host, port)}{FILE_ROUTE.format(file_id=file_id)}?token={token}"
+ except BaseException:
+ await io.delete_tree(directory)
+ raise
+
+ @asynccontextmanager
+ async def acquire(self, file_id: str, token: str) -> AsyncIterator[PublishedFile]:
+ if not token:
+ raise FileAuthorizationError(401)
+ entry = self._files.get(file_id)
+ if entry is None or entry.expires_at <= self.clock():
+ raise FileAuthorizationError(404)
+ if not token.isascii() or not secrets.compare_digest(entry.token, token):
+ raise FileAuthorizationError(401)
+ entry.readers += 1
+ try:
+ if not await io.is_file(entry.path):
+ raise FileAuthorizationError(404)
+ yield entry
+ finally:
+ entry.readers -= 1
+ if entry.expires_at <= self.clock() or self.port is None:
+ await self.cleanup(all_files=self.port is None)
+
+ async def cleanup(self, *, all_files: bool = False) -> None:
+ for file_id, entry in list(self._files.items()):
+ if not entry.readers and (all_files or entry.expires_at <= self.clock()):
+ # 删除前撤销登记,确保新请求不能在删除期间取得租约。
+ self._files.pop(file_id, None)
+ await io.delete_tree(entry.path.parent)
+
+ async def _cleanup_loop(self) -> None:
+ while True:
+ await asyncio.sleep(min(60.0, self.retention))
+ try:
+ await self.cleanup()
+ except OSError:
+ logger.warning("[OneBot文件] 清理过期 URL 副本失败", exc_info=True)
diff --git a/src/Undefined/onebot/file_transport.py b/src/Undefined/onebot/file_transport.py
new file mode 100644
index 00000000..1c3e70b3
--- /dev/null
+++ b/src/Undefined/onebot/file_transport.py
@@ -0,0 +1,303 @@
+"""OneBot 本地文件的单次准备、传输预算和引用替换。"""
+
+import asyncio
+import base64
+from collections.abc import AsyncIterator, Awaitable, Callable
+from contextlib import AsyncExitStack, asynccontextmanager
+from dataclasses import dataclass
+import hashlib
+import logging
+from pathlib import Path, PurePosixPath, PureWindowsPath
+import time
+from typing import Any
+from uuid import uuid4
+
+from Undefined.config.onebot import FileSendSettings
+from Undefined.onebot.file_errors import FileTransferError, OneBotAPIError
+from Undefined.onebot.file_references import local_file_path, map_file_references
+from Undefined.onebot.file_store import (
+ DELIVERY_TIMEOUT,
+ FILE_CHUNK_SIZE,
+ OneBotFileStore,
+)
+from Undefined.utils import io
+
+logger = logging.getLogger(__name__)
+APICall = Callable[[str, dict[str, Any]], Awaitable[dict[str, Any]]]
+
+
+@dataclass(frozen=True)
+class PreparedFiles:
+ replacements: dict[str, str]
+
+ def apply(self, action: str, params: dict[str, Any]) -> dict[str, Any]:
+ return map_file_references(
+ action, params, lambda source: self.replacements.get(source, source)
+ )
+
+
+class OneBotFileTransport:
+ def __init__(
+ self,
+ call_api: APICall,
+ *,
+ config_getter: Callable[[], Any] | None = None,
+ store: OneBotFileStore | None = None,
+ chunk_size: int = FILE_CHUNK_SIZE,
+ timeout: float = DELIVERY_TIMEOUT,
+ ) -> None:
+ if chunk_size <= 0 or timeout <= 0:
+ raise ValueError("文件传输分块大小和超时必须大于零")
+ self.call_api = call_api
+ self.config_getter = config_getter
+ self.store = store if store is not None else OneBotFileStore()
+ self.chunk_size = chunk_size
+ self.timeout = timeout
+ self._stream_lock = asyncio.Lock()
+
+ @asynccontextmanager
+ async def prepare(
+ self, action: str, params: dict[str, Any]
+ ) -> AsyncIterator[PreparedFiles]:
+ sources: dict[str, Path] = {}
+
+ def collect(source: str) -> str:
+ path = local_file_path(source)
+ if path is not None:
+ sources[source] = path
+ return source
+
+ map_file_references(action, params, collect)
+ if not sources:
+ yield PreparedFiles({})
+ return
+ try:
+ config = self.config_getter() if self.config_getter else None
+ settings = (
+ config
+ if isinstance(config, FileSendSettings)
+ else FileSendSettings.from_config(config)
+ )
+ except ValueError as exc:
+ raise FileTransferError("config", str(exc), stage="config") from exc
+ if settings.mode == "local":
+ yield PreparedFiles({})
+ return
+
+ # 快照在排队前取得;排队不占传输预算,文本发送也不会取得此锁。
+ async with AsyncExitStack() as stack:
+ if settings.mode == "stream":
+ await stack.enter_async_context(self._stream_lock)
+ started = time.monotonic()
+ stage = "prepare"
+ outcome = "failed"
+ try:
+ async with asyncio.timeout(self.timeout):
+ prepared: dict[Path, str] = {}
+ replacements: dict[str, str] = {}
+ for source, path in sources.items():
+ resolved = await io.resolve_path(path)
+ if resolved not in prepared:
+ size = (await io.file_fingerprint(resolved))[2]
+ logger.info(
+ "[OneBot文件] mode=%s stage=prepare size=%d",
+ settings.mode,
+ size,
+ )
+ if settings.mode == "stream":
+ prepared[resolved] = await self._upload(resolved)
+ else:
+ prepared[resolved] = await self.store.publish(
+ resolved, settings.host
+ )
+ replacements[source] = prepared[resolved]
+ logger.info(
+ "[OneBot文件] mode=%s stage=prepared elapsed=%.3fs",
+ settings.mode,
+ time.monotonic() - started,
+ )
+ stage = "send"
+ yield PreparedFiles(replacements)
+ outcome = "success"
+ except TimeoutError as exc:
+ raise FileTransferError(
+ settings.mode, "文件准备或发送超过本次投递总时间预算", stage=stage
+ ) from exc
+ except OSError as exc:
+ if stage != "prepare":
+ raise
+ raise FileTransferError(
+ settings.mode, "无法读取或制作本地文件,请确认文件存在且可读"
+ ) from exc
+ except asyncio.CancelledError:
+ outcome = "cancelled"
+ raise
+ finally:
+ logger.info(
+ "[OneBot文件] mode=%s stage=%s elapsed=%.3fs status=%s",
+ settings.mode,
+ stage,
+ time.monotonic() - started,
+ outcome,
+ )
+
+ async def _upload(self, path: Path) -> str:
+ before = await io.file_fingerprint(path)
+ size = before[2]
+ if not size:
+ raise FileTransferError(
+ "stream", "NapCat Stream API 不支持零字节文件,请使用 local 或 url 模式"
+ )
+ checksum = hashlib.sha256()
+ async for chunk in io.iter_file_chunks(path, self.chunk_size):
+ checksum.update(chunk)
+ if await io.file_fingerprint(path) != before:
+ raise FileTransferError("stream", "计算校验值期间源文件发生变化")
+ expected_hash = checksum.hexdigest()
+ total = (size + self.chunk_size - 1) // self.chunk_size
+ stream_id = uuid4().hex
+ common: dict[str, Any] = {
+ "stream_id": stream_id,
+ "total_chunks": total,
+ "file_size": size,
+ "expected_sha256": expected_hash,
+ "filename": f"{uuid4().hex}{path.suffix}",
+ "file_retention": int(self.timeout * 2 * 1000),
+ }
+ attempted = False
+ complete = False
+ unsupported = False
+ try:
+ sent_hash = hashlib.sha256()
+ sent_size = 0
+ index = 0
+ async for chunk in io.iter_file_chunks(path, self.chunk_size):
+ if index >= total:
+ raise FileTransferError("stream", "上传期间源文件发生变化")
+ attempted = True
+ result = await self.call_api(
+ "upload_file_stream",
+ {
+ **common,
+ "chunk_index": index,
+ "chunk_data": base64.b64encode(chunk).decode("ascii"),
+ },
+ )
+ self._ack(result, stream_id, "chunk_received", index + 1, total)
+ sent_hash.update(chunk)
+ sent_size += len(chunk)
+ index += 1
+ logger.debug(
+ "[OneBot文件] mode=stream stage=chunk received=%d total=%d",
+ index,
+ total,
+ )
+ if (
+ sent_size != size
+ or sent_hash.hexdigest() != expected_hash
+ or await io.file_fingerprint(path) != before
+ ):
+ raise FileTransferError("stream", "上传期间源文件发生变化,已停止发送")
+ result = await self.call_api(
+ "upload_file_stream", {**common, "is_complete": True}
+ )
+ raw_data = result.get("data")
+ complete = (
+ isinstance(raw_data, dict)
+ and raw_data.get("status") == "file_complete"
+ and raw_data.get("stream_id") == stream_id
+ )
+ data = self._ack(result, stream_id, "file_complete", total, total)
+ remote_path = data.get("file_path")
+ if (
+ not isinstance(remote_path, str)
+ or not remote_path.strip()
+ or "\x00" in remote_path
+ or not (
+ PurePosixPath(remote_path).is_absolute()
+ or PureWindowsPath(remote_path).is_absolute()
+ )
+ or type(data.get("file_size")) is not int
+ or data["file_size"] != size
+ or not isinstance(data.get("sha256"), str)
+ or data["sha256"].lower() != expected_hash
+ ):
+ raise FileTransferError(
+ "stream", "完成响应的文件路径、大小或 SHA-256 校验不符"
+ )
+ return remote_path
+ except OneBotAPIError as exc:
+ text = exc.message.casefold()
+ unsupported = exc.retcode == 1404 or (
+ "upload_file_stream" in text
+ and any(
+ marker in text
+ for marker in (
+ "不支持",
+ "unsupported",
+ "not supported",
+ "unknown action",
+ )
+ )
+ )
+ if unsupported:
+ raise FileTransferError(
+ "stream",
+ '协议端不支持 upload_file_stream;请升级支持该扩展的 NapCat,或将 onebot.file_send_mode 改为 "local" 或 "url"',
+ ) from exc
+ raise FileTransferError(
+ "stream", "协议端拒绝 Stream 上传,请检查 NapCat 日志及文件状态"
+ ) from exc
+ except (TimeoutError, ConnectionError, OSError) as exc:
+ raise FileTransferError(
+ "stream", "Stream 上传连接中断或等待确认超时"
+ ) from exc
+ except FileTransferError:
+ raise
+ except RuntimeError as exc:
+ raise FileTransferError(
+ "stream", "Stream 上传服务未连接或暂时不可用"
+ ) from exc
+ finally:
+ if attempted and not complete and not unsupported:
+ await self._reset(stream_id)
+
+ @staticmethod
+ def _ack(
+ result: dict[str, Any], stream_id: str, status: str, count: int, total: int
+ ) -> dict[str, Any]:
+ data = result.get("data")
+ expected_type = "response" if status == "file_complete" else "stream"
+ if (
+ result.get("status") != "ok"
+ or result.get("retcode", 0) != 0
+ or not isinstance(data, dict)
+ or data.get("type") != expected_type
+ or data.get("stream_id") != stream_id
+ or data.get("status") != status
+ or type(data.get("received_chunks")) is not int
+ or data["received_chunks"] != count
+ or type(data.get("total_chunks")) is not int
+ or data["total_chunks"] != total
+ ):
+ raise FileTransferError("stream", "Stream 确认响应畸形或分块数量不符")
+ return data
+
+ async def _reset(self, stream_id: str) -> None:
+ try:
+ await asyncio.wait_for(
+ self.call_api(
+ "upload_file_stream",
+ {
+ "stream_id": stream_id,
+ "reset": True,
+ "file_retention": int(self.timeout * 2 * 1000),
+ },
+ ),
+ timeout=5.0,
+ )
+ except OneBotAPIError as exc:
+ if "stream reset completed" not in exc.message.lower():
+ logger.warning("[OneBot文件] 重置未完成 Stream 失败")
+ except (Exception, asyncio.CancelledError):
+ logger.warning("[OneBot文件] 重置未完成 Stream 失败,保留原始错误")
diff --git a/src/Undefined/skills/toolsets/messages/README.md b/src/Undefined/skills/toolsets/messages/README.md
index fbf6f445..9f7af9a3 100644
--- a/src/Undefined/skills/toolsets/messages/README.md
+++ b/src/Undefined/skills/toolsets/messages/README.md
@@ -14,6 +14,9 @@
- 单文件、轻量交付优先使用 `messages.send_text_file`
- 需要把网络文件直接发到群/私聊时使用 `messages.send_url_file`
- 仅在用户明确要求语音消息时使用 `messages.send_voice`;普通 `` 保持文件语义
+- QQ 本地图片、语音、视频与文件统一由 OneBotClient 根据 `onebot.file_send_mode` 选择 `local`(默认,兼容旧部署)、`url` 或 `stream`。后两种模式需要显式启用。工具继续提供原始本地路径,保持展示名称、附件 UID 和历史来源不变;已有远程 URL、Base64 和协议端资源标识原样通过。
+- 文件准备失败不会计作已发送或触发文件消息段回退。协议端不支持 Stream 时返回包含 `onebot.file_send_mode` 切换方法的说明,工具不得自动换模式或重试。Stream 不支持空文件;URL 模式需要协议端访问已启动的 Runtime。
+- Stream/URL 的准备、实际发送和明确失败后的文件消息段回退共用 8 分钟,临时文件保留 16 分钟;URL 副本不会因工具删除源文件而失效。Stream 按块上传,但不保证上游合并及附件登记的整条链路固定内存占用。
- 消息只包含普通附件标签时会直接派发文件,不会先向 OneBot/微信发送空正文;独立文件发送负责写入历史
- OneBot 在文本、附件或语音发送阶段超时时,工具会把结果标记为“未确认但按已投递处理”,禁止 AI 自动重试;私聊不会再尝试群临时会话或其他共享群,只有用户后续明确要求重发时才再次发送。`mark_sent=false` 的后台播报仍保留防重记录,但不会计作本轮用户回复
- 多文件工程、需要执行命令验证或打包交付,优先使用 `code_delivery_agent`
diff --git a/src/Undefined/skills/toolsets/messages/context_utils.py b/src/Undefined/skills/toolsets/messages/context_utils.py
index 6158ed6e..70815e65 100644
--- a/src/Undefined/skills/toolsets/messages/context_utils.py
+++ b/src/Undefined/skills/toolsets/messages/context_utils.py
@@ -26,6 +26,19 @@ def is_delivery_uncertain_error(error: BaseException) -> bool:
return bool(getattr(error, "delivery_uncertain", False))
+def file_transfer_error_message(error: BaseException) -> str | None:
+ """通过公共错误属性读取可展示说明,技能无需导入运行时传输模块。"""
+ if not getattr(error, "file_transfer_error", False):
+ return None
+ return str(getattr(error, "user_message", str(error)))
+
+
+def file_transfer_error_dispatched_count(error: BaseException) -> int:
+ """读取传输错误携带的已成功派发附件数量,未携带时为 0。"""
+ count = getattr(error, "dispatched_file_count", 0)
+ return count if isinstance(count, int) and count > 0 else 0
+
+
def handle_delivery_uncertain(context: dict[str, Any]) -> str:
"""Mark an ambiguous attempt as sent and return non-retry tool feedback."""
diff --git a/src/Undefined/skills/toolsets/messages/send_message/handler.py b/src/Undefined/skills/toolsets/messages/send_message/handler.py
index cb2fb205..d49200a2 100644
--- a/src/Undefined/skills/toolsets/messages/send_message/handler.py
+++ b/src/Undefined/skills/toolsets/messages/send_message/handler.py
@@ -14,6 +14,8 @@
)
from Undefined.skills.toolsets.messages.context_utils import (
handle_delivery_uncertain,
+ file_transfer_error_dispatched_count,
+ file_transfer_error_message,
is_delivery_uncertain_error,
mark_message_sent,
normalize_sent_message_id,
@@ -154,6 +156,7 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str:
if history_attachments:
send_kwargs["attachments"] = history_attachments
sent_message_id: Any = None
+ body_sent = False
if has_delivery_message:
send_address_message = getattr(sender, "send_address_message", None)
if callable(send_address_message):
@@ -178,6 +181,7 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str:
)
else:
raise RuntimeError("当前 sender 不支持微信投递地址")
+ body_sent = True
mark_message_sent(context)
dispatched_file_count = await dispatch_pending_file_sends(
rendered,
@@ -206,6 +210,14 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str:
except ValueError as exc:
return f"发送失败:{exc}"
except Exception as e:
+ if transfer_message := file_transfer_error_message(e):
+ delivered = file_transfer_error_dispatched_count(e)
+ if delivered > 0:
+ mark_message_sent(context)
+ if not body_sent and delivered <= 0:
+ return transfer_message
+ prefix = "消息正文已发送,但仅成功发送 " if body_sent else "仅成功发送 "
+ return f"{prefix}{delivered}/{pending_file_count} 个附件:{transfer_message}"
if is_delivery_uncertain_error(e):
logger.warning(
"[发送消息] 投递结果未确认,阻止自动重试: "
diff --git a/src/Undefined/skills/toolsets/messages/send_private_message/handler.py b/src/Undefined/skills/toolsets/messages/send_private_message/handler.py
index 2d557866..d7d086e3 100644
--- a/src/Undefined/skills/toolsets/messages/send_private_message/handler.py
+++ b/src/Undefined/skills/toolsets/messages/send_private_message/handler.py
@@ -8,6 +8,8 @@
)
from Undefined.skills.toolsets.messages.context_utils import (
handle_delivery_uncertain,
+ file_transfer_error_dispatched_count,
+ file_transfer_error_message,
is_delivery_uncertain_error,
mark_message_sent,
normalize_sent_message_id,
@@ -103,6 +105,7 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str:
if history_attachments:
send_kwargs["attachments"] = history_attachments
sent_message_id: Any = None
+ body_sent = False
if has_delivery_message:
send_address_message = getattr(sender, "send_address_message", None)
if callable(send_address_message):
@@ -119,6 +122,7 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str:
)
else:
raise RuntimeError("当前 sender 不支持微信投递地址")
+ body_sent = True
mark_message_sent(context)
dispatched_file_count = await dispatch_pending_file_sends(
rendered,
@@ -147,6 +151,14 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str:
except ValueError as exc:
return f"发送失败:{exc}"
except Exception as e:
+ if transfer_message := file_transfer_error_message(e):
+ delivered = file_transfer_error_dispatched_count(e)
+ if delivered > 0:
+ mark_message_sent(context)
+ if not body_sent and delivered <= 0:
+ return transfer_message
+ prefix = "私聊正文已发送,但仅成功发送 " if body_sent else "仅成功发送 "
+ return f"{prefix}{delivered}/{pending_file_count} 个私聊附件:{transfer_message}"
if is_delivery_uncertain_error(e):
logger.warning(
"[私聊发送] 投递结果未确认,阻止自动重试: user=%s request_id=%s",
diff --git a/src/Undefined/skills/toolsets/messages/send_text_file/handler.py b/src/Undefined/skills/toolsets/messages/send_text_file/handler.py
index 9d1d344c..591b7ee8 100644
--- a/src/Undefined/skills/toolsets/messages/send_text_file/handler.py
+++ b/src/Undefined/skills/toolsets/messages/send_text_file/handler.py
@@ -11,6 +11,7 @@
from Undefined.skills.toolsets.messages.context_utils import (
handle_delivery_uncertain,
+ file_transfer_error_message,
is_delivery_uncertain_error,
)
from Undefined.utils.message_turn import mark_message_sent_this_turn
@@ -464,6 +465,8 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str:
except UnicodeEncodeError:
return f"编码 {encoding} 无法表示当前内容,请改用 utf-8"
except Exception as exc:
+ if transfer_message := file_transfer_error_message(exc):
+ return transfer_message
if is_delivery_uncertain_error(exc):
logger.warning(
"[发送文本文件] 投递结果未确认,阻止自动重试: "
diff --git a/src/Undefined/skills/toolsets/messages/send_url_file/handler.py b/src/Undefined/skills/toolsets/messages/send_url_file/handler.py
index 5b039005..158533f5 100644
--- a/src/Undefined/skills/toolsets/messages/send_url_file/handler.py
+++ b/src/Undefined/skills/toolsets/messages/send_url_file/handler.py
@@ -12,6 +12,7 @@
from Undefined.skills.http_config import get_request_timeout
from Undefined.skills.toolsets.messages.context_utils import (
handle_delivery_uncertain,
+ file_transfer_error_message,
is_delivery_uncertain_error,
)
from Undefined.utils.http_download import (
@@ -528,6 +529,8 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str:
)
return f"发送失败:{exc}"
except Exception as exc:
+ if transfer_message := file_transfer_error_message(exc):
+ return transfer_message
if is_delivery_uncertain_error(exc):
logger.warning(
"[URL文件发送] 投递结果未确认,阻止自动重试: "
diff --git a/src/Undefined/skills/toolsets/messages/send_voice/handler.py b/src/Undefined/skills/toolsets/messages/send_voice/handler.py
index b9b92e37..da005052 100644
--- a/src/Undefined/skills/toolsets/messages/send_voice/handler.py
+++ b/src/Undefined/skills/toolsets/messages/send_voice/handler.py
@@ -6,6 +6,7 @@
from Undefined.skills.toolsets.messages.context_utils import (
handle_delivery_uncertain,
+ file_transfer_error_message,
is_delivery_uncertain_error,
mark_message_sent,
)
@@ -85,6 +86,8 @@ async def execute(args: dict[str, Any], context: dict[str, Any]) -> str:
except ValueError as exc:
return f"发送失败:{exc}"
except Exception as exc:
+ if transfer_message := file_transfer_error_message(exc):
+ return transfer_message
if is_delivery_uncertain_error(exc):
logger.warning(
"[语音发送] 投递结果未确认,阻止自动重试: uid=%s address=%s",
diff --git a/src/Undefined/utils/io.py b/src/Undefined/utils/io.py
index 41fdf4f8..5082a39f 100644
--- a/src/Undefined/utils/io.py
+++ b/src/Undefined/utils/io.py
@@ -12,11 +12,53 @@
from pathlib import Path
from typing import Any, Optional
+import aiofiles
+
from Undefined.utils.file_lock import FileLock
logger = logging.getLogger(__name__)
+async def iter_file_chunks(path: Path, chunk_size: int) -> AsyncIterator[bytes]:
+ """逐块读取文件;磁盘操作在线程池中执行。"""
+ if chunk_size <= 0:
+ raise ValueError("chunk_size 必须大于零")
+ async with aiofiles.open(path, "rb") as handle:
+ while chunk := await handle.read(chunk_size):
+ yield chunk
+
+
+async def file_fingerprint(path: Path) -> tuple[int, int, int, int]:
+ """用于检测传输期间源文件替换、大小或修改时间变化。"""
+ info = await asyncio.to_thread(path.stat)
+ if not await is_file(path):
+ raise OSError("文件来源不是普通文件")
+ return info.st_dev, info.st_ino, info.st_size, info.st_mtime_ns
+
+
+async def move_path(source: Path, target: Path) -> None:
+ """在同一文件系统中原子移动文件或目录。"""
+ await asyncio.to_thread(os.replace, source, target)
+
+
+async def copy_file_atomic(source: Path, target: Path, chunk_size: int) -> None:
+ """分块复制并原子发布,不把整文件装入内存;失败时删除临时文件。"""
+ await ensure_dir(target.parent)
+ fd, name = await asyncio.to_thread(
+ tempfile.mkstemp, prefix=f".{target.name}.", suffix=".tmp", dir=target.parent
+ )
+ os.close(fd)
+ temporary = Path(name)
+ try:
+ async with aiofiles.open(temporary, "wb") as handle:
+ async for chunk in iter_file_chunks(source, chunk_size):
+ await handle.write(chunk)
+ await handle.flush()
+ await asyncio.to_thread(os.replace, temporary, target)
+ finally:
+ await delete_file(temporary)
+
+
def iter_text_lines(
file_path: Path | str,
*,
diff --git a/src/Undefined/utils/logging.py b/src/Undefined/utils/logging.py
index 50adfdf1..2454e7b7 100644
--- a/src/Undefined/utils/logging.py
+++ b/src/Undefined/utils/logging.py
@@ -22,13 +22,14 @@
"secret",
"password",
"onebot_token",
+ "chunk_data",
)
# 敏感信息正则表达式
_BEARER_RE = re.compile(r"(Bearer\s+)[A-Za-z0-9._~+/=-]+", re.IGNORECASE)
_KV_TOKEN_RE = re.compile(
- r"(?i)(api_key|apikey|access_token|refresh_token|id_token|token|secret|password)"
- r"(\s*[:=]\s*)(['\"]?)([^'\"\s]+)"
+ r"(?i)(api_key|apikey|access_token|refresh_token|id_token|token|secret|password|chunk_data)"
+ r"(['\"]?\s*[:=]\s*)(['\"]?)([^'\"\s]+)"
)
_SK_RE = re.compile(r"\bsk-[A-Za-z0-9]{8,}\b")
@@ -65,8 +66,8 @@ def redact_string(text: str) -> str:
"""
if not text:
return text
- masked = _BEARER_RE.sub(r"\\1***", text)
- masked = _KV_TOKEN_RE.sub(r"\\1\\2\\3***", masked)
+ masked = _BEARER_RE.sub(r"\1***", text)
+ masked = _KV_TOKEN_RE.sub(r"\1\2\3***", masked)
masked = _SK_RE.sub("sk-***", masked)
return masked
diff --git a/src/Undefined/utils/paths.py b/src/Undefined/utils/paths.py
index 330672d9..21feeb3c 100644
--- a/src/Undefined/utils/paths.py
+++ b/src/Undefined/utils/paths.py
@@ -7,6 +7,7 @@
DATA_DIR: Path = Path("data")
HISTORY_DIR: Path = DATA_DIR / "history"
CACHE_DIR: Path = DATA_DIR / "cache"
+ONEBOT_FILE_CACHE_DIR: Path = CACHE_DIR / "onebot_files"
RENDER_CACHE_DIR: Path = CACHE_DIR / "render"
IMAGE_CACHE_DIR: Path = CACHE_DIR / "images"
ATTACHMENT_CACHE_DIR: Path = CACHE_DIR / "attachments"
diff --git a/src/Undefined/utils/sender.py b/src/Undefined/utils/sender.py
index 05177c3a..5e1f3325 100644
--- a/src/Undefined/utils/sender.py
+++ b/src/Undefined/utils/sender.py
@@ -29,6 +29,7 @@
from Undefined.config import Config
from Undefined.onebot import OneBotClient
from Undefined.onebot.client import OneBotDeliveryUncertainError
+from Undefined.onebot.file_errors import FileTransferError
from Undefined.utils import io
from Undefined.utils.history import MessageHistoryManager
from Undefined.utils.common import (
@@ -1289,6 +1290,8 @@ async def _send_private_segments(
mark_sent=mark_sent,
)
return result, temp_group_id
+ except FileTransferError:
+ raise
except OneBotDeliveryUncertainError:
logger.warning(
"[发送消息] 复用群临时会话投递结果未确认,停止回退: "
@@ -1319,6 +1322,8 @@ async def _send_private_segments(
mark_sent=mark_sent,
)
return result, None
+ except FileTransferError:
+ raise
except OneBotDeliveryUncertainError:
logger.warning(
"[发送消息] 私聊直发投递结果未确认,停止临时会话回退: user=%s",
@@ -1371,6 +1376,8 @@ async def _try_group(group_id: int) -> tuple[object, int] | None:
group_id,
)
return result, group_id
+ except FileTransferError:
+ raise
except OneBotDeliveryUncertainError:
logger.warning(
"[发送消息] 群临时会话投递结果未确认,停止遍历共享群: "
diff --git a/src/Undefined/webui/static/js/config-form.js b/src/Undefined/webui/static/js/config-form.js
index 44d756c1..187e8284 100644
--- a/src/Undefined/webui/static/js/config-form.js
+++ b/src/Undefined/webui/static/js/config-form.js
@@ -416,6 +416,10 @@ const FIELD_SELECT_EMPTY_OPTION = {
};
const FIELD_SELECT_OPTION_RULES = [
+ {
+ match: (path) => path === "onebot.file_send_mode",
+ options: ["local", "url", "stream"],
+ },
{
match: (path) => path.endsWith(".pool.strategy"),
options: ["default", "round_robin", "random"],
diff --git a/tests/test_bilibili_sender.py b/tests/test_bilibili_sender.py
index fbbac4a7..ea489637 100644
--- a/tests/test_bilibili_sender.py
+++ b/tests/test_bilibili_sender.py
@@ -10,6 +10,8 @@
import Undefined.bilibili.sender as bilibili_sender
from Undefined.attachments import AttachmentRegistry
from Undefined.bilibili.models import DanmakuItem, VideoStats
+from Undefined.onebot.file_errors import FileTransferError
+from Undefined.onebot.client import OneBotDeliveryUncertainError
def _video_info() -> Any:
@@ -28,6 +30,40 @@ def _video_info() -> Any:
)
+@pytest.mark.parametrize("uncertain", [False, True])
+async def test_bilibili_does_not_fallback_after_transport_error(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, uncertain: bool
+) -> None:
+ path = tmp_path / "video.mp4"
+ path.write_bytes(b"video")
+ error = (
+ OneBotDeliveryUncertainError("send_forward_msg", "timeout")
+ if uncertain
+ else FileTransferError("stream", "unsupported")
+ )
+ sender: Any = SimpleNamespace(
+ send_group_forward_message=AsyncMock(side_effect=error)
+ )
+ monkeypatch.setattr(
+ bilibili_sender, "normalize_to_bvid", AsyncMock(return_value="BV1xx411c7mD")
+ )
+ monkeypatch.setattr(
+ bilibili_sender,
+ "download_video",
+ AsyncMock(return_value=(path, _video_info(), 80)),
+ )
+ with pytest.raises(type(error)):
+ await bilibili_sender.send_bilibili_video(
+ "BV1xx411c7mD",
+ sender,
+ cast(Any, SimpleNamespace()),
+ "group",
+ 1,
+ danmaku_enabled=False,
+ )
+ sender.send_group_forward_message.assert_awaited_once()
+
+
@pytest.mark.asyncio
async def test_send_bilibili_video_records_history_for_video_message(
monkeypatch: pytest.MonkeyPatch,
diff --git a/tests/test_onebot_delivery.py b/tests/test_onebot_delivery.py
index 82add24a..fd7ccf74 100644
--- a/tests/test_onebot_delivery.py
+++ b/tests/test_onebot_delivery.py
@@ -9,6 +9,8 @@
import pytest
from Undefined.context import RequestContext
+from Undefined.config.onebot import FileSendSettings
+from Undefined.onebot.file_errors import OneBotAPIError
from Undefined.onebot.client import (
OneBotClient,
OneBotDeliveryUncertainError,
@@ -27,21 +29,94 @@ def __init__(
self.client = client
self.response = response
self.send_count = 0
+ self.requests: list[dict[str, Any]] = []
async def send(self, payload: str) -> None:
self.send_count += 1
request = json.loads(payload)
+ self.requests.append(request)
echo = str(request["echo"])
response = {**self.response, "echo": echo}
self.client._pending_responses[echo].set_result(response)
+class _SilentWebSocket:
+ close_code: int | None = None
+
+ def __init__(self) -> None:
+ self.sent = asyncio.Event()
+
+ async def send(self, payload: str) -> None:
+ self.sent.set()
+
+
+@pytest.mark.asyncio
+async def test_delivery_cancellation_propagates_and_blocks_repeat() -> None:
+ client = OneBotClient(
+ "ws://example.invalid", config_getter=lambda: FileSendSettings("local")
+ )
+ websocket = _SilentWebSocket()
+ client.ws = cast(Any, websocket)
+
+ async with RequestContext(
+ request_type="group",
+ group_id=10001,
+ sender_id=20002,
+ ) as request_context:
+ task = asyncio.create_task(client.send_group_message(10001, "hello"))
+ await websocket.sent.wait()
+ task.cancel()
+ # 取消必须原样传播,不能被改写成普通投递错误。
+ with pytest.raises(asyncio.CancelledError):
+ await task
+
+ # 请求可能已发出:同一请求内相同投递仍禁止重发。
+ with pytest.raises(OneBotDeliveryUncertainError):
+ await client.send_group_message(10001, "hello")
+ assert was_message_sent(request_context) is True
+
+ assert not client._pending_responses
+
+
+@pytest.mark.parametrize("target_type", ["group", "private"])
+@pytest.mark.parametrize("upload_file", [False, True])
+async def test_default_client_preserves_local_file_delivery(
+ tmp_path: Path, target_type: str, upload_file: bool
+) -> None:
+ client = OneBotClient("ws://example.invalid")
+ websocket = _RespondingWebSocket(client, {"status": "ok"})
+ client.ws = cast(Any, websocket)
+ # 本地模式允许把 Bot 上不存在的路径交给协议端,不会读取或上传该文件。
+ path = tmp_path / "legacy.png"
+ media = [{"type": "image", "data": {"file": str(path)}}]
+ if upload_file:
+ if target_type == "group":
+ await client.upload_group_file(1, str(path))
+ else:
+ await client.upload_private_file(1, str(path))
+ elif target_type == "group":
+ await client.send_group_message(1, media)
+ else:
+ await client.send_private_message(1, media)
+ assert websocket.send_count == 1
+ request = websocket.requests[0]
+ if upload_file:
+ assert request["action"] == f"upload_{target_type}_file"
+ assert request["params"]["file"] == path.as_uri()
+ assert request["params"]["name"] == path.name
+ else:
+ assert request["action"] == f"send_{target_type}_msg"
+ assert request["params"]["message"] == media
+
+
@pytest.mark.asyncio
async def test_upload_group_file_does_not_fallback_or_repeat_after_timeout(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
- client = OneBotClient("ws://example.invalid")
+ client = OneBotClient(
+ "ws://example.invalid", config_getter=lambda: FileSendSettings("local")
+ )
websocket = _RespondingWebSocket(
client,
{
@@ -80,17 +155,26 @@ async def test_upload_group_file_keeps_fallback_for_definitive_failure(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
- client = OneBotClient("ws://example.invalid")
- upload = AsyncMock(side_effect=RuntimeError("消息体无法解析"))
- fallback = AsyncMock(return_value={"status": "ok"})
- monkeypatch.setattr(client, "_call_api", upload)
- monkeypatch.setattr(client, "send_group_message", fallback)
+ client = OneBotClient(
+ "ws://example.invalid", config_getter=lambda: FileSendSettings("local")
+ )
+ upload = AsyncMock(
+ side_effect=[OneBotAPIError("消息体无法解析", 1200), {"status": "ok"}]
+ )
+ monkeypatch.setattr(client, "_call_api_raw", upload)
file_path = tmp_path / "song.mp3"
result = await client.upload_group_file(10001, str(file_path), "song.mp3")
assert result == {"status": "ok"}
- fallback.assert_awaited_once()
+ assert [call.args[0] for call in upload.await_args_list] == [
+ "upload_group_file",
+ "send_group_msg",
+ ]
+ assert (
+ upload.await_args_list[0].args[1]["file"]
+ == upload.await_args_list[1].args[1]["message"][0]["data"]["file"]
+ )
@pytest.mark.asyncio
diff --git a/tests/test_onebot_file_config.py b/tests/test_onebot_file_config.py
new file mode 100644
index 00000000..25703195
--- /dev/null
+++ b/tests/test_onebot_file_config.py
@@ -0,0 +1,115 @@
+from pathlib import Path
+import tomllib
+import pytest
+
+from Undefined.config.loader import Config
+from Undefined.config.onebot import (
+ FileSendSettings,
+ parse_file_send_host,
+ parse_file_send_mode,
+)
+from Undefined.config.load_sections.core import load_core
+from Undefined.config.env_registry import ENV_REGISTRY
+
+
+@pytest.mark.parametrize(
+ "raw,expected",
+ [
+ (None, "local"),
+ ("", "local"),
+ (" ", "local"),
+ (" Stream ", "stream"),
+ ("url", "url"),
+ ("LOCAL", "local"),
+ ],
+)
+def test_mode_normalization(raw: str | None, expected: str) -> None:
+ assert parse_file_send_mode(raw) == expected
+
+
+@pytest.mark.parametrize("mode", [None, "", " "])
+def test_legacy_config_defaults_to_local(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mode: str | None
+) -> None:
+ monkeypatch.delenv("ONEBOT_FILE_SEND_MODE", raising=False)
+ source = '[onebot]\nws_url = "ws://localhost:3001"\n'
+ if mode is not None:
+ source += f'file_send_mode = "{mode}"\n'
+ path = tmp_path / "config.toml"
+ path.write_text(source, encoding="utf-8")
+ cfg = Config.load(path, strict=False)
+ assert cfg.onebot_file_send_mode == "local"
+ assert FileSendSettings.from_config(cfg).mode == "local"
+ assert FileSendSettings().mode == "local"
+
+
+def test_template_defaults_to_local() -> None:
+ source = Path("config.toml.example").read_text(encoding="utf-8")
+ assert tomllib.loads(source)["onebot"]["file_send_mode"] == "local"
+
+
+@pytest.mark.parametrize("raw", ["auto", "base64", "stream api"])
+def test_invalid_mode_rejected(raw: str) -> None:
+ with pytest.raises(ValueError, match="onebot.file_send_mode"):
+ load_core({"onebot": {"file_send_mode": raw}})
+
+
+@pytest.mark.parametrize(
+ "raw,expected",
+ [
+ (None, "127.0.0.1"),
+ ("", "127.0.0.1"),
+ ("example.com", "example.com"),
+ ("::1", "::1"),
+ ("[::1]", "::1"),
+ ("192.168.1.2", "192.168.1.2"),
+ ],
+)
+def test_host_normalization(raw: str | None, expected: str) -> None:
+ assert parse_file_send_host(raw) == expected
+
+
+@pytest.mark.parametrize(
+ "raw",
+ [
+ "http://localhost",
+ "localhost:8788",
+ "localhost/path",
+ "user@host",
+ "[localhost]",
+ "bad host",
+ "127.0.0.1?x=1",
+ "::1%/path",
+ ],
+)
+def test_host_rejects_non_host(raw: str) -> None:
+ with pytest.raises(ValueError, match="onebot.file_send_host"):
+ parse_file_send_host(raw)
+
+
+def test_env_precedence_and_loaded_config(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ monkeypatch.setenv("ONEBOT_FILE_SEND_MODE", " URL ")
+ monkeypatch.setenv("ONEBOT_FILE_SEND_HOST", "::1")
+ values = load_core({})
+ assert values["onebot_file_send_mode"] == "url"
+ assert values["onebot_file_send_host"] == "::1"
+ assert (
+ load_core({"onebot": {"file_send_mode": "local"}})["onebot_file_send_mode"]
+ == "local"
+ )
+ # TOML 空值也遵循 TOML 优先,使用默认值而非环境变量。
+ assert (
+ load_core({"onebot": {"file_send_mode": ""}})["onebot_file_send_mode"]
+ == "local"
+ )
+ path = tmp_path / "config.toml"
+ path.write_text(
+ '[onebot]\nfile_send_mode = "stream"\nfile_send_host = "127.0.0.1"\n'
+ )
+ cfg = Config.load(path, strict=False)
+ assert cfg.onebot_file_send_mode == "stream"
+ assert cfg.onebot_file_send_host == "127.0.0.1"
+ assert ENV_REGISTRY[("onebot", "file_send_mode")] == "ONEBOT_FILE_SEND_MODE"
+ assert ENV_REGISTRY[("onebot", "file_send_host")] == "ONEBOT_FILE_SEND_HOST"
diff --git a/tests/test_onebot_file_transport.py b/tests/test_onebot_file_transport.py
new file mode 100644
index 00000000..1f19c7a6
--- /dev/null
+++ b/tests/test_onebot_file_transport.py
@@ -0,0 +1,763 @@
+from __future__ import annotations
+
+import asyncio
+import base64
+from collections.abc import AsyncIterator
+from contextlib import asynccontextmanager
+from copy import deepcopy
+import hashlib
+import json
+import logging
+from pathlib import Path
+from types import SimpleNamespace
+from typing import Any
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+from websockets.asyncio.server import ServerConnection, serve
+
+from Undefined.config.onebot import FileSendSettings
+from Undefined.context import RequestContext
+from Undefined.onebot.client import OneBotClient, OneBotDeliveryUncertainError
+from Undefined.onebot.file_errors import FileTransferError, OneBotAPIError
+from Undefined.onebot.file_references import map_file_references
+from Undefined.onebot.file_transport import OneBotFileTransport
+from Undefined.utils.coerce import was_message_sent
+from Undefined.utils.logging import sanitize_data
+from Undefined.attachments import AttachmentRegistry
+from Undefined.utils.sender import MessageSender
+
+
+def _stream_settings() -> FileSendSettings:
+ return FileSendSettings("stream")
+
+
+class NapCat:
+ """模拟实际 WebSocket 协议,严格检查逐块请求、完成请求及文件字节。"""
+
+ def __init__(self) -> None:
+ self.requests: list[dict[str, Any]] = []
+ self.echoes: list[str] = []
+ self.chunks: dict[str, list[bytes]] = {}
+ self.completed: dict[str, bytes] = {}
+ self.remote_path = r"C:\NapCat\temp\uploaded.bin"
+ self.failure = ""
+ self.block = asyncio.Event()
+ self.received = asyncio.Event()
+
+ async def respond(
+ self,
+ action: str,
+ params: dict[str, Any] | None = None,
+ *,
+ suppress_error_retcodes: set[int] | None = None,
+ ) -> dict[str, Any]:
+ params = params or {}
+ self.requests.append({"action": action, "params": deepcopy(params)})
+ if action != "upload_file_stream":
+ if self.failure == "send_blocked":
+ self.received.set()
+ await self.block.wait()
+ if self.failure == "delivery_timeout":
+ raise OneBotDeliveryUncertainError(action, "Timeout")
+ if self.failure == "fallback" and action.startswith("upload_"):
+ raise OneBotAPIError("file action failed", 1200)
+ return {"status": "ok", "data": {"message_id": 42}}
+ stream_id = params["stream_id"]
+ if params.get("reset"):
+ self.chunks.pop(stream_id, None)
+ if self.failure == "reset_failure":
+ raise OSError("reset failed")
+ raise OneBotAPIError("Stream reset completed", 1200)
+ if self.failure == "unsupported":
+ raise OneBotAPIError("不支持的API upload_file_stream", 1404)
+ if self.failure == "network":
+ raise ConnectionError("lost connection")
+ if self.failure == "blocked":
+ self.received.set()
+ await self.block.wait()
+ total = params["total_chunks"]
+ assert params["file_retention"] > 0
+ if "chunk_data" in params:
+ assert "is_complete" not in params
+ chunks = self.chunks.setdefault(stream_id, [])
+ assert params["chunk_index"] == len(chunks)
+ chunks.append(base64.b64decode(params["chunk_data"], validate=True))
+ data = {
+ "type": "stream",
+ "stream_id": stream_id,
+ "status": "chunk_received",
+ "received_chunks": len(chunks),
+ "total_chunks": total,
+ }
+ if self.failure in {"ack", "reset_failure"}:
+ data["received_chunks"] = 0
+ else:
+ assert params["is_complete"] is True
+ blob = b"".join(self.chunks[stream_id])
+ assert len(self.chunks[stream_id]) == total
+ assert len(blob) == params["file_size"]
+ assert hashlib.sha256(blob).hexdigest() == params["expected_sha256"]
+ self.completed[stream_id] = blob
+ data = {
+ "type": "response",
+ "stream_id": stream_id,
+ "status": "file_complete",
+ "received_chunks": total,
+ "total_chunks": total,
+ "file_path": self.remote_path,
+ "file_size": len(blob),
+ "sha256": hashlib.sha256(blob).hexdigest(),
+ }
+ if self.failure == "hash":
+ data["sha256"] = "0" * 64
+ return {"status": "ok", "retcode": 0, "data": data}
+
+ async def websocket(self, ws: ServerConnection) -> None:
+ async for payload in ws:
+ request = json.loads(payload)
+ self.echoes.append(request["echo"])
+ is_upload = request["action"] == "upload_file_stream"
+ if (self.failure == "disconnect_upload" and is_upload) or (
+ self.failure == "disconnect_send" and not is_upload
+ ):
+ await ws.close()
+ return
+ try:
+ response = await self.respond(request["action"], request["params"])
+ except OneBotAPIError as exc:
+ response = {
+ "status": "failed",
+ "retcode": exc.retcode,
+ "message": exc.message,
+ }
+ await ws.send(
+ json.dumps(
+ {**response, "echo": request["echo"], "stream": "stream-action"}
+ )
+ )
+
+
+@asynccontextmanager
+async def connected(napcat: NapCat, token: str = "") -> AsyncIterator[OneBotClient]:
+ server_logger = logging.getLogger("tests.napcat")
+ server_logger.setLevel(logging.INFO)
+ async with serve(napcat.websocket, "127.0.0.1", 0, logger=server_logger) as server:
+ port = server.sockets[0].getsockname()[1]
+ client = OneBotClient(
+ f"ws://127.0.0.1:{port}", token, config_getter=_stream_settings
+ )
+ await client.connect()
+ task = asyncio.create_task(client.run())
+ try:
+ yield client
+ finally:
+ napcat.block.set()
+ await client.disconnect()
+ await task
+
+
+@pytest.mark.parametrize("size", [1, 65535, 65536, 65537, 131072])
+async def test_real_websocket_stream_bytes_and_sequence(
+ tmp_path: Path, size: int, caplog: pytest.LogCaptureFixture
+) -> None:
+ path = tmp_path / "中文 音频.bin"
+ blob = bytes(range(256)) * (size // 256) + bytes(range(size % 256))
+ path.write_bytes(blob)
+ napcat = NapCat()
+ caplog.set_level("DEBUG")
+ async with connected(napcat) as client:
+ original = [
+ {"type": "video", "data": {"file": path.as_uri(), "thumb": str(path)}}
+ ]
+ before = deepcopy(original)
+ await client.send_group_message(1, original)
+ assert original == before
+ assert list(napcat.completed.values()) == [blob]
+ assert len(set(napcat.echoes)) == len(napcat.requests)
+ uploads = [r["params"] for r in napcat.requests[:-1]]
+ assert len(uploads) == (size + 65535) // 65536 + 1
+ assert uploads[-1]["is_complete"] is True
+ assert uploads[0]["file_retention"] == 960000
+ assert uploads[0]["filename"] != path.name
+ assert uploads[0]["filename"].endswith(".bin")
+ assert max(len(base64.b64decode(p["chunk_data"])) for p in uploads[:-1]) <= 65536
+ assert napcat.requests[-1]["params"]["message"][0]["data"] == {
+ "file": napcat.remote_path,
+ "thumb": napcat.remote_path,
+ }
+ if size > 65536:
+ assert uploads[0]["chunk_data"] not in caplog.text
+
+
+@pytest.mark.parametrize(
+ "action",
+ [
+ "send_group_msg",
+ "send_private_msg",
+ "send_forward_msg",
+ "send_private_forward_msg",
+ ],
+)
+async def test_all_media_nested_cq_and_originals(tmp_path: Path, action: str) -> None:
+ path = tmp_path / "中 文,[x]&.png"
+ path.write_bytes(b"file bytes")
+ napcat = NapCat()
+ transport = OneBotFileTransport(
+ napcat.respond, config_getter=_stream_settings, chunk_size=3
+ )
+ escaped = (
+ str(path)
+ .replace("&", "&")
+ .replace("[", "[")
+ .replace("]", "]")
+ .replace(",", ",")
+ )
+ segments: list[dict[str, Any]] = [
+ {"type": kind, "data": {"file": path.as_uri()}}
+ for kind in ("image", "record", "video", "file")
+ ]
+ segments += [
+ {
+ "type": "node",
+ "data": {
+ "content": [
+ {
+ "type": "node",
+ "data": {
+ "content": f"原文 [CQ:image,file={escaped}] [CQ:unknown,file={escaped}]"
+ },
+ }
+ ]
+ },
+ }
+ ]
+ segments += [
+ {"type": "text", "data": {"text": str(path)}},
+ {"type": "unknown", "data": {"file": str(path)}},
+ ]
+ params = {"messages" if "forward" in action else "message": segments}
+ original = deepcopy(params)
+ async with transport.prepare(action, params) as prepared:
+ converted = prepared.apply(action, params)
+ assert params == original
+ assert len(napcat.completed) == 1
+ result = next(iter(converted.values()))
+ for segment in result[:4]:
+ assert segment["data"]["file"] == napcat.remote_path
+ assert napcat.remote_path in result[4]["data"]["content"][0]["data"]["content"]
+ assert result[-2:] == segments[-2:]
+ assert (
+ f"[CQ:unknown,file={escaped}]"
+ in result[4]["data"]["content"][0]["data"]["content"]
+ )
+
+
+@pytest.mark.parametrize(
+ "source",
+ [
+ "https://example.org/a.png",
+ "http://host/a?token=test",
+ "base64://AA==",
+ "data:image/png;base64,AA==",
+ "ABCDEF123.image",
+ "123456",
+ ],
+)
+async def test_remote_sources_passthrough(source: str) -> None:
+ call = AsyncMock()
+ transport = OneBotFileTransport(call, config_getter=_stream_settings)
+ params = {"message": f"[CQ:image,file={source}]"}
+ async with transport.prepare("send_group_msg", params) as prepared:
+ assert prepared.apply("send_group_msg", params) == params
+ call.assert_not_called()
+
+
+@pytest.mark.parametrize("explicit", [False, True])
+async def test_local_and_default_do_no_io_or_transfer(
+ tmp_path: Path, explicit: bool
+) -> None:
+ call = AsyncMock()
+ transport = OneBotFileTransport(
+ call, config_getter=(lambda: FileSendSettings("local")) if explicit else None
+ )
+ params = {"file": (tmp_path / "does-not-exist.zip").as_uri()}
+ async with transport.prepare("upload_group_file", params) as prepared:
+ assert prepared.apply("upload_group_file", params) == params
+ call.assert_not_called()
+
+
+@pytest.mark.parametrize(
+ "failure,match",
+ [
+ ("unsupported", "onebot.file_send_mode"),
+ ("network", "连接中断"),
+ ("ack", "分块数量"),
+ ("hash", "SHA-256"),
+ ("reset_failure", "分块数量"),
+ ],
+)
+async def test_prepare_errors_never_send_or_fallback(
+ tmp_path: Path, failure: str, match: str
+) -> None:
+ path = tmp_path / "data.txt"
+ path.write_bytes(b"hello")
+ napcat = NapCat()
+ napcat.failure = failure
+ client = OneBotClient(
+ "ws://unused",
+ file_transport=OneBotFileTransport(
+ napcat.respond, config_getter=_stream_settings
+ ),
+ )
+ client._call_api_raw = AsyncMock() # type: ignore[method-assign]
+ async with RequestContext(request_type="group", group_id=1, sender_id=2) as ctx:
+ with pytest.raises(FileTransferError, match=match):
+ await client.upload_group_file(1, str(path))
+ assert not was_message_sent(ctx)
+ client._call_api_raw.assert_not_called()
+ reset = [r for r in napcat.requests if r["params"].get("reset")]
+ assert bool(reset) == (failure not in {"unsupported", "hash"})
+
+
+async def test_empty_file_fails_before_upload(tmp_path: Path) -> None:
+ path = tmp_path / "empty"
+ path.touch()
+ call = AsyncMock()
+ transport = OneBotFileTransport(call, config_getter=_stream_settings)
+ with pytest.raises(FileTransferError, match="零字节"):
+ async with transport.prepare("upload_private_file", {"file": str(path)}):
+ pytest.fail("must not prepare")
+ call.assert_not_called()
+
+
+async def test_source_change_prevents_completion(tmp_path: Path) -> None:
+ path = tmp_path / "source"
+ path.write_bytes(b"123456")
+ napcat = NapCat()
+
+ async def mutate(action: str, params: dict[str, Any]) -> dict[str, Any]:
+ result = await napcat.respond(action, params)
+ if "chunk_data" in params:
+ path.write_bytes(b"changed")
+ return result
+
+ transport = OneBotFileTransport(
+ mutate, config_getter=_stream_settings, chunk_size=3
+ )
+ with pytest.raises(FileTransferError, match="源文件发生变化"):
+ async with transport.prepare("upload_private_file", {"file": str(path)}):
+ pytest.fail("must not prepare")
+ assert not napcat.completed
+ assert napcat.requests[-1]["params"]["reset"] is True
+
+
+async def test_fallback_reuses_upload_and_original_idempotence(tmp_path: Path) -> None:
+ path = tmp_path / "archive.zip"
+ path.write_bytes(b"archive")
+ napcat = NapCat()
+ napcat.failure = "fallback"
+ client = OneBotClient(
+ "ws://unused",
+ file_transport=OneBotFileTransport(
+ napcat.respond, config_getter=_stream_settings
+ ),
+ )
+ client._call_api_raw = napcat.respond # type: ignore[method-assign]
+ await client.upload_private_file(1, str(path), "展示.zip")
+ assert len(napcat.completed) == 1
+ assert [r["action"] for r in napcat.requests[-2:]] == [
+ "upload_private_file",
+ "send_private_msg",
+ ]
+ primary, fallback = napcat.requests[-2:]
+ assert primary["params"]["file"] == fallback["params"]["message"][0]["data"]["file"]
+ assert fallback["params"]["message"][0]["data"]["name"] == "展示.zip"
+
+
+async def test_uncertain_forward_blocks_reupload(tmp_path: Path) -> None:
+ path = tmp_path / "video.mp4"
+ path.write_bytes(b"video")
+ napcat = NapCat()
+ napcat.failure = "delivery_timeout"
+ client = OneBotClient(
+ "ws://unused",
+ file_transport=OneBotFileTransport(
+ napcat.respond, config_getter=_stream_settings
+ ),
+ )
+ client._call_api_raw = napcat.respond # type: ignore[method-assign]
+ nodes = [
+ {
+ "type": "node",
+ "data": {"content": [{"type": "video", "data": {"file": str(path)}}]},
+ }
+ ]
+ async with RequestContext(request_type="group", group_id=1, sender_id=2) as ctx:
+ for _ in range(2):
+ with pytest.raises(OneBotDeliveryUncertainError):
+ await client.send_forward_msg(1, nodes)
+ assert was_message_sent(ctx)
+ assert len(napcat.completed) == 1
+ assert len([r for r in napcat.requests if r["action"] == "send_forward_msg"]) == 1
+
+
+async def test_cancel_and_budget_reset_only_current_stream(tmp_path: Path) -> None:
+ path = tmp_path / "data"
+ path.write_bytes(b"abc")
+ for cancel in (False, True):
+ napcat = NapCat()
+ napcat.failure = "blocked"
+ transport = OneBotFileTransport(
+ napcat.respond,
+ config_getter=_stream_settings,
+ timeout=0.03 if not cancel else 10,
+ )
+
+ async def prepare() -> None:
+ async with transport.prepare("upload_group_file", {"file": str(path)}):
+ pytest.fail("must not send")
+
+ task = asyncio.create_task(prepare())
+ await napcat.received.wait()
+ if cancel:
+ task.cancel()
+ with pytest.raises(asyncio.CancelledError if cancel else FileTransferError):
+ await task
+ assert napcat.requests[-1]["params"]["reset"] is True
+ assert (
+ napcat.requests[-1]["params"]["stream_id"]
+ == napcat.requests[0]["params"]["stream_id"]
+ )
+
+
+async def test_hot_reload_snapshot_queue_and_text_bypass(tmp_path: Path) -> None:
+ path = tmp_path / "data"
+ path.write_bytes(b"abc")
+ cfg = SimpleNamespace(
+ onebot_file_send_mode="stream", onebot_file_send_host="127.0.0.1"
+ )
+ napcat = NapCat()
+ napcat.failure = "blocked"
+ transport = OneBotFileTransport(napcat.respond, config_getter=lambda: cfg)
+
+ async def prepare() -> dict[str, Any]:
+ async with transport.prepare("upload_group_file", {"file": str(path)}) as files:
+ return files.apply("upload_group_file", {"file": str(path)})
+
+ first = asyncio.create_task(prepare())
+ await napcat.received.wait()
+ queued = asyncio.create_task(prepare())
+ await asyncio.sleep(0)
+ assert len(napcat.requests) == 1
+ async with transport.prepare("send_group_msg", {"message": "text"}):
+ pass
+ cfg.onebot_file_send_mode = "local"
+ assert await prepare() == {"file": str(path)}
+ napcat.block.set()
+ assert (await first)["file"] == napcat.remote_path
+ assert (await queued)["file"] == napcat.remote_path
+ assert len(napcat.completed) == 2
+ assert len({r["params"]["filename"] for r in napcat.requests}) == 2
+
+
+async def test_duplicate_and_late_echo_ignored() -> None:
+ client = OneBotClient("ws://unused")
+ future: asyncio.Future[dict[str, Any]] = asyncio.get_running_loop().create_future()
+ client._pending_responses["x"] = future
+ future.cancel()
+ await client._dispatch_message({"echo": "x"})
+ await client._dispatch_message({"echo": "unknown"})
+ done: asyncio.Future[dict[str, Any]] = asyncio.get_running_loop().create_future()
+ client._pending_responses["y"] = done
+ await client._dispatch_message({"echo": "y", "data": 1})
+ await client._dispatch_message({"echo": "y", "data": 2})
+ assert done.result()["data"] == 1
+
+
+def test_logging_redacts_upload_and_file_token() -> None:
+ safe = json.dumps(
+ sanitize_data(
+ {
+ "chunk_data": "ABCDEF",
+ "file": "http://host/api/v1/onebot/files/a?token=SECRET",
+ "Authorization": "Bearer AUTH",
+ }
+ )
+ )
+ assert "ABCDEF" not in safe and "SECRET" not in safe and "AUTH" not in safe
+ assert "CHUNK" not in str(sanitize_data('{"chunk_data": "CHUNK"}'))
+
+
+async def test_websocket_debug_does_not_leak_frames_or_auth(
+ tmp_path: Path, caplog: pytest.LogCaptureFixture
+) -> None:
+ source = tmp_path / "file"
+ content = b"private-file-payload"
+ source.write_bytes(content)
+ caplog.set_level(logging.DEBUG)
+ async with connected(NapCat(), token="private-onebot-token") as client:
+ await client.upload_group_file(1, str(source))
+ assert base64.b64encode(content).decode() not in caplog.text
+ assert "private-onebot-token" not in caplog.text
+
+
+def test_cq_replacement_does_not_touch_text() -> None:
+ params = {"message": "ordinary /path [CQ:unknown,file=/path] [CQ:image,file=/path]"}
+ assert (
+ map_file_references("send_group_msg", params, lambda _: "/remote")["message"]
+ == "ordinary /path [CQ:unknown,file=/path] [CQ:image,file=/remote]"
+ )
+
+
+@pytest.mark.parametrize(
+ "message",
+ [
+ "[CQ:image]",
+ "[CQ:record,cache=0]",
+ "[CQ:video,file=base64://AA==]",
+ "[CQ:unknown]",
+ ],
+)
+def test_unmodified_cq_keeps_exact_format(message: str) -> None:
+ params = {"message": message}
+ assert (
+ map_file_references("send_group_msg", params, lambda source: source) == params
+ )
+
+
+def test_file_segment_preserves_implicit_display_name() -> None:
+ params = {
+ "message": [
+ {"type": "file", "data": {"file": "file:///tmp/%E4%B8%AD%E6%96%87.txt"}}
+ ]
+ }
+ result = map_file_references(
+ "send_group_msg", params, lambda _: "/napcat/random.txt"
+ )
+ assert result["message"][0]["data"]["name"] == "中文.txt"
+ cq = map_file_references(
+ "send_group_msg",
+ {"message": "[CQ:file,file=/tmp/demo.txt]"},
+ lambda _: "/napcat/random.txt",
+ )
+ assert "name=demo.txt" in cq["message"]
+
+
+@pytest.mark.parametrize(
+ "failure", ["disconnect_upload", "disconnect_send", "unsupported"]
+)
+async def test_real_websocket_failure_phase(tmp_path: Path, failure: str) -> None:
+ source = tmp_path / "file"
+ source.write_bytes(b"data")
+ napcat = NapCat()
+ napcat.failure = failure
+ async with connected(napcat) as client:
+ async with RequestContext(request_type="group", group_id=1, sender_id=2) as ctx:
+ expected = (
+ OneBotDeliveryUncertainError
+ if failure == "disconnect_send"
+ else FileTransferError
+ )
+ with pytest.raises(expected):
+ await asyncio.wait_for(
+ client.upload_group_file(1, str(source)), timeout=2
+ )
+ assert was_message_sent(ctx) == (failure == "disconnect_send")
+
+
+@pytest.mark.parametrize("cancel", [True, False])
+async def test_real_send_cancellation_and_total_budget_are_uncertain(
+ tmp_path: Path, cancel: bool
+) -> None:
+ source = tmp_path / "file"
+ source.write_bytes(b"data")
+ napcat = NapCat()
+ napcat.failure = "send_blocked"
+ async with connected(napcat) as client:
+ client.file_transport.timeout = 0.3 if not cancel else 10
+ async with RequestContext(request_type="group", group_id=1, sender_id=2) as ctx:
+ task = asyncio.create_task(client.upload_group_file(1, str(source)))
+ await asyncio.wait_for(napcat.received.wait(), timeout=2)
+ if cancel:
+ task.cancel()
+ # 外部取消原样传播;总预算耗尽才转换为未确认投递。
+ expected = (
+ asyncio.CancelledError if cancel else OneBotDeliveryUncertainError
+ )
+ with pytest.raises(expected):
+ await asyncio.wait_for(task, timeout=2)
+ assert was_message_sent(ctx)
+ # 两种情况都按未确认投递登记,同一请求内相同投递禁止重发。
+ with pytest.raises(OneBotDeliveryUncertainError):
+ await client.upload_group_file(1, str(source))
+ assert len(napcat.completed) == 1
+ assert not any(r["params"].get("reset") for r in napcat.requests)
+
+
+async def test_cancelled_queue_and_queue_wait_outside_budget(tmp_path: Path) -> None:
+ path = tmp_path / "data"
+ path.write_bytes(b"abc")
+ napcat = NapCat()
+ transport = OneBotFileTransport(
+ napcat.respond, config_getter=_stream_settings, timeout=0.1
+ )
+ await transport._stream_lock.acquire()
+
+ async def prepare() -> None:
+ async with transport.prepare("upload_group_file", {"file": str(path)}):
+ pass
+
+ cancelled = asyncio.create_task(prepare())
+ queued = asyncio.create_task(prepare())
+ await asyncio.sleep(0.12)
+ assert not napcat.requests
+ cancelled.cancel()
+ with pytest.raises(asyncio.CancelledError):
+ await cancelled
+ transport._stream_lock.release()
+ await queued
+ assert len(napcat.completed) == 1
+
+
+@pytest.mark.parametrize("remote_path", ["/app/napcat/temp/data", r"D:\temp\data"])
+async def test_remote_completion_path_is_not_read_on_bot(
+ tmp_path: Path, remote_path: str
+) -> None:
+ source = tmp_path / "file"
+ source.write_bytes(b"data")
+ napcat = NapCat()
+ napcat.remote_path = remote_path
+ transport = OneBotFileTransport(napcat.respond, config_getter=_stream_settings)
+ async with transport.prepare("upload_group_file", {"file": str(source)}) as files:
+ assert (
+ files.apply("upload_group_file", {"file": str(source)})["file"]
+ == remote_path
+ )
+
+
+@pytest.mark.parametrize(
+ "field,value",
+ [
+ ("type", "stream"),
+ ("received_chunks", 0),
+ ("total_chunks", 2),
+ ("file_size", 0),
+ ("file_path", "relative"),
+ ("file_path", ""),
+ ("sha256", None),
+ ],
+)
+async def test_malformed_completion_never_sends(
+ tmp_path: Path, field: str, value: Any
+) -> None:
+ source = tmp_path / "file"
+ source.write_bytes(b"data")
+ napcat = NapCat()
+
+ async def corrupt(action: str, params: dict[str, Any]) -> dict[str, Any]:
+ response = await napcat.respond(action, params)
+ if params.get("is_complete"):
+ response["data"][field] = value
+ return response
+
+ transport = OneBotFileTransport(corrupt, config_getter=_stream_settings)
+ with pytest.raises(FileTransferError):
+ async with transport.prepare("upload_group_file", {"file": str(source)}):
+ pytest.fail("must not send")
+
+
+async def test_only_incomplete_second_file_is_reset(tmp_path: Path) -> None:
+ first, second = tmp_path / "first.txt", tmp_path / "second.txt"
+ first.write_bytes(b"one")
+ second.write_bytes(b"two")
+ napcat = NapCat()
+
+ async def fail_second(action: str, params: dict[str, Any]) -> dict[str, Any]:
+ if napcat.completed:
+ napcat.failure = "ack"
+ return await napcat.respond(action, params)
+
+ transport = OneBotFileTransport(fail_second, config_getter=_stream_settings)
+ params = {
+ "message": [
+ {"type": "image", "data": {"file": str(p)}} for p in (first, second)
+ ]
+ }
+ with pytest.raises(FileTransferError):
+ async with transport.prepare("send_group_msg", params):
+ pytest.fail("must not send")
+ assert len(napcat.completed) == 1
+ resets = [r for r in napcat.requests if r["params"].get("reset")]
+ assert len(resets) == 1
+ assert resets[0]["params"]["stream_id"] not in napcat.completed
+
+
+@pytest.mark.parametrize(
+ "entrypoint",
+ ["group_file", "private_file", "group_image", "private_record", "forward_video"],
+)
+async def test_sender_history_registers_original_local_source(
+ tmp_path: Path, entrypoint: str
+) -> None:
+ source = tmp_path / "原始文件.png"
+ source.write_bytes(b"media")
+ napcat = NapCat()
+ client = OneBotClient(
+ "ws://unused",
+ file_transport=OneBotFileTransport(
+ napcat.respond, config_getter=_stream_settings
+ ),
+ )
+ client._call_api_raw = napcat.respond # type: ignore[method-assign]
+ registry = AttachmentRegistry(
+ registry_path=tmp_path / "registry.json", cache_dir=tmp_path / "attachments"
+ )
+ register = AsyncMock(wraps=registry.register_local_file)
+ registry.register_local_file = register # type: ignore[method-assign]
+ history: Any = SimpleNamespace(
+ add_group_message=AsyncMock(), add_private_message=AsyncMock()
+ )
+ cfg = MagicMock()
+ cfg.is_group_allowed.return_value = True
+ cfg.is_private_allowed.return_value = True
+ sender = MessageSender(
+ client, history, bot_qq=99, config=cfg, attachment_registry=registry
+ )
+ if entrypoint == "group_file":
+ await sender.send_group_file(1, str(source), "展示.png")
+ elif entrypoint == "private_file":
+ await sender.send_private_file(1, str(source), "展示.png")
+ elif entrypoint == "group_image":
+ await sender.send_group_message(1, f"[CQ:image,file={source.as_uri()}]")
+ elif entrypoint == "private_record":
+ await sender.send_private_message(1, f"[CQ:record,file={source.as_uri()}]")
+ else:
+ await sender.send_group_forward_message(
+ 1,
+ [
+ {
+ "type": "node",
+ "data": {
+ "content": [
+ {"type": "video", "data": {"file": source.as_uri()}}
+ ]
+ },
+ }
+ ],
+ history_message="Bilibili 视频",
+ )
+ assert register.await_count == 1
+ call = register.await_args
+ assert call is not None
+ assert str(source) in str(call)
+ assert napcat.remote_path not in str(call)
+ history_call = (
+ history.add_private_message
+ if entrypoint.startswith("private")
+ else history.add_group_message
+ ).await_args
+ assert history_call is not None
+ assert history_call.kwargs["attachments"]
+ assert napcat.remote_path not in str(history_call)
+ assert len(napcat.completed) == 1
diff --git a/tests/test_onebot_file_urls.py b/tests/test_onebot_file_urls.py
new file mode 100644
index 00000000..a4f45817
--- /dev/null
+++ b/tests/test_onebot_file_urls.py
@@ -0,0 +1,366 @@
+from __future__ import annotations
+
+import asyncio
+from collections.abc import AsyncIterator
+from contextlib import asynccontextmanager
+from pathlib import Path
+from types import SimpleNamespace
+from typing import Any
+from unittest.mock import AsyncMock
+from urllib.parse import parse_qs, urlsplit
+from uuid import uuid4
+
+from aiohttp import ClientSession, web
+import pytest
+
+from Undefined.api import RuntimeAPIServer
+from Undefined.api._context import RuntimeAPIContext
+from Undefined.api._openapi import _build_openapi_spec
+from Undefined.config.models import APIConfig
+from Undefined.onebot.client import OneBotClient
+from Undefined.onebot.file_errors import FileTransferError
+from Undefined.onebot.file_store import OneBotFileStore
+from Undefined.onebot.file_transport import OneBotFileTransport
+
+
+class Clock:
+ def __init__(self) -> None:
+ self.now = 2000000000.0
+
+ def __call__(self) -> float:
+ return self.now
+
+
+def context(cfg: Any, client: OneBotClient) -> RuntimeAPIContext:
+ return RuntimeAPIContext(
+ config_getter=lambda: cfg,
+ onebot=client,
+ ai=SimpleNamespace(),
+ command_dispatcher=None,
+ queue_manager=None,
+ history_manager=None,
+ )
+
+
+@asynccontextmanager
+async def runtime(
+ tmp_path: Path,
+) -> AsyncIterator[tuple[RuntimeAPIServer, OneBotFileStore, OneBotClient, Any, Clock]]:
+ clock = Clock()
+ cfg = SimpleNamespace(
+ api=APIConfig(auth_key="API-KEY"),
+ onebot_file_send_mode="url",
+ onebot_file_send_host="127.0.0.1",
+ )
+ store = OneBotFileStore(tmp_path / "cache", clock=clock)
+ client = OneBotClient("ws://unused", config_getter=lambda: cfg)
+ client.file_transport.store = store
+ server = RuntimeAPIServer(context(cfg, client), "127.0.0.1", 0)
+ await server.start()
+ try:
+ yield server, store, client, cfg, clock
+ finally:
+ await server.stop()
+
+
+async def test_get_head_range_tokens_source_cleanup_mode_and_actual_port(
+ tmp_path: Path, caplog: pytest.LogCaptureFixture
+) -> None:
+ source = tmp_path / "中文 文件.txt"
+ source.write_bytes(b"0123456789")
+ caplog.set_level("DEBUG")
+ async with runtime(tmp_path) as (_, store, client, cfg, clock):
+ sender = AsyncMock(return_value={"status": "ok"})
+ client._call_api_raw = sender # type: ignore[method-assign]
+ cfg.api.port = 9 # 未重启时新配置不能影响实际下载端口。
+ await client.upload_group_file(1, str(source), "展示名称.txt")
+ sent = sender.await_args_list[0].args[1]
+ assert sent["name"] == "展示名称.txt"
+ url = sent["file"]
+ assert urlsplit(url).port == store.port and store.port != 9
+ token = parse_qs(urlsplit(url).query)["token"][0]
+ base = url.split("?", 1)[0]
+ source.unlink()
+ cfg.onebot_file_send_mode = "local"
+ async with ClientSession() as http:
+ for _ in range(2):
+ async with http.get(url) as response:
+ assert response.status == 200
+ assert await response.read() == b"0123456789"
+ assert (
+ "filename*=UTF-8''" in response.headers["Content-Disposition"]
+ )
+ assert response.headers["X-Content-Type-Options"] == "nosniff"
+ async with http.head(url) as response:
+ assert response.status == 200
+ assert response.content_length == 10
+ assert await response.read() == b""
+ async with http.get(url, headers={"Range": "bytes=2-5"}) as response:
+ assert response.status == 206
+ assert await response.read() == b"2345"
+ for bad_url in (base, f"{base}?token=wrong"):
+ async with http.get(bad_url) as response:
+ assert response.status == 401
+ async with http.get(
+ f"http://127.0.0.1:{store.port}/api/v1/probes/internal?token={token}"
+ ) as response:
+ assert response.status == 401
+ async with http.get(f"{base}-missing?token={token}") as response:
+ assert response.status == 404
+ clock.now += 960
+ async with http.get(url) as response:
+ assert response.status == 404
+ await store.cleanup()
+ assert not list((tmp_path / "cache").iterdir())
+ # 对其他 API 携带 token 的请求也不应泄露查询令牌。
+ assert token not in caplog.text
+
+
+async def test_tokens_are_per_file_and_ipv6_format(tmp_path: Path) -> None:
+ source = tmp_path / "file"
+ source.write_bytes(b"abc")
+ store = OneBotFileStore(tmp_path / "cache")
+ await store.start(12345)
+ try:
+ first = await store.publish(source, "::1")
+ second = await store.publish(source, "::1")
+ assert first.startswith("http://[::1]:12345/")
+ first_token = parse_qs(urlsplit(first).query)["token"][0]
+ second_id = urlsplit(second).path.rsplit("/", 1)[1]
+ from Undefined.onebot.file_store import FileAuthorizationError
+
+ with pytest.raises(FileAuthorizationError) as exc:
+ async with store.acquire(second_id, first_token):
+ pytest.fail("token must not authorize another file")
+ assert exc.value.status == 401
+ finally:
+ await store.stop()
+
+
+async def test_expiry_keeps_in_progress_download_alive(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ source = tmp_path / "large.bin"
+ source.write_bytes(b"test payload" * 1000)
+ entered = asyncio.Event()
+ release = asyncio.Event()
+ real_prepare = web.FileResponse.prepare
+
+ async def slow_prepare(self: web.FileResponse, request: web.Request) -> Any:
+ entered.set()
+ await release.wait()
+ return await real_prepare(self, request)
+
+ monkeypatch.setattr(web.FileResponse, "prepare", slow_prepare)
+ async with runtime(tmp_path) as (_, store, _, _, clock):
+ url = await store.publish(source, "127.0.0.1")
+ async with ClientSession() as http:
+
+ async def read() -> bytes:
+ async with http.get(url) as response:
+ assert response.status == 200
+ return await response.read()
+
+ task = asyncio.create_task(read())
+ await entered.wait()
+ clock.now += 961
+ await store.cleanup()
+ assert list((tmp_path / "cache").iterdir())
+ async with http.get(url) as response:
+ assert response.status == 404
+ release.set()
+ assert await task == source.read_bytes()
+ # 读者退出之后立即释放过期副本。
+ await store.cleanup()
+ assert not store._files
+
+
+async def test_unready_runtime_preparation_error(tmp_path: Path) -> None:
+ source = tmp_path / "data"
+ source.write_bytes(b"data")
+ store = OneBotFileStore(tmp_path / "cache")
+ transport = OneBotFileTransport(
+ AsyncMock(),
+ config_getter=lambda: SimpleNamespace(onebot_file_send_mode="url"),
+ store=store,
+ )
+ with pytest.raises(FileTransferError, match="Runtime 文件服务未就绪"):
+ async with transport.prepare("upload_group_file", {"file": str(source)}):
+ pytest.fail("must not send")
+ assert not (tmp_path / "cache").exists()
+
+
+async def test_runtime_start_failure_does_not_publish(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ store = OneBotFileStore(tmp_path / "cache")
+ cfg = SimpleNamespace(api=APIConfig(auth_key="key"))
+ client = OneBotClient("ws://unused")
+ server = RuntimeAPIServer(context(cfg, client), "127.0.0.1", 0, file_store=store)
+ monkeypatch.setattr(
+ web.TCPSite, "start", AsyncMock(side_effect=OSError("bind failed"))
+ )
+ with pytest.raises(OSError, match="bind failed"):
+ await server.start()
+ assert store.port is None
+ assert server._runner is None
+
+
+async def test_start_prunes_only_expired_module_cache_and_stop_owns_its_files(
+ tmp_path: Path,
+) -> None:
+ clock = Clock()
+ cache = tmp_path / "cache"
+ cache.mkdir()
+ expired = cache / f"onebot-{int((clock.now - 1) * 1000)}-{uuid4().hex}"
+ active = cache / f"onebot-{int((clock.now + 1000) * 1000)}-{uuid4().hex}"
+ unrelated = cache / "unrelated"
+ for directory in (expired, active, unrelated):
+ directory.mkdir()
+ (directory / "content").write_bytes(b"data")
+ store = OneBotFileStore(cache, clock=clock)
+ await store.start(12345)
+ assert not expired.exists() and active.exists() and unrelated.exists()
+ source = tmp_path / "file"
+ source.write_bytes(b"data")
+ await store.publish(source, "localhost")
+ await store.stop()
+ assert set(cache.iterdir()) == {active, unrelated}
+
+
+async def test_url_host_snapshot_during_copy(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ from Undefined.utils import io
+
+ source = tmp_path / "data"
+ source.write_bytes(b"data")
+ original_copy = io.copy_file_atomic
+ async with runtime(tmp_path) as (_, store, client, cfg, _):
+
+ async def copy(source: Path, target: Path, chunk_size: int) -> None:
+ cfg.onebot_file_send_host = "new.example"
+ await original_copy(source, target, chunk_size)
+
+ monkeypatch.setattr(io, "copy_file_atomic", copy)
+ async with client.file_transport.prepare(
+ "upload_group_file", {"file": str(source)}
+ ) as files:
+ first = files.apply("upload_group_file", {"file": str(source)})["file"]
+ async with client.file_transport.prepare(
+ "upload_group_file", {"file": str(source)}
+ ) as files:
+ second = files.apply("upload_group_file", {"file": str(source)})["file"]
+ assert urlsplit(first).hostname == "127.0.0.1"
+ assert urlsplit(second).hostname == "new.example"
+ assert len(store._files) == 2
+
+
+def test_openapi_file_token_scope() -> None:
+ from aiohttp.test_utils import make_mocked_request
+
+ cfg = SimpleNamespace(api=APIConfig())
+ spec = _build_openapi_spec(
+ context(cfg, OneBotClient("ws://unused")),
+ make_mocked_request("GET", "/openapi.json", headers={"Host": "localhost"}),
+ )
+ route = spec["paths"]["/api/v1/onebot/files/{file_id}"]
+ assert set(route) == {"get", "head"}
+ assert route["get"]["security"] == [{"OneBotFileToken": []}]
+ assert spec["security"] == [{"ApiKeyAuth": []}]
+
+
+async def test_text_file_tool_cleanup_preserves_published_url(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ from Undefined.skills.toolsets.messages.send_text_file.handler import execute
+
+ monkeypatch.chdir(tmp_path)
+ async with runtime(tmp_path) as (_, _, client, _, _):
+ send = AsyncMock(return_value={"status": "ok"})
+ client._call_api_raw = send # type: ignore[method-assign]
+ ctx: dict[str, Any] = {
+ "request_type": "group",
+ "group_id": 1,
+ "sender": SimpleNamespace(send_group_file=client.upload_group_file),
+ }
+ result = await execute({"filename": "说明.txt", "content": "保留文件内容"}, ctx)
+ assert "文件已发送" in result
+ assert not list((tmp_path / "data/cache/text_files").rglob("说明.txt"))
+ assert ctx["message_sent_this_turn"] is True
+ url = send.await_args_list[0].args[1]["file"]
+ async with ClientSession() as http:
+ async with http.get(url) as response:
+ assert await response.text() == "保留文件内容"
+
+
+async def test_text_file_tool_exposes_preparation_error(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ from Undefined.skills.toolsets.messages.send_text_file.handler import execute
+
+ monkeypatch.chdir(tmp_path)
+ error = FileTransferError("stream", "请切换 onebot.file_send_mode")
+ send = AsyncMock(side_effect=error)
+ ctx: dict[str, Any] = {
+ "request_type": "group",
+ "group_id": 1,
+ "sender": SimpleNamespace(send_group_file=send),
+ }
+ assert (
+ await execute({"filename": "说明.txt", "content": "文件内容"}, ctx)
+ == error.user_message
+ )
+ assert not ctx.get("message_sent_this_turn")
+ assert not list((tmp_path / "data/cache/text_files").rglob("说明.txt"))
+
+
+@pytest.mark.parametrize("fail", [False, True])
+async def test_url_file_tool_cleanup_and_error_feedback(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, fail: bool
+) -> None:
+ from Undefined.skills.toolsets.messages.send_url_file import handler
+ from Undefined.utils import io
+
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setattr(
+ handler,
+ "probe_remote_file",
+ AsyncMock(
+ return_value=SimpleNamespace(
+ content_length=6, final_url="https://example.org/file.txt", headers={}
+ )
+ ),
+ )
+
+ async def download(**kwargs: Any) -> tuple[str, int]:
+ path = Path(kwargs["target_path"])
+ await io.write_bytes(path, b"remote")
+ return str(path.resolve()), 6
+
+ monkeypatch.setattr(handler, "_download_to_local_file", download)
+ async with runtime(tmp_path) as (_, _, client, _, _):
+ send = AsyncMock(return_value={"status": "ok"})
+ client._call_api_raw = send # type: ignore[method-assign]
+ error = FileTransferError("stream", "请切换 onebot.file_send_mode")
+ file_send = AsyncMock(side_effect=error) if fail else client.upload_group_file
+ ctx: dict[str, Any] = {
+ "request_type": "group",
+ "group_id": 1,
+ "sender": SimpleNamespace(send_group_file=file_send),
+ }
+ result = await handler.execute(
+ {"url": "https://example.org/file.txt", "filename": "file.txt"}, ctx
+ )
+ assert not list((tmp_path / "data/cache/url_files").rglob("file.txt"))
+ if fail:
+ assert result == error.user_message
+ assert not ctx.get("message_sent_this_turn")
+ send.assert_not_awaited()
+ else:
+ assert "文件已发送" in result
+ async with ClientSession() as http:
+ async with http.get(
+ send.await_args_list[0].args[1]["file"]
+ ) as response:
+ assert await response.read() == b"remote"
diff --git a/tests/test_send_message_tool.py b/tests/test_send_message_tool.py
index aaf10487..4c26e7e7 100644
--- a/tests/test_send_message_tool.py
+++ b/tests/test_send_message_tool.py
@@ -12,6 +12,7 @@
from Undefined.attachments import AttachmentRecord, AttachmentRegistry
from Undefined.context import RequestContext
from Undefined.onebot.client import OneBotDeliveryUncertainError
+from Undefined.onebot.file_errors import FileTransferError
from Undefined.skills.toolsets.messages.context_utils import DELIVERY_UNCERTAIN_RESULT
from Undefined.skills.toolsets.messages.send_message.handler import execute
from Undefined.utils import io as async_io
@@ -30,6 +31,128 @@ def _tool_context(**values: Any) -> dict[str, Any]:
return {"mark_message_sent_this_turn": mark_message_sent_this_turn, **values}
+async def test_file_only_message_surfaces_preparation_error(tmp_path: Path) -> None:
+ registry = AttachmentRegistry(
+ registry_path=tmp_path / "registry.json", cache_dir=tmp_path / "attachments"
+ )
+ record = await registry.register_bytes(
+ "group:10001", b"file", kind="file", display_name="data.txt", source_kind="test"
+ )
+ error = FileTransferError("stream", "请切换 onebot.file_send_mode")
+ sender = SimpleNamespace(
+ send_address_message=AsyncMock(), send_address_file=AsyncMock(side_effect=error)
+ )
+ context = _tool_context(
+ request_type="group",
+ group_id=10001,
+ sender_id=20002,
+ runtime_config=_build_runtime_config(),
+ sender=sender,
+ attachment_registry=registry,
+ )
+ result = await execute({"message": f''}, context)
+ assert result == error.user_message
+ assert not context.get("message_sent_this_turn")
+ sender.send_address_file.assert_awaited_once()
+ sender.send_address_message.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_send_message_reports_partial_delivery_before_transfer_error(
+ tmp_path: Path,
+) -> None:
+ registry = AttachmentRegistry(
+ registry_path=tmp_path / "registry.json", cache_dir=tmp_path / "attachments"
+ )
+ first = await registry.register_bytes(
+ "group:10001", b"one", kind="file", display_name="one.txt", source_kind="test"
+ )
+ second = await registry.register_bytes(
+ "group:10001", b"two", kind="file", display_name="two.txt", source_kind="test"
+ )
+ error = FileTransferError("stream", "请切换 onebot.file_send_mode")
+ sender = SimpleNamespace(
+ send_address_message=AsyncMock(),
+ send_address_file=AsyncMock(side_effect=[{"status": "ok"}, error]),
+ )
+ context = _tool_context(
+ request_type="group",
+ group_id=10001,
+ sender_id=20002,
+ runtime_config=_build_runtime_config(),
+ sender=sender,
+ attachment_registry=registry,
+ )
+
+ result = await execute(
+ {
+ "message": (
+ f''
+ )
+ },
+ context,
+ )
+
+ assert result == f"仅成功发送 1/2 个附件:{error.user_message}"
+ assert context["message_sent_this_turn"] is True
+ assert sender.send_address_file.await_count == 2
+
+
+@pytest.mark.asyncio
+async def test_send_message_reports_body_sent_when_file_transfer_fails(
+ tmp_path: Path,
+) -> None:
+ registry = AttachmentRegistry(
+ registry_path=tmp_path / "registry.json", cache_dir=tmp_path / "attachments"
+ )
+ record = await registry.register_bytes(
+ "group:10001", b"file", kind="file", display_name="data.txt", source_kind="test"
+ )
+ error = FileTransferError("stream", "请切换 onebot.file_send_mode")
+ sender = SimpleNamespace(
+ send_address_message=AsyncMock(), send_address_file=AsyncMock(side_effect=error)
+ )
+ context = _tool_context(
+ request_type="group",
+ group_id=10001,
+ sender_id=20002,
+ runtime_config=_build_runtime_config(),
+ sender=sender,
+ attachment_registry=registry,
+ )
+
+ result = await execute(
+ {"message": f'附件如下\n'}, context
+ )
+
+ assert result == f"消息正文已发送,但仅成功发送 0/1 个附件:{error.user_message}"
+ assert context["message_sent_this_turn"] is True
+ sender.send_address_message.assert_awaited_once()
+ sender.send_address_file.assert_awaited_once()
+
+
+@pytest.mark.asyncio
+async def test_send_message_does_not_claim_body_sent_when_body_transfer_fails() -> None:
+ error = FileTransferError("url", "Runtime 文件服务未就绪", stage="prepare")
+ sender = SimpleNamespace(
+ send_address_message=AsyncMock(side_effect=error),
+ send_address_file=AsyncMock(),
+ )
+ context = _tool_context(
+ request_type="group",
+ group_id=10001,
+ sender_id=20002,
+ runtime_config=_build_runtime_config(),
+ sender=sender,
+ )
+
+ result = await execute({"message": "带内联图片的正文"}, context)
+
+ assert result == error.user_message
+ assert not context.get("message_sent_this_turn")
+ sender.send_address_file.assert_not_awaited()
+
+
@pytest.mark.asyncio
async def test_send_message_schema_rejects_mixed_address_parameters() -> None:
config_text = await async_io.read_text(
diff --git a/tests/test_send_private_message_tool.py b/tests/test_send_private_message_tool.py
index 47456cbc..99f5ac12 100644
--- a/tests/test_send_private_message_tool.py
+++ b/tests/test_send_private_message_tool.py
@@ -12,6 +12,7 @@
from Undefined.attachments import AttachmentRegistry
from Undefined.context import RequestContext
from Undefined.onebot.client import OneBotDeliveryUncertainError
+from Undefined.onebot.file_errors import FileTransferError
from Undefined.skills.toolsets.messages.context_utils import DELIVERY_UNCERTAIN_RESULT
from Undefined.skills.toolsets.messages.send_private_message.handler import execute
from Undefined.utils import io as async_io
@@ -231,6 +232,74 @@ async def test_send_private_message_dispatches_file_only_without_empty_message(
assert context["message_sent_this_turn"] is True
+@pytest.mark.asyncio
+async def test_send_private_message_reports_partial_delivery_before_transfer_error(
+ tmp_path: Path,
+) -> None:
+ registry = AttachmentRegistry(
+ registry_path=tmp_path / "attachment_registry.json",
+ cache_dir=tmp_path / "attachments",
+ )
+ first = await registry.register_bytes(
+ "private:12345", b"one", kind="file", display_name="one.txt", source_kind="test"
+ )
+ second = await registry.register_bytes(
+ "private:12345", b"two", kind="file", display_name="two.txt", source_kind="test"
+ )
+ error = FileTransferError("stream", "请切换 onebot.file_send_mode")
+ sender = SimpleNamespace(
+ send_address_message=AsyncMock(),
+ send_address_file=AsyncMock(side_effect=[{"status": "ok"}, error]),
+ )
+ context: dict[str, Any] = _tool_context(
+ request_type="private",
+ user_id=12345,
+ sender_id=12345,
+ request_id="req-private-file-partial",
+ runtime_config=_build_runtime_config(),
+ sender=sender,
+ attachment_registry=registry,
+ )
+
+ result = await execute(
+ {
+ "message": (
+ f''
+ )
+ },
+ context,
+ )
+
+ assert result == f"仅成功发送 1/2 个私聊附件:{error.user_message}"
+ assert context["message_sent_this_turn"] is True
+ assert sender.send_address_file.await_count == 2
+
+
+@pytest.mark.asyncio
+async def test_send_private_message_does_not_claim_body_sent_when_body_transfer_fails() -> (
+ None
+):
+ error = FileTransferError("url", "Runtime 文件服务未就绪", stage="prepare")
+ sender = SimpleNamespace(
+ send_address_message=AsyncMock(side_effect=error),
+ send_address_file=AsyncMock(),
+ )
+ context: dict[str, Any] = _tool_context(
+ request_type="private",
+ user_id=12345,
+ sender_id=12345,
+ request_id="req-private-body-failure",
+ runtime_config=_build_runtime_config(),
+ sender=sender,
+ )
+
+ result = await execute({"message": "带内联图片的正文"}, context)
+
+ assert result == error.user_message
+ assert not context.get("message_sent_this_turn")
+ sender.send_address_file.assert_not_awaited()
+
+
@pytest.mark.asyncio
async def test_send_private_message_marks_uncertain_file_delivery_as_attempted(
tmp_path: Path,
diff --git a/tests/test_sender.py b/tests/test_sender.py
index b11d5ea6..ef6b85aa 100644
--- a/tests/test_sender.py
+++ b/tests/test_sender.py
@@ -22,6 +22,7 @@
from Undefined.attachments import AttachmentRegistry
from Undefined.context import RequestContext
from Undefined.onebot.client import OneBotDeliveryUncertainError
+from Undefined.onebot.file_errors import FileTransferError
from Undefined.utils import io as async_io
from Undefined.utils.message_reply import ReplyContext
from Undefined.utils.message_targets import DeliveryAddress
@@ -53,6 +54,25 @@ def sender() -> MessageSender:
return MessageSender(onebot, history_manager, bot_qq=10000, config=config)
+@pytest.mark.parametrize("temp_group_id", [None, 123])
+async def test_private_preparation_error_does_not_try_other_sessions(
+ sender: MessageSender, temp_group_id: int | None
+) -> None:
+ onebot = cast(Any, sender.onebot)
+ onebot.send_private_message = AsyncMock(
+ side_effect=FileTransferError("stream", "unsupported")
+ )
+ onebot.get_group_list = AsyncMock()
+ with pytest.raises(FileTransferError):
+ await sender._send_private_segments(
+ 1,
+ [{"type": "image", "data": {"file": "/local/image.png"}}],
+ temp_group_id=temp_group_id,
+ )
+ onebot.send_private_message.assert_awaited_once()
+ onebot.get_group_list.assert_not_awaited()
+
+
def test_file_uri_path_text_supports_windows_drive_and_unc(
monkeypatch: pytest.MonkeyPatch,
) -> None:
diff --git a/tests/test_webui_config_form_frontend.py b/tests/test_webui_config_form_frontend.py
index 13301d35..ce736b9d 100644
--- a/tests/test_webui_config_form_frontend.py
+++ b/tests/test_webui_config_form_frontend.py
@@ -25,6 +25,64 @@ def _read_source(path: Path) -> str:
return text
+def test_onebot_file_mode_select_save_reload_and_other_enums() -> None:
+ if shutil.which("node") is None:
+ pytest.skip("node is required")
+ source = _read_source(CONFIG_FORM_JS)
+ section = source[
+ source.index("const FIELD_SELECT_EMPTY_OPTION") : source.index(
+ "const AOT_PATHS"
+ )
+ ]
+ script = r"""
+const fs = require("node:fs");
+const vm = require("node:vm");
+const assert = require("node:assert/strict");
+let saved = null;
+let current = null;
+const context = {
+ document: { createElement(tag) { return {
+ tag, dataset: {}, children: [],
+ appendChild(child) { this.children.push(child); },
+ setAttribute() {},
+ }; } },
+ getComment: () => "仅 URL 模式使用,不包含协议、端口或路径",
+ isSensitiveKey: () => false,
+ isLongText: () => false,
+ autoSave: () => { saved = JSON.stringify({ onebot: { file_send_mode: current.value } }); },
+ scheduleAutoSave() {},
+};
+vm.createContext(context);
+vm.runInContext(fs.readFileSync(0, "utf8"), context);
+for (const mode of ["stream", "local", "url"]) {
+ const group = context.createField("onebot.file_send_mode", mode);
+ const select = group.children.at(-1);
+ assert.equal(select.tag, "select");
+ assert.equal(select.dataset.valueType, "string");
+ assert.deepEqual(select.children.map(o => o.value), ["local", "url", "stream"]);
+ assert.equal(select.children.find(o => o.selected).value, mode);
+ current = select;
+ select.value = mode;
+ select.onchange();
+ const restored = context.createField("onebot.file_send_mode", JSON.parse(saved).onebot.file_send_mode);
+ assert.equal(restored.children.at(-1).children.find(o => o.selected).value, mode);
+}
+const host = context.createField("onebot.file_send_host", "127.0.0.1");
+assert.equal(host.children.at(-1).tag, "input");
+assert.match(host.children[1].innerText, /仅 URL 模式使用/);
+assert.equal(Array.from(context.getFieldSelectOptions("access.mode")).join(","), "off,blacklist,allowlist");
+assert.equal(Array.from(context.getFieldSelectOptions("message_batcher.strategy")).join(","), "extend,fixed");
+"""
+ result = subprocess.run(
+ ["node", "-e", script],
+ input=section,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+ assert result.returncode == 0, result.stderr
+
+
def _has_bare_form_group_query(source: str) -> bool:
"""True if source still queries all .form-group nodes (not only [data-path])."""
return (
diff --git a/uv.lock b/uv.lock
index e0955908..cb8f0255 100644
--- a/uv.lock
+++ b/uv.lock
@@ -4704,7 +4704,7 @@ wheels = [
[[package]]
name = "undefined-bot"
-version = "3.13.3"
+version = "3.14.0"
source = { editable = "." }
dependencies = [
{ name = "aiofiles" },