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" },