diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b71e641..8f61a87b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +## v3.18.1 JM 本子文件改到转发外发送 + +本版本修复禁漫自动提取的加密 PDF 在合并转发节点里下载失败的问题:转发退回「本子信息 / 解密密码」两个节点,PDF 改为转发之后单独发一条文件消息。 + +- 加密 PDF 不再放进合并转发:转发节点里的文件在 QQ 客户端下载会失败,改为紧随转发之后单独发送(群聊走群文件上传、私聊走私聊文件),独立文件消息可以正常下载;群聊下同一个 PDF 仍会出现在群文件列表里。`build_forward_nodes()` 因此不再接收 PDF 路径,没有 PDF 时依然是「本子信息 / 状态」两个节点。 +- 转发与文件分离后的投递语义:转发被协议端**明确拒绝**时退化为「信息 + 密码两条普通消息」,文件照旧单独发送;转发遇到「投递结果未确认」(`delivery_uncertain`)或「文件传输错误」(`file_transfer_error`)时不重发转发与信息/密码(转发可能已经送达),但文件此前从未发出、不算重复投递,仍会继续单独发送,否则整本下载白跑。文件自身投递结果未确认时直接上抛不重发,普通失败时补一条「PDF 上传失败」提示(这条提示写入历史,转发投递未确认时提示里也不会声称信息与密码已经送达)。 +- 发送状态与历史摘要同步:状态前缀改为「已发送合并转发 / 合并转发被拒,已改为普通消息发送信息与密码 / 合并转发投递结果未确认」+「PDF 文件已单独发送」或「PDF 文件发送失败」;写历史时摘要只说「PDF: 加密文件随后单独发送(…,密码见转发节点)」——这条摘要随转发落地、此时文件尚未上传,不能提前声称已发送,文件真正发出后由 `send_group_file` 自己补一条 `[文件] …` 历史记录。密码仍然只出现在转发节点里、不进历史。 + +--- + ## v3.18.0 JM 漫画自动提取 本版本新增禁漫自动提取管线:消息里出现 `JM` 车号或禁漫链接时,自动获取本子详情、把全部章节合成为一个带密码的 PDF,以「本子信息 / 解密密码 / PDF 文件」三节点合并转发发送(文件只放在转发里,群聊下同时进入群文件列表),并新增与 `arxiv_paper` 同构的 `jm_book` 工具供 AI 按需取详情或附件。功能默认关闭,密码随机生成且不写进历史。 diff --git a/README.md b/README.md index 3545f4b0..3e1acf77 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ - **可选音乐工具集**:配置独立部署的 [lxmusic2api](https://github.com/69gg/lxmusic2api) 后,主 AI 可搜索歌曲/歌单、浏览热搜与排行榜、读取歌词/封面/评论、跨平台匹配并交付普通音频附件;完整 Track 通过任务内短引用流转,只暴露面向用户的高层 `music.*` 能力,不暴露下载作业生命周期接口。详见 [使用指南](docs/usage.md#音乐-music) 与 [配置说明](docs/configuration.md#4201-lxmusic2api-音乐服务)。 - **arXiv 论文提取、搜索与分析**:自动检测消息中的 arXiv 链接/标识并发送论文信息与 PDF;`arxiv_paper` 也可只返回 PDF 附件 UID,供 `file_analysis_agent` 做文本提取或指定页视觉分析;`arxiv_search` 负责论文检索。 - **GitHub 仓库卡片**:自动检测 GitHub 仓库链接或 `owner/repo` 仓库 ID,获取 public 仓库信息并发送简洁图片卡片,展示头像、简介、stars、forks、issues、contributors 等概览;API 与头像下载统一遵循 `[github].use_proxy`,头像以内联资源进入离线渲染器。 -- **禁漫(JM)本子提取**:自动检测 `JM`+车号或禁漫链接,下载全部章节并合成为一个 AES-256 加密 PDF,以「本子信息 / 解密密码 / PDF 文件」三节点合并转发发送(文件只在转发里,群聊下同时进入群文件列表),密码每次随机生成且不写入历史;`jm_book` 工具也可只返回本子信息或未加密 PDF 附件 UID,供 `file_analysis_agent` 解析。默认关闭,`[jm].auto_extract_enabled` 打开。详见 [配置说明](docs/configuration.md#4203-jm-禁漫自动提取)。 +- **禁漫(JM)本子提取**:自动检测 `JM`+车号或禁漫链接,下载全部章节并合成为一个 AES-256 加密 PDF,以「本子信息 / 解密密码」两节点合并转发发送,加密 PDF 不进转发、随后单独作为一条文件消息发送(转发里的文件在 QQ 客户端下载会失败,独立文件消息才下得动,群聊下同时进入群文件列表),密码每次随机生成且不写入历史;`jm_book` 工具也可只返回本子信息或未加密 PDF 附件 UID,供 `file_analysis_agent` 解析。默认关闭,`[jm].auto_extract_enabled` 打开。详见 [配置说明](docs/configuration.md#4203-jm-禁漫自动提取)。 - **自动处理管线**:Bilibili、arXiv、GitHub、JM 等自动提取统一运行在 `skills/pipelines` 中,斜杠命令优先级更高;命令输入/输出会写入历史,非命令消息会并行检测和处理命中管线,结果通过统一发送层写入历史并登记附件 UID 后再进入 AI 回复。远程大附件超过 `[attachments].remote_download_max_size_mb` 时只登记 URL 引用,避免无界下载和缓存膨胀。 - **同 sender 短时消息合并**:默认开启。连续发的多条消息会合并到同一轮 AI 调用,AI 一次看到全部意图自行识别"独立请求/修正/打断";告别"画猫→改成狗"的重复触发与回复打架。主提示词按 batcher 的"当前输入批次"语义适配,关闭该功能可能导致连续补充/修正消息与提示词不匹配,需要单独适配。可选投机预发送让用户停顿时 LLM 提前开跑、新消息可在未发出回复前取消,进一步压低响应延迟。详见 [docs/message-batching.md](docs/message-batching.md)。 - **思维链支持**:支持开启思维链,提升复杂逻辑推理能力。 diff --git a/apps/undefined-chat/package-lock.json b/apps/undefined-chat/package-lock.json index 836ece28..2dbbf402 100644 --- a/apps/undefined-chat/package-lock.json +++ b/apps/undefined-chat/package-lock.json @@ -1,12 +1,12 @@ { "name": "undefined-chat", - "version": "3.18.0", + "version": "3.18.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "undefined-chat", - "version": "3.18.0", + "version": "3.18.1", "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 249ab812..2af7a9d4 100644 --- a/apps/undefined-chat/package.json +++ b/apps/undefined-chat/package.json @@ -1,7 +1,7 @@ { "name": "undefined-chat", "private": true, - "version": "3.18.0", + "version": "3.18.1", "type": "module", "scripts": { "tauri": "tauri", diff --git a/apps/undefined-chat/src-tauri/Cargo.lock b/apps/undefined-chat/src-tauri/Cargo.lock index 7fd69e38..c7858ea8 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.18.0" +version = "3.18.1" dependencies = [ "futures-util", "keyring", diff --git a/apps/undefined-chat/src-tauri/Cargo.toml b/apps/undefined-chat/src-tauri/Cargo.toml index aa85a510..8fb2c3bc 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.18.0" +version = "3.18.1" 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 0cf1a429..a8fee1ec 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.18.0", + "version": "3.18.1", "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 c6c248a0..48ff527c 100644 --- a/apps/undefined-console/package-lock.json +++ b/apps/undefined-console/package-lock.json @@ -1,12 +1,12 @@ { "name": "undefined-console", - "version": "3.18.0", + "version": "3.18.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "undefined-console", - "version": "3.18.0", + "version": "3.18.1", "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 9897006c..063f4cc7 100644 --- a/apps/undefined-console/package.json +++ b/apps/undefined-console/package.json @@ -1,7 +1,7 @@ { "name": "undefined-console", "private": true, - "version": "3.18.0", + "version": "3.18.1", "type": "module", "scripts": { "tauri": "tauri", diff --git a/apps/undefined-console/src-tauri/Cargo.lock b/apps/undefined-console/src-tauri/Cargo.lock index c9d1ccb5..d91f1628 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.18.0" +version = "3.18.1" dependencies = [ "serde", "serde_json", diff --git a/apps/undefined-console/src-tauri/Cargo.toml b/apps/undefined-console/src-tauri/Cargo.toml index bba400fd..c16cc646 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.18.0" +version = "3.18.1" 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 464fef3d..fd27b87b 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.18.0", + "version": "3.18.1", "identifier": "com.undefined.console", "build": { "beforeDevCommand": "npm run dev", diff --git a/docs/configuration.md b/docs/configuration.md index c1750999..43bbbbc3 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1139,15 +1139,15 @@ api_key = "replace-with-your-key" - 裸数字不触发,避免群号、时间戳等误报;QQ 分享卡片(`json` 消息段)里的链接同样会解析。 自动提取行为: -- 命中后发送一次外层合并转发,固定三个节点:**本子信息**(车号、标题、作者、章节数、标签、观看/点赞、页数、大小、简介预览,末尾是可直接复制的 `JM<车号>`)、**PDF 解密密码**(每次随机生成 8 位,字母表去掉 `0/O/1/l/I` 等形近字符)、**PDF 文件**(本地合成后随转发上传,群聊下会成为真正的群文件)。文件只走转发,不额外发独立文件消息。 +- 命中后发送一次外层合并转发,固定两个节点:**本子信息**(车号、标题、作者、章节数、标签、观看/点赞、页数、大小、简介预览,末尾是可直接复制的 `JM<车号>`)、**PDF 解密密码**(每次随机生成 8 位,字母表去掉 `0/O/1/l/I` 等形近字符)。加密 PDF **不进转发**,而是紧随转发之后单独作为一条文件消息发送。 - 多章节本子会把全部章节按顺序下载后合成为一个 PDF(`max_chapters` 可限制章数),PDF 用 AES-256 加密,打开时需要密码。 - 每页按 `pdf_dpi` 统一换算物理尺寸(`页宽 = 像素宽 ÷ DPI × 72`),下载与合成都不缩放像素:PDF 里的页就是站点源图的原始分辨率,画质上限由站点自己提供的图决定。 - 每页只做一次有损编码:取服务端原始字节(无损落盘)→ 解扰 → JPEG(`image_quality`,4:4:4)→ 直接作为 PDF 的图像数据,中间不再多一代重编码。需要更高画质时可把 `image_quality` 提到 `98`/`100`(约 `+30%`/`+50%` 体积),源图本身的分辨率无法通过配置提升。 -- 单次合并转发里的 PDF 在内存中合成后写盘,所以 `max_file_size` 只是**近似**内存上限:先按各章节原始图片累计预判(超限即停止下载),组装时再按**编码后字节**累计复核,超过上限就提前中止、不发文件;PDF 是重编码 JPEG(4:4:4 / q95 可能比源 WebP 大),峰值可以略高于该值。 +- 单次合并转发的 PDF 在内存中合成后写盘,所以 `max_file_size` 只是**近似**内存上限:先按各章节原始图片累计预判(超限即停止下载),组装时再按**编码后字节**累计复核,超过上限就提前中止、不发文件;PDF 是重编码 JPEG(4:4:4 / q95 可能比源 WebP 大),峰值可以略高于该值。 - 密码只出现在转发节点里;写入历史的摘要只说明「密码见转发节点」,因此后续 AI 轮次能看到「发过什么」,但不会把密码复述出来。 -- 转发节点里的 PDF 随转发一起上传:群聊下 NapCat 会把它作为群文件上传(`busid=102`),所以同一个 PDF 也会出现在群文件列表里,节点里的文件可以直接下载(已用 14MB 的 PDF 实测)。文件只发一次,不额外发独立文件消息。 -- 转发本身被拒时退化为两条普通消息(信息、密码)+ 一条独立文件消息(此时文件没有别的入口,必须发出来);密码消息写进历史时只记「PDF 解密密码已单独发送」,不重复写摘要,上传彻底失败时再补一条不写历史的提示。 -- 「投递结果未确认」(`delivery_uncertain`)与「文件传输错误」(`file_transfer_error`)**不降级重发**,直接上抛给调用方:这两种情况下转发可能已经送达,换个 action 再发会造成真实重复投递(判据与 B 站发送器一致)。 +- PDF 不放进转发:转发节点里的文件在 QQ 客户端下载会失败(2026-10-01 实测),所以加密 PDF 在转发之后单独作为一条文件消息发送,独立文件消息可以正常下载;群聊下同一个 PDF 因此也会出现在群文件列表里。 +- 转发本身被明确拒绝时退化为两条普通消息(信息、密码),文件照旧单独发送;密码消息写进历史时只记「PDF 解密密码已单独发送」,不重复写摘要。独立文件也上传失败时再补一条会写入历史的「PDF 上传失败」提示(转发投递结果未确认时,提示不声称信息与密码已经送达)。历史摘要只说文件「随后单独发送」,上传成功由 `send_group_file` 自己补一条 `[文件] …` 记录,因此历史能区分文件到底发出去没有。 +- 「投递结果未确认」(`delivery_uncertain`)与「文件传输错误」(`file_transfer_error`)**不降级重发**转发与信息/密码:这两种情况下转发可能已经送达,换个 action 再发会造成真实重复投递(判据与 B 站发送器一致)。文件是另一条此前从未发出的消息,这种情况下仍会继续单独发送;文件自身投递结果未确认时直接上抛,不重发。 - 降级路径会把密码作为普通消息发出(转发发不出去时这是唯一能拿到密码的方式),因此这条消息的正文也会进入本地 INFO 日志的「内容摘要」,转发节点路径则不会。密码始终不进历史摘要与转发卡片之外的用户可见正文。 - 下载量超过 `max_file_size` 或没有下到任何页面时,只发送信息与状态说明两个节点(此时信息里不展示 PDF 大小,因为还没有 PDF),不发密码与文件。 - 车号不存在、接口不可用时发送一行 `JM 提取失败:<原因>`,日志记录异常类型与堆栈;车号与链接混排时按原文顺序取第一个,分享卡片结构异常只跳过该段。 diff --git a/docs/pipelines.md b/docs/pipelines.md index e6d56f48..94980bdc 100644 --- a/docs/pipelines.md +++ b/docs/pipelines.md @@ -53,16 +53,16 @@ Bilibili 图文管线命中 `bilibili.com/opus/`、`t.bilibili.com/`、` JM 管线命中 `JM` 前缀加 5–8 位车号(`JM1114751`、`jm 1114751`、`jm:1114751`)或主机名含 `18comic` / `jmcomic` 的链接(`/album/`、`/photo/`、`?id=`)后,发送一次外层合并转发,节点顺序固定: 1. `本子信息`:车号与标题、作者、章节数、标签、观看/点赞、页数、PDF 大小、简介预览,末行是可直接复制的 `JM<车号>`(不带站点链接); -2. `解密密码`:每次随机生成的 8 位 PDF 打开密码; -3. `PDF`:本地合成好的加密 PDF(群聊下会作为真正的群文件上传)。 +2. `解密密码`:每次随机生成的 8 位 PDF 打开密码。 -PDF 随转发一起上传,节点里的文件可以直接下载(已用 14MB 的 PDF 实测);群聊下 NapCat 会把它作为群文件上传(`isGroupFile`、`busid=102`,元素里带 `fileId` / `fileMd5` / `fileSha1`),所以同一个 PDF 也会出现在群文件列表里。文件只发一次,不额外发独立文件消息;只有转发本身发送失败时才退化为「信息 + 密码 + 独立文件消息」。 +加密 PDF **不进转发**,而是紧随转发之后单独发一条文件消息:转发节点里的文件在 QQ 客户端下载会失败(2026-10-01 实测,同一份文件独立发送可正常下载),独立文件消息才是可下载的入口,群聊下同一个 PDF 也会出现在群文件列表里。 多章节本子按章节顺序全部下载(`[jm].max_chapters` 可限制),再合成为一个 AES-256 加密 PDF。裸数字不触发,避免群号、时间戳等误报;`xxjm1234567` 这类前缀也不触发。 失败语义: -- 转发被拒时退化为两条普通消息(信息、密码)+ 独立文件消息(这种兜底情况下文件没有别的入口)。密码始终不会写进历史摘要。 +- 转发被明确拒绝时退化为两条普通消息(信息、密码),文件照旧单独发送;密码始终不会写进历史摘要。 +- 投递结果未确认(`delivery_uncertain` / `file_transfer_error`)时不重发转发与信息/密码(可能已经送达),但文件此前从未发出,仍会继续单独发送,否则整本下载白跑;文件自身投递结果未确认时直接上抛,不重发。 - 下载量或 PDF 体积超过 `[jm].max_file_size`(原始图片累计预判 + 组装时按编码后字节复核)、以及没有下到任何页面时,只发信息与状态两个节点,不发密码与文件。 PDF 合成没有走 `jmcpy.imaging.write_pdf`,而是自己用 PyMuPDF 逐页写入:jmcpy 的下载 API 一章只能产出一个 PDF,而这里要把多个章节合进同一个文件;同时 PATH 输出会先把解扰后的图重新编码一次、合成时再编码第二次,这里改为 `decode=False` 取服务端原始字节(无损落盘)、自己解扰、每页只编码一次 JPEG(`[jm].image_quality`,色度 4:4:4)后直接作为 PDF 图像数据,并按 `[jm].pdf_dpi` 统一页尺寸、做 AES-256 加密。页像素始终不做缩放,画质上限由站点源图自身分辨率决定。(jmcpy ≤0.1.1 的 `write_pdf` 另有「追加页退回 72 DPI、同文档页尺寸不一致」的问题,已在 0.1.2 修复;页内色度采样从 0.1.3 起默认也是 4:4:4。本管线依赖 `jmcpy>=0.1.3`。) diff --git a/pyproject.toml b/pyproject.toml index 3b1c5064..23d7ce14 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "Undefined-bot" -version = "3.18.0" +version = "3.18.1" 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 9a7b1e24..7e7a119a 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.18.0" +__version__: str = "3.18.1" # symbol -> (module_path, attribute_name);首次访问时才 importlib 加载 _LAZY_IMPORTS: dict[str, tuple[str, str]] = { diff --git a/src/Undefined/jm/sender.py b/src/Undefined/jm/sender.py index f7d74bc4..6e339c5f 100644 --- a/src/Undefined/jm/sender.py +++ b/src/Undefined/jm/sender.py @@ -1,6 +1,7 @@ """禁漫的合并转发发送与附件交付。 -自动提取一次发送三个节点:本子信息、PDF 解密密码、PDF 文件(本地合成后随转发上传)。 +自动提取先发「本子信息 + PDF 解密密码」两节点合并转发,随后把本地合成的加密 PDF +作为独立文件消息单独发出:转发节点里的文件在 QQ 客户端下载会失败,所以文件不进转发。 工具侧另有两条路:``format_jm_book_info`` 只输出详情(不下载), ``fetch_jm_book_attachment`` 下载整本并把**未加密** PDF 注册为附件 UID(不发送), 供 ``file_analysis_agent`` 继续解析。密码始终不会写进历史。 @@ -117,19 +118,16 @@ def build_password_text(password: str) -> str: def build_forward_nodes( info_text: str, *, - pdf_path: Path | None = None, password: str = "", status_text: str = "", ) -> list[dict[str, Any]]: - """构建合并转发节点:信息 / 密码或状态 / PDF 文件。 + """构建合并转发节点:信息 +(密码或状态)。 - PDF 是本地合成好的真实文件,随转发一起上传:群聊下 NapCat 会把它作为群文件上传 - (``isGroupFile``、``busid=102``,元素里带 ``fileId`` / ``fileMd5`` / ``fileSha1``), - 因此同一个 PDF 也会出现在群文件列表里,转发节点里的文件可以直接下载 - (2026-10-01 用 14MB PDF 实测通过)。文件只发一次,不另外发独立文件消息。 + PDF 不放进转发:转发节点里的文件在 QQ 客户端下载会失败,改由 :func:`_send_result` + 在转发之后单独发一条文件消息(2026-10-01 实测,同一份文件独立发送可正常下载)。 """ - if pdf_path is None: - # 没有可发送的 PDF 时用状态说明替代密码与文件节点 + if not password: + # 没有可发送的 PDF 时用状态说明替代密码节点 return [ _node(info_text, name="本子信息"), _node(status_text or "PDF 未发送", name="状态"), @@ -137,18 +135,6 @@ def build_forward_nodes( return [ _node(info_text, name="本子信息"), _node(build_password_text(password), name="解密密码"), - _node( - [ - { - "type": "file", - "data": { - "file": f"file://{pdf_path.resolve()}", - "name": pdf_path.name, - }, - } - ], - name="PDF", - ), ] @@ -161,8 +147,10 @@ def build_history_message(result: JmDownload) -> str: f"章节: {result.chapter_count} 章 | 页数: {result.page_count}", ] if result.ok: + # 这条摘要随转发写入,此时文件还没上传:不能提前声称已发送。 + # 上传成功时 send_group_file 会自己补一条「[文件] … 」历史,失败时补提示。 lines.append( - f"PDF: 已发送加密文件({_format_size(result.size_bytes)},密码见转发节点)" + f"PDF: 加密文件随后单独发送({_format_size(result.size_bytes)},密码见转发节点)" ) else: lines.append("PDF: 未发送") @@ -332,6 +320,12 @@ def _is_fatal_delivery_error(exc: BaseException) -> bool: ) +#: 转发投递结果的三态,既进日志也拼进返回值 +_FORWARD_SENT = "已发送合并转发" +_FORWARD_REJECTED = "合并转发被拒,已改为普通消息发送信息与密码" +_FORWARD_UNCERTAIN = "合并转发投递结果未确认" + + async def _send_result( sender: "MessageSender", target_type: Literal["group", "private"], @@ -343,57 +337,68 @@ async def _send_result( pdf_path: Path, history_message: str, ) -> str: - """发送「信息 + 密码 + PDF」三节点合并转发,返回可记录的真实投递状态。 - - 文件只在转发里,不再额外发独立文件消息。群聊下这次上传会让 PDF 同时出现在群的 - 文件列表里(``busid=102``),那是 QQ 自己的行为,不是我们单独发出去的。 - 投递未确认 / 文件传输错误一律上抛,只有协议端**明确拒绝**才降级重发;降级时 - 文件没有别的入口,必须补发一条独立文件消息,否则用户拿不到 PDF。 + """发送「信息 + 密码」合并转发,再单独发送 PDF 文件,返回可记录的真实投递状态。 + + 文件不进转发:转发节点里的文件在 QQ 客户端下载会失败,独立文件消息才下得动。 + 转发被协议端**明确拒绝**时降级为两条普通消息(信息 / 密码)。投递结果未确认 + (``delivery_uncertain`` / ``file_transfer_error``)时不能降级重发信息与密码—— + 转发可能已经送达;但文件是另一条消息、此前从未发出,必须继续单独发送,否则用户 + 整本下载白跑。文件自身投递结果未确认时同样不重发,直接上抛调用方;普通失败时补 + 一条写入历史的「PDF 上传失败」提示,转发投递未确认时提示不声称信息与密码已送达。 """ - nodes = build_forward_nodes(info_text, pdf_path=pdf_path, password=password) + nodes = build_forward_nodes(info_text, password=password) + forward_status = _FORWARD_SENT try: await _send_forward( sender, target_type, target_id, nodes, history_message=history_message ) except Exception as exc: if _is_fatal_delivery_error(exc): - logger.error("[JM] 合并转发投递结果未确认,不重发: book=%s", book_id) - raise - logger.exception("[JM] 合并转发失败,回退为普通消息 + 文件: book=%s", book_id) - # 历史里不留密码:历史摘要与用户可见正文分开;密码消息只记「已单独发送」 - await _send_text( - sender, - target_type, - target_id, - info_text, - history_message=history_message, - ) - await _send_text( - sender, - target_type, - target_id, - build_password_text(password), - history_message="[JM] PDF 解密密码已单独发送", - ) - try: - await _send_file( - sender, target_type, target_id, str(pdf_path), str(pdf_path.name) + logger.error( + "[JM] 合并转发投递结果未确认,不重发信息与密码,继续单独发送 PDF: book=%s", + book_id, + ) + forward_status = _FORWARD_UNCERTAIN + else: + logger.exception( + "[JM] 合并转发失败,回退为普通消息发送信息与密码: book=%s", book_id + ) + forward_status = _FORWARD_REJECTED + # 历史里不留密码:历史摘要与用户可见正文分开;密码消息只记「已单独发送」 + await _send_text( + sender, + target_type, + target_id, + info_text, + history_message=history_message, ) - except Exception as file_exc: - if _is_fatal_delivery_error(file_exc): - logger.error("[JM] 文件投递结果未确认,不重发: book=%s", book_id) - raise - logger.exception("[JM] 降级后的 PDF 文件发送失败: book=%s", book_id) await _send_text( sender, target_type, target_id, - "PDF 上传失败,本次没有发送文件(信息与密码如上)。", - auto_history=False, + build_password_text(password), + history_message="[JM] PDF 解密密码已单独发送", ) - return "合并转发与 PDF 文件均发送失败,已改为普通消息发送信息与密码" - return "合并转发被拒,已改为普通消息发送信息与密码,并补发独立 PDF 文件" - return "已发送合并转发与 PDF 文件" + + try: + await _send_file( + sender, target_type, target_id, str(pdf_path), str(pdf_path.name) + ) + except Exception as file_exc: + if _is_fatal_delivery_error(file_exc): + logger.error("[JM] 文件投递结果未确认,不重发: book=%s", book_id) + raise + logger.exception("[JM] PDF 文件发送失败: book=%s", book_id) + # 转发投递未确认时不能声称「信息与密码如上」——转发可能压根没送达 + notice = ( + "PDF 上传失败,本次没有发送文件(合并转发投递结果未确认,信息与密码可能没有送达)。" + if forward_status == _FORWARD_UNCERTAIN + else "PDF 上传失败,本次没有发送文件(信息与密码如上)。" + ) + # 写进历史:转发摘要只说「随后发送」,真正发没发出去靠这条记录收口 + await _send_text(sender, target_type, target_id, notice) + return f"{forward_status},PDF 文件发送失败" + return f"{forward_status},PDF 文件已单独发送" async def send_jm_book( @@ -404,7 +409,7 @@ async def send_jm_book( target_id: int, config: Any, ) -> str: - """下载整本并发送合并转发,返回可记录的状态说明。""" + """下载整本,发送「信息 + 密码」合并转发,再单独发送加密 PDF 文件。""" password = generate_pdf_password() result, task_dir = await download_book_pdf( book_id, config=config, password=password diff --git a/src/Undefined/skills/shared.py b/src/Undefined/skills/shared.py index ffabc5f3..82f36404 100644 --- a/src/Undefined/skills/shared.py +++ b/src/Undefined/skills/shared.py @@ -109,7 +109,7 @@ async def jm_send_book( target_id: int, config: Any, ) -> str: - """下载整本并发送「信息 / 密码 / PDF」三节点合并转发。""" + """下载整本,发送「信息 / 密码」合并转发并单独发送加密 PDF 文件。""" from Undefined.jm.sender import send_jm_book as _send_jm_book return await _send_jm_book( diff --git a/src/Undefined/skills/tools/jm_book/README.md b/src/Undefined/skills/tools/jm_book/README.md index 08063b28..d87f463c 100644 --- a/src/Undefined/skills/tools/jm_book/README.md +++ b/src/Undefined/skills/tools/jm_book/README.md @@ -1,17 +1,17 @@ # jm_book 工具 -处理禁漫(JM / 18comic)本子。默认下载整本并通过合并转发发送;也支持只注册为附件 UID 供文件分析使用,或只获取本子信息。支持 `JM350234`、`350234`、`https://18comic.vip/album/350234` 与 `?id=` 链接。 +处理禁漫(JM / 18comic)本子。默认下载整本并通过合并转发 + 独立文件消息发送;也支持只注册为附件 UID 供文件分析使用,或只获取本子信息。支持 `JM350234`、`350234`、`https://18comic.vip/album/350234` 与 `?id=` 链接。 常用参数: - `book_id`:本子车号(`JM350234` / `350234` / 禁漫链接,车号 5-8 位) - `target_type`:可选,目标会话类型(`group`/`private`) - `target_id`:可选,目标会话 ID -- `output_mode`:可选,`send`(默认,发送三节点合并转发)、`uid`(只返回 ``,不发送消息)或 `info`(只返回本子信息,不下载不发送) +- `output_mode`:可选,`send`(默认,发送「信息 / 密码」合并转发并单独发送 PDF 文件)、`uid`(只返回 ``,不发送消息)或 `info`(只返回本子信息,不下载不发送) `send` 模式流程: 1. 解析车号 2. 逐章下载并按章节顺序合成为一个 AES-256 加密 PDF(`[jm].max_chapters` / `max_file_size` 限制) -3. 发送「本子信息 / PDF 解密密码 / PDF 文件」三节点合并转发,PDF 同时进入群文件列表 +3. 发送「本子信息 / PDF 解密密码」两节点合并转发,再单独发一条 PDF 文件消息(转发里的文件在 QQ 客户端下载会失败,独立文件消息才下得动,PDF 同时进入群文件列表) 4. 密码随机生成 8 位,只出现在转发节点里,不写入历史 `uid` 模式流程: diff --git a/src/Undefined/skills/tools/jm_book/config.json b/src/Undefined/skills/tools/jm_book/config.json index 212ae8d9..cdb42e9e 100644 --- a/src/Undefined/skills/tools/jm_book/config.json +++ b/src/Undefined/skills/tools/jm_book/config.json @@ -2,7 +2,7 @@ "type": "function", "function": { "name": "jm_book", - "description": "处理禁漫(JM / 18comic)本子。默认下载整本并通过合并转发发送;可设置 output_mode=uid 只注册 PDF 附件 UID,或 output_mode=info 只获取本子信息。支持 JM350234、350234 或禁漫链接。", + "description": "处理禁漫(JM / 18comic)本子。默认下载整本,发送信息与密码的合并转发并单独发送加密 PDF 文件;可设置 output_mode=uid 只注册 PDF 附件 UID,或 output_mode=info 只获取本子信息。支持 JM350234、350234 或禁漫链接。", "parameters": { "type": "object", "properties": { @@ -22,7 +22,7 @@ "output_mode": { "type": "string", "enum": ["send", "uid", "info"], - "description": "输出模式:send(默认,下载整本并发送「信息 / 解密密码 / PDF」三节点合并转发)、uid(只下载并注册未加密 PDF 附件 UID,不发送消息)、info(只返回本子信息,不下载不发送)" + "description": "输出模式:send(默认,下载整本、发送「信息 / 解密密码」合并转发并单独发送加密 PDF 文件)、uid(只下载并注册未加密 PDF 附件 UID,不发送消息)、info(只返回本子信息,不下载不发送)" } }, "required": ["book_id"] diff --git a/tests/test_jm_sender.py b/tests/test_jm_sender.py index 40b78ef4..83a35b3d 100644 --- a/tests/test_jm_sender.py +++ b/tests/test_jm_sender.py @@ -53,13 +53,22 @@ def _result( def _sender() -> Any: + order: list[str] = [] + + def _record(name: str) -> AsyncMock: + async def _side_effect(*args: Any, **kwargs: Any) -> None: + order.append(name) + + return AsyncMock(side_effect=_side_effect) + return SimpleNamespace( - send_group_message=AsyncMock(), - send_private_message=AsyncMock(), - send_group_forward_message=AsyncMock(), - send_private_forward_message=AsyncMock(), - send_group_file=AsyncMock(), - send_private_file=AsyncMock(), + order=order, + send_group_message=_record("group_message"), + send_private_message=_record("private_message"), + send_group_forward_message=_record("group_forward"), + send_private_forward_message=_record("private_forward"), + send_group_file=_record("group_file"), + send_private_file=_record("private_file"), ) @@ -93,7 +102,7 @@ def test_generate_pdf_password_uses_safe_eight_char_alphabet() -> None: @pytest.mark.asyncio -async def test_send_jm_book_sends_forward_only( +async def test_send_jm_book_sends_forward_then_standalone_file( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: sender = _sender() @@ -108,17 +117,14 @@ async def test_send_jm_book_sends_forward_only( config=SimpleNamespace(), ) - assert status.startswith("JM1114751 已发送合并转发与 PDF 文件") + assert status.startswith("JM1114751 已发送合并转发,PDF 文件已单独发送") pdf_path = result.pdf_path assert pdf_path is not None args = sender.send_group_forward_message.await_args nodes = args.args[1] - # 需求要求 PDF 节点留在转发里;可下载的入口是紧随其后的独立文件消息 - assert [node["data"]["name"] for node in nodes] == ["本子信息", "解密密码", "PDF"] - file_segment = nodes[2]["data"]["content"][0] - assert file_segment["type"] == "file" - assert file_segment["data"]["file"] == f"file://{pdf_path.resolve()}" - assert file_segment["data"]["name"] == pdf_path.name + # 文件不进转发:转发节点里的文件在 QQ 客户端下载会失败 + assert [node["data"]["name"] for node in nodes] == ["本子信息", "解密密码"] + assert all(isinstance(node["data"]["content"], str) for node in nodes) info_text = nodes[0]["data"]["content"] assert "JM1114751" in info_text assert "测试本子" in info_text @@ -127,23 +133,26 @@ async def test_send_jm_book_sends_forward_only( assert "18comic" not in info_text assert f":{captured['password']}" in nodes[1]["data"]["content"] assert len(captured["password"]) == 8 - # 文件只在转发里:不外发独立文件消息 - sender.send_group_file.assert_not_awaited() - # 历史摘要不含密码,只说明密码在转发节点里 + # 可下载的入口是转发之后那条独立文件消息 + sender.send_group_file.assert_awaited_once_with(20001, str(pdf_path), pdf_path.name) + assert sender.order == ["group_forward", "group_file"] + # 历史摘要不含密码,只说明密码在转发节点里;文件还没上传,不能提前声称已发送 history_message = args.kwargs["history_message"] assert captured["password"] not in history_message assert "密码见转发节点" in history_message + assert "随后单独发送" in history_message + assert "已单独发送" not in history_message captured["cleanup"].assert_awaited_once_with(tmp_path / "task") @pytest.mark.asyncio -async def test_send_jm_book_falls_back_to_file_message_when_forward_fails( +async def test_send_jm_book_falls_back_to_plain_messages_then_file( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: sender = _sender() result = _result(tmp_path) sender.send_group_forward_message.side_effect = RuntimeError("forward rejected") - _patch_download(monkeypatch, result, tmp_path / "task") + captured = _patch_download(monkeypatch, result, tmp_path / "task") await send_jm_book( "1114751", @@ -153,22 +162,35 @@ async def test_send_jm_book_falls_back_to_file_message_when_forward_fails( config=SimpleNamespace(), ) - # 转发本身发不出去时才退化为独立文件消息,否则用户拿不到 PDF + # 转发发不出去时才退化为两条普通消息,文件照旧单独发送 + assert sender.order == ["group_message", "group_message", "group_file"] + password_call = sender.send_group_message.await_args_list[1] + assert captured["password"] in password_call.args[1] + # 普通消息兜底时历史里同样不留密码 + for call in sender.send_group_message.await_args_list: + history_message = call.kwargs["history_message"] + assert history_message is not None + assert captured["password"] not in history_message pdf_path = result.pdf_path assert pdf_path is not None sender.send_group_file.assert_awaited_once_with(20001, str(pdf_path), pdf_path.name) @pytest.mark.asyncio -async def test_send_jm_book_falls_back_to_plain_messages( +async def test_send_jm_book_keeps_sending_file_when_forward_is_uncertain( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: + """转发投递结果未确认时不降级重发,但独立文件消息此前从未发出,必须照发。""" + + class _Uncertain(RuntimeError): + delivery_uncertain = True + sender = _sender() result = _result(tmp_path) - sender.send_group_forward_message.side_effect = RuntimeError("forward rejected") - captured = _patch_download(monkeypatch, result, tmp_path / "task") + sender.send_group_forward_message.side_effect = _Uncertain("timeout") + _patch_download(monkeypatch, result, tmp_path / "task") - await send_jm_book( + status = await send_jm_book( "1114751", sender=sender, target_type="group", @@ -176,29 +198,52 @@ async def test_send_jm_book_falls_back_to_plain_messages( config=SimpleNamespace(), ) - assert sender.send_group_message.await_count == 2 - password_call = sender.send_group_message.await_args_list[1] - assert captured["password"] in password_call.args[1] - # 普通消息兜底时历史里同样不留密码 - for call in sender.send_group_message.await_args_list: - history_message = call.kwargs["history_message"] - assert history_message is not None - assert captured["password"] not in history_message + # 转发可能已经送达,绝不重发转发或信息/密码 + assert sender.send_group_forward_message.await_count == 1 + sender.send_group_message.assert_not_awaited() + # 文件丢在这里等于整本下载白跑 sender.send_group_file.assert_awaited_once() + assert "合并转发投递结果未确认" in status + assert "PDF 文件已单独发送" in status @pytest.mark.asyncio -async def test_send_jm_book_does_not_retry_when_delivery_is_uncertain( +async def test_send_jm_book_does_not_resend_when_forward_transfer_failed( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: - """投递结果未确认时绝不能降级重发(仓库约定)。""" + class _TransferFailed(RuntimeError): + file_transfer_error = True + + sender = _sender() + result = _result(tmp_path) + sender.send_group_forward_message.side_effect = _TransferFailed("prepare failed") + _patch_download(monkeypatch, result, tmp_path / "task") + + status = await send_jm_book( + "1114751", + sender=sender, + target_type="group", + target_id=20001, + config=SimpleNamespace(), + ) + + sender.send_group_message.assert_not_awaited() + sender.send_group_file.assert_awaited_once() + assert "合并转发投递结果未确认" in status + + +@pytest.mark.asyncio +async def test_send_jm_book_does_not_retry_when_file_delivery_is_uncertain( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """文件自身投递结果未确认时同样不重发(仓库约定),也不补发任何提示。""" class _Uncertain(RuntimeError): delivery_uncertain = True sender = _sender() result = _result(tmp_path) - sender.send_group_forward_message.side_effect = _Uncertain("timeout") + sender.send_group_file.side_effect = _Uncertain("timeout") _patch_download(monkeypatch, result, tmp_path / "task") with pytest.raises(_Uncertain): @@ -210,8 +255,8 @@ class _Uncertain(RuntimeError): config=SimpleNamespace(), ) + assert sender.send_group_file.await_count == 1 sender.send_group_message.assert_not_awaited() - sender.send_group_file.assert_not_awaited() @pytest.mark.asyncio @@ -223,7 +268,7 @@ class _TransferFailed(RuntimeError): sender = _sender() result = _result(tmp_path) - sender.send_group_forward_message.side_effect = _TransferFailed("prepare failed") + sender.send_group_file.side_effect = _TransferFailed("prepare failed") _patch_download(monkeypatch, result, tmp_path / "task") with pytest.raises(_TransferFailed): @@ -235,17 +280,16 @@ class _TransferFailed(RuntimeError): config=SimpleNamespace(), ) + assert sender.send_group_file.await_count == 1 sender.send_group_message.assert_not_awaited() - sender.send_group_file.assert_not_awaited() @pytest.mark.asyncio -async def test_send_jm_book_reports_when_pdf_upload_also_fails( +async def test_send_jm_book_reports_when_pdf_upload_fails( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: sender = _sender() result = _result(tmp_path) - sender.send_group_forward_message.side_effect = RuntimeError("forward rejected") sender.send_group_file.side_effect = RuntimeError("upload failed") _patch_download(monkeypatch, result, tmp_path / "task") @@ -257,11 +301,45 @@ async def test_send_jm_book_reports_when_pdf_upload_also_fails( config=SimpleNamespace(), ) - assert status.startswith("JM1114751 合并转发与 PDF 文件均发送失败"), status + assert status.startswith("JM1114751 已发送合并转发,PDF 文件发送失败"), status last = sender.send_group_message.await_args_list[-1] assert "PDF 上传失败" in last.args[1] - # 提示消息不写历史(避免第三条重复摘要):auto_history 是第三个位置参数 - assert last.args[2] is False + assert "信息与密码如上" in last.args[1] + # 提示写入历史:转发摘要只说「随后发送」,真正发没发出去靠这条收口 + assert last.args[2] is True + + +@pytest.mark.asyncio +async def test_send_jm_book_upload_failure_notice_avoids_claiming_uncertain_forward( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """转发投递未确认 + 文件也失败时,提示不能说「信息与密码如上」。""" + + class _Uncertain(RuntimeError): + delivery_uncertain = True + + sender = _sender() + result = _result(tmp_path) + sender.send_group_forward_message.side_effect = _Uncertain("timeout") + sender.send_group_file.side_effect = RuntimeError("upload failed") + _patch_download(monkeypatch, result, tmp_path / "task") + + status = await send_jm_book( + "1114751", + sender=sender, + target_type="group", + target_id=20001, + config=SimpleNamespace(), + ) + + assert status.startswith("JM1114751 合并转发投递结果未确认,PDF 文件发送失败"), ( + status + ) + notice = sender.send_group_message.await_args_list[-1].args[1] + assert "投递结果未确认" in notice + assert "信息与密码如上" not in notice + # 转发本身不重发,这里只发一条提示 + assert sender.send_group_message.await_count == 1 @pytest.mark.asyncio @@ -282,7 +360,7 @@ async def test_send_jm_book_reports_fallback_status( ) assert "已改为普通消息发送信息与密码" in status - assert "补发独立 PDF 文件" in status + assert "PDF 文件已单独发送" in status @pytest.mark.asyncio @@ -370,6 +448,9 @@ async def test_send_jm_book_uses_private_forward( sender.send_private_forward_message.assert_awaited_once() sender.send_group_forward_message.assert_not_awaited() assert sender.send_private_forward_message.await_args.args[0] == 30001 + # 私聊同样单独发文件,而不是把文件塞进转发 + sender.send_private_file.assert_awaited_once() + sender.send_group_file.assert_not_awaited() def test_format_jm_book_info_lists_metadata() -> None: diff --git a/uv.lock b/uv.lock index f00a0657..d52b3f20 100644 --- a/uv.lock +++ b/uv.lock @@ -4729,7 +4729,7 @@ wheels = [ [[package]] name = "undefined-bot" -version = "3.18.0" +version = "3.18.1" source = { editable = "." } dependencies = [ { name = "aiofiles" },