From b85a34eb409e873d7ea4431557d913ae26371479 Mon Sep 17 00:00:00 2001 From: FlanChanXwO Date: Thu, 6 Aug 2026 10:29:27 +0800 Subject: [PATCH 1/6] feat: add doujinshi PDF delivery and recoverable revocation --- AGENTS.md | 7 +- CHANGELOG.md | 17 +- CLAUDE.md | 50 +- README.md | 10 +- _conf_schema.json | 15 +- docs/dev/testing.md | 3 +- docs/project/architecture.md | 20 +- docs/project/sending-limits.md | 2 +- docs/usage/commands.md | 24 +- docs/usage/configuration.md | 20 +- main.py | 91 +- metadata.yaml | 4 +- requirements.txt | 1 + src/application/setu/tag_resolution.py | 38 + .../astrbot/commands/fortune.py | 54 +- src/infrastructure/astrbot/commands/setu.py | 184 +++- src/infrastructure/config/legacy_migration.py | 16 + src/infrastructure/doujinshi/__init__.py | 11 + src/infrastructure/doujinshi/service.py | 236 ++++++ src/infrastructure/sending/__init__.py | 14 +- .../sending/doujinshi_sender.py | 45 + src/infrastructure/sending/image_sender.py | 9 +- .../sending/revoke_scheduler.py | 796 +++++++++++++++--- src/infrastructure/sending/send_strategies.py | 108 +-- src/shared/config/models.py | 47 +- .../infrastructure/test_doujinshi_command.py | 128 +++ tests/infrastructure/test_doujinshi_sender.py | 67 ++ .../infrastructure/test_doujinshi_service.py | 133 +++ tests/infrastructure/test_image_sender.py | 187 +--- .../test_recoverable_revoke_scheduler.py | 351 ++++++++ .../test_setu_tag_alias_integration.py | 20 +- tests/shared/test_config_models.py | 60 ++ tests/test_main_command_routing.py | 70 ++ tests/test_main_config_source.py | 9 + 34 files changed, 2402 insertions(+), 445 deletions(-) create mode 100644 src/application/setu/tag_resolution.py create mode 100644 src/infrastructure/doujinshi/__init__.py create mode 100644 src/infrastructure/doujinshi/service.py create mode 100644 src/infrastructure/sending/doujinshi_sender.py create mode 100644 tests/infrastructure/test_doujinshi_command.py create mode 100644 tests/infrastructure/test_doujinshi_sender.py create mode 100644 tests/infrastructure/test_doujinshi_service.py create mode 100644 tests/infrastructure/test_recoverable_revoke_scheduler.py diff --git a/AGENTS.md b/AGENTS.md index 86e6cf1..6b880e4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,14 +8,14 @@ ## 项目形态 -- 这是一个 AstrBot 随机图片插件,采用 DDD 分层。 +- 这是一个 AstrBot 随机图片与随机本子 PDF 插件,采用 DDD 分层。 - 管理功能属于 Plugin Pages(统一 dashboard 页面,含会话配置和访问控制标签页)。 主要目录: - `src/domain/`: 领域实体、值对象、标签解析、访问控制。 - `src/application/`: 用例、DTO、端口接口、会话配置服务。 -- `src/infrastructure/`: 配置、持久化、provider、sender、AstrBot 适配。 +- `src/infrastructure/`: 配置、持久化、provider、随机本子 PDF、sender、AstrBot 适配。 - `src/shared/`: 配置模型、日志、发送缓存。 - `pages/`: Plugin Pages 前端(统一 dashboard)。 - `templates/`: 运势卡片 HTML 模板与字体。 @@ -35,6 +35,9 @@ - 插件运行数据必须通过 `StarTools.get_data_dir(self.name)` 获取,不要硬编码路径。 - 从插件目录本地调试时,不要创建或使用 `/data` 作为运行态目录。 - 所有用户可见提示必须走 `MessagesConfig` / `resolve_message()`,不要在 handler 内硬编码提示文案。 +- 随机本子 PDF 由 `infrastructure/doujinshi/` 生成;OneBot/NapCat 使用 `Nodes` 合并转发并按消息 ID 进入统一可恢复撤回,其他平台直接发送 `File`。 +- 色图与随机本子都必须使用 `application/setu/tag_resolution.py` 解析标签,保持分隔符和别名映射语义一致。 +- `delivery.auto_revoke_targets` 以单一列表选择色图、今日运势和本子是否进入自动清理;三者共用 `auto_revoke_delay`,本子合并转发必须在发送时取得并立即持久化 `message_id`,避免异步上传可见性导致清理状态丢失。 - 其他领域值、平台行为和配置边界不要写进本文件,放到 `docs/project/` 或 `docs/dev/`。 ## 文档纪律 diff --git a/CHANGELOG.md b/CHANGELOG.md index b7cbaf2..71c4ed0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,25 @@ # Changelog +## [2.2.0] - 2026-08-06 + +### Added + +- **随机本子 PDF**:新增 `/随机本子`(`/本子`、`/doujinshi`)命令;调用 Atri 随机本子 API,下载全部页图并封装为 PDF。 +- **本子自然语言入口**:支持发送“来份本子”或“来一份本子”触发随机本子。 +- **平台文件发送策略**:OneBot v11/NapCat 类平台把 PDF 作为 `File` 放入合并转发节点发送,其他平台直接发送 PDF 文件,不调用群文件上传接口。 +- **PDF 文件名**:合并转发中的 PDF 使用 API 返回的本子标题作为文件名。 +- **统一可恢复撤回队列**:图片自动撤回与 OneBot/NapCat 本子群文件延迟清理统一保存到 `revoke_tasks.json`;插件重启后按原到期时间继续执行,旧 `doujinshi_file_cleanup_tasks.json` 会自动迁移。 +- **可配置提示**:新增 `doujinshi_fetching`、`doujinshi_failed` 消息键。 + +### Changed + +- **正则入口收敛**:色图自然语言与纯文本今日运势改由 `main.py` 的单一 regex 路由函数分发。 +- **撤回调度重构**:移除仅在内存存活的撤回调度,所有已登记的 OneBot `delete_msg` 与 `delete_group_file` 任务均使用统一可恢复调度器。 + ## [2.1.2] - 2026-07-09 ### Fixed - **Provider over-return 数量保护**:修复部分上游 API 在请求 `num=1` 时返回多个图片 URL,导致插件连续发送 2 张图片的问题。下载层现在会按本轮缺口裁剪候选 URL,只下载并交付用户请求数量的图片;正常补齐、下载重试、缓存落盘和 sender 发送策略不变。新增回归测试覆盖「请求 1 张但 provider 返回 2 个 URL」场景。 -- **NapCat 发送确认超时去重**:修复 OneBot/NapCat `send_group_msg` 返回 retcode `1200` 且 wording 为 NTQQ `sendMsg` 超时时被误判为可重试失败的问题。此类结果现在仅在 OneBot-like 平台且匹配已知 NTQQ `sendMsg` 超时标记时视为 pending delivery,不再继续触发普通发送、stream 或 HTML fallback,避免平台实际已送达但确认丢失时把同一张图片重复发送;非 OneBot 平台和不相关的 retcode `1200` 超时仍按普通失败处理。 ## [2.1.1] - 2026-06-15 diff --git a/CLAUDE.md b/CLAUDE.md index 4b19fd2..6b880e4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,29 +1,25 @@ -# CLAUDE.md — astrbot_plugin_setu +# AGENTS.md — astrbot_plugin_setu -本文件只保留 Claude 协作入口规则。业务细节按需阅读 `docs/project/`,开发维护规则优先阅读 `docs/dev/maintenance.md`。 +本文件只保留协作 agent 的入口规则。业务细节按需阅读 `docs/project/`,开发维护规则优先阅读 `docs/dev/maintenance.md`。 ## 沟通语言 -必须使用中文与用户交流。 +- 与用户沟通必须使用中文。 ## 项目形态 -- **语言**: Python 3.10+ -- **框架**: AstrBot plugin system -- **架构**: DDD 分层 -- **许可证**: AGPL +- 这是一个 AstrBot 随机图片与随机本子 PDF 插件,采用 DDD 分层。 +- 管理功能属于 Plugin Pages(统一 dashboard 页面,含会话配置和访问控制标签页)。 主要目录: -```text -src/domain/ 领域实体、值对象、标签解析、访问控制 -src/application/ 用例、DTO、端口接口、会话配置服务 -src/infrastructure/ 配置、持久化、provider、sender、AstrBot 适配 -src/shared/ 配置模型、日志、发送缓存 -pages/ Plugin Pages 前端(统一 dashboard 页面) -templates/ 运势卡片 HTML 模板与字体 -tests/ 单元测试、集成测试与测试夹具 -``` +- `src/domain/`: 领域实体、值对象、标签解析、访问控制。 +- `src/application/`: 用例、DTO、端口接口、会话配置服务。 +- `src/infrastructure/`: 配置、持久化、provider、随机本子 PDF、sender、AstrBot 适配。 +- `src/shared/`: 配置模型、日志、发送缓存。 +- `pages/`: Plugin Pages 前端(统一 dashboard)。 +- `templates/`: 运势卡片 HTML 模板与字体。 +- `tests/`: 单元测试、集成测试与测试夹具。 ## 阅读入口 @@ -33,23 +29,27 @@ tests/ 单元测试、集成测试与测试夹具 - 修改消息配置、提示文案或占位符时看:`src/shared/config/models.py` - 修改 provider 适配或 sender 策略时看:`src/infrastructure/providers/` 和 `src/infrastructure/sending/` -## 技能 - -如果当前会话可用,修改本插件时优先参考 `astrbot-dev-skill`。它对 AstrBot 命令装饰器、Plugin Pages bridge、统一会话 ID 和平台适配边界有帮助。 - ## 硬约束 - 不要把业务逻辑编排塞进 `main.py`;保持注册和路由专注。 - 插件运行数据必须通过 `StarTools.get_data_dir(self.name)` 获取,不要硬编码路径。 - 从插件目录本地调试时,不要创建或使用 `/data` 作为运行态目录。 - 所有用户可见提示必须走 `MessagesConfig` / `resolve_message()`,不要在 handler 内硬编码提示文案。 +- 随机本子 PDF 由 `infrastructure/doujinshi/` 生成;OneBot/NapCat 使用 `Nodes` 合并转发并按消息 ID 进入统一可恢复撤回,其他平台直接发送 `File`。 +- 色图与随机本子都必须使用 `application/setu/tag_resolution.py` 解析标签,保持分隔符和别名映射语义一致。 +- `delivery.auto_revoke_targets` 以单一列表选择色图、今日运势和本子是否进入自动清理;三者共用 `auto_revoke_delay`,本子合并转发必须在发送时取得并立即持久化 `message_id`,避免异步上传可见性导致清理状态丢失。 - 其他领域值、平台行为和配置边界不要写进本文件,放到 `docs/project/` 或 `docs/dev/`。 ## 文档纪律 -- 文档是改动的一部分。代码改动导致现有说明失真时,必须在同一 patch 中更新相关 `docs/`。 -- 命令行为、Plugin Pages 行为、配置语义、provider、sender、消息配置、访问控制变化时,通常需要更新文档。 -- repo-wide 约束或 agent 入口说明变化时,同步更新 `AGENTS.md` 和 `CLAUDE.md`。 +- 文档不是可选收尾。行为、边界、入口、配置、流程或维护约定变化时,必须同步更新对应 `docs/`。 +- 下列变化默认必须同步文档: + - 命令行为或参数变化 + - Plugin Pages 交互变化 + - 配置项、默认值或兼容规则变化 + - provider、sender、消息配置算法变化 + - 访问控制判定逻辑变化 +- 如果修改 repo-wide 维护规则或 agent 入口约定,同步更新 `AGENTS.md` 和 `CLAUDE.md`。 ## 测试与检查命令 @@ -70,9 +70,9 @@ uv run ruff format data/plugins/astrbot_plugin_setu uv run ruff check data/plugins/astrbot_plugin_setu ``` -## 维护 +## 更新策略 -当架构、命令面、发送策略、配置路径或测试 / lint 流程变化时,同步更新 `AGENTS.md` 和 `CLAUDE.md`。 +当架构、命令面、发送策略、配置路径或测试 / lint 流程变化时,同步更新 `CLAUDE.md` 和 `AGENTS.md`。 ## 篇幅约束 diff --git a/README.md b/README.md index 598f370..1917c9e 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Moe Counter -**一个支持多平台、可自定义、带防审核机制的随机色图插件,支持多 API、会话级配置、LLM 工具调用。** +**一个支持多平台、可自定义、带防审核机制的随机色图插件,支持多 API、随机本子 PDF、会话级配置、LLM 工具调用。** [![License: AGPL](https://img.shields.io/badge/License-AGPL-blue.svg)](https://opensource.org/licenses/agpl-3.0) ![Python Version](https://img.shields.io/badge/Python-3.10%2B-blue) @@ -56,6 +56,7 @@ - 🤖 **LLM 工具调用** - 可通过大模型自动获取色图 - 🏷️ **标签搜索** - 支持多标签、中文标签、模糊匹配 - 🔄 **多种发送模式** - 直接发送、合并转发、文件封装 +- 📚 **随机本子 PDF** - 获取 API 返回的全部页图并封装为 PDF;OneBot 合并转发消息可按统一可恢复队列延迟撤回 - 🛡️ **防审核机制** - HTML 卡片 fallback、NapCat 流式上传、延迟撤回、Docx 封装 - ⚡ **性能优化** - 磁盘缓存、自动补图、httpx、可观测下载重试 - 🌐 **多平台适配** - 兼容 AstrBot 支持的所有平台 @@ -91,12 +92,15 @@ 来9份白丝 萝莉色图 /setu 白丝 萝莉 /setu 3 白丝 +来份本子 +/随机本子 /session_config set setu.content_mode r18 ``` - 数量范围支持中文数字 - 标签支持空格、逗号、顿号分隔 - `/session_config` 统一管理当前会话的覆盖配置 +- `/随机本子`(别名 `/本子`、`/doujinshi`)生成并发送随机本子 PDF;OneBot 使用合并转发,其他平台直接发送文件;OneBot 群聊可按配置延迟撤回对应的合并转发消息 完整的命令说明见 [`docs/usage/commands.md`](./docs/usage/commands.md)。 @@ -158,13 +162,17 @@ |--------|------|--------| | `html_card_strategy` | HTML 卡片策略(never / fallback / always) | `fallback` | | `platform_transports` | 平台传输模板列表,可添加 NapCat 模板 | `[]` | +| `auto_revoke_targets` | 自动撤回内容列表(`setu` / `fortune` / `doujinshi`) | `["doujinshi"]` | | `auto_revoke_scope` | 自动撤回范围(none / sfw / r18 / all) | `none` | +| `auto_revoke_delay` | 已启用内容共用的自动清理延迟(秒,`0` 全部关闭) | `30` | | `r18_docx_mode` | R18 是否使用 Docx 封装 | `true` | 图片下载遇到短暂网络错误时会先按 `max_replenish_rounds` 对同一 URL 做确认重试;发送接口超时或 OneBot/NapCat 类适配器未返回 message id 时会标记为可能仍在送达,不会立刻触发降级重复发图。旧版 `auto_revoke_r18` 会在启动时迁移为 `auto_revoke_scope`,迁移后不再作为公开配置项展示。 NapCat stream 上传的分块内容按 NapCat 协议仍为 base64 字符串;若 AstrBot 与 NapCat 共享同一图片目录,可在 `platform_transports` 添加 NapCat 模板,将共享目录加入 `local_file_allowed_roots` 并把 `local_file_mode` 设为 `always` 或 `fallback`,让直发模式通过 raw OneBot `file://` 路径绕过 AstrBot 标准链路的 base64 转换。 +`auto_revoke_targets` 是单一内容列表,默认只含 `doujinshi`,因此色图和今日运势默认不撤回;按需加入 `setu` 或 `fortune`。色图加入后还须命中 `auto_revoke_scope`。三类内容共享 `auto_revoke_delay` 和 OneBot 可恢复队列;设置 `1800` 即为 30 分钟,设为 `0` 会关闭全部自动清理。随机本子只适用于群聊合并转发:发送后若 NapCat 尚未把文件写入群根目录,插件会先持久化待识别任务,到期时再按发送前快照、文件名和体积精确识别 `file_id` 后删除。所有任务保存于插件运行数据目录的 `revoke_tasks.json`,插件重启后仍按原到期时间继续执行。 + ### 模板覆盖 | 配置项 | 说明 | diff --git a/_conf_schema.json b/_conf_schema.json index 6ea13bc..e2b8d8a 100644 --- a/_conf_schema.json +++ b/_conf_schema.json @@ -252,14 +252,21 @@ "auto_revoke_scope": { "type": "string", "description": "自动撤回范围", - "hint": "none=不撤回;sfw=仅撤回全年龄图片;r18=仅撤回 R18 图片/文件;all=全部 Setu 图片都会撤回。", + "hint": "none=不撤回;sfw=仅撤回全年龄图片;r18=仅撤回 R18 图片/文件;all=全部 Setu 图片都会撤回。仅在自动撤回内容含 setu 时生效。", "default": "none", "options": ["none", "sfw", "r18", "all"] }, + "auto_revoke_targets": { + "type": "list", + "description": "自动撤回内容", + "hint": "每行填写一个:setu(色图)、fortune(今日运势)、doujinshi(随机本子)。默认仅 doujinshi;色图还须命中自动撤回范围。", + "default": ["doujinshi"], + "options": ["setu", "fortune", "doujinshi"] + }, "auto_revoke_delay": { "type": "int", - "description": "撤回延迟时间(秒)", - "hint": "命中自动撤回范围后多久撤回,默认 30 秒。", + "description": "自动清理延迟时间(秒)", + "hint": "同时用于自动撤回内容列表中启用的 Setu 图片、今日运势消息和 OneBot/NapCat 随机本子群文件。默认 30 秒;设为 0 可关闭全部自动清理,填 1800 即为 30 分钟。", "default": 30 }, "platform_transports": { @@ -455,6 +462,8 @@ "count_out_of_range", "fetch_timeout", "fetch_failed", + "doujinshi_fetching", + "doujinshi_failed", "no_result", "empty_payload", "r18_docx_failed", diff --git a/docs/dev/testing.md b/docs/dev/testing.md index 45f39af..ebf9774 100644 --- a/docs/dev/testing.md +++ b/docs/dev/testing.md @@ -40,6 +40,7 @@ - [ ] HTML 卡片 fallback 在原图发送失败时仍能触发。 - [ ] 运势卡片渲染失败时降级为纯文本。 - [ ] 会话配置读写不锁死(并发安全)。 -- [ ] `auto_revoke_scope` 的 `none` / `sfw` / `r18` / `all` 覆盖正确,旧 `setu.auto_revoke` 与 `auto_revoke_r18` 只通过迁移保留。 +- [ ] `auto_revoke_targets` 默认仅含 `doujinshi`,并能分别将 `setu`、`fortune`、`doujinshi` 传递到对应链路;`auto_revoke_scope` 的 `none` / `sfw` / `r18` / `all` 仅过滤已启用的色图。 +- [ ] OneBot 图片、今日运势和本子合并转发均使用 `auto_revoke_delay` 写入统一持久化消息任务;本子通过原始合并转发 action 取得 `message_id` 并在到期后调用 `delete_msg`,旧字段与旧队列均可迁移;删除任务连续失败三次后会移除持久化记录。 - [ ] `tests/conftest.py` 固定的 `ASTRBOT_ROOT` 仍能阻止插件目录污染。 - [ ] 访问控制黑白名单互斥逻辑正常。 diff --git a/docs/project/architecture.md b/docs/project/architecture.md index 6c8d607..37d4c38 100644 --- a/docs/project/architecture.md +++ b/docs/project/architecture.md @@ -25,6 +25,7 @@ tests/ # 单元与集成测试 - `src/infrastructure/astrbot/` - 命令处理器(`commands/setu.py`、`commands/fortune.py`、`commands/session_config.py`) + - 随机本子服务(`doujinshi/service.py`)、统一可恢复撤回调度器(`sending/revoke_scheduler.py`)与 PDF 文件发送器(`sending/doujinshi_sender.py`) - 配置加载与自愈 - Web API 注册 - 运势渲染器 @@ -41,6 +42,7 @@ tests/ # 单元与集成测试 4. 发送接口超时或 OneBot/NapCat 类适配器未返回 message id 时标记为可能仍在送达,不立即触发 fallback,避免延迟送达后重复发图 5. 发送结果通过 `resolve_message()` 生成可配置提示 6. 缓存命中时复用本地文件,降低内存压力 +7. 自动撤回内容含 `setu`、命中撤回范围且拿到 `message_id` 时,统一撤回调度器将任务写入 `revoke_tasks.json`,重启后按原到期时间调用 OneBot `delete_msg` ### 2. Fortune 运势生成 @@ -48,8 +50,18 @@ tests/ # 单元与集成测试 2. `FortuneService` 查询或生成今日运势记录 3. 有缓存图片时直接复用;否则获取背景、渲染模板、缓存卡片 4. 渲染失败降级为纯文本 +5. 自动撤回内容含 `fortune` 时,OneBot 发送结果会登记到统一可恢复撤回队列 -### 3. 访问控制 +### 3. 随机本子 PDF + +1. `/随机本子 [标签...]` 与“来份标签本子”通过 `SetuCommandHandler` 复用 Setu 访问控制和标签解析 +2. `DoujinshiService` 将每个解析后的标签作为重复 `tag` 参数调用随机本子 API,校验响应并按页图顺序写入 PDF +3. `build_doujinshi_file_chain()` 根据平台构造消息:OneBot/NapCat 使用包含 `File` 的 `Nodes` 合并转发,其他平台使用普通 `File` +4. 自动撤回内容含 `doujinshi` 且 OneBot 群聊启用时,`DirectSendStrategy` 通过原始合并转发 action 取得 `message_id`,统一撤回调度器立即持久化消息任务 +5. 所有新的消息撤回任务共同写入 `StarTools.get_data_dir()` 返回的运行目录;插件重启后按原绝对到期时间调用 OneBot `delete_msg`。合并转发附件不依赖 `get_group_root_files`,因为 NapCat 可能不会将其作为可删除的群文件返回 +6. PDF 写入同一插件运行目录,供 AstrBot 文件发送链路读取 + +### 4. 访问控制 1. 命令或 tool 触发前检查 `AccessControlService` 2. Setu 和 Fortune 独立判定 @@ -57,7 +69,7 @@ tests/ # 单元与集成测试 4. 任一维度拒绝则最终拒绝 5. 黑白名单互斥:同一功能下同一 ID 不会同时存在两份名单中 -### 4. 会话配置 +### 5. 会话配置 1. `/session_config` 命令或 LLM tool 读写 `session_overrides.json` 2. 不修改全局 WebUI 配置 @@ -71,11 +83,13 @@ flowchart TD A --> C["FortuneCommandHandler"] A --> D["AccessControlService"] A --> E["SessionConfigService"] + A --> O["DoujinshiService"] B --> F["ImageProviderPort"] F --> G["LoliconProvider / AtriProvider / SexNyanProvider / CustomProvider / MultiProvider"] B --> H["ImageSender"] H --> I["SendStrategy / NapCatStream / SendFilters"] + O --> P["PDF File / OneBot Nodes"] C --> J["FortuneService"] J --> K["FortuneRepository"] @@ -130,6 +144,8 @@ flowchart TD - 访问控制:JSON 文件(`AccessControlRepository`) - 会话配置:JSON 文件(`SessionConfigJsonRepository`) - 发送缓存:磁盘文件(`send_cache.py`) +- 随机本子 PDF:插件数据目录下的 `doujinshi/` +- 可恢复撤回任务(消息撤回,含本子合并转发):插件数据目录下的 `revoke_tasks.json` - 标签别名:配置模板(`tag_alias_templates`) ## 深入章节 diff --git a/docs/project/sending-limits.md b/docs/project/sending-limits.md index 0fd7baa..80964a9 100644 --- a/docs/project/sending-limits.md +++ b/docs/project/sending-limits.md @@ -74,7 +74,7 @@ - **`uin` 校验**:`ForwardSendStrategy._build_forward_nodes` 中 `event.get_self_id()` 为空时记 warning,保留空字符串避免序列化崩溃。 - **direct + 本地 file:// 直通(可选)**:新增 `delivery.platform_transports`,其中 NapCat 模板包含 `local_file_mode`(默认 `disabled`)和 `local_file_allowed_roots`。仅 OneBot/NapCat 类平台、直发模式、真实文件且路径位于发送缓存目录或显式共享目录时,才走 raw OneBot `file://` 直通。 - **stream 分块可配置**:NapCat 模板包含 `stream_chunk_kb`(默认 64)。NapCat `upload_file_stream` 的 `chunk_data` 仍为 base64 字符串,本配置只改变每块原始字节大小。旧版 `delivery.napcat_*` 平铺字段仍作为兼容兜底读取。 -- **自动撤回需要 message_id**:`auto_revoke_scope` 命中时,OneBot/NapCat 类平台的 direct、HTML fallback、stream、file:// 直通、forward 和 R18 Docx 会优先走 raw OneBot action 以提取 `message_id`。拿不到 id、平台不支持 `delete_msg` 或删除失败时只记录 warning,不触发重复发送或阻断发图。 +- **自动撤回需要 message_id**:`auto_revoke_targets` 包含 `setu` 且 `auto_revoke_scope` 命中时,OneBot/NapCat 类平台的 direct、HTML fallback、stream、file:// 直通、forward 和 R18 Docx 会优先走 raw OneBot action 以提取 `message_id`。目标列表包含 `fortune` 时,今日运势也会走同一消息撤回队列;包含 `doujinshi` 时,本子 `Nodes` 合并转发也会直接取得其消息 ID,而非反查群文件。拿不到 id、平台不支持 `delete_msg` 或删除失败时只记录 warning,不触发重复发送或阻断发图;成功登记的撤回任务写入 `revoke_tasks.json`,重启后仍会恢复。 **未实现(计划或待验证):** diff --git a/docs/usage/commands.md b/docs/usage/commands.md index d404c5c..643de50 100644 --- a/docs/usage/commands.md +++ b/docs/usage/commands.md @@ -18,6 +18,27 @@ - 数量范围支持中文数字 - 标签支持空格、逗号、顿号分隔 +## 随机本子命令 + +```text +来份本子 +来一份本子 +来份碧蓝档案本子 +/随机本子 +/随机本子 碧蓝档案 +/本子 +/doujinshi +``` + +- 调用 `https://api.atri.rodeo/v1/doujinshi/random`,下载响应中的全部页图并生成一个 PDF。 +- 本子标签与 Setu 使用同一套空格、逗号、顿号分隔和标签别名映射;解析出的每个标签都会以重复 `tag` 查询参数传给 API。 +- 与 Setu 共用同一套用户和群组访问控制;被禁止使用色图的会话也不能获取随机本子。 +- OneBot v11/NapCat 类平台将 PDF 放进一个合并转发节点发送;QQ 后端仍可能把附件显示为群文件。其他平台直接发送 PDF 文件。 +- PDF 文件名使用 API 返回的本子标题,并对平台不允许的路径字符做安全替换。 +- PDF 会写入插件运行数据目录,供 AstrBot 在发送期间读取;不使用插件目录下的 `data/`。 +- OneBot 群聊的本子合并转发是否自动撤回由全局 `delivery.auto_revoke_targets` 中的 `doujinshi` 控制,默认启用;它与色图、今日运势共用 `delivery.auto_revoke_delay`。填 `1800` 即为 30 分钟,`0` 可关闭全部自动清理。任务保存在插件数据目录的可恢复队列中,重启后仍会恢复。 +- 本子发送会使用 OneBot 原始 `send_group_forward_msg` 取得合并转发消息的 `message_id`,到期时调用 `delete_msg`。NapCat 对这类附件的 `get_group_root_files` 可能返回空列表,不能用 `delete_group_file` 删除;QQ 客户端中的“群文件”展示由该合并转发消息承载。 + ## 会话配置命令(管理员设置) 会话覆盖配置会写入插件数据目录下的 `session_overrides.json`,不会修改全局 WebUI 配置。 @@ -38,7 +59,7 @@ 可用配置项:`setu.content_mode`、`setu.r18_docx`、`setu.auto_revoke_scope`、`setu.send_mode`、`fortune.tags`、`fortune.content_mode`。 -`setu.auto_revoke_scope` 可选 `none`、`sfw`、`r18`、`all`,只作用于 Setu 图片发送;今日运势不自动撤回。 +`setu.auto_revoke_scope` 可选 `none`、`sfw`、`r18`、`all`,只作用于 Setu 图片发送。今日运势是否自动撤回由全局 `delivery.auto_revoke_targets` 的 `fortune` 值控制,默认禁用。 ## 黑白名单管理命令(管理员) @@ -89,5 +110,6 @@ ## 命令行为说明 - Setu 获取无结果时走 `MessagesConfig` 配置提示,不再硬编码文案。 +- 随机本子的获取中和失败提示分别使用 `doujinshi_fetching`、`doujinshi_failed` 消息键,可在提示消息覆盖中调整。 - 运势卡片渲染失败时降级为纯文本提示。 - 会话配置支持并发安全读写。 diff --git a/docs/usage/configuration.md b/docs/usage/configuration.md index f3756a2..4413f12 100644 --- a/docs/usage/configuration.md +++ b/docs/usage/configuration.md @@ -22,7 +22,9 @@ |--------|------|------|--------|--------| | `html_card_strategy` | 字符串 | HTML 卡片策略 | `never` / `fallback` / `always` | `fallback` | | `platform_transports` | template_list | 平台传输能力模板;当前内置 NapCat 模板 | 见下文 | `[]` | +| `auto_revoke_targets` | 列表 | 自动撤回内容 | `setu` / `fortune` / `doujinshi` | `["doujinshi"]` | | `auto_revoke_scope` | 字符串 | Setu 图片自动撤回范围 | `none` / `sfw` / `r18` / `all` | `none` | +| `auto_revoke_delay` | 整数 | 已启用内容共用自动清理延迟(秒) | ≥0,`0` 全部关闭 | `30` | | `r18_docx_mode` | 布尔值 | R18 是否使用 Docx 封装 | `true` / `false` | `true` | ### HTML 卡片策略详解 @@ -35,9 +37,11 @@ 发送接口超时或 OneBot/NapCat 类适配器未返回 message id 时,插件会把结果标记为“可能仍在送达”,不会立刻触发 NapCat 流式或 HTML 卡片 fallback,避免原图稍后送达时又重复发送降级图片。明确抛出的发送异常仍会进入原有 fallback 链路。 -### 自动撤回范围 +### 自动撤回内容与范围 -`auto_revoke_scope` 只作用于 Setu 图片发送,不作用于今日运势。可选值: +先用一个 `auto_revoke_targets` 列表选择内容,每行填写一个值:`setu`(色图)、`fortune`(今日运势)或 `doujinshi`(随机本子)。默认值为 `["doujinshi"]`,所以色图和今日运势默认禁用,本子默认启用。 + +`auto_revoke_scope` 只作用于列表中已启用的 `setu` 图片发送,不作用于今日运势或本子。可选值: | 值 | 说明 | |----|------| @@ -46,7 +50,15 @@ | `r18` | 只撤回 R18 Setu 图片或 R18 Docx 文件 | | `all` | SFW 与 R18 Setu 发送都撤回 | -撤回依赖 OneBot-like 平台返回的 `message_id`。如果平台不支持 `delete_msg`、发送返回里没有 `message_id`,或删除失败,插件只记录 warning,不阻止图片发送。旧版 `delivery.auto_revoke_r18` 启动时会迁移为 `auto_revoke_scope`:`true` → `r18`,`false` → `none`,迁移后旧字段会被移除。 +色图和今日运势的消息撤回依赖 OneBot-like 平台返回的 `message_id`。如果平台不支持 `delete_msg`、发送返回里没有 `message_id`,或删除失败,插件只记录 warning,不阻止消息发送。已登记的撤回任务会写入插件数据目录中的 `revoke_tasks.json`,插件退出时仅停止内存计时,重启后仍按原到期时间恢复。旧版 `delivery.auto_revoke_r18` 启动时会迁移为 `auto_revoke_scope`:`true` → `r18`,`false` → `none`,迁移后旧字段会被移除。 + +### 自动撤回与本子合并转发 + +`auto_revoke_delay` 同时控制 `auto_revoke_targets` 中启用的 Setu 图片、今日运势消息和 OneBot/NapCat 群聊中合并转发的随机本子 PDF。默认 `30` 秒;设为 `0` 会关闭全部自动清理;如需 30 分钟,设为 `1800`。旧 `doujinshi_file_cleanup_delay` 会在插件启动时迁移到新字段;两者同时存在时以 `auto_revoke_delay` 为准。 + +待撤回记录统一保存于插件运行数据目录的 `revoke_tasks.json`。本子发送时会直接调用 OneBot 合并转发 action 取得 `message_id`,并立即将该消息撤回任务持久化;到期后统一调用 `delete_msg`。NapCat 会把这类附件显示在 QQ 的群文件界面,但 `get_group_root_files` 可能返回空列表,说明它不是可由 `delete_group_file` 管理的真实群文件,因此插件不再依赖文件名、体积或根目录快照反查。插件退出或重启只停止内存计时,下一次初始化会按原绝对到期时间恢复;已有 `doujinshi_file_cleanup_tasks.json` 会迁移到统一队列。OneBot action 不可用、发送结果没有 `message_id` 或删除 action 返回错误时,任务会保留并记录 warning,不会重复发送。实际到期删除失败时,任务会将连续失败次数持久化;前两次失败保留以便下次插件启动恢复,第三次连续失败会自动从 `revoke_tasks.json` 移除,避免无效任务无限累积。启用本子自动撤回后,发送成功会立即写入该文件并记录“已登记合并转发自动撤回”日志;若没有任务记录,可从 warning 区分调度器、插件上下文或 `message_id` 缺失的原因。 + +旧版本已经写入的 `group_file` 任务没有对应的合并转发 `message_id`,而空的群文件接口也无法反向取得它;插件会保留这些旧任务,避免静默丢失状态。需要先在 QQ 客户端手动处理旧附件,再按需清理对应的旧任务记录。 ### NapCat 本地文件直通 @@ -127,7 +139,9 @@ NapCat `upload_file_stream` 的 `chunk_data` 仍是 base64 字符串,这是 Na }, "delivery": { "send_mode": "auto", + "auto_revoke_targets": ["setu", "doujinshi"], "auto_revoke_scope": "r18", + "auto_revoke_delay": 1800, "r18_docx_mode": false }, "messages": { diff --git a/main.py b/main.py index 95a61a5..8504f9a 100644 --- a/main.py +++ b/main.py @@ -45,11 +45,19 @@ register_session_config_web_apis, ) from .src.infrastructure.config import heal_astrbot_plugin_config +from .src.infrastructure.sending import ( + init_revoke_scheduler, + stop_revoke_scheduler, +) from .src.shared.send_cache import clear_send_cache, init_send_cache # Regex patterns for command triggers SETU_REGEX_PATTERN = r"^/?(来\s*(.*?)(份|个|张|点))(.*?)(?:福利|色|瑟|涩|塞)?图$" +DOUJINSHI_REGEX_PATTERN = r"^/?来\s*(?:一\s*)?份(?P.*?)本子$" FORTUNE_REGEX_PATTERN = r"^(?!/)(今日运势|jrys)$" +REGEX_COMMAND_PATTERN = ( + rf"(?:{SETU_REGEX_PATTERN}|{DOUJINSHI_REGEX_PATTERN}|{FORTUNE_REGEX_PATTERN})" +) # Module-level handler singletons _setu_handler: SetuCommandHandler | None = None @@ -135,6 +143,40 @@ def _is_fortune_command_invocation(event: AstrMessageEvent) -> bool: return _get_invoked_command(event) in {"今日运势", "jrys"} +async def _route_regex_command( + event: AstrMessageEvent, + setu_handler: SetuCommandHandler | None, + fortune_handler: FortuneCommandHandler | None, +) -> AsyncGenerator[Any, None]: + """把所有纯文本正则入口集中分发到对应命令处理器。""" + message = str(getattr(event, "message_str", "") or "").strip() + if re.match(SETU_REGEX_PATTERN, message): + if setu_handler is None: + yield event.plain_result("插件未初始化") + return + async for result in setu_handler.get_random_picture(event): + yield result + return + + if doujinshi_match := re.match(DOUJINSHI_REGEX_PATTERN, message): + if setu_handler is None: + yield event.plain_result("插件未初始化") + return + tags = doujinshi_match.group("tags").strip() + async for result in setu_handler.random_doujinshi_command(event, tags=tags): + yield result + return + + if re.match(FORTUNE_REGEX_PATTERN, message): + if _is_fortune_command_invocation(event): + return + if fortune_handler is None: + yield event.plain_result("插件未初始化") + return + async for result in fortune_handler.fortune_command(event): + yield result + + def _fortune_auto_refresh_enabled(config: Any) -> bool: fortune = getattr(config, "fortune", None) return ( @@ -236,8 +278,13 @@ async def initialize(self) -> None: max_items=cfg.cache_max_items, cleanup_on_start=cfg.cache_cleanup_on_start, ) + revoke_scheduler = await init_revoke_scheduler(data_dir, self.context) - _setu_handler = SetuCommandHandler() + _setu_handler = SetuCommandHandler( + data_dir=Path(data_dir), + plugin_context=self.context, + revoke_scheduler=revoke_scheduler, + ) _fortune_handler = FortuneCommandHandler() _session_config_handler = SessionConfigCommandHandler() if _fortune_auto_refresh_enabled(cfg): @@ -263,7 +310,6 @@ async def terminate(self) -> None: clear_repo, clear_session_config_repo, ) - from .src.infrastructure.sending import clear_revoke_scheduler if self._fortune_pregenerate_task is not None: self._fortune_pregenerate_task.cancel() @@ -282,7 +328,7 @@ async def terminate(self) -> None: clear_repo() clear_fortune_repo() clear_session_config_repo() - await clear_revoke_scheduler() + await stop_revoke_scheduler() clear_send_cache() _setu_handler = None @@ -361,15 +407,12 @@ def _persist_runtime_plugin_config(self, healed_config: dict[str, Any]) -> bool: # ==================== Setu Commands ==================== - @filter.regex(SETU_REGEX_PATTERN) - async def get_random_picture( - self, event: AstrMessageEvent - ) -> AsyncGenerator[Any, None]: - """来份色图 / 来张图 etc.""" - if _setu_handler is None: - yield event.plain_result("插件未初始化") - return - async for result in _setu_handler.get_random_picture(event): + @filter.regex(REGEX_COMMAND_PATTERN) + async def regex_command(self, event: AstrMessageEvent) -> AsyncGenerator[Any, None]: + """集中处理所有纯文本正则命令入口。""" + async for result in _route_regex_command( + event, _setu_handler, _fortune_handler + ): yield result @filter.command("setu") @@ -383,6 +426,17 @@ async def setu_command( async for result in _setu_handler.setu_command(event, count, tags=tags): yield result + @filter.command("随机本子", alias={"本子", "doujinshi"}) + async def random_doujinshi_command( + self, event: AstrMessageEvent, *, tags: str = "" + ) -> AsyncGenerator[Any, None]: + """获取可选标签的随机本子 PDF。""" + if _setu_handler is None: + yield event.plain_result("插件未初始化") + return + async for result in _setu_handler.random_doujinshi_command(event, tags=tags): + yield result + @filter.command("session_config") async def session_config_command( self, event: AstrMessageEvent, args: str = "" @@ -407,19 +461,6 @@ async def fortune_command( async for result in _fortune_handler.fortune_command(event): yield result - @filter.regex(FORTUNE_REGEX_PATTERN) - async def fortune_regex_command( - self, event: AstrMessageEvent - ) -> AsyncGenerator[Any, None]: - """纯文本今日运势/jrys入口(不带命令前缀)。""" - if _is_fortune_command_invocation(event): - return - if _fortune_handler is None: - yield event.plain_result("插件未初始化") - return - async for result in _fortune_handler.fortune_command(event): - yield result - @filter.command( "运势刷新", alias={ diff --git a/metadata.yaml b/metadata.yaml index b430426..5ec304e 100644 --- a/metadata.yaml +++ b/metadata.yaml @@ -1,6 +1,6 @@ name: astrbot_plugin_setu display_name: 瑟瑟! -version: v2.1.2 +version: v2.2.0 author: FlanChanXwO -desc: 随机福利图插件,支持标签与数量,以及图片分级控制,可以针对于特定平台配置概率绕过审核的发送方式。 +desc: 随机福利图与随机本子 PDF 插件,支持标签与数量、图片分级控制,以及面向不同平台的发送方式。 repo: https://github.com/FlanChanXwO/astrbot_plugin_setu diff --git a/requirements.txt b/requirements.txt index 7f5d691..685e593 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ python-docx>=1.0.0 +Pillow>=10.0.0 httpx[http2]>=0.28.0 pydantic>=2.0 aiosqlite>=0.19.0 diff --git a/src/application/setu/tag_resolution.py b/src/application/setu/tag_resolution.py new file mode 100644 index 0000000..37a3b53 --- /dev/null +++ b/src/application/setu/tag_resolution.py @@ -0,0 +1,38 @@ +"""面向命令输入的标签解析与别名映射。""" + +from __future__ import annotations + +from collections.abc import Iterable + +from ...domain.setu import TagResolverService + + +def resolve_user_tags(raw_tags: str, configured_aliases: str) -> list[str]: + """按命令分隔规则解析标签,并应用配置的别名映射。""" + if not raw_tags or not raw_tags.strip(): + return [] + return _build_resolver(configured_aliases).resolve_tags(raw_tags) + + +def resolve_user_tag_list( + raw_tags: Iterable[object], configured_aliases: str +) -> list[str]: + """为结构化标签列表应用与命令文本相同的别名映射。""" + resolver = _build_resolver(configured_aliases, split_alias_spaces=False) + resolved_tags: list[str] = [] + for raw_tag in raw_tags: + tag = str(raw_tag).strip() + if tag: + resolved_tags.append(resolver.resolve_tag(tag)) + return resolved_tags + + +def _build_resolver( + configured_aliases: str, *, split_alias_spaces: bool = True +) -> TagResolverService: + """构造优先使用插件配置的标签解析器。""" + # 同一解析入口保证色图、本子与 LLM 的别名语义一致。 + alias_map = TagResolverService.parse_alias_map_from_string( + configured_aliases, split_spaces=split_alias_spaces + ) + return TagResolverService(alias_map or TagResolverService.DEFAULT_TAG_ALIAS) diff --git a/src/infrastructure/astrbot/commands/fortune.py b/src/infrastructure/astrbot/commands/fortune.py index c53aace..1f32140 100644 --- a/src/infrastructure/astrbot/commands/fortune.py +++ b/src/infrastructure/astrbot/commands/fortune.py @@ -24,7 +24,9 @@ from ...permission_service import PermissionService from ...persistence import get_fortune_repo from ...providers import init_provider_from_config -from ..config import get_config +from ...sending import DirectSendStrategy, schedule_revoke +from ...sending.platform_capabilities import is_onebot_like_platform +from ..config import get_config, get_plugin_context from ..fortune_renderer import FortuneRenderer logger = get_logger() @@ -69,14 +71,60 @@ async def fortune_command( result = await service.get_or_create_fortune(request) fortune_image = await self._render_fortune_image(result, service) if fortune_image: - yield event.chain_result([Comp.Image.fromBytes(fortune_image)]) + chain = [Comp.Image.fromBytes(fortune_image)] + if await self._send_fortune_with_auto_revoke(event, chain, config): + return + yield event.chain_result(chain) else: - yield event.plain_result(self._format_fortune(result)) + text = self._format_fortune(result) + if await self._send_fortune_with_auto_revoke( + event, [Comp.Plain(text)], config + ): + return + yield event.plain_result(text) except Exception as e: text = self._message("fortune_get_failed", error=e) if result := self._plain(event, text): yield result + async def _send_fortune_with_auto_revoke( + self, + event: AstrMessageEvent, + chain: list[Any], + config: Any, + ) -> bool: + """在启用且支持的情况下发送今日运势并登记可恢复撤回任务。""" + if not getattr(config, "auto_revoke_fortune_enabled", False): + return False + if config.auto_revoke_delay <= 0: + return False + + platform_name = getattr(getattr(event, "platform", None), "name", None) + if not is_onebot_like_platform(platform_name): + return False + + plugin_context = get_plugin_context() + if plugin_context is None: + logger.warning("[fortune] 自动撤回已启用但插件上下文未初始化") + return False + + send_result = await DirectSendStrategy(plugin_context).send_with_status( + event, chain, auto_revoke=True + ) + if not send_result.accepted: + return False + if not send_result.message_ids: + logger.warning("[fortune] 发送成功但未返回 message_id,无法登记自动撤回") + return True + + scheduled = 0 + for message_id in send_result.message_ids: + if await schedule_revoke(event, message_id, config.auto_revoke_delay): + scheduled += 1 + if scheduled == 0: + logger.warning("[fortune] 未能登记今日运势自动撤回任务") + return True + async def refresh_fortune_command( self, event: AstrMessageEvent ) -> AsyncGenerator[Any, None]: diff --git a/src/infrastructure/astrbot/commands/setu.py b/src/infrastructure/astrbot/commands/setu.py index 27729a7..2c7d93e 100644 --- a/src/infrastructure/astrbot/commands/setu.py +++ b/src/infrastructure/astrbot/commands/setu.py @@ -7,6 +7,7 @@ import re import time from collections.abc import AsyncGenerator +from pathlib import Path from typing import Any from astrbot.api.event import AstrMessageEvent @@ -14,14 +15,26 @@ from ....application.session_config import SessionConfigService from ....application.setu.get_images import GetSetuImagesUseCase +from ....application.setu.tag_resolution import ( + resolve_user_tag_list, + resolve_user_tags, +) from ....domain.access_control import AccessPolicy from ....domain.access_control.service import AccessControlService -from ....domain.setu import SetuRequest, TagResolverService +from ....domain.setu import SetuRequest from ....shared import get_logger from ... import get_access_control_repo, get_provider from ...persistence import get_session_config_repo from ...providers import init_provider_from_config -from ..config import get_config +from ...doujinshi import DoujinshiService +from ...sending import ( + DirectSendStrategy, + build_doujinshi_file_chain, + get_revoke_scheduler, +) +from ...sending.platform_capabilities import supports_forward_messages +from ...sending.revoke_scheduler import RecoverableRevokeScheduler +from ..config import get_config, get_plugin_context from ..session_identity import get_event_session_identity logger = get_logger() @@ -30,6 +43,19 @@ SETU_REGEX_PATTERN = r"^/?(来\s*(.*?)(份|个|张|点))(.*?)(?:福利|色|瑟|涩|塞)?图$" +def _platform_name(event: AstrMessageEvent) -> str | None: + """返回事件平台名称,并兼容测试替身的最小接口。""" + name = getattr(getattr(event, "platform", None), "name", None) + if isinstance(name, str) and name: + return name + getter = getattr(event, "get_platform_name", None) + if callable(getter): + value = getter() + if isinstance(value, str) and value: + return value + return None + + class RateLimiter: """Simple rate limiter to prevent concurrent requests from same user.""" @@ -84,6 +110,20 @@ class SetuCommandHandler: Commands are auto-registered by AstrBot decorators. """ + def __init__( + self, + data_dir: Path | str | None = None, + *, + plugin_context: object | None = None, + revoke_scheduler: RecoverableRevokeScheduler | None = None, + ) -> None: + self._doujinshi_service = ( + DoujinshiService(data_dir) if data_dir is not None else None + ) + # 生命周期依赖在初始化时注入,避免热重载时读取到另一模块实例的全局单例。 + self._plugin_context = plugin_context + self._revoke_scheduler = revoke_scheduler + # ==================== Command Handlers ==================== async def get_random_picture( @@ -139,7 +179,7 @@ async def _handle_random_picture_internal( return tag_str = match.group(4).strip() - tags = self._resolve_tags(tag_str, config) + tags = resolve_user_tags(tag_str, getattr(config, "tag_alias", "")) effective_mode = await self._get_effective_content_mode(event) is_r18 = self._mode_requires_r18(effective_mode) @@ -177,6 +217,117 @@ async def setu_command( finally: await _rate_limiter.release(event) + async def random_doujinshi_command( + self, event: AstrMessageEvent, *, tags: str = "" + ) -> AsyncGenerator[Any, None]: + """按可选标签获取随机本子并发送由全部页图组成的 PDF。""" + if not await _rate_limiter.acquire(event): + if result := self._plain(event, self._message("rate_limited")): + yield result + return + + try: + async for result in self._handle_random_doujinshi_internal(event, tags): + yield result + finally: + await _rate_limiter.release(event) + + async def _handle_random_doujinshi_internal( + self, event: AstrMessageEvent, raw_tags: str + ) -> AsyncGenerator[Any, None]: + config = get_config() + if not config: + if result := self._plain(event, self._message("config_not_loaded")): + yield result + return + + has_perm, msg = await self._check_access(event, config) + if not has_perm: + if result := self._plain(event, msg): + yield result + return + + if result := self._plain(event, self._message("doujinshi_fetching")): + yield result + + if self._doujinshi_service is None: + logger.error( + "Doujinshi service is unavailable before plugin initialization" + ) + if result := self._plain(event, self._message("doujinshi_failed")): + yield result + return + + try: + tags = resolve_user_tags(raw_tags, getattr(config, "tag_alias", "")) + generated = await self._doujinshi_service.fetch_random_pdf(tags=tags) + platform_name = _platform_name(event) + chain = build_doujinshi_file_chain( + generated, + platform_name=platform_name, + self_id=event.get_self_id(), + ) + scheduler = self._revoke_scheduler + if scheduler is None: + scheduler = get_revoke_scheduler() + plugin_context = self._plugin_context + if plugin_context is None: + plugin_context = get_plugin_context() + revoke_delay = config.auto_revoke_delay + auto_revoke_requested = ( + config.auto_revoke_doujinshi_enabled + and supports_forward_messages(platform_name) + and bool(event.get_group_id()) + and revoke_delay > 0 + ) + if auto_revoke_requested: + if scheduler is None: + logger.warning( + "[doujinshi] 自动撤回已启用但调度器未初始化," + "将按普通合并转发发送" + ) + elif plugin_context is None: + logger.warning( + "[doujinshi] 自动撤回已启用但插件上下文未初始化," + "将按普通合并转发发送" + ) + else: + send_result = await DirectSendStrategy( + plugin_context + ).send_with_status(event, chain, auto_revoke=True) + if send_result.accepted: + scheduled_count = 0 + for message_id in send_result.message_ids: + if await scheduler.schedule_revoke( + event, message_id, revoke_delay + ): + scheduled_count += 1 + if not send_result.message_ids: + logger.warning( + "[doujinshi] 合并转发已发送但未返回 message_id," + "无法登记自动撤回" + ) + elif scheduled_count != len(send_result.message_ids): + logger.warning( + "[doujinshi] 部分合并转发消息未能登记自动撤回: " + "scheduled=%s, total=%s", + scheduled_count, + len(send_result.message_ids), + ) + else: + logger.info( + "[doujinshi] 已登记合并转发自动撤回: " + "messages=%s, delay=%ss", + scheduled_count, + revoke_delay, + ) + return + yield event.chain_result(chain) + except Exception: + logger.exception("random doujinshi command failed") + if result := self._plain(event, self._message("doujinshi_failed")): + yield result + async def _handle_setu_command_internal( self, event: AstrMessageEvent, count: str, tags: str ) -> AsyncGenerator[Any, None]: @@ -211,7 +362,7 @@ async def _handle_setu_command_internal( yield result return - parsed_tags = self._resolve_tags(all_tags, config) + parsed_tags = resolve_user_tags(all_tags, getattr(config, "tag_alias", "")) effective_mode = await self._get_effective_content_mode(event) is_r18 = self._mode_requires_r18(effective_mode) @@ -250,7 +401,9 @@ async def _llm_get_setu_handler( effective_mode = await self._get_effective_content_mode(event) request = SetuRequest.from_user_input( count=count, - tags=self._resolve_tags_from_list(tags or [], config), + tags=resolve_user_tag_list( + tags or [], getattr(config, "tag_alias", "") + ), r18=self._mode_requires_r18(effective_mode), exclude_ai=config.exclude_ai, max_replenish_rounds=config.max_replenish_rounds, @@ -396,27 +549,6 @@ def _message(self, key: str, **kwargs: Any) -> str: return text return "" - def _resolve_tags(self, raw_tags: str, config: Any) -> list[str]: - """Normalize raw tags and apply configured alias mapping.""" - if not raw_tags: - return [] - - alias_map = TagResolverService.parse_alias_map_from_string( - getattr(config, "tag_alias", "") - ) - resolver = TagResolverService(alias_map or TagResolverService.DEFAULT_TAG_ALIAS) - return resolver.resolve_tags(raw_tags) - - def _resolve_tags_from_list(self, tags: list[str], config: Any) -> list[str]: - """Apply tag alias mapping for list inputs.""" - if not tags: - return [] - alias_map = TagResolverService.parse_alias_map_from_string( - getattr(config, "tag_alias", ""), split_spaces=False - ) - resolver = TagResolverService(alias_map or TagResolverService.DEFAULT_TAG_ALIAS) - return [resolver.resolve_tag(str(tag)) for tag in tags if str(tag).strip()] - # ==================== LLM Tools Registration ==================== diff --git a/src/infrastructure/config/legacy_migration.py b/src/infrastructure/config/legacy_migration.py index 8d462d4..2d4bad7 100644 --- a/src/infrastructure/config/legacy_migration.py +++ b/src/infrastructure/config/legacy_migration.py @@ -36,6 +36,22 @@ def apply_legacy_config_aliases( "removed legacy key because auto_revoke_scope exists", ) + if isinstance(delivery, dict) and "doujinshi_file_cleanup_delay" in delivery: + legacy_value = delivery.pop("doujinshi_file_cleanup_delay") + if "auto_revoke_delay" not in delivery: + delivery["auto_revoke_delay"] = legacy_value + record_config_heal( + changes, + "delivery.doujinshi_file_cleanup_delay", + "migrated to delivery.auto_revoke_delay", + ) + else: + record_config_heal( + changes, + "delivery.doujinshi_file_cleanup_delay", + "removed legacy key because auto_revoke_delay exists", + ) + session_configs = normalized.get("session_configs") if isinstance(session_configs, list): for index, item in enumerate(session_configs): diff --git a/src/infrastructure/doujinshi/__init__.py b/src/infrastructure/doujinshi/__init__.py new file mode 100644 index 0000000..d1bc427 --- /dev/null +++ b/src/infrastructure/doujinshi/__init__.py @@ -0,0 +1,11 @@ +"""随机本子 API 与 PDF 生成适配。""" + +from __future__ import annotations + +from .service import GeneratedDoujinshiPdf, DoujinshiGallery, DoujinshiService + +__all__ = [ + "GeneratedDoujinshiPdf", + "DoujinshiGallery", + "DoujinshiService", +] diff --git a/src/infrastructure/doujinshi/service.py b/src/infrastructure/doujinshi/service.py new file mode 100644 index 0000000..3be34db --- /dev/null +++ b/src/infrastructure/doujinshi/service.py @@ -0,0 +1,236 @@ +"""随机本子 API 的响应解析与文件生成服务。""" + +from __future__ import annotations + +import asyncio +from collections.abc import Mapping +from dataclasses import dataclass +from io import BytesIO +from pathlib import Path +from urllib.parse import urlparse +from uuid import uuid4 + +import httpx +from PIL import Image +from PIL import ImageOps + +from ...domain import HTTP_TIMEOUT_SECONDS + + +@dataclass(frozen=True) +class DoujinshiGallery: + """可下载并封装为 PDF 的本子元数据。""" + + id: int + title: str + page_urls: tuple[str, ...] + + +@dataclass(frozen=True) +class GeneratedDoujinshiPdf: + """已经落盘、可作为 AstrBot 文件消息发送的随机本子 PDF。""" + + gallery: DoujinshiGallery + path: Path + + +class DoujinshiService: + """解析 Atri 随机本子 API 的公开响应。""" + + API_URL = "https://api.atri.rodeo/v1/doujinshi/random" + REQUEST_HEADERS = { + "Accept": "application/json", + "User-Agent": ( + "astrbot-plugin-setu/2.2.0 " + "(+https://github.com/FlanChanXwO/astrbot_plugin_setu)" + ), + "Sec-Fetch-Dest": "empty", + } + + def __init__(self, data_dir: Path | str) -> None: + self._output_dir = Path(data_dir) / "doujinshi" + + async def fetch_random_pdf( + self, + tags: list[str] | None = None, + *, + client: httpx.AsyncClient | None = None, + ) -> GeneratedDoujinshiPdf: + """获取随机本子并将 API 返回的全部页图封装为 PDF。""" + normalized_tags = [tag for tag in tags or [] if tag] + if client is not None: + return await self._fetch_random_pdf_with_client(client, normalized_tags) + + async with httpx.AsyncClient( + timeout=HTTP_TIMEOUT_SECONDS, + follow_redirects=True, + ) as managed_client: + return await self._fetch_random_pdf_with_client( + managed_client, normalized_tags + ) + + async def _fetch_random_pdf_with_client( + self, client: httpx.AsyncClient, tags: list[str] + ) -> GeneratedDoujinshiPdf: + # 与 Atri 图片接口一致,重复 tag 参数以保留所有解析后的标签。 + response = await client.get( + self.API_URL, + headers=self.REQUEST_HEADERS, + params=[("tag", tag) for tag in tags], + ) + response.raise_for_status() + try: + raw_payload = response.json() + except ValueError as exc: + raise ValueError("随机本子 API 返回的不是有效 JSON") from exc + if not isinstance(raw_payload, Mapping): + raise ValueError("随机本子 API 响应格式无效") + + gallery = self.parse_gallery(raw_payload) + output_path = self._build_output_path(gallery) + try: + await self._create_pdf_from_urls( + client, gallery.page_urls, output_path, gallery.title + ) + except BaseException: + output_path.unlink(missing_ok=True) + raise + return GeneratedDoujinshiPdf(gallery=gallery, path=output_path) + + @staticmethod + def parse_gallery(payload: Mapping[str, object]) -> DoujinshiGallery: + """将 API 响应校验为完整的本子元数据。""" + gallery_id = payload.get("id") + if not isinstance(gallery_id, int) or isinstance(gallery_id, bool): + raise ValueError("随机本子 API 响应缺少有效 ID") + + title = DoujinshiService._resolve_title(payload.get("title"), gallery_id) + page_urls = DoujinshiService._resolve_page_urls(payload.get("pages")) + return DoujinshiGallery( + id=gallery_id, + title=title, + page_urls=tuple(page_urls), + ) + + @staticmethod + def create_pdf(pages: list[Image.Image], output_path: Path, title: str) -> None: + """按下载顺序将所有页图写入一个多页 PDF。""" + if not pages: + raise ValueError("无法从空页图列表生成 PDF") + + prepared_pages = [DoujinshiService._prepare_pdf_page(page) for page in pages] + try: + output_path.parent.mkdir(parents=True, exist_ok=True) + first_page, *remaining_pages = prepared_pages + first_page.save( + output_path, + "PDF", + save_all=True, + append_images=remaining_pages, + title=title, + resolution=72.0, + ) + finally: + for page in prepared_pages: + page.close() + + async def _create_pdf_from_urls( + self, + client: httpx.AsyncClient, + page_urls: tuple[str, ...], + output_path: Path, + title: str, + ) -> None: + for page_number, page_url in enumerate(page_urls, start=1): + try: + response = await client.get(page_url) + response.raise_for_status() + await asyncio.to_thread( + self._append_pdf_page, + output_path, + response.content, + title, + is_first_page=page_number == 1, + ) + except (httpx.HTTPError, OSError) as exc: + raise RuntimeError( + f"随机本子第 {page_number} 页下载或转换失败" + ) from exc + + def _build_output_path(self, gallery: DoujinshiGallery) -> Path: + self._output_dir.mkdir(parents=True, exist_ok=True) + return self._output_dir / f"doujinshi-{gallery.id}-{uuid4().hex}.pdf" + + @staticmethod + def _append_pdf_page( + output_path: Path, + page_bytes: bytes, + title: str, + *, + is_first_page: bool, + ) -> None: + with Image.open(BytesIO(page_bytes)) as source: + prepared_page = DoujinshiService._prepare_pdf_page(source) + try: + if is_first_page: + prepared_page.save( + output_path, + "PDF", + title=title, + resolution=72.0, + ) + else: + prepared_page.save(output_path, "PDF", append=True) + finally: + prepared_page.close() + + @staticmethod + def _prepare_pdf_page(page: Image.Image) -> Image.Image: + normalized_page = ImageOps.exif_transpose(page) + try: + if ( + "A" in normalized_page.getbands() + or "transparency" in normalized_page.info + ): + rgba_page = normalized_page.convert("RGBA") + background = Image.new("RGB", rgba_page.size, "white") + background.paste(rgba_page, mask=rgba_page.getchannel("A")) + rgba_page.close() + return background + if normalized_page.mode == "RGB": + return normalized_page.copy() + return normalized_page.convert("RGB") + finally: + if normalized_page is not page: + normalized_page.close() + + @staticmethod + def _resolve_title(raw_title: object, gallery_id: int) -> str: + if isinstance(raw_title, Mapping): + for key in ("pretty", "english", "japanese"): + value = raw_title.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + return f"随机本子 {gallery_id}" + + @staticmethod + def _resolve_page_urls(raw_pages: object) -> list[str]: + if not isinstance(raw_pages, list) or not raw_pages: + raise ValueError("随机本子 API 响应不包含页图") + + page_urls: list[str] = [] + for page_number, raw_page in enumerate(raw_pages, start=1): + if not isinstance(raw_page, Mapping): + raise ValueError(f"随机本子第 {page_number} 页格式无效") + raw_url = raw_page.get("url") + if not isinstance(raw_url, str) or not DoujinshiService._is_http_url( + raw_url + ): + raise ValueError(f"随机本子第 {page_number} 页缺少可下载的图片 URL") + page_urls.append(raw_url) + return page_urls + + @staticmethod + def _is_http_url(value: str) -> bool: + parsed = urlparse(value) + return parsed.scheme in {"http", "https"} and bool(parsed.netloc) diff --git a/src/infrastructure/sending/__init__.py b/src/infrastructure/sending/__init__.py index 0d36ff4..fa55c00 100644 --- a/src/infrastructure/sending/__init__.py +++ b/src/infrastructure/sending/__init__.py @@ -3,8 +3,14 @@ from __future__ import annotations from .dto import SendOptions +from .doujinshi_sender import build_doujinshi_file_chain, get_doujinshi_file_name from .image_sender import ImageSender -from .revoke_scheduler import clear_revoke_scheduler, schedule_revoke +from .revoke_scheduler import ( + get_revoke_scheduler, + init_revoke_scheduler, + schedule_revoke, + stop_revoke_scheduler, +) from .send_filters import ( SendFilter, SendResult, @@ -27,8 +33,12 @@ "HtmlCardFallbackStrategy", "SendOptions", "schedule_revoke", - "clear_revoke_scheduler", + "get_revoke_scheduler", + "init_revoke_scheduler", + "stop_revoke_scheduler", "resolve_send_mode", + "build_doujinshi_file_chain", + "get_doujinshi_file_name", # Filter chain (new) "send_with_filter_chain", "SendResult", diff --git a/src/infrastructure/sending/doujinshi_sender.py b/src/infrastructure/sending/doujinshi_sender.py new file mode 100644 index 0000000..fc9d7c7 --- /dev/null +++ b/src/infrastructure/sending/doujinshi_sender.py @@ -0,0 +1,45 @@ +"""随机本子 PDF 的平台发送链构造。""" + +from __future__ import annotations + +import re + +import astrbot.api.message_components as Comp + +from ..doujinshi import GeneratedDoujinshiPdf +from .platform_capabilities import supports_forward_messages + + +def build_doujinshi_file_chain( + generated: GeneratedDoujinshiPdf, + *, + platform_name: str | None, + self_id: str | int | None, +) -> list[Comp.BaseMessageComponent]: + """按平台能力构造 PDF 文件消息链。 + + OneBot 平台使用一个包含文件段的合并转发节点;其他平台只返回普通文件段, + 这样不会向不支持 OneBot 节点协议的平台发送不兼容的消息结构。 + """ + file_name = get_doujinshi_file_name(generated) + file_component = Comp.File(name=file_name, file=str(generated.path)) + if not supports_forward_messages(platform_name): + return [file_component] + + node = Comp.Node( + content=[file_component], + name=generated.gallery.title, + uin=str(self_id or ""), + ) + return [Comp.Nodes([node])] + + +def get_doujinshi_file_name(generated: GeneratedDoujinshiPdf) -> str: + """使用本子标题作为文件名,并移除平台不接受的路径字符。""" + title = re.sub(r'[\\/:*?"<>|\x00-\x1f]', "_", generated.gallery.title).strip(" .") + if not title: + title = f"随机本子-{generated.gallery.id}" + return f"{title}.pdf" + + +__all__ = ["build_doujinshi_file_chain", "get_doujinshi_file_name"] diff --git a/src/infrastructure/sending/image_sender.py b/src/infrastructure/sending/image_sender.py index 1b4f07a..b8a02a2 100644 --- a/src/infrastructure/sending/image_sender.py +++ b/src/infrastructure/sending/image_sender.py @@ -103,12 +103,17 @@ async def _build_options( ) html_card_strategy = config.html_card_strategy + revoke_delay = config.auto_revoke_delay return SendOptions( send_mode=send_mode, use_html_card=html_card_strategy != "never", - auto_revoke=should_auto_revoke(auto_revoke_scope, is_r18), + auto_revoke=( + config.auto_revoke_setu_enabled + and revoke_delay > 0 + and should_auto_revoke(auto_revoke_scope, is_r18) + ), auto_revoke_scope=auto_revoke_scope, - revoke_delay=config.auto_revoke_delay, + revoke_delay=revoke_delay, r18_docx_mode=r18_docx_mode, html_padding=config.html_card_padding, html_gap=config.html_card_gap, diff --git a/src/infrastructure/sending/revoke_scheduler.py b/src/infrastructure/sending/revoke_scheduler.py index f52ce9d..8bbf946 100644 --- a/src/infrastructure/sending/revoke_scheduler.py +++ b/src/infrastructure/sending/revoke_scheduler.py @@ -1,11 +1,16 @@ -"""Delayed OneBot message revocation for Setu sends.""" +"""可跨插件重启恢复的 OneBot 撤回与群文件删除调度。""" from __future__ import annotations import asyncio import inspect +import json +from collections.abc import Mapping +from dataclasses import dataclass, replace +from pathlib import Path +from time import time from typing import Any -from unittest.mock import Mock +from uuid import uuid4 from astrbot.api.event import AstrMessageEvent @@ -14,12 +19,141 @@ logger = get_logger() - -class RevokeScheduler: - """Track delayed delete_msg tasks so plugin shutdown can cancel them.""" - - def __init__(self) -> None: - self._tasks: set[asyncio.Task[None]] = set() +_STORAGE_NAME = "revoke_tasks.json" +_LEGACY_DOUJINSHI_STORAGE_NAME = "doujinshi_file_cleanup_tasks.json" +_MESSAGE_TARGET = "message" +_GROUP_FILE_TARGET = "group_file" +_MAX_CONSECUTIVE_FAILURES = 3 + + +@dataclass(frozen=True, slots=True) +class GroupFile: + """OneBot 群根目录中的文件摘要。""" + + file_id: str + file_name: str + file_size: int | None + + +@dataclass(frozen=True, slots=True) +class RevokeTask: + """可恢复的 OneBot 删除任务。""" + + task_id: str + target: str + platform_id: str + due_at: float + message_id: str | None = None + group_id: str | None = None + file_id: str | None = None + file_name: str | None = None + before_file_ids: tuple[str, ...] = () + expected_file_size: int | None = None + failure_count: int = 0 + + def to_dict(self) -> dict[str, object]: + """序列化为 JSON 存储结构。""" + payload: dict[str, object] = { + "task_id": self.task_id, + "target": self.target, + "platform_id": self.platform_id, + "due_at": self.due_at, + } + if self.message_id is not None: + payload["message_id"] = self.message_id + if self.group_id is not None: + payload["group_id"] = self.group_id + if self.file_id is not None: + payload["file_id"] = self.file_id + if self.file_name is not None: + payload["file_name"] = self.file_name + if self.target == _GROUP_FILE_TARGET and self.file_id is None: + payload["before_file_ids"] = list(self.before_file_ids) + if self.expected_file_size is not None: + payload["expected_file_size"] = self.expected_file_size + if self.failure_count: + payload["failure_count"] = self.failure_count + return payload + + @classmethod + def from_dict(cls, payload: Mapping[str, object]) -> RevokeTask: + """从 JSON 存储结构恢复任务。""" + target = _required_text(payload, "target") + task_id = _required_text(payload, "task_id") + platform_id = _required_text(payload, "platform_id") + due_at = _required_due_at(payload) + failure_count = _optional_failure_count(payload.get("failure_count")) + + if target == _MESSAGE_TARGET: + return cls( + task_id=task_id, + target=target, + platform_id=platform_id, + due_at=due_at, + message_id=_required_text(payload, "message_id"), + failure_count=failure_count, + ) + if target == _GROUP_FILE_TARGET: + raw_file_id = payload.get("file_id") + if raw_file_id is None and "before_file_ids" not in payload: + raise ValueError("待识别群文件任务缺少 before_file_ids") + return cls( + task_id=task_id, + target=target, + platform_id=platform_id, + due_at=due_at, + group_id=_required_text(payload, "group_id"), + file_id=( + _required_text(payload, "file_id") + if raw_file_id is not None + else None + ), + file_name=_required_text(payload, "file_name"), + before_file_ids=_optional_file_ids(payload.get("before_file_ids")), + expected_file_size=_optional_file_size( + payload.get("expected_file_size") + ), + failure_count=failure_count, + ) + raise ValueError(f"不支持的删除目标: {target}") + + +class RecoverableRevokeScheduler: + """统一维护消息撤回与群文件删除的持久化任务。""" + + def __init__(self, data_dir: Path | str, context: object) -> None: + data_path = Path(data_dir) + self._storage_path = data_path / _STORAGE_NAME + self._legacy_doujinshi_path = data_path / _LEGACY_DOUJINSHI_STORAGE_NAME + self._context = context + self._records: dict[str, RevokeTask] = {} + self._tasks: dict[str, asyncio.Task[None]] = {} + self._lock = asyncio.Lock() + + @property + def storage_path(self) -> Path: + """返回统一撤回任务的持久化文件路径。""" + return self._storage_path + + async def initialize(self) -> None: + """加载待删除任务,迁移旧本子队列并按到期时间恢复。""" + records, migrated_legacy_tasks = await asyncio.to_thread(self._load_records) + async with self._lock: + self._records = {record.task_id: record for record in records} + if migrated_legacy_tasks: + await self._persist_records() + await asyncio.to_thread(self._legacy_doujinshi_path.unlink) + for record in records: + self._schedule_record(record) + + async def stop(self) -> None: + """停止内存计时协程,保留持久化任务供下次启动恢复。""" + tasks = tuple(self._tasks.values()) + for task in tasks: + task.cancel() + if tasks: + await asyncio.gather(*tasks, return_exceptions=True) + self._tasks.clear() async def schedule_revoke( self, @@ -27,147 +161,607 @@ async def schedule_revoke( message_id: str, delay: int, ) -> bool: - """Schedule one message revoke task and return whether it was accepted.""" + """持久化登记一条 OneBot 消息撤回任务。""" normalized_id = str(message_id or "").strip() if not normalized_id: - logger.warning("[revoke] skip scheduling: empty message_id") + logger.warning("[revoke] 跳过登记:message_id 为空") + return False + if not _is_onebot_event(event): + logger.warning( + "[revoke] 跳过登记:当前平台不是 OneBot,message_id=%s", normalized_id + ) return False - platform_name = _platform_name(event) - if not is_onebot_like_platform(platform_name): + platform_id = _event_text(event, "get_platform_id") + if not platform_id: logger.warning( - "[revoke] skip scheduling: platform=%s is not OneBot-like, message_id=%s", - platform_name or "unknown", - normalized_id, + "[revoke] 跳过登记:缺少平台 ID,message_id=%s", normalized_id ) return False - bot_client = _get_bot_client(event) - if bot_client is None or not _supports_delete_msg(bot_client): + try: + client = _event_client(event) + except RuntimeError as exc: + logger.warning("[revoke] 跳过登记:%s", exc) + return False + if not _supports_delete_msg(client): logger.warning( - "[revoke] skip scheduling: delete_msg unsupported, platform=%s, message_id=%s", - platform_name or "unknown", - normalized_id, + "[revoke] 跳过登记:delete_msg 不受支持,message_id=%s", normalized_id ) return False - task = asyncio.create_task( - self._delete_after_delay(bot_client, platform_name, normalized_id, delay), - name=f"setu_revoke_{normalized_id}", + record = RevokeTask( + task_id=uuid4().hex, + target=_MESSAGE_TARGET, + platform_id=platform_id, + due_at=time() + max(0, int(delay)), + message_id=normalized_id, ) - self._tasks.add(task) - task.add_done_callback(self._tasks.discard) + await self._register(record) logger.info( - "[revoke] scheduled: platform=%s, message_id=%s, delay=%ss", - platform_name or "unknown", + "[revoke] 已登记可恢复消息撤回: platform_id=%s, message_id=%s, delay=%ss", + platform_id, normalized_id, delay, ) return True - async def cancel_all(self) -> None: - """Cancel every pending revoke task.""" - if not self._tasks: - return - tasks = tuple(self._tasks) - for task in tasks: - task.cancel() - await asyncio.gather(*tasks, return_exceptions=True) - self._tasks.clear() + async def snapshot_group_file_ids( + self, event: AstrMessageEvent + ) -> frozenset[str] | None: + """读取发送前的群根目录文件 ID,用于发送后识别新增文件。""" + if not _is_onebot_event(event): + return None + try: + files = await self._list_group_files(event) + except Exception as exc: + logger.warning("[revoke] 无法读取发送前群文件列表: %s", exc) + return None + return frozenset(file.file_id for file in files) - async def _delete_after_delay( + async def schedule_group_file_revoke( self, - bot_client: Any, - platform_name: str | None, - message_id: str, + event: AstrMessageEvent, + *, + before_file_ids: frozenset[str], + file_name: str, + expected_file_size: int | None, delay: int, - ) -> None: + ) -> bool: + """持久化群文件删除任务,并在可见时提前锁定文件 ID。""" + if delay <= 0: + return False + if not _is_onebot_event(event): + logger.warning("[revoke] 跳过群文件登记:当前平台不是 OneBot") + return False + + group_id = _event_text(event, "get_group_id") + platform_id = _event_text(event, "get_platform_id") + normalized_name = file_name.strip() + if not group_id or not platform_id or not normalized_name: + logger.warning("[revoke] 跳过群文件登记:缺少群号、平台 ID 或文件名") + return False + + record = RevokeTask( + task_id=uuid4().hex, + target=_GROUP_FILE_TARGET, + platform_id=platform_id, + due_at=time() + delay, + group_id=group_id, + file_name=normalized_name, + before_file_ids=tuple(sorted(before_file_ids)), + expected_file_size=expected_file_size, + ) + await self._register(record) + try: - await asyncio.sleep(max(0, int(delay))) - await _call_delete_msg(bot_client, message_id) + file_id = await self._resolve_group_file_id(_event_client(event), record) + except Exception as exc: + logger.warning( + "[revoke] 发送后无法读取群文件列表;已保留待识别任务: " + "group_id=%s, file_name=%s, error=%s", + group_id, + normalized_name, + exc, + ) + return True + + if file_id is None: logger.info( - "[revoke] deleted: platform=%s, message_id=%s", - platform_name or "unknown", - message_id, + "[revoke] 群文件暂未可见;已保留待识别任务至到期删除: " + "group_id=%s, file_name=%s", + group_id, + normalized_name, ) + return True + + await self._replace_record(replace(record, file_id=file_id)) + logger.info( + "[revoke] 已登记可恢复群文件删除: group_id=%s, file_id=%s, delay=%ss", + group_id, + file_id, + delay, + ) + return True + + async def _register(self, record: RevokeTask) -> None: + async with self._lock: + self._records[record.task_id] = record + await self._persist_records() + self._schedule_record(record) + + async def _replace_record(self, record: RevokeTask) -> None: + """用已解析的目标信息更新已持久化任务。""" + async with self._lock: + if record.task_id not in self._records: + return + self._records[record.task_id] = record + await self._persist_records() + + async def _list_group_files(self, event: AstrMessageEvent) -> tuple[GroupFile, ...]: + group_id = _event_text(event, "get_group_id") + if not group_id: + raise ValueError("当前消息不是群聊") + return await self._list_group_files_for_group(_event_client(event), group_id) + + async def _list_group_files_for_group( + self, client: Any, group_id: str + ) -> tuple[GroupFile, ...]: + response = await _call_onebot_action( + client, "get_group_root_files", group_id=group_id + ) + return _parse_group_files(response) + + async def _resolve_group_file_id( + self, client: Any, record: RevokeTask + ) -> str | None: + """根据发送前快照和文件特征定位唯一新增群文件。""" + files = await self._list_group_files_for_group( + client, _required_task_value(record.group_id) + ) + candidates = _new_group_file_candidates(files, record) + return candidates[0].file_id if len(candidates) == 1 else None + + def _schedule_record(self, record: RevokeTask) -> None: + if record.task_id in self._tasks: + return + task = asyncio.create_task( + self._delete_when_due(record.task_id, record.due_at), + name=f"setu_revoke_{record.target}_{record.task_id}", + ) + self._tasks[record.task_id] = task + task.add_done_callback( + lambda completed, task_id=record.task_id: self._tasks.pop(task_id, None) + ) + + async def _delete_when_due(self, task_id: str, due_at: float) -> None: + record: RevokeTask | None = None + try: + await asyncio.sleep(max(0.0, due_at - time())) + async with self._lock: + record = self._records.get(task_id) + if record is None: + return + client = self._get_client_for_task(record) + if record.target == _MESSAGE_TARGET: + await _call_delete_msg(client, _required_task_value(record.message_id)) + else: + file_id = record.file_id + if file_id is None: + file_id = await self._resolve_group_file_id(client, record) + if file_id is None: + raise RuntimeError( + "到期时未能唯一识别本子群文件: " + f"group_id={record.group_id}, file_name={record.file_name}" + ) + await _call_onebot_action( + client, + "delete_group_file", + group_id=_required_task_value(record.group_id), + file_id=file_id, + ) except asyncio.CancelledError: - logger.debug("[revoke] cancelled: message_id=%s", message_id) + logger.debug("[revoke] 已停止等待删除: task_id=%s", task_id) raise except Exception as exc: - logger.warning( - "[revoke] delete_msg failed: platform=%s, message_id=%s, error=%s", - platform_name or "unknown", - message_id, - exc, + if record is None: + logger.warning( + "[revoke] 删除任务启动失败,无法登记失败次数: task_id=%s, error=%s", + task_id, + exc, + ) + return + failure_state = await self._record_delete_failure(record) + if failure_state is None: + logger.warning( + "[revoke] 删除失败,但任务已不在队列中: " + "task_id=%s, target=%s, error=%s", + record.task_id, + record.target, + exc, + ) + return + failure_count, removed = failure_state + if removed: + logger.warning( + "[revoke] 删除连续失败达到上限,已移除任务记录: " + "task_id=%s, target=%s, failure_count=%s, error=%s", + record.task_id, + record.target, + failure_count, + exc, + ) + else: + logger.warning( + "[revoke] 删除失败,保留任务供下次启动恢复: " + "task_id=%s, target=%s, failure_count=%s/%s, error=%s", + record.task_id, + record.target, + failure_count, + _MAX_CONSECUTIVE_FAILURES, + exc, + ) + return + + async with self._lock: + if self._records.pop(task_id, None) is not None: + await self._persist_records() + logger.info( + "[revoke] 已执行删除: task_id=%s, target=%s", + record.task_id, + record.target, + ) + + async def _record_delete_failure( + self, record: RevokeTask + ) -> tuple[int, bool] | None: + """持久化一次实际删除失败,并在第三次失败时丢弃任务。""" + async with self._lock: + current = self._records.get(record.task_id) + if current is None: + return None + failure_count = current.failure_count + 1 + reached_limit = failure_count >= _MAX_CONSECUTIVE_FAILURES + if reached_limit: + self._records.pop(record.task_id) + else: + self._records[record.task_id] = replace( + current, failure_count=failure_count + ) + await self._persist_records() + return failure_count, reached_limit + + def _get_client_for_task(self, record: RevokeTask) -> Any: + get_platform_inst = _callable_attr(self._context, "get_platform_inst") + if get_platform_inst is None: + raise RuntimeError("AstrBot context 不支持按平台 ID 获取适配器") + platform = get_platform_inst(record.platform_id) + if platform is None: + raise RuntimeError(f"找不到平台适配器: {record.platform_id}") + get_client = _callable_attr(platform, "get_client") + if get_client is None: + raise RuntimeError(f"平台不支持 OneBot 客户端: {record.platform_id}") + return get_client() + + async def _persist_records(self) -> None: + await asyncio.to_thread(self._write_records, tuple(self._records.values())) + + def _load_records(self) -> tuple[tuple[RevokeTask, ...], bool]: + records = self._load_current_records() + legacy_records = self._load_legacy_doujinshi_records() + if not legacy_records: + return records, False + + merged_records = {record.task_id: record for record in records} + for legacy_record in legacy_records: + current_record = merged_records.get(legacy_record.task_id) + if current_record is None: + merged_records[legacy_record.task_id] = legacy_record + elif current_record != legacy_record: + raise RuntimeError("撤回任务与旧本子清理任务存在冲突 ID") + return tuple(merged_records.values()), True + + def _load_current_records(self) -> tuple[RevokeTask, ...]: + payload = _load_json_payload(self._storage_path, "撤回任务") + if payload is None: + return () + if payload.get("version") != 1: + raise RuntimeError("撤回任务文件格式或版本无效") + raw_records = payload.get("tasks") + if not isinstance(raw_records, list): + raise RuntimeError("撤回任务文件缺少 tasks 列表") + return _parse_task_records(raw_records, RevokeTask.from_dict, "撤回任务") + + def _load_legacy_doujinshi_records(self) -> tuple[RevokeTask, ...]: + payload = _load_json_payload(self._legacy_doujinshi_path, "旧本子清理任务") + if payload is None: + return () + if payload.get("version") != 1: + raise RuntimeError("旧本子清理任务文件格式或版本无效") + raw_records = payload.get("tasks") + if not isinstance(raw_records, list): + raise RuntimeError("旧本子清理任务文件缺少 tasks 列表") + return _parse_task_records( + raw_records, + _legacy_doujinshi_task_from_dict, + "旧本子清理任务", + ) + + def _write_records(self, records: tuple[RevokeTask, ...]) -> None: + self._storage_path.parent.mkdir(parents=True, exist_ok=True) + payload = { + "version": 1, + "tasks": [record.to_dict() for record in records], + } + temporary_path = self._storage_path.with_name( + f".{self._storage_path.name}.{uuid4().hex}.tmp" + ) + try: + temporary_path.write_text( + json.dumps(payload, ensure_ascii=False, indent=2), + encoding="utf-8", ) + temporary_path.replace(self._storage_path) + finally: + temporary_path.unlink(missing_ok=True) -_scheduler: RevokeScheduler | None = None +_scheduler: RecoverableRevokeScheduler | None = None -def get_revoke_scheduler() -> RevokeScheduler: - """Return the process-local Setu revoke scheduler.""" +async def init_revoke_scheduler( + data_dir: Path | str, + context: object, +) -> RecoverableRevokeScheduler: + """初始化统一可恢复撤回调度器。""" global _scheduler - if _scheduler is None: - _scheduler = RevokeScheduler() + if _scheduler is not None: + await _scheduler.stop() + _scheduler = RecoverableRevokeScheduler(data_dir, context) + await _scheduler.initialize() + return _scheduler + + +def get_revoke_scheduler() -> RecoverableRevokeScheduler | None: + """返回已初始化的统一可恢复撤回调度器。""" return _scheduler +async def stop_revoke_scheduler() -> None: + """停止内存计时协程,保留任务供下一次启动恢复。""" + global _scheduler + if _scheduler is None: + return + await _scheduler.stop() + _scheduler = None + + async def schedule_revoke( event: AstrMessageEvent, message_id: str, delay: int, ) -> bool: - """Schedule one message revoke through the shared scheduler.""" - return await get_revoke_scheduler().schedule_revoke(event, message_id, delay) + """通过统一可恢复调度器登记消息撤回。""" + scheduler = get_revoke_scheduler() + if scheduler is None: + logger.warning("[revoke] 调度器尚未初始化,未登记 message_id=%s", message_id) + return False + return await scheduler.schedule_revoke(event, message_id, delay) + + +def _legacy_doujinshi_task_from_dict(payload: Mapping[str, object]) -> RevokeTask: + return RevokeTask( + task_id=_required_text(payload, "task_id"), + target=_GROUP_FILE_TARGET, + platform_id=_required_text(payload, "platform_id"), + due_at=_required_due_at(payload), + group_id=_required_text(payload, "group_id"), + file_id=_required_text(payload, "file_id"), + file_name=_required_text(payload, "file_name"), + ) -async def clear_revoke_scheduler() -> None: - """Cancel and drop pending revoke tasks.""" - global _scheduler - if _scheduler is None: - return - await _scheduler.cancel_all() - _scheduler = None +def _load_json_payload(path: Path, label: str) -> Mapping[str, object] | None: + if not path.exists(): + return None + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise RuntimeError(f"无法读取{label}文件: {path}") from exc + if not isinstance(payload, Mapping): + raise RuntimeError(f"{label}文件根节点无效") + return payload + + +def _parse_task_records( + raw_records: list[object], + parser: Any, + label: str, +) -> tuple[RevokeTask, ...]: + records: list[RevokeTask] = [] + for index, item in enumerate(raw_records): + if not isinstance(item, Mapping): + raise RuntimeError(f"{label} #{index} 格式无效") + try: + records.append(parser(item)) + except ValueError as exc: + raise RuntimeError(f"{label} #{index} 格式无效") from exc + if len({record.task_id for record in records}) != len(records): + raise RuntimeError(f"{label}文件包含重复任务 ID") + return tuple(records) + + +def _required_text(payload: Mapping[str, object], key: str) -> str: + value = payload.get(key) + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{key} 必须是非空字符串") + return value.strip() + + +def _required_due_at(payload: Mapping[str, object]) -> float: + due_at = payload.get("due_at") + if not isinstance(due_at, int | float) or isinstance(due_at, bool): + raise ValueError("due_at 必须是数字") + return float(due_at) + + +def _optional_file_ids(value: object) -> tuple[str, ...]: + if value is None: + return () + if not isinstance(value, list): + raise ValueError("before_file_ids 必须是列表") + file_ids = tuple( + item.strip() for item in value if isinstance(item, str) and item.strip() + ) + if len(file_ids) != len(value) or len(set(file_ids)) != len(file_ids): + raise ValueError("before_file_ids 必须由不重复的非空字符串组成") + return file_ids + + +def _optional_file_size(value: object) -> int | None: + if value is None: + return None + parsed = _parse_file_size(value) + if parsed is None: + raise ValueError("expected_file_size 必须是非负整数") + return parsed + + +def _optional_failure_count(value: object) -> int: + if value is None: + return 0 + if not isinstance(value, int) or isinstance(value, bool) or value < 0: + raise ValueError("failure_count 必须是非负整数") + return value + + +def _required_task_value(value: str | None) -> str: + if value is None: + raise RuntimeError("持久化撤回任务缺少必要目标字段") + return value + + +def _event_text(event: AstrMessageEvent, method_name: str) -> str: + method = _callable_attr(event, method_name) + if method is None: + return "" + value = method() + return str(value).strip() if value else "" + + +def _is_onebot_event(event: AstrMessageEvent) -> bool: + return is_onebot_like_platform(_platform_name(event)) def _platform_name(event: AstrMessageEvent) -> str | None: platform = getattr(event, "platform", None) name = getattr(platform, "name", None) - if name: - return str(name) - getter = getattr(event, "get_platform_name", None) - if callable(getter): - try: - return str(getter()) - except Exception: - return None - return None + if isinstance(name, str) and name: + return name + getter = _callable_attr(event, "get_platform_name") + if getter is None: + return None + value = getter() + return str(value) if value else None -def _get_bot_client(event: AstrMessageEvent) -> Any | None: - return getattr(event, "bot", None) or getattr(event, "_bot", None) +def _event_client(event: AstrMessageEvent) -> Any: + client = getattr(event, "bot", None) or getattr(event, "_bot", None) + if client is None: + raise RuntimeError("当前事件不提供 OneBot 客户端") + return client -def _supports_delete_msg(bot_client: Any) -> bool: +def _supports_delete_msg(client: Any) -> bool: return ( - _callable_attr(bot_client, "delete_msg") is not None - or _callable_attr(getattr(bot_client, "api", None), "call_action") is not None - or _callable_attr(bot_client, "call_action") is not None + _callable_attr(client, "delete_msg") is not None + or _callable_attr(getattr(client, "api", None), "call_action") is not None + or _callable_attr(client, "call_action") is not None ) -async def _call_delete_msg(bot_client: Any, message_id: str) -> Any: - method = _callable_attr(bot_client, "delete_msg") - if method is not None: - return await _maybe_await(method(message_id=message_id)) - call_action = _callable_attr(getattr(bot_client, "api", None), "call_action") - if call_action is not None: - return await _maybe_await(call_action("delete_msg", message_id=message_id)) - call_action = _callable_attr(bot_client, "call_action") - if call_action is not None: - return await _maybe_await(call_action("delete_msg", message_id=message_id)) - raise RuntimeError("delete_msg unsupported") +async def _call_delete_msg(client: Any, message_id: str) -> Any: + delete_msg = _callable_attr(client, "delete_msg") + if delete_msg is not None: + return await _maybe_await(delete_msg(message_id=message_id)) + return await _call_onebot_action(client, "delete_msg", message_id=message_id) + + +async def _call_onebot_action(client: Any, action: str, **kwargs: object) -> Any: + call_action = _callable_attr(client, "call_action") + if call_action is None: + call_action = _callable_attr(getattr(client, "api", None), "call_action") + if call_action is None: + raise RuntimeError(f"OneBot 客户端不支持 {action}") + result = await _maybe_await(call_action(action, **kwargs)) + if isinstance(result, Mapping): + status = result.get("status") + retcode = result.get("retcode") + if status in {"failed", "error"} or ( + isinstance(retcode, int) and not isinstance(retcode, bool) and retcode != 0 + ): + raise RuntimeError(f"OneBot action {action} 返回失败: {result}") + return result + + +def _parse_group_files(response: object) -> tuple[GroupFile, ...]: + files: list[GroupFile] = [] + for item in _group_file_items(response): + if not isinstance(item, Mapping): + continue + file_id = item.get("file_id") + file_name = item.get("file_name") + if not isinstance(file_id, str) or not file_id: + continue + if not isinstance(file_name, str) or not file_name: + continue + files.append( + GroupFile( + file_id=file_id, + file_name=file_name, + file_size=_parse_file_size(item.get("file_size")), + ) + ) + return tuple(files) + + +def _group_file_items(response: object) -> list[object]: + payload = response + if isinstance(payload, Mapping) and isinstance(payload.get("data"), Mapping | list): + payload = payload["data"] + if isinstance(payload, Mapping): + for key in ("files", "root_files"): + files = payload.get(key) + if isinstance(files, list): + return files + return payload if isinstance(payload, list) else [] + + +def _parse_file_size(value: object) -> int | None: + if isinstance(value, int) and not isinstance(value, bool): + return value if value >= 0 else None + if isinstance(value, str) and value.isdigit(): + return int(value) + return None + + +def _matches_expected_size(actual: int | None, expected: int | None) -> bool: + return actual is None or expected is None or actual == expected + + +def _new_group_file_candidates( + files: tuple[GroupFile, ...], record: RevokeTask +) -> list[GroupFile]: + """筛选发送快照之后出现且与待删除 PDF 一致的群文件。""" + file_name = _required_task_value(record.file_name) + before_file_ids = frozenset(record.before_file_ids) + return [ + file + for file in files + if file.file_id not in before_file_ids + and file.file_name == file_name + and _matches_expected_size(file.file_size, record.expected_file_size) + ] async def _maybe_await(value: Any) -> Any: @@ -176,10 +770,18 @@ async def _maybe_await(value: Any) -> Any: return value -def _callable_attr(obj: Any, name: str) -> Any | None: +def _callable_attr(obj: object | None, name: str) -> Any | None: if obj is None: return None - if isinstance(obj, Mock) and name not in vars(obj): - return None - attr = getattr(obj, name, None) - return attr if callable(attr) else None + value = getattr(obj, name, None) + return value if callable(value) else None + + +__all__ = [ + "RecoverableRevokeScheduler", + "RevokeTask", + "get_revoke_scheduler", + "init_revoke_scheduler", + "schedule_revoke", + "stop_revoke_scheduler", +] diff --git a/src/infrastructure/sending/send_strategies.py b/src/infrastructure/sending/send_strategies.py index 0868a4b..d77b103 100644 --- a/src/infrastructure/sending/send_strategies.py +++ b/src/infrastructure/sending/send_strategies.py @@ -24,14 +24,6 @@ logger = get_logger() -_UNKNOWN_PLATFORM_NAME = "unknown" -_ONEBOT_UNCERTAIN_DELIVERY_RETCODE = "1200" -_ONEBOT_SENDMSG_TIMEOUT_MARKERS = ( - "Timeout", - "NodeIKernelMsgService/sendMsg", - "NodeIKernelMsgListener/onMsgInfoListUpdate", -) - def extract_message_ids(response: Any) -> tuple[str, ...]: """Extract OneBot message ids from common adapter response shapes.""" @@ -73,14 +65,6 @@ def _get_bot_client(event: AstrMessageEvent) -> Any | None: return getattr(event, "bot", None) or getattr(event, "_bot", None) -def _platform_name(event: AstrMessageEvent) -> str: - """统一提取平台名,避免不同发送策略对缺省值处理不一致。""" - return ( - str(getattr(getattr(event, "platform", None), "name", "") or "") - or _UNKNOWN_PLATFORM_NAME - ) - - def _onebot_target(event: AstrMessageEvent) -> tuple[str, int] | None: group_id = event.get_group_id() if group_id: @@ -155,23 +139,6 @@ async def _component_to_onebot_message(comp: Any) -> dict[str, Any]: return comp.toDict() -def _is_onebot_uncertain_delivery_error(exc: Exception) -> bool: - """识别 OneBot/NapCat 已提交但确认超时的发送错误。""" - retcode = getattr(exc, "retcode", None) - text = " ".join( - str(value) - for value in ( - getattr(exc, "message", ""), - getattr(exc, "wording", ""), - str(exc), - ) - if value - ) - return str(retcode) == _ONEBOT_UNCERTAIN_DELIVERY_RETCODE and all( - marker in text for marker in _ONEBOT_SENDMSG_TIMEOUT_MARKERS - ) - - class SendStrategy(ABC): """Abstract base class for send strategies.""" @@ -250,7 +217,7 @@ async def send_with_status( try: send_result = await self._send_message(event, chain, auto_revoke) - platform_name = _platform_name(event) + platform_name = getattr(event.platform, "name", "unknown") # AstrBot/OneBot 适配器偶尔会在平台侧已接收消息时返回 None。 # 这里不把不确定返回当作失败,避免图片仍在延迟送达时误触发回退策略。 @@ -279,34 +246,18 @@ async def send_with_status( ) return SendAttemptResult.success(message_ids) except TimeoutError as exc: - platform_name = _platform_name(event) # 发送接口超时后无法判断平台侧是否已经接收消息,重发 fallback 可能造成重复图片。 logger.warning( "[send] direct send confirmation timed out, treating as pending delivery: platform=%s, chain=%d, error=%s", - platform_name, + getattr(event.platform, "name", "unknown"), len(chain), exc, ) return SendAttemptResult.pending_delivery("send confirmation timed out") except Exception as exc: - platform_name = _platform_name(event) - if is_onebot_like_platform( - platform_name - ) and _is_onebot_uncertain_delivery_error(exc): - # NapCat/NTQQ 可能已经发送成功但没有等到本地确认; - # 此时进入 stream/HTML fallback 会把同一张图再发一遍。 - logger.warning( - "[send] direct send returned uncertain OneBot timeout, treating as pending delivery: platform=%s, chain=%d, error=%s", - platform_name, - len(chain), - exc, - ) - return SendAttemptResult.pending_delivery( - "onebot send confirmation timed out" - ) logger.exception( "[send] direct send failed: platform=%s, chain=%d, error=%s", - platform_name, + getattr(event.platform, "name", "unknown"), len(chain), exc, ) @@ -319,7 +270,7 @@ async def _send_message( auto_revoke: bool, ) -> Any: if self._requires_onebot_passthrough(event, chain, auto_revoke): - attempted, send_result = await self._send_onebot_image_chain(event, chain) + attempted, send_result = await self._send_onebot_message_chain(event, chain) if attempted: return send_result result = event.chain_result(chain) @@ -328,9 +279,11 @@ async def _send_message( def _requires_onebot_passthrough( self, event: AstrMessageEvent, chain: list[Any], auto_revoke: bool ) -> bool: - platform_name = _platform_name(event) + platform_name = getattr(getattr(event, "platform", None), "name", "") or "" if not is_onebot_like_platform(platform_name): return False + if auto_revoke and len(chain) == 1 and isinstance(chain[0], Comp.Nodes): + return True if auto_revoke and any( self._is_revoke_capable_component(comp) for comp in chain ): @@ -342,7 +295,7 @@ def _requires_onebot_passthrough( ) def _is_revoke_capable_component(self, comp: Any) -> bool: - return isinstance(comp, Comp.Image | Comp.File) + return isinstance(comp, Comp.Image | Comp.File | Comp.Plain) def _is_onebot_image_ref(self, comp: Comp.Image) -> bool: file_value = getattr(comp, "file", None) @@ -356,18 +309,35 @@ def _is_onebot_image_ref(self, comp: Comp.Image) -> bool: or file_value.startswith("base64://") ) - async def _send_onebot_image_chain( + async def _send_onebot_message_chain( self, event: AstrMessageEvent, chain: list[Any] ) -> tuple[bool, Any]: - message: list[dict[str, Any]] = [] - for comp in chain: - message.append(await _component_to_onebot_message(comp)) - target = _onebot_target(event) if target is None: return False, None target_type, target_id = target + # 合并转发附件不一定会出现在 NapCat 的可删除群文件接口中, + # 因而在发送时直接取得可恢复撤回所需的 message_id。 + if len(chain) == 1 and isinstance(chain[0], Comp.Nodes): + payload = await chain[0].to_dict() + messages = payload.get("messages", []) + if target_type == "group": + return await _call_onebot_action( + event, + "send_group_forward_msg", + {"group_id": target_id, "messages": messages}, + ) + return await _call_onebot_action( + event, + "send_private_forward_msg", + {"user_id": target_id, "messages": messages}, + ) + + message: list[dict[str, Any]] = [] + for comp in chain: + message.append(await _component_to_onebot_message(comp)) + if target_type == "group": return await _call_onebot_action( event, @@ -469,7 +439,7 @@ async def _send_nodes_direct_with_status( ) -> SendAttemptResult: """Send forward nodes and expose whether fallback is safe.""" try: - platform_name = _platform_name(event) + platform_name = getattr(getattr(event, "platform", None), "name", "") if auto_revoke and is_onebot_like_platform(platform_name): attempted, raw_result = await self._send_nodes_raw(event, nodes) if attempted: @@ -504,28 +474,14 @@ async def _send_nodes_direct_with_status( logger.info("[forward] send completed: nodes=%d", len(nodes)) return SendAttemptResult.success(extract_message_ids(send_result)) except TimeoutError as exc: - platform_name = _platform_name(event) # 合并转发也可能在平台侧已接收后只丢失本地确认,立刻 fallback 会造成重复消息。 logger.warning( - "[forward] send confirmation timed out, treating as pending delivery: platform=%s, nodes=%d, error=%s", - platform_name, + "[forward] send confirmation timed out, treating as pending delivery: nodes=%d, error=%s", len(nodes), exc, ) return SendAttemptResult.pending_delivery("forward confirmation timed out") except Exception as exc: - platform_name = _platform_name(event) - if is_onebot_like_platform( - platform_name - ) and _is_onebot_uncertain_delivery_error(exc): - logger.warning( - "[forward] send returned uncertain OneBot timeout, treating as pending delivery: nodes=%d, error=%s", - len(nodes), - exc, - ) - return SendAttemptResult.pending_delivery( - "forward onebot confirmation timed out" - ) logger.exception( "[forward] send failed: nodes=%d, error=%s", len(nodes), diff --git a/src/shared/config/models.py b/src/shared/config/models.py index d3dcf1c..d04d685 100644 --- a/src/shared/config/models.py +++ b/src/shared/config/models.py @@ -73,6 +73,14 @@ class AutoRevokeScopeStr(str, Enum): ALL = "all" +class AutoRevokeTargetStr(str, Enum): + """Content types that can enter the shared auto-revoke scheduler.""" + + SETU = "setu" + FORTUNE = "fortune" + DOUJINSHI = "doujinshi" + + def should_auto_revoke(scope: str | AutoRevokeScopeStr, is_r18: bool) -> bool: """Return whether a Setu payload should be auto-revoked for this scope.""" normalized = str(scope.value if isinstance(scope, AutoRevokeScopeStr) else scope) @@ -189,12 +197,15 @@ class SetuGeneralConfig(BaseModel): class DeliveryConfig(BaseModel): - """Image delivery configuration.""" + """消息发送与自动清理配置。""" send_mode: SendModeStr = SendModeStr.AUTO r18_docx_mode: bool = True auto_revoke_scope: AutoRevokeScopeStr = AutoRevokeScopeStr.NONE - auto_revoke_delay: int = Field(default=30, ge=5, le=300) + auto_revoke_targets: list[AutoRevokeTargetStr] = Field( + default_factory=lambda: [AutoRevokeTargetStr.DOUJINSHI] + ) + auto_revoke_delay: int = Field(default=30, ge=0) platform_transports: list[PlatformTransportConfig] = Field(default_factory=list) # 兼容旧版平铺 NapCat 配置;新配置入口是 platform_transports 模板。 napcat_stream_mode: NapcatStreamModeStr = NapcatStreamModeStr.FALLBACK @@ -290,6 +301,8 @@ class MessagesConfig(BaseModel): "count_out_of_range": "图片数量必须在{min_count}-{max_count}之间哦~", "fetch_timeout": "获取图片超时,网络可能不稳定,请稍后再试。", "fetch_failed": "获取图片失败,请稍后再试", + "doujinshi_fetching": "正在获取随机本子并生成 PDF,请稍候...", + "doujinshi_failed": "随机本子获取或 PDF 生成失败,请稍后再试。", "no_result": "未找到{tags_info}符合要求的图片~", "empty_payload": "运气不好,一张图都没拿到...", "r18_docx_failed": "R18 Docx 封装失败,请稍后再试或联系管理员。", @@ -370,6 +383,16 @@ def fill_missing_message_text(cls, data: Any) -> Any: fetch_failed: MessageTextConfig = Field( default_factory=lambda: MessageTextConfig(text="获取图片失败,请稍后再试") ) + doujinshi_fetching: MessageTextConfig = Field( + default_factory=lambda: MessageTextConfig( + text="正在获取随机本子并生成 PDF,请稍候..." + ) + ) + doujinshi_failed: MessageTextConfig = Field( + default_factory=lambda: MessageTextConfig( + text="随机本子获取或 PDF 生成失败,请稍后再试。" + ) + ) no_result: MessageTextConfig = Field( default_factory=lambda: MessageTextConfig( text="未找到{tags_info}符合要求的图片~" @@ -592,6 +615,26 @@ def auto_revoke_delay(self) -> int: """Get auto-revoke delay.""" return self.delivery.auto_revoke_delay + @property + def auto_revoke_setu_enabled(self) -> bool: + """Return whether Setu image sends may enter auto-revoke scheduling.""" + return AutoRevokeTargetStr.SETU in self.delivery.auto_revoke_targets + + @property + def auto_revoke_fortune_enabled(self) -> bool: + """Return whether today's-fortune responses may enter auto-revoke scheduling.""" + return AutoRevokeTargetStr.FORTUNE in self.delivery.auto_revoke_targets + + @property + def auto_revoke_doujinshi_enabled(self) -> bool: + """Return whether random doujinshi group files may enter cleanup scheduling.""" + return AutoRevokeTargetStr.DOUJINSHI in self.delivery.auto_revoke_targets + + @property + def doujinshi_file_cleanup_delay(self) -> int: + """兼容旧调用方:本子清理由统一撤回延迟控制。""" + return self.delivery.auto_revoke_delay + @property def napcat_stream_mode(self) -> str: """Get NapCat stream upload mode.""" diff --git a/tests/infrastructure/test_doujinshi_command.py b/tests/infrastructure/test_doujinshi_command.py new file mode 100644 index 0000000..6ef5a5c --- /dev/null +++ b/tests/infrastructure/test_doujinshi_command.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + +import astrbot.api.message_components as Comp +import pytest + +from astrbot_plugin_setu.src.infrastructure.astrbot.commands.setu import ( + SetuCommandHandler, +) +from astrbot_plugin_setu.src.infrastructure.doujinshi import ( + DoujinshiGallery, + GeneratedDoujinshiPdf, +) + + +class _Config: + auto_revoke_delay = 1800 + auto_revoke_doujinshi_enabled = True + tag_alias = "碧蓝档案=blue_archive" + + def resolve_message(self, key: str, **kwargs: object) -> str: + return { + "doujinshi_fetching": "正在生成 PDF", + "doujinshi_failed": "生成失败", + }.get(key, "") + + +class _DoujinshiService: + def __init__(self, generated: GeneratedDoujinshiPdf) -> None: + self.generated = generated + self.requested_tags: list[str] | None = None + + async def fetch_random_pdf( + self, tags: list[str] | None = None + ) -> GeneratedDoujinshiPdf: + self.requested_tags = tags + return self.generated + + +@pytest.mark.asyncio +async def test_random_doujinshi_command_yields_onebot_forwarded_pdf( + tmp_path: Path, mock_event, monkeypatch: pytest.MonkeyPatch +) -> None: + generated = GeneratedDoujinshiPdf( + gallery=DoujinshiGallery( + id=123, + title="测试本子", + page_urls=("https://example.com/1.jpg",), + ), + path=tmp_path / "doujinshi-123.pdf", + ) + handler = SetuCommandHandler(tmp_path) + service = _DoujinshiService(generated) + handler._doujinshi_service = service + mock_event.platform.name = "aiocqhttp" + + async def allow_access(event, config) -> tuple[bool, str]: + return True, "" + + monkeypatch.setattr( + "astrbot_plugin_setu.src.infrastructure.astrbot.commands.setu.get_config", + lambda: _Config(), + ) + monkeypatch.setattr(handler, "_check_access", allow_access) + + results = [ + result + async for result in handler.random_doujinshi_command( + mock_event, tags="blue_archive" + ) + ] + + assert mock_event.plain_result.call_args.args == ("正在生成 PDF",) + assert service.requested_tags == ["碧蓝档案"] + assert isinstance(results[-1].result_chain[0], Comp.Nodes) + assert isinstance(results[-1].result_chain[0].nodes[0].content[0], Comp.File) + + +@pytest.mark.asyncio +async def test_random_doujinshi_schedules_forward_message_revoke( + tmp_path: Path, mock_event, monkeypatch: pytest.MonkeyPatch +) -> None: + generated_path = tmp_path / "doujinshi-123.pdf" + generated_path.write_bytes(b"test-pdf") + generated = GeneratedDoujinshiPdf( + gallery=DoujinshiGallery( + id=123, + title="测试本子", + page_urls=("https://example.com/1.jpg",), + ), + path=generated_path, + ) + context = MagicMock() + scheduler = MagicMock() + scheduler.schedule_revoke = AsyncMock(return_value=True) + handler = SetuCommandHandler( + tmp_path, + plugin_context=context, + revoke_scheduler=scheduler, + ) + handler._doujinshi_service = _DoujinshiService(generated) + mock_event.platform.name = "aiocqhttp" + mock_event.get_group_id.return_value = "10001" + mock_event.get_self_id.return_value = "10000" + mock_event.bot = MagicMock() + mock_event.bot.send_group_forward_msg = AsyncMock( + return_value={"data": {"message_id": "forward-message"}} + ) + + async def allow_access(event, config) -> tuple[bool, str]: + return True, "" + + monkeypatch.setattr( + "astrbot_plugin_setu.src.infrastructure.astrbot.commands.setu.get_config", + lambda: _Config(), + ) + monkeypatch.setattr(handler, "_check_access", allow_access) + + results = [result async for result in handler.random_doujinshi_command(mock_event)] + + assert len(results) == 1 + scheduler.schedule_revoke.assert_awaited_once_with( + mock_event, "forward-message", 1800 + ) + mock_event.bot.send_group_forward_msg.assert_awaited_once() + context.send_message.assert_not_called() diff --git a/tests/infrastructure/test_doujinshi_sender.py b/tests/infrastructure/test_doujinshi_sender.py new file mode 100644 index 0000000..e06f603 --- /dev/null +++ b/tests/infrastructure/test_doujinshi_sender.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from pathlib import Path + +import astrbot.api.message_components as Comp + +from astrbot_plugin_setu.src.infrastructure.doujinshi import ( + DoujinshiGallery, + GeneratedDoujinshiPdf, +) +from astrbot_plugin_setu.src.infrastructure.sending import build_doujinshi_file_chain + + +def _generated_pdf(tmp_path: Path) -> GeneratedDoujinshiPdf: + return GeneratedDoujinshiPdf( + gallery=DoujinshiGallery( + id=123, + title="测试本子", + page_urls=("https://example.com/1.jpg",), + ), + path=tmp_path / "doujinshi-123.pdf", + ) + + +def test_onebot_uses_file_inside_merged_forward_node(tmp_path: Path) -> None: + chain = build_doujinshi_file_chain( + _generated_pdf(tmp_path), platform_name="aiocqhttp", self_id="10001" + ) + + assert len(chain) == 1 + assert isinstance(chain[0], Comp.Nodes) + assert len(chain[0].nodes) == 1 + node = chain[0].nodes[0] + assert node.name == "测试本子" + assert node.uin == "10001" + assert isinstance(node.content[0], Comp.File) + assert node.content[0].name == "测试本子.pdf" + + +def test_non_onebot_sends_direct_file(tmp_path: Path) -> None: + chain = build_doujinshi_file_chain( + _generated_pdf(tmp_path), platform_name="telegram", self_id="10001" + ) + + assert len(chain) == 1 + assert isinstance(chain[0], Comp.File) + assert chain[0].file_ == str(tmp_path / "doujinshi-123.pdf") + assert chain[0].name == "测试本子.pdf" + + +def test_file_name_sanitizes_path_characters_but_keeps_title( + tmp_path: Path, +) -> None: + generated = GeneratedDoujinshiPdf( + gallery=DoujinshiGallery( + id=123, + title="测试/本子", + page_urls=("https://example.com/1.jpg",), + ), + path=tmp_path / "doujinshi-123.pdf", + ) + + chain = build_doujinshi_file_chain( + generated, platform_name="aiocqhttp", self_id="10001" + ) + + assert chain[0].nodes[0].content[0].name == "测试_本子.pdf" diff --git a/tests/infrastructure/test_doujinshi_service.py b/tests/infrastructure/test_doujinshi_service.py new file mode 100644 index 0000000..e97778e --- /dev/null +++ b/tests/infrastructure/test_doujinshi_service.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +import re +from io import BytesIO +from pathlib import Path + +import httpx +import pytest +from PIL import Image + +from astrbot_plugin_setu.src.infrastructure.doujinshi.service import ( + DoujinshiService, +) + + +def test_parse_gallery_returns_title_and_all_page_urls() -> None: + payload = { + "id": 493454, + "title": { + "english": "English title", + "japanese": "日本語タイトル", + "pretty": "Pretty title", + }, + "pages": [ + {"url": "https://example.com/1.jpg", "width": 1280, "height": 1785}, + {"url": "https://example.com/2.jpg", "width": 1280, "height": 1785}, + ], + } + + gallery = DoujinshiService.parse_gallery(payload) + + assert gallery.id == 493454 + assert gallery.title == "Pretty title" + assert gallery.page_urls == ( + "https://example.com/1.jpg", + "https://example.com/2.jpg", + ) + + +def test_parse_gallery_rejects_response_without_downloadable_pages() -> None: + payload = {"id": 493454, "title": {"pretty": "Empty"}, "pages": []} + + try: + DoujinshiService.parse_gallery(payload) + except ValueError as exc: + assert "页图" in str(exc) + else: + raise AssertionError("缺少页图的响应必须失败") + + +def test_create_pdf_preserves_every_downloaded_page(tmp_path: Path) -> None: + page_one = Image.new("RGB", (24, 36), "red") + page_two = Image.new("RGB", (36, 24), "blue") + output_path = tmp_path / "gallery.pdf" + + DoujinshiService.create_pdf([page_one, page_two], output_path, "测试本子") + + pdf_bytes = output_path.read_bytes() + assert pdf_bytes.startswith(b"%PDF-") + assert len(re.findall(rb"/Type\s*/Page\b", pdf_bytes)) == 2 + + +@pytest.mark.asyncio +async def test_fetch_random_pdf_downloads_all_api_pages_in_order( + tmp_path: Path, +) -> None: + first_page = _image_bytes("red") + second_page = _image_bytes("blue") + + def responder(request: httpx.Request) -> httpx.Response: + if request.url == httpx.URL(DoujinshiService.API_URL): + assert request.headers["sec-fetch-dest"] == "empty" + assert "astrbot-plugin-setu" in request.headers["user-agent"] + return httpx.Response( + 200, + json={ + "id": 493454, + "title": {"pretty": "测试本子"}, + "pages": [ + {"url": "https://example.com/1.jpg"}, + {"url": "https://example.com/2.jpg"}, + ], + }, + ) + if request.url == httpx.URL("https://example.com/1.jpg"): + return httpx.Response(200, content=first_page) + if request.url == httpx.URL("https://example.com/2.jpg"): + return httpx.Response(200, content=second_page) + raise AssertionError(f"未预期的请求:{request.url}") + + async with httpx.AsyncClient(transport=httpx.MockTransport(responder)) as client: + generated = await DoujinshiService(tmp_path).fetch_random_pdf(client=client) + + assert generated.gallery.id == 493454 + assert generated.gallery.title == "测试本子" + assert generated.path.parent == tmp_path / "doujinshi" + pdf_bytes = generated.path.read_bytes() + assert len(re.findall(rb"/Type\s*/Page\b", pdf_bytes)) == 2 + + +@pytest.mark.asyncio +async def test_fetch_random_pdf_repeats_resolved_tags_in_api_request( + tmp_path: Path, +) -> None: + page = _image_bytes("red") + + def responder(request: httpx.Request) -> httpx.Response: + if request.url.path == "/v1/doujinshi/random": + assert request.url.params.get_list("tag") == ["碧蓝档案", "白丝"] + return httpx.Response( + 200, + json={ + "id": 493454, + "title": {"pretty": "测试本子"}, + "pages": [{"url": "https://example.com/1.jpg"}], + }, + ) + if request.url == httpx.URL("https://example.com/1.jpg"): + return httpx.Response(200, content=page) + raise AssertionError(f"未预期的请求:{request.url}") + + async with httpx.AsyncClient(transport=httpx.MockTransport(responder)) as client: + generated = await DoujinshiService(tmp_path).fetch_random_pdf( + tags=["碧蓝档案", "白丝"], client=client + ) + + assert generated.gallery.id == 493454 + + +def _image_bytes(color: str) -> bytes: + buffer = BytesIO() + Image.new("RGB", (24, 36), color).save(buffer, format="PNG") + return buffer.getvalue() diff --git a/tests/infrastructure/test_image_sender.py b/tests/infrastructure/test_image_sender.py index 208e978..0be9c55 100644 --- a/tests/infrastructure/test_image_sender.py +++ b/tests/infrastructure/test_image_sender.py @@ -18,7 +18,7 @@ from astrbot_plugin_setu.src.infrastructure.sending.dto import SendOptions from astrbot_plugin_setu.src.infrastructure.sending.image_sender import ImageSender from astrbot_plugin_setu.src.infrastructure.sending.revoke_scheduler import ( - RevokeScheduler, + RecoverableRevokeScheduler, ) from astrbot_plugin_setu.src.infrastructure.sending.send_strategies import ( DirectSendStrategy, @@ -53,21 +53,6 @@ def without_delivery_notices(config_dict: dict[str, Any]) -> dict[str, Any]: return updated -class OneBotActionTimeout(Exception): - retcode = 1200 - message = ( - "Timeout: NTEvent serviceAndMethod:NodeIKernelMsgService/sendMsg " - "ListenerName:NodeIKernelMsgListener/onMsgInfoListUpdate EventRet:\n{}\n" - ) - wording = message - - -class GenericRetcodeTimeout(Exception): - retcode = 1200 - message = "Timeout while waiting for an unrelated operation" - wording = message - - @pytest.fixture(autouse=True) def reset_singletons() -> None: """Keep config/context singletons isolated.""" @@ -205,134 +190,6 @@ async def test_send_images_treats_napcat_none_ack_as_pending( mock_event.bot.call_action.assert_not_called() -@pytest.mark.asyncio -async def test_send_images_treats_onebot_action_timeout_as_pending( - tmp_path: Path, mock_event, sample_config_dict -) -> None: - """NapCat raw action 超时确认不触发 stream fallback,避免同图重复发送。""" - image_path = tmp_path / "shared" / "image.jpg" - image_path.parent.mkdir() - image_path.write_bytes(b"image-data") - - context = MagicMock() - context.send_message = AsyncMock() - set_plugin_context(context) - - mock_event.platform.name = "aiocqhttp" - mock_event.get_group_id.return_value = "123456" - mock_event.get_sender_id.return_value = "654321" - mock_event.bot = MagicMock() - mock_event.bot.send_group_msg = AsyncMock(side_effect=OneBotActionTimeout()) - mock_event.bot.call_action = AsyncMock() - - config_dict = with_napcat_transport( - without_delivery_notices(sample_config_dict), - local_file_mode="always", - local_file_allowed_roots=[str(tmp_path / "shared")], - stream_mode="fallback", - ) - config = SetuPluginConfig(**config_dict) - payload = ImagePayload( - urls=("https://example.com/image.jpg",), - raw_bytes=(), - file_paths=(image_path,), - items=(image_path,), - r18=False, - tags=(), - ) - - results = [ - item async for item in ImageSender(config).send_images(payload, mock_event) - ] - - assert results == [{"send_success": True, "image_count": 1, "send_pending": True}] - mock_event.bot.send_group_msg.assert_awaited_once() - mock_event.bot.call_action.assert_not_called() - context.send_message.assert_not_called() - - -@pytest.mark.asyncio -async def test_direct_send_strategy_keeps_action_timeout_failure_on_non_onebot( - mock_event, -) -> None: - """非 OneBot 平台不把 NapCat/NTQQ timeout 归类为待确认投递。""" - context = MagicMock() - context.send_message = AsyncMock(side_effect=OneBotActionTimeout()) - strategy = DirectSendStrategy(context) - - mock_event.platform.name = "telegram" - - result = await strategy.send_with_status( - mock_event, - [Comp.Image.fromBytes(b"image-data")], - ) - - assert result.accepted is False - assert result.pending is False - - -@pytest.mark.asyncio -async def test_forward_send_strategy_keeps_action_timeout_failure_on_non_onebot( - mock_event, -) -> None: - """合并转发同样只在 OneBot-like 平台识别 NapCat/NTQQ 待确认投递。""" - context = MagicMock() - context.send_message = AsyncMock(side_effect=OneBotActionTimeout()) - strategy = ForwardSendStrategy(context) - - mock_event.platform.name = "telegram" - mock_event.get_self_id.return_value = "10000" - - result = await strategy.send_with_status( - mock_event, - [Comp.Image.fromBytes(b"image-data")], - ) - - assert result.accepted is False - assert result.pending is False - - -@pytest.mark.asyncio -async def test_forward_send_strategy_treats_onebot_action_timeout_as_pending( - mock_event, -) -> None: - """OneBot-like 合并转发遇到 NapCat/NTQQ 确认超时时不触发后续 fallback。""" - context = MagicMock() - context.send_message = AsyncMock(side_effect=OneBotActionTimeout()) - strategy = ForwardSendStrategy(context) - - mock_event.platform.name = "aiocqhttp" - mock_event.get_self_id.return_value = "10000" - - result = await strategy.send_with_status( - mock_event, - [Comp.Image.fromBytes(b"image-data")], - ) - - assert result.accepted is True - assert result.pending is True - - -@pytest.mark.asyncio -async def test_direct_send_strategy_rejects_unrelated_onebot_retcode_timeout( - mock_event, -) -> None: - """OneBot retcode 1200 也必须匹配已知 sendMsg 超时模式才视为待投递。""" - context = MagicMock() - context.send_message = AsyncMock(side_effect=GenericRetcodeTimeout()) - strategy = DirectSendStrategy(context) - - mock_event.platform.name = "aiocqhttp" - - result = await strategy.send_with_status( - mock_event, - [Comp.Image.fromBytes(b"image-data")], - ) - - assert result.accepted is False - assert result.pending is False - - @pytest.mark.asyncio async def test_send_images_reports_partial_batch_failure( tmp_path: Path, mock_event, sample_config_dict @@ -849,15 +706,16 @@ async def test_direct_send_strategy_keeps_none_passthrough_ack_pending( @pytest.mark.parametrize( - ("scope", "is_r18", "expected_scheduled"), + ("scope", "is_r18", "revoke_delay", "expected_scheduled"), [ - ("none", False, 0), - ("sfw", False, 1), - ("sfw", True, 0), - ("r18", False, 0), - ("r18", True, 1), - ("all", False, 1), - ("all", True, 1), + ("none", False, 30, 0), + ("sfw", False, 30, 1), + ("sfw", True, 30, 0), + ("r18", False, 30, 0), + ("r18", True, 30, 1), + ("all", False, 30, 1), + ("all", True, 30, 1), + ("all", False, 0, 0), ], ) @pytest.mark.asyncio @@ -868,6 +726,7 @@ async def test_send_images_schedules_revoke_by_scope( monkeypatch, scope: str, is_r18: bool, + revoke_delay: int, expected_scheduled: int, ) -> None: """自动撤回范围按 SFW/R18/全部/关闭计算。""" @@ -895,6 +754,8 @@ async def fake_schedule_revoke(_event: Any, message_id: str, delay: int) -> bool config_dict["delivery"] = { **sample_config_dict["delivery"], "auto_revoke_scope": scope, + "auto_revoke_targets": ["setu"], + "auto_revoke_delay": revoke_delay, "r18_docx_mode": False, } config = SetuPluginConfig(**config_dict) @@ -914,7 +775,7 @@ async def fake_schedule_revoke(_event: Any, message_id: str, delay: int) -> bool assert results == [{"send_success": True, "image_count": 1}] assert len(scheduled) == expected_scheduled if expected_scheduled: - assert scheduled == [("raw", 30)] + assert scheduled == [("raw", revoke_delay)] mock_event.bot.send_group_msg.assert_awaited_once() else: mock_event.bot.send_group_msg.assert_not_awaited() @@ -977,20 +838,28 @@ async def test_forward_send_strategy_returns_raw_message_id_for_auto_revoke( @pytest.mark.asyncio -async def test_revoke_scheduler_calls_delete_msg(mock_event) -> None: - """调度器到期后通过 OneBot delete_msg 撤回消息。""" - scheduler = RevokeScheduler() +async def test_recoverable_revoke_scheduler_calls_delete_msg( + tmp_path: Path, mock_event +) -> None: + """可恢复调度器到期后通过 OneBot delete_msg 撤回消息。""" mock_event.platform.name = "napcat" + mock_event.get_platform_id.return_value = "onebot-main" mock_event.bot = MagicMock() mock_event.bot.call_action = AsyncMock(return_value={"status": "ok"}) + platform = MagicMock() + platform.get_client.return_value = mock_event.bot + context = MagicMock() + context.get_platform_inst.return_value = platform + scheduler = RecoverableRevokeScheduler(tmp_path, context) + await scheduler.initialize() assert await scheduler.schedule_revoke(mock_event, "123", 0) is True - await asyncio.sleep(0) - await asyncio.sleep(0) - await scheduler.cancel_all() + await asyncio.gather(*tuple(scheduler._tasks.values())) mock_event.bot.call_action.assert_awaited_once_with("delete_msg", message_id="123") + assert scheduler.storage_path.read_text(encoding="utf-8") + await scheduler.stop() @pytest.mark.asyncio diff --git a/tests/infrastructure/test_recoverable_revoke_scheduler.py b/tests/infrastructure/test_recoverable_revoke_scheduler.py new file mode 100644 index 0000000..1b252e5 --- /dev/null +++ b/tests/infrastructure/test_recoverable_revoke_scheduler.py @@ -0,0 +1,351 @@ +from __future__ import annotations + +import asyncio +import json +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from astrbot_plugin_setu.src.infrastructure.sending.revoke_scheduler import ( + RecoverableRevokeScheduler, +) + + +class _Bot: + def __init__( + self, group_file_responses: list[object], *, delete_fails: bool = False + ) -> None: + self._group_file_responses = iter(group_file_responses) + self._delete_fails = delete_fails + self.calls: list[tuple[str, dict[str, object]]] = [] + + async def call_action(self, action: str, **kwargs: object) -> object: + self.calls.append((action, kwargs)) + if action == "get_group_root_files": + return next(self._group_file_responses) + if action in {"delete_group_file", "delete_msg"}: + if self._delete_fails: + raise RuntimeError("模拟删除失败") + return {"status": "ok"} + raise AssertionError(f"unexpected action: {action}") + + +class _Event: + def __init__(self, bot: _Bot) -> None: + self.bot = bot + self.platform = SimpleNamespace(name="napcat") + + def get_group_id(self) -> str: + return "10001" + + def get_platform_id(self) -> str: + return "onebot-main" + + +class _Platform: + def __init__(self, bot: _Bot) -> None: + self._bot = bot + + def get_client(self) -> _Bot: + return self._bot + + +class _Context: + def __init__(self, bot: _Bot) -> None: + self._platform = _Platform(bot) + + def get_platform_inst(self, platform_id: str) -> _Platform | None: + return self._platform if platform_id == "onebot-main" else None + + +@pytest.mark.asyncio +async def test_scheduler_persists_unique_new_group_file(tmp_path: Path) -> None: + existing_file = { + "file_id": "old-file", + "file_name": "旧本子.pdf", + "file_size": 10, + } + new_file = { + "file_id": "new-file", + "file_name": "测试本子.pdf", + "file_size": 128, + } + bot = _Bot([{"files": [existing_file]}, {"files": [existing_file, new_file]}]) + scheduler = RecoverableRevokeScheduler(tmp_path, _Context(bot)) + event = _Event(bot) + + await scheduler.initialize() + before_file_ids = await scheduler.snapshot_group_file_ids(event) + assert before_file_ids == frozenset({"old-file"}) + + scheduled = await scheduler.schedule_group_file_revoke( + event, + before_file_ids=before_file_ids, + file_name="测试本子.pdf", + expected_file_size=128, + delay=1800, + ) + + assert scheduled is True + stored = json.loads(scheduler.storage_path.read_text(encoding="utf-8")) + assert stored["version"] == 1 + assert stored["tasks"][0]["platform_id"] == "onebot-main" + assert stored["tasks"][0]["target"] == "group_file" + assert stored["tasks"][0]["group_id"] == "10001" + assert stored["tasks"][0]["file_id"] == "new-file" + assert stored["tasks"][0]["file_name"] == "测试本子.pdf" + await scheduler.stop() + + +@pytest.mark.asyncio +async def test_scheduler_persists_pending_file_when_napcat_is_still_uploading( + tmp_path: Path, +) -> None: + existing_file = { + "file_id": "old-file", + "file_name": "旧本子.pdf", + "file_size": 10, + } + bot = _Bot([{"files": [existing_file]}, {"files": [existing_file]}]) + scheduler = RecoverableRevokeScheduler(tmp_path, _Context(bot)) + event = _Event(bot) + + await scheduler.initialize() + before_file_ids = await scheduler.snapshot_group_file_ids(event) + scheduled = await scheduler.schedule_group_file_revoke( + event, + before_file_ids=before_file_ids or frozenset(), + file_name="测试本子.pdf", + expected_file_size=128, + delay=1800, + ) + + assert scheduled is True + stored = json.loads(scheduler.storage_path.read_text(encoding="utf-8")) + assert stored["tasks"] == [ + { + "task_id": stored["tasks"][0]["task_id"], + "target": "group_file", + "platform_id": "onebot-main", + "due_at": stored["tasks"][0]["due_at"], + "group_id": "10001", + "file_name": "测试本子.pdf", + "before_file_ids": ["old-file"], + "expected_file_size": 128, + } + ] + await scheduler.stop() + + +@pytest.mark.asyncio +async def test_scheduler_resolves_pending_file_at_due_time(tmp_path: Path) -> None: + storage_path = tmp_path / "revoke_tasks.json" + storage_path.write_text( + json.dumps( + { + "version": 1, + "tasks": [ + { + "task_id": "pending-file-task", + "target": "group_file", + "platform_id": "onebot-main", + "group_id": "10001", + "file_name": "测试本子.pdf", + "before_file_ids": ["old-file"], + "expected_file_size": 128, + "due_at": 0, + } + ], + } + ), + encoding="utf-8", + ) + bot = _Bot( + [ + { + "files": [ + {"file_id": "old-file", "file_name": "旧本子.pdf", "file_size": 10}, + { + "file_id": "new-file", + "file_name": "测试本子.pdf", + "file_size": 128, + }, + ] + } + ] + ) + scheduler = RecoverableRevokeScheduler(tmp_path, _Context(bot)) + + await scheduler.initialize() + await asyncio.gather(*tuple(scheduler._tasks.values())) + + assert ( + "delete_group_file", + {"group_id": "10001", "file_id": "new-file"}, + ) in bot.calls + assert json.loads(storage_path.read_text(encoding="utf-8")) == { + "version": 1, + "tasks": [], + } + await scheduler.stop() + + +@pytest.mark.asyncio +async def test_scheduler_restores_due_task_and_deletes_file(tmp_path: Path) -> None: + storage_path = tmp_path / "revoke_tasks.json" + storage_path.write_text( + json.dumps( + { + "version": 1, + "tasks": [ + { + "task_id": "restore-task", + "target": "group_file", + "platform_id": "onebot-main", + "group_id": "10001", + "file_id": "expired-file", + "file_name": "过期本子.pdf", + "due_at": 0, + } + ], + } + ), + encoding="utf-8", + ) + bot = _Bot([]) + scheduler = RecoverableRevokeScheduler(tmp_path, _Context(bot)) + + await scheduler.initialize() + await asyncio.gather(*tuple(scheduler._tasks.values())) + + assert ( + "delete_group_file", + {"group_id": "10001", "file_id": "expired-file"}, + ) in bot.calls + stored = json.loads(storage_path.read_text(encoding="utf-8")) + assert stored == {"version": 1, "tasks": []} + await scheduler.stop() + + +@pytest.mark.asyncio +async def test_scheduler_restores_due_message_revoke(tmp_path: Path) -> None: + storage_path = tmp_path / "revoke_tasks.json" + storage_path.write_text( + json.dumps( + { + "version": 1, + "tasks": [ + { + "task_id": "message-task", + "target": "message", + "platform_id": "onebot-main", + "message_id": "7788", + "due_at": 0, + } + ], + } + ), + encoding="utf-8", + ) + bot = _Bot([]) + scheduler = RecoverableRevokeScheduler(tmp_path, _Context(bot)) + + await scheduler.initialize() + await asyncio.gather(*tuple(scheduler._tasks.values())) + + assert ("delete_msg", {"message_id": "7788"}) in bot.calls + assert json.loads(storage_path.read_text(encoding="utf-8")) == { + "version": 1, + "tasks": [], + } + await scheduler.stop() + + +@pytest.mark.asyncio +async def test_scheduler_drops_record_after_three_consecutive_delete_failures( + tmp_path: Path, +) -> None: + storage_path = tmp_path / "revoke_tasks.json" + storage_path.write_text( + json.dumps( + { + "version": 1, + "tasks": [ + { + "task_id": "failing-message-task", + "target": "message", + "platform_id": "onebot-main", + "message_id": "7788", + "due_at": 0, + } + ], + } + ), + encoding="utf-8", + ) + bot = _Bot([], delete_fails=True) + context = _Context(bot) + + for expected_failure_count in (1, 2): + scheduler = RecoverableRevokeScheduler(tmp_path, context) + await scheduler.initialize() + await asyncio.gather(*tuple(scheduler._tasks.values())) + + stored = json.loads(storage_path.read_text(encoding="utf-8")) + assert stored["tasks"][0]["failure_count"] == expected_failure_count + await scheduler.stop() + + scheduler = RecoverableRevokeScheduler(tmp_path, context) + await scheduler.initialize() + await asyncio.gather(*tuple(scheduler._tasks.values())) + + assert json.loads(storage_path.read_text(encoding="utf-8")) == { + "version": 1, + "tasks": [], + } + assert sum(action == "delete_msg" for action, _ in bot.calls) == 3 + await scheduler.stop() + + +@pytest.mark.asyncio +async def test_scheduler_migrates_legacy_doujinshi_cleanup_queue( + tmp_path: Path, +) -> None: + legacy_path = tmp_path / "doujinshi_file_cleanup_tasks.json" + legacy_path.write_text( + json.dumps( + { + "version": 1, + "tasks": [ + { + "task_id": "legacy-file-task", + "platform_id": "onebot-main", + "group_id": "10001", + "file_id": "legacy-file", + "file_name": "旧本子.pdf", + "due_at": 1800, + } + ], + } + ), + encoding="utf-8", + ) + scheduler = RecoverableRevokeScheduler(tmp_path, _Context(_Bot([]))) + + await scheduler.initialize() + + stored = json.loads(scheduler.storage_path.read_text(encoding="utf-8")) + assert stored["tasks"] == [ + { + "task_id": "legacy-file-task", + "target": "group_file", + "platform_id": "onebot-main", + "due_at": 1800.0, + "group_id": "10001", + "file_id": "legacy-file", + "file_name": "旧本子.pdf", + } + ] + assert legacy_path.exists() is False + await scheduler.stop() diff --git a/tests/infrastructure/test_setu_tag_alias_integration.py b/tests/infrastructure/test_setu_tag_alias_integration.py index d1e980b..cd1c1e7 100644 --- a/tests/infrastructure/test_setu_tag_alias_integration.py +++ b/tests/infrastructure/test_setu_tag_alias_integration.py @@ -1,39 +1,37 @@ -"""Integration-like tests for Setu tag alias mapping in command handler.""" +"""标签解析共享辅助函数的集成式测试。""" from __future__ import annotations from types import SimpleNamespace -from astrbot_plugin_setu.src.infrastructure.astrbot.commands.setu import ( - SetuCommandHandler, +from astrbot_plugin_setu.src.application.setu.tag_resolution import ( + resolve_user_tag_list, + resolve_user_tags, ) class TestSetuTagAliasIntegration: - """Verify configured tag aliases are applied in runtime command paths.""" + """验证色图与本子可共享的标签别名解析。""" def test_resolve_tags_uses_configured_alias_map(self) -> None: - handler = SetuCommandHandler() config = SimpleNamespace(tag_alias="二次元=acg,anime") - resolved = handler._resolve_tags("acg cute", config) + resolved = resolve_user_tags("acg cute", config.tag_alias) assert resolved == ["二次元", "cute"] def test_resolve_tags_from_list_uses_configured_alias_map(self) -> None: - handler = SetuCommandHandler() config = SimpleNamespace(tag_alias="少女=girl") - resolved = handler._resolve_tags_from_list(["girl", "kawaii"], config) + resolved = resolve_user_tag_list(["girl", "kawaii"], config.tag_alias) assert resolved == ["少女", "kawaii"] def test_resolve_tags_from_list_keeps_multi_word_tags(self) -> None: - handler = SetuCommandHandler() config = SimpleNamespace(tag_alias="碧蓝档案=blue archive") - resolved = handler._resolve_tags_from_list( - ["blue archive", "white hair"], config + resolved = resolve_user_tag_list( + ["blue archive", "white hair"], config.tag_alias ) assert resolved == ["碧蓝档案", "white hair"] diff --git a/tests/shared/test_config_models.py b/tests/shared/test_config_models.py index 8a940d9..8453762 100644 --- a/tests/shared/test_config_models.py +++ b/tests/shared/test_config_models.py @@ -23,10 +23,29 @@ def test_auto_revoke_scope_defaults_to_none() -> None: config = SetuPluginConfig() assert config.auto_revoke_scope == "none" + assert config.auto_revoke_setu_enabled is False + assert config.auto_revoke_fortune_enabled is False + assert config.auto_revoke_doujinshi_enabled is True assert should_auto_revoke(config.auto_revoke_scope, is_r18=False) is False assert should_auto_revoke(config.auto_revoke_scope, is_r18=True) is False +def test_auto_revoke_delay_and_targets_are_configured_independently() -> None: + config = SetuPluginConfig( + delivery={ + "auto_revoke_delay": 1800, + "auto_revoke_targets": ["setu", "fortune"], + } + ) + + assert config.auto_revoke_delay == 1800 + assert config.doujinshi_file_cleanup_delay == 1800 + assert config.auto_revoke_setu_enabled is True + assert config.auto_revoke_fortune_enabled is True + assert config.auto_revoke_doujinshi_enabled is False + assert SetuPluginConfig(delivery={"auto_revoke_delay": 0}).auto_revoke_delay == 0 + + def test_auto_revoke_scope_validation(sample_config_dict) -> None: config_dict = sample_config_dict.copy() config_dict["delivery"] = { @@ -93,6 +112,19 @@ def test_message_defaults_fill_text_when_message_object_omits_text() -> None: ) +def test_doujinshi_message_defaults_support_overrides() -> None: + config = SetuPluginConfig(messages={"doujinshi_fetching": {"enabled": True}}) + + assert ( + config.resolve_message("doujinshi_fetching") + == "正在获取随机本子并生成 PDF,请稍候..." + ) + assert ( + config.resolve_message("doujinshi_failed") + == "随机本子获取或 PDF 生成失败,请稍后再试。" + ) + + def test_resolve_message_supports_placeholders(sample_config_dict) -> None: config_dict = sample_config_dict.copy() config_dict["messages"] = { @@ -177,6 +209,34 @@ def test_config_healer_keeps_new_auto_revoke_scope_when_legacy_exists() -> None: assert changes +def test_config_healer_migrates_legacy_doujinshi_cleanup_delay() -> None: + schema = { + "delivery": { + "type": "object", + "items": {"auto_revoke_delay": {"type": "int", "default": 30}}, + } + } + + healed, changes = heal_astrbot_plugin_config( + {"delivery": {"doujinshi_file_cleanup_delay": 1800}}, schema + ) + + assert healed["delivery"] == {"auto_revoke_delay": 1800} + assert changes + + healed, changes = heal_astrbot_plugin_config( + { + "delivery": { + "auto_revoke_delay": 45, + "doujinshi_file_cleanup_delay": 1800, + } + }, + schema, + ) + assert healed["delivery"] == {"auto_revoke_delay": 45} + assert changes + + def test_config_healer_migrates_legacy_session_template_revoke_scope() -> None: healed, changes = heal_astrbot_plugin_config( {"session_configs": [{"session_id": "g1", "auto_revoke_r18": "y"}]}, diff --git a/tests/test_main_command_routing.py b/tests/test_main_command_routing.py index 915289b..55eb38d 100644 --- a/tests/test_main_command_routing.py +++ b/tests/test_main_command_routing.py @@ -2,15 +2,36 @@ import re +import pytest + from astrbot_plugin_setu.main import ( + DOUJINSHI_REGEX_PATTERN, FORTUNE_REGEX_PATTERN, + REGEX_COMMAND_PATTERN, _is_fortune_command_invocation, + _route_regex_command, _resolve_fortune_refresh_target, _resolve_fortune_toggle_action, _resolve_fortune_user_action, ) +class _RegexHandler: + def __init__(self, marker: str) -> None: + self.marker = marker + self.doujinshi_tags: list[str] = [] + + async def get_random_picture(self, event): + yield self.marker + + async def random_doujinshi_command(self, event, *, tags: str = ""): + self.doujinshi_tags.append(tags) + yield "doujinshi" + + async def fortune_command(self, event): + yield self.marker + + def test_resolve_fortune_refresh_target_from_new_command(mock_event) -> None: mock_event.message_str = "/运势刷新 本群" assert _resolve_fortune_refresh_target(mock_event, "本群") == "group" @@ -47,6 +68,55 @@ def test_fortune_regex_pattern_matches_plain_jrys() -> None: assert not re.match(FORTUNE_REGEX_PATTERN, "/jrys") +def test_combined_regex_pattern_matches_both_plain_command_families() -> None: + assert re.match(REGEX_COMMAND_PATTERN, "来一份色图") + assert re.match(REGEX_COMMAND_PATTERN, "来份本子") + assert re.match(REGEX_COMMAND_PATTERN, "jrys") + + +def test_doujinshi_regex_pattern_accepts_common_spacing() -> None: + assert re.match(DOUJINSHI_REGEX_PATTERN, "来份本子") + assert re.match(DOUJINSHI_REGEX_PATTERN, "来一份本子") + tagged_match = re.match(DOUJINSHI_REGEX_PATTERN, "来份碧蓝档案本子") + assert tagged_match and tagged_match.group("tags") == "碧蓝档案" + assert not re.match(DOUJINSHI_REGEX_PATTERN, "来两份本子") + + +@pytest.mark.asyncio +async def test_regex_router_dispatches_setu_and_fortune(mock_event) -> None: + setu_handler = _RegexHandler("setu") + fortune_handler = _RegexHandler("fortune") + + mock_event.message_str = "来一份色图" + setu_results = [ + result + async for result in _route_regex_command( + mock_event, setu_handler, fortune_handler + ) + ] + mock_event.message_str = "jrys" + fortune_results = [ + result + async for result in _route_regex_command( + mock_event, setu_handler, fortune_handler + ) + ] + + assert setu_results == ["setu"] + assert fortune_results == ["fortune"] + + mock_event.message_str = "来份碧蓝档案本子" + doujinshi_results = [ + result + async for result in _route_regex_command( + mock_event, setu_handler, fortune_handler + ) + ] + + assert doujinshi_results == ["doujinshi"] + assert setu_handler.doujinshi_tags == ["碧蓝档案"] + + def test_fortune_regex_dedup_skips_command_invocation(mock_event) -> None: mock_event.is_at_or_wake_command = True mock_event.message_str = "/jrys" diff --git a/tests/test_main_config_source.py b/tests/test_main_config_source.py index 1b3a84c..eea77a7 100644 --- a/tests/test_main_config_source.py +++ b/tests/test_main_config_source.py @@ -22,6 +22,14 @@ def test_conf_schema_exposes_sexnyan_and_platform_transport_templates() -> None: "r18", "all", ] + assert "doujinshi_file_cleanup_delay" not in delivery_items + assert delivery_items["auto_revoke_targets"]["default"] == ["doujinshi"] + assert delivery_items["auto_revoke_targets"]["options"] == [ + "setu", + "fortune", + "doujinshi", + ] + assert delivery_items["auto_revoke_delay"]["default"] == 30 transport_templates = delivery_items["platform_transports"]["templates"] napcat_items = transport_templates["napcat"]["items"] @@ -181,6 +189,7 @@ def fake_init_config(raw_config): "astrbot_plugin_setu.main.init_session_config_repo", AsyncMock() ) monkeypatch.setattr("astrbot_plugin_setu.main.init_send_cache", AsyncMock()) + monkeypatch.setattr("astrbot_plugin_setu.main.init_revoke_scheduler", AsyncMock()) monkeypatch.setattr( "astrbot_plugin_setu.main.register_setu_llm_tools", lambda: None ) From acc9faba576b17b5a89718bd00a876d5edd5d5a7 Mon Sep 17 00:00:00 2001 From: FlanChanXwO Date: Thu, 6 Aug 2026 10:56:25 +0800 Subject: [PATCH 2/6] fix: normalize raw forward PDF file URIs --- CHANGELOG.md | 4 +++ docs/project/sending-limits.md | 1 + src/infrastructure/sending/send_strategies.py | 29 ++++++++++++++++++- .../infrastructure/test_doujinshi_command.py | 3 ++ 4 files changed, 36 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 71c4ed0..a759571 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ - **正则入口收敛**:色图自然语言与纯文本今日运势改由 `main.py` 的单一 regex 路由函数分发。 - **撤回调度重构**:移除仅在内存存活的撤回调度,所有已登记的 OneBot `delete_msg` 与 `delete_group_file` 任务均使用统一可恢复调度器。 +### Fixed + +- **OneBot 本子转发文件 URI**:修复为取得 `message_id` 而直调 `send_group_forward_msg` 时,绕过 aiocqhttp 对绝对本地文件路径的 `file://` 规范化,导致 NapCat 虽接受 action 但 QQ 客户端下载 PDF 失败的问题。 + ## [2.1.2] - 2026-07-09 ### Fixed diff --git a/docs/project/sending-limits.md b/docs/project/sending-limits.md index 80964a9..8307213 100644 --- a/docs/project/sending-limits.md +++ b/docs/project/sending-limits.md @@ -75,6 +75,7 @@ - **direct + 本地 file:// 直通(可选)**:新增 `delivery.platform_transports`,其中 NapCat 模板包含 `local_file_mode`(默认 `disabled`)和 `local_file_allowed_roots`。仅 OneBot/NapCat 类平台、直发模式、真实文件且路径位于发送缓存目录或显式共享目录时,才走 raw OneBot `file://` 直通。 - **stream 分块可配置**:NapCat 模板包含 `stream_chunk_kb`(默认 64)。NapCat `upload_file_stream` 的 `chunk_data` 仍为 base64 字符串,本配置只改变每块原始字节大小。旧版 `delivery.napcat_*` 平铺字段仍作为兼容兜底读取。 - **自动撤回需要 message_id**:`auto_revoke_targets` 包含 `setu` 且 `auto_revoke_scope` 命中时,OneBot/NapCat 类平台的 direct、HTML fallback、stream、file:// 直通、forward 和 R18 Docx 会优先走 raw OneBot action 以提取 `message_id`。目标列表包含 `fortune` 时,今日运势也会走同一消息撤回队列;包含 `doujinshi` 时,本子 `Nodes` 合并转发也会直接取得其消息 ID,而非反查群文件。拿不到 id、平台不支持 `delete_msg` 或删除失败时只记录 warning,不触发重复发送或阻断发图;成功登记的撤回任务写入 `revoke_tasks.json`,重启后仍会恢复。 +- **raw forward 文件 URI**:本子合并转发绕过 aiocqhttp 常规消息段适配时,会将节点内的绝对本地路径递归转换为 `file://` URI;否则 NapCat 可能返回消息 ID,但 QQ 客户端打开附件时显示下载失败。 **未实现(计划或待验证):** diff --git a/src/infrastructure/sending/send_strategies.py b/src/infrastructure/sending/send_strategies.py index d77b103..5a6450b 100644 --- a/src/infrastructure/sending/send_strategies.py +++ b/src/infrastructure/sending/send_strategies.py @@ -139,6 +139,32 @@ async def _component_to_onebot_message(comp: Any) -> dict[str, Any]: return comp.toDict() +def _normalize_onebot_file_uris(value: Any) -> Any: + """将 raw forward 中的本地文件路径转换为 OneBot 可识别的 file URI。""" + if isinstance(value, list): + return [_normalize_onebot_file_uris(item) for item in value] + if isinstance(value, tuple): + return tuple(_normalize_onebot_file_uris(item) for item in value) + if not isinstance(value, dict): + return value + + normalized = {key: _normalize_onebot_file_uris(item) for key, item in value.items()} + if normalized.get("type") != "file": + return normalized + + data = normalized.get("data") + if not isinstance(data, dict): + return normalized + file_value = data.get("file") + if not isinstance(file_value, str) or not file_value or "://" in file_value: + return normalized + + file_path = Path(file_value) + if file_path.is_absolute(): + data["file"] = file_path.as_uri() + return normalized + + class SendStrategy(ABC): """Abstract base class for send strategies.""" @@ -320,7 +346,8 @@ async def _send_onebot_message_chain( # 合并转发附件不一定会出现在 NapCat 的可删除群文件接口中, # 因而在发送时直接取得可恢复撤回所需的 message_id。 if len(chain) == 1 and isinstance(chain[0], Comp.Nodes): - payload = await chain[0].to_dict() + # raw action 绕过 aiocqhttp 的文件段适配,需补上 file URI 规范化。 + payload = _normalize_onebot_file_uris(await chain[0].to_dict()) messages = payload.get("messages", []) if target_type == "group": return await _call_onebot_action( diff --git a/tests/infrastructure/test_doujinshi_command.py b/tests/infrastructure/test_doujinshi_command.py index 6ef5a5c..9964a11 100644 --- a/tests/infrastructure/test_doujinshi_command.py +++ b/tests/infrastructure/test_doujinshi_command.py @@ -125,4 +125,7 @@ async def allow_access(event, config) -> tuple[bool, str]: mock_event, "forward-message", 1800 ) mock_event.bot.send_group_forward_msg.assert_awaited_once() + payload = mock_event.bot.send_group_forward_msg.await_args.kwargs + file_value = payload["messages"][0]["data"]["content"][0]["data"]["file"] + assert file_value == generated_path.as_uri() context.send_message.assert_not_called() From 6291040571b7df268b1932173cda9f6cc2c4e955 Mon Sep 17 00:00:00 2001 From: FlanChanXwO Date: Thu, 6 Aug 2026 11:26:26 +0800 Subject: [PATCH 3/6] feat: append doujinshi metadata to forward --- CHANGELOG.md | 1 + docs/project/architecture.md | 2 +- docs/usage/commands.md | 1 + src/infrastructure/doujinshi/service.py | 37 +++++++++++++++-- .../sending/doujinshi_sender.py | 30 ++++++++++---- tests/infrastructure/test_doujinshi_sender.py | 41 +++++++++++++++---- .../infrastructure/test_doujinshi_service.py | 3 ++ 7 files changed, 97 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a759571..bb037c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - **本子自然语言入口**:支持发送“来份本子”或“来一份本子”触发随机本子。 - **平台文件发送策略**:OneBot v11/NapCat 类平台把 PDF 作为 `File` 放入合并转发节点发送,其他平台直接发送 PDF 文件,不调用群文件上传接口。 - **PDF 文件名**:合并转发中的 PDF 使用 API 返回的本子标题作为文件名。 +- **本子元数据节点**:OneBot 合并转发在 PDF 后按上游实际提供情况追加标题与原始地址节点,不为缺失字段生成占位内容。 - **统一可恢复撤回队列**:图片自动撤回与 OneBot/NapCat 本子群文件延迟清理统一保存到 `revoke_tasks.json`;插件重启后按原到期时间继续执行,旧 `doujinshi_file_cleanup_tasks.json` 会自动迁移。 - **可配置提示**:新增 `doujinshi_fetching`、`doujinshi_failed` 消息键。 diff --git a/docs/project/architecture.md b/docs/project/architecture.md index 37d4c38..3c1ceaa 100644 --- a/docs/project/architecture.md +++ b/docs/project/architecture.md @@ -56,7 +56,7 @@ tests/ # 单元与集成测试 1. `/随机本子 [标签...]` 与“来份标签本子”通过 `SetuCommandHandler` 复用 Setu 访问控制和标签解析 2. `DoujinshiService` 将每个解析后的标签作为重复 `tag` 参数调用随机本子 API,校验响应并按页图顺序写入 PDF -3. `build_doujinshi_file_chain()` 根据平台构造消息:OneBot/NapCat 使用包含 `File` 的 `Nodes` 合并转发,其他平台使用普通 `File` +3. `build_doujinshi_file_chain()` 根据平台构造消息:OneBot/NapCat 使用包含 `File` 的 `Nodes` 合并转发;上游实际提供 `title` / `url` 时,按顺序追加对应的文本节点,缺失字段不造占位节点;其他平台使用普通 `File` 4. 自动撤回内容含 `doujinshi` 且 OneBot 群聊启用时,`DirectSendStrategy` 通过原始合并转发 action 取得 `message_id`,统一撤回调度器立即持久化消息任务 5. 所有新的消息撤回任务共同写入 `StarTools.get_data_dir()` 返回的运行目录;插件重启后按原绝对到期时间调用 OneBot `delete_msg`。合并转发附件不依赖 `get_group_root_files`,因为 NapCat 可能不会将其作为可删除的群文件返回 6. PDF 写入同一插件运行目录,供 AstrBot 文件发送链路读取 diff --git a/docs/usage/commands.md b/docs/usage/commands.md index 643de50..d50ae79 100644 --- a/docs/usage/commands.md +++ b/docs/usage/commands.md @@ -34,6 +34,7 @@ - 本子标签与 Setu 使用同一套空格、逗号、顿号分隔和标签别名映射;解析出的每个标签都会以重复 `tag` 查询参数传给 API。 - 与 Setu 共用同一套用户和群组访问控制;被禁止使用色图的会话也不能获取随机本子。 - OneBot v11/NapCat 类平台将 PDF 放进一个合并转发节点发送;QQ 后端仍可能把附件显示为群文件。其他平台直接发送 PDF 文件。 +- 当上游响应实际包含非空 `title` 或有效 `url` 时,OneBot 合并转发会在 PDF 节点后按顺序追加“标题”和“原始地址”文本节点;缺失字段不会创建占位节点。 - PDF 文件名使用 API 返回的本子标题,并对平台不允许的路径字符做安全替换。 - PDF 会写入插件运行数据目录,供 AstrBot 在发送期间读取;不使用插件目录下的 `data/`。 - OneBot 群聊的本子合并转发是否自动撤回由全局 `delivery.auto_revoke_targets` 中的 `doujinshi` 控制,默认启用;它与色图、今日运势共用 `delivery.auto_revoke_delay`。填 `1800` 即为 30 分钟,`0` 可关闭全部自动清理。任务保存在插件数据目录的可恢复队列中,重启后仍会恢复。 diff --git a/src/infrastructure/doujinshi/service.py b/src/infrastructure/doujinshi/service.py index 3be34db..a3b9b12 100644 --- a/src/infrastructure/doujinshi/service.py +++ b/src/infrastructure/doujinshi/service.py @@ -19,11 +19,17 @@ @dataclass(frozen=True) class DoujinshiGallery: - """可下载并封装为 PDF 的本子元数据。""" + """可下载并封装为 PDF 的本子元数据。 + + ``title`` 可在上游缺失时回退为本地标题;其余两个字段只保留 API + 实际提供的元数据,供合并转发决定是否追加对应节点。 + """ id: int title: str page_urls: tuple[str, ...] + upstream_title: str | None = None + source_url: str | None = None @dataclass(frozen=True) @@ -104,12 +110,17 @@ def parse_gallery(payload: Mapping[str, object]) -> DoujinshiGallery: if not isinstance(gallery_id, int) or isinstance(gallery_id, bool): raise ValueError("随机本子 API 响应缺少有效 ID") - title = DoujinshiService._resolve_title(payload.get("title"), gallery_id) + raw_title = payload.get("title") + upstream_title = DoujinshiService._resolve_upstream_title(raw_title) + title = DoujinshiService._resolve_title(raw_title, gallery_id) + source_url = DoujinshiService._resolve_source_url(payload.get("url")) page_urls = DoujinshiService._resolve_page_urls(payload.get("pages")) return DoujinshiGallery( id=gallery_id, title=title, page_urls=tuple(page_urls), + upstream_title=upstream_title, + source_url=source_url, ) @staticmethod @@ -206,12 +217,32 @@ def _prepare_pdf_page(page: Image.Image) -> Image.Image: @staticmethod def _resolve_title(raw_title: object, gallery_id: int) -> str: + return ( + DoujinshiService._resolve_upstream_title(raw_title) + or f"随机本子 {gallery_id}" + ) + + @staticmethod + def _resolve_upstream_title(raw_title: object) -> str | None: + """提取可展示的上游标题,不把本地回退值伪装成上游元数据。""" if isinstance(raw_title, Mapping): for key in ("pretty", "english", "japanese"): value = raw_title.get(key) if isinstance(value, str) and value.strip(): return value.strip() - return f"随机本子 {gallery_id}" + if isinstance(raw_title, str) and raw_title.strip(): + return raw_title.strip() + return None + + @staticmethod + def _resolve_source_url(raw_url: object) -> str | None: + """仅保留上游提供的有效原始地址,缺失时交由发送器省略节点。""" + if not isinstance(raw_url, str): + return None + source_url = raw_url.strip() + if not source_url or not DoujinshiService._is_http_url(source_url): + return None + return source_url @staticmethod def _resolve_page_urls(raw_pages: object) -> list[str]: diff --git a/src/infrastructure/sending/doujinshi_sender.py b/src/infrastructure/sending/doujinshi_sender.py index fc9d7c7..3f7d29c 100644 --- a/src/infrastructure/sending/doujinshi_sender.py +++ b/src/infrastructure/sending/doujinshi_sender.py @@ -18,7 +18,8 @@ def build_doujinshi_file_chain( ) -> list[Comp.BaseMessageComponent]: """按平台能力构造 PDF 文件消息链。 - OneBot 平台使用一个包含文件段的合并转发节点;其他平台只返回普通文件段, + OneBot 平台使用一个包含文件段的合并转发节点;若上游实际提供标题或 + 原始地址,则按标题、URL 顺序追加纯文本节点。其他平台只返回普通文件段, 这样不会向不支持 OneBot 节点协议的平台发送不兼容的消息结构。 """ file_name = get_doujinshi_file_name(generated) @@ -26,12 +27,27 @@ def build_doujinshi_file_chain( if not supports_forward_messages(platform_name): return [file_component] - node = Comp.Node( - content=[file_component], - name=generated.gallery.title, - uin=str(self_id or ""), - ) - return [Comp.Nodes([node])] + uin = str(self_id or "") + nodes = [ + Comp.Node( + content=[file_component], + name=generated.gallery.title, + uin=uin, + ) + ] + for label, value in ( + ("标题", generated.gallery.upstream_title), + ("原始地址", generated.gallery.source_url), + ): + if value: + nodes.append( + Comp.Node( + content=[Comp.Plain(text=value)], + name=label, + uin=uin, + ) + ) + return [Comp.Nodes(nodes)] def get_doujinshi_file_name(generated: GeneratedDoujinshiPdf) -> str: diff --git a/tests/infrastructure/test_doujinshi_sender.py b/tests/infrastructure/test_doujinshi_sender.py index e06f603..1116d7c 100644 --- a/tests/infrastructure/test_doujinshi_sender.py +++ b/tests/infrastructure/test_doujinshi_sender.py @@ -11,30 +11,57 @@ from astrbot_plugin_setu.src.infrastructure.sending import build_doujinshi_file_chain -def _generated_pdf(tmp_path: Path) -> GeneratedDoujinshiPdf: +def _generated_pdf( + tmp_path: Path, + *, + upstream_title: str | None = "测试本子", + source_url: str | None = "https://example.com/galleries/123", +) -> GeneratedDoujinshiPdf: return GeneratedDoujinshiPdf( gallery=DoujinshiGallery( id=123, title="测试本子", page_urls=("https://example.com/1.jpg",), + upstream_title=upstream_title, + source_url=source_url, ), path=tmp_path / "doujinshi-123.pdf", ) -def test_onebot_uses_file_inside_merged_forward_node(tmp_path: Path) -> None: +def test_onebot_uses_file_and_upstream_metadata_inside_merged_forward_node( + tmp_path: Path, +) -> None: chain = build_doujinshi_file_chain( _generated_pdf(tmp_path), platform_name="aiocqhttp", self_id="10001" ) assert len(chain) == 1 assert isinstance(chain[0], Comp.Nodes) + assert len(chain[0].nodes) == 3 + file_node, title_node, url_node = chain[0].nodes + assert file_node.name == "测试本子" + assert file_node.uin == "10001" + assert isinstance(file_node.content[0], Comp.File) + assert file_node.content[0].name == "测试本子.pdf" + assert title_node.name == "标题" + assert title_node.uin == "10001" + assert isinstance(title_node.content[0], Comp.Plain) + assert title_node.content[0].text == "测试本子" + assert url_node.name == "原始地址" + assert url_node.uin == "10001" + assert isinstance(url_node.content[0], Comp.Plain) + assert url_node.content[0].text == "https://example.com/galleries/123" + + +def test_onebot_omits_metadata_nodes_missing_from_upstream(tmp_path: Path) -> None: + chain = build_doujinshi_file_chain( + _generated_pdf(tmp_path, upstream_title=None, source_url=None), + platform_name="aiocqhttp", + self_id="10001", + ) + assert len(chain[0].nodes) == 1 - node = chain[0].nodes[0] - assert node.name == "测试本子" - assert node.uin == "10001" - assert isinstance(node.content[0], Comp.File) - assert node.content[0].name == "测试本子.pdf" def test_non_onebot_sends_direct_file(tmp_path: Path) -> None: diff --git a/tests/infrastructure/test_doujinshi_service.py b/tests/infrastructure/test_doujinshi_service.py index e97778e..d670770 100644 --- a/tests/infrastructure/test_doujinshi_service.py +++ b/tests/infrastructure/test_doujinshi_service.py @@ -21,6 +21,7 @@ def test_parse_gallery_returns_title_and_all_page_urls() -> None: "japanese": "日本語タイトル", "pretty": "Pretty title", }, + "url": "https://example.com/galleries/493454", "pages": [ {"url": "https://example.com/1.jpg", "width": 1280, "height": 1785}, {"url": "https://example.com/2.jpg", "width": 1280, "height": 1785}, @@ -31,6 +32,8 @@ def test_parse_gallery_returns_title_and_all_page_urls() -> None: assert gallery.id == 493454 assert gallery.title == "Pretty title" + assert gallery.upstream_title == "Pretty title" + assert gallery.source_url == "https://example.com/galleries/493454" assert gallery.page_urls == ( "https://example.com/1.jpg", "https://example.com/2.jpg", From 9c3202fb236bd862559f33100a7503c405740dad Mon Sep 17 00:00:00 2001 From: FlanChanXwO Date: Fri, 7 Aug 2026 17:15:45 +0800 Subject: [PATCH 4/6] feat: support configurable doujinshi file delivery --- AGENTS.md | 8 +- CHANGELOG.md | 11 +- README.md | 13 +- _conf_schema.json | 13 +- docs/dev/testing.md | 2 +- docs/project/architecture.md | 21 +-- docs/project/overview.md | 2 +- docs/project/sending-limits.md | 4 +- docs/usage/commands.md | 13 +- docs/usage/configuration.md | 23 ++- metadata.yaml | 2 +- src/infrastructure/astrbot/commands/setu.py | 28 ++-- src/infrastructure/doujinshi/__init__.py | 10 +- src/infrastructure/doujinshi/service.py | 131 +++++++++++++++--- .../sending/doujinshi_sender.py | 62 +++------ .../sending/revoke_scheduler.py | 7 + src/infrastructure/sending/send_strategies.py | 11 +- src/shared/config/__init__.py | 2 + src/shared/config/models.py | 21 ++- tests/conftest.py | 1 + .../infrastructure/test_doujinshi_command.py | 68 ++++++--- tests/infrastructure/test_doujinshi_sender.py | 61 +++----- .../infrastructure/test_doujinshi_service.py | 47 ++++++- tests/shared/test_config_models.py | 14 +- tests/test_main_config_source.py | 5 + 25 files changed, 387 insertions(+), 193 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6b880e4..6f5857c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,14 +8,14 @@ ## 项目形态 -- 这是一个 AstrBot 随机图片与随机本子 PDF 插件,采用 DDD 分层。 +- 这是一个 AstrBot 随机图片与随机本子文件插件,采用 DDD 分层。 - 管理功能属于 Plugin Pages(统一 dashboard 页面,含会话配置和访问控制标签页)。 主要目录: - `src/domain/`: 领域实体、值对象、标签解析、访问控制。 - `src/application/`: 用例、DTO、端口接口、会话配置服务。 -- `src/infrastructure/`: 配置、持久化、provider、随机本子 PDF、sender、AstrBot 适配。 +- `src/infrastructure/`: 配置、持久化、provider、随机本子文件、sender、AstrBot 适配。 - `src/shared/`: 配置模型、日志、发送缓存。 - `pages/`: Plugin Pages 前端(统一 dashboard)。 - `templates/`: 运势卡片 HTML 模板与字体。 @@ -35,9 +35,9 @@ - 插件运行数据必须通过 `StarTools.get_data_dir(self.name)` 获取,不要硬编码路径。 - 从插件目录本地调试时,不要创建或使用 `/data` 作为运行态目录。 - 所有用户可见提示必须走 `MessagesConfig` / `resolve_message()`,不要在 handler 内硬编码提示文案。 -- 随机本子 PDF 由 `infrastructure/doujinshi/` 生成;OneBot/NapCat 使用 `Nodes` 合并转发并按消息 ID 进入统一可恢复撤回,其他平台直接发送 `File`。 +- 随机本子由 `infrastructure/doujinshi/` 按配置生成 PDF 或 ZIP;所有平台统一发送普通 `File`,不再包装 `Nodes` 合并转发。OneBot/NapCat 若启用自动撤回,则在发送普通文件时取得消息 ID 并进入统一可恢复撤回队列。 - 色图与随机本子都必须使用 `application/setu/tag_resolution.py` 解析标签,保持分隔符和别名映射语义一致。 -- `delivery.auto_revoke_targets` 以单一列表选择色图、今日运势和本子是否进入自动清理;三者共用 `auto_revoke_delay`,本子合并转发必须在发送时取得并立即持久化 `message_id`,避免异步上传可见性导致清理状态丢失。 +- `delivery.doujinshi_send_mode` 选择本子文件格式(`pdf` 或 `archive`,默认 `pdf`);`delivery.auto_revoke_targets` 以单一列表选择色图、今日运势和本子是否进入自动清理,三者共用 `auto_revoke_delay`。OneBot/NapCat 本子普通文件发送若启用自动撤回,必须在发送时取得并立即持久化 `message_id`,避免异步上传可见性导致清理状态丢失。 - 其他领域值、平台行为和配置边界不要写进本文件,放到 `docs/project/` 或 `docs/dev/`。 ## 文档纪律 diff --git a/CHANGELOG.md b/CHANGELOG.md index bb037c6..0217421 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,12 +4,11 @@ ### Added -- **随机本子 PDF**:新增 `/随机本子`(`/本子`、`/doujinshi`)命令;调用 Atri 随机本子 API,下载全部页图并封装为 PDF。 +- **随机本子文件**:新增 `/随机本子`(`/本子`、`/doujinshi`)命令;调用 Atri 随机本子 API,下载全部页图并支持 PDF/ZIP 两种生成模式。 - **本子自然语言入口**:支持发送“来份本子”或“来一份本子”触发随机本子。 -- **平台文件发送策略**:OneBot v11/NapCat 类平台把 PDF 作为 `File` 放入合并转发节点发送,其他平台直接发送 PDF 文件,不调用群文件上传接口。 -- **PDF 文件名**:合并转发中的 PDF 使用 API 返回的本子标题作为文件名。 -- **本子元数据节点**:OneBot 合并转发在 PDF 后按上游实际提供情况追加标题与原始地址节点,不为缺失字段生成占位内容。 -- **统一可恢复撤回队列**:图片自动撤回与 OneBot/NapCat 本子群文件延迟清理统一保存到 `revoke_tasks.json`;插件重启后按原到期时间继续执行,旧 `doujinshi_file_cleanup_tasks.json` 会自动迁移。 +- **本子文件发送策略**:新增 `delivery.doujinshi_send_mode`(`pdf`/`archive`);两种模式在所有平台均直接发送普通 `File`,取消本子合并转发及标题/原始地址节点。 +- **本子文件名与撤回**:PDF/ZIP 使用 API 返回的本子标题作为文件名;OneBot/NapCat 普通文件消息可按统一可恢复队列延迟撤回,不再反查群文件。 +- **统一可恢复撤回队列**:图片自动撤回与 OneBot/NapCat 本子文件消息延迟撤回统一保存到 `revoke_tasks.json`;插件重启后按原到期时间继续执行,旧 `doujinshi_file_cleanup_tasks.json` 会自动迁移。 - **可配置提示**:新增 `doujinshi_fetching`、`doujinshi_failed` 消息键。 ### Changed @@ -19,7 +18,7 @@ ### Fixed -- **OneBot 本子转发文件 URI**:修复为取得 `message_id` 而直调 `send_group_forward_msg` 时,绕过 aiocqhttp 对绝对本地文件路径的 `file://` 规范化,导致 NapCat 虽接受 action 但 QQ 客户端下载 PDF 失败的问题。 +- **OneBot 本子文件 URI**:普通文件直发取得 `message_id` 时,统一将绝对本地路径规范化为 `file://`,避免 NapCat 虽接受 action 但 QQ 客户端打开附件显示下载失败。 ## [2.1.2] - 2026-07-09 diff --git a/README.md b/README.md index 1917c9e..b51f740 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Moe Counter -**一个支持多平台、可自定义、带防审核机制的随机色图插件,支持多 API、随机本子 PDF、会话级配置、LLM 工具调用。** +**一个支持多平台、可自定义、带防审核机制的随机色图插件,支持多 API、随机本子 PDF/ZIP、会话级配置、LLM 工具调用。** [![License: AGPL](https://img.shields.io/badge/License-AGPL-blue.svg)](https://opensource.org/licenses/agpl-3.0) ![Python Version](https://img.shields.io/badge/Python-3.10%2B-blue) @@ -55,8 +55,8 @@ - 🖼️ **HTML 卡片包装** - 防止平台审核,支持自定义样式 - 🤖 **LLM 工具调用** - 可通过大模型自动获取色图 - 🏷️ **标签搜索** - 支持多标签、中文标签、模糊匹配 -- 🔄 **多种发送模式** - 直接发送、合并转发、文件封装 -- 📚 **随机本子 PDF** - 获取 API 返回的全部页图并封装为 PDF;OneBot 合并转发消息可按统一可恢复队列延迟撤回 +- 🔄 **多种色图发送模式** - 直接发送、合并转发、文件封装 +- 📚 **随机本子文件** - 获取 API 返回的全部页图,可配置封装为 PDF 或 ZIP;OneBot/NapCat 普通文件消息可按统一可恢复队列延迟撤回 - 🛡️ **防审核机制** - HTML 卡片 fallback、NapCat 流式上传、延迟撤回、Docx 封装 - ⚡ **性能优化** - 磁盘缓存、自动补图、httpx、可观测下载重试 - 🌐 **多平台适配** - 兼容 AstrBot 支持的所有平台 @@ -100,7 +100,7 @@ - 数量范围支持中文数字 - 标签支持空格、逗号、顿号分隔 - `/session_config` 统一管理当前会话的覆盖配置 -- `/随机本子`(别名 `/本子`、`/doujinshi`)生成并发送随机本子 PDF;OneBot 使用合并转发,其他平台直接发送文件;OneBot 群聊可按配置延迟撤回对应的合并转发消息 +- `/随机本子`(别名 `/本子`、`/doujinshi`)按 `delivery.doujinshi_send_mode` 生成并发送随机本子 PDF 或 ZIP;所有平台均直接发送文件,不再使用合并转发;OneBot 群聊可按配置延迟撤回对应的普通文件消息 完整的命令说明见 [`docs/usage/commands.md`](./docs/usage/commands.md)。 @@ -149,7 +149,8 @@ | 配置项 | 说明 | 默认值 | |--------|------|--------| | `api_type` | API 类型(lolicon / atri / sexnyan / custom / all) | `lolicon` | -| `send_mode` | 发送模式(auto / image / forward) | `auto` | +| `send_mode` | 色图发送模式(auto / image / forward) | `auto` | +| `doujinshi_send_mode` | 本子文件格式(pdf / archive) | `pdf` | | `content_mode` | 内容模式(sfw / r18 / mix) | `sfw` | | `max_count` | 单次最大图片数(1-10) | `10` | | `max_replenish_rounds` | 下载暂时失败时的同 URL 确认尝试次数/补图轮次 | `3` | @@ -171,7 +172,7 @@ NapCat stream 上传的分块内容按 NapCat 协议仍为 base64 字符串;若 AstrBot 与 NapCat 共享同一图片目录,可在 `platform_transports` 添加 NapCat 模板,将共享目录加入 `local_file_allowed_roots` 并把 `local_file_mode` 设为 `always` 或 `fallback`,让直发模式通过 raw OneBot `file://` 路径绕过 AstrBot 标准链路的 base64 转换。 -`auto_revoke_targets` 是单一内容列表,默认只含 `doujinshi`,因此色图和今日运势默认不撤回;按需加入 `setu` 或 `fortune`。色图加入后还须命中 `auto_revoke_scope`。三类内容共享 `auto_revoke_delay` 和 OneBot 可恢复队列;设置 `1800` 即为 30 分钟,设为 `0` 会关闭全部自动清理。随机本子只适用于群聊合并转发:发送后若 NapCat 尚未把文件写入群根目录,插件会先持久化待识别任务,到期时再按发送前快照、文件名和体积精确识别 `file_id` 后删除。所有任务保存于插件运行数据目录的 `revoke_tasks.json`,插件重启后仍按原到期时间继续执行。 +`auto_revoke_targets` 是单一内容列表,默认只含 `doujinshi`,因此色图和今日运势默认不撤回;按需加入 `setu` 或 `fortune`。色图加入后还须命中 `auto_revoke_scope`。三类内容共享 `auto_revoke_delay` 和 OneBot 可恢复队列;设置 `1800` 即为 30 分钟,设为 `0` 会关闭全部自动清理。随机本子无论选择 PDF 还是 ZIP 都以普通文件消息发送;OneBot/NapCat 群聊发送成功后会直接登记消息 ID,到期调用 `delete_msg`,不再反查群文件。所有任务保存于插件运行数据目录的 `revoke_tasks.json`,插件重启后仍按原到期时间继续执行。 ### 模板覆盖 diff --git a/_conf_schema.json b/_conf_schema.json index e2b8d8a..bbebecb 100644 --- a/_conf_schema.json +++ b/_conf_schema.json @@ -238,11 +238,18 @@ "items": { "send_mode": { "type": "string", - "description": "发送模式", - "hint": "auto 在图片数量>1且平台支持时使用合并转发(仅 aiocqhttp/OneBot 类平台支持,其它平台自动回退直发);image 始终直发;forward 始终尝试合并转发。", + "description": "色图发送模式", + "hint": "仅作用于色图:auto 在图片数量>1且平台支持时使用合并转发(仅 aiocqhttp/OneBot 类平台支持,其它平台自动回退直发);image 始终直发;forward 始终尝试合并转发。", "default": "auto", "options": ["image", "forward", "auto"] }, + "doujinshi_send_mode": { + "type": "string", + "description": "随机本子文件格式", + "hint": "pdf=将全部页图封装为 PDF;archive=将原始页图按顺序打包为 ZIP。两种模式都以普通文件发送,不使用合并转发。", + "default": "pdf", + "options": ["pdf", "archive"] + }, "r18_docx_mode": { "type": "bool", "description": "R18 Docx 打包模式", @@ -266,7 +273,7 @@ "auto_revoke_delay": { "type": "int", "description": "自动清理延迟时间(秒)", - "hint": "同时用于自动撤回内容列表中启用的 Setu 图片、今日运势消息和 OneBot/NapCat 随机本子群文件。默认 30 秒;设为 0 可关闭全部自动清理,填 1800 即为 30 分钟。", + "hint": "同时用于自动撤回内容列表中启用的 Setu 图片、今日运势消息和 OneBot/NapCat 随机本子文件消息。默认 30 秒;设为 0 可关闭全部自动清理,填 1800 即为 30 分钟。", "default": 30 }, "platform_transports": { diff --git a/docs/dev/testing.md b/docs/dev/testing.md index ebf9774..98cecf2 100644 --- a/docs/dev/testing.md +++ b/docs/dev/testing.md @@ -41,6 +41,6 @@ - [ ] 运势卡片渲染失败时降级为纯文本。 - [ ] 会话配置读写不锁死(并发安全)。 - [ ] `auto_revoke_targets` 默认仅含 `doujinshi`,并能分别将 `setu`、`fortune`、`doujinshi` 传递到对应链路;`auto_revoke_scope` 的 `none` / `sfw` / `r18` / `all` 仅过滤已启用的色图。 -- [ ] OneBot 图片、今日运势和本子合并转发均使用 `auto_revoke_delay` 写入统一持久化消息任务;本子通过原始合并转发 action 取得 `message_id` 并在到期后调用 `delete_msg`,旧字段与旧队列均可迁移;删除任务连续失败三次后会移除持久化记录。 +- [ ] OneBot 图片、今日运势和本子普通文件消息均使用 `auto_revoke_delay` 写入统一持久化消息任务;本子通过原始 `send_group_msg` 取得 `message_id` 并在到期后调用 `delete_msg`,旧字段与旧队列均可迁移;删除任务连续失败三次后会移除持久化记录。 - [ ] `tests/conftest.py` 固定的 `ASTRBOT_ROOT` 仍能阻止插件目录污染。 - [ ] 访问控制黑白名单互斥逻辑正常。 diff --git a/docs/project/architecture.md b/docs/project/architecture.md index 3c1ceaa..7b1490a 100644 --- a/docs/project/architecture.md +++ b/docs/project/architecture.md @@ -25,7 +25,7 @@ tests/ # 单元与集成测试 - `src/infrastructure/astrbot/` - 命令处理器(`commands/setu.py`、`commands/fortune.py`、`commands/session_config.py`) - - 随机本子服务(`doujinshi/service.py`)、统一可恢复撤回调度器(`sending/revoke_scheduler.py`)与 PDF 文件发送器(`sending/doujinshi_sender.py`) + - 随机本子服务(`doujinshi/service.py`)、统一可恢复撤回调度器(`sending/revoke_scheduler.py`)与文件发送器(`sending/doujinshi_sender.py`) - 配置加载与自愈 - Web API 注册 - 运势渲染器 @@ -52,14 +52,14 @@ tests/ # 单元与集成测试 4. 渲染失败降级为纯文本 5. 自动撤回内容含 `fortune` 时,OneBot 发送结果会登记到统一可恢复撤回队列 -### 3. 随机本子 PDF +### 3. 随机本子文件 1. `/随机本子 [标签...]` 与“来份标签本子”通过 `SetuCommandHandler` 复用 Setu 访问控制和标签解析 -2. `DoujinshiService` 将每个解析后的标签作为重复 `tag` 参数调用随机本子 API,校验响应并按页图顺序写入 PDF -3. `build_doujinshi_file_chain()` 根据平台构造消息:OneBot/NapCat 使用包含 `File` 的 `Nodes` 合并转发;上游实际提供 `title` / `url` 时,按顺序追加对应的文本节点,缺失字段不造占位节点;其他平台使用普通 `File` -4. 自动撤回内容含 `doujinshi` 且 OneBot 群聊启用时,`DirectSendStrategy` 通过原始合并转发 action 取得 `message_id`,统一撤回调度器立即持久化消息任务 -5. 所有新的消息撤回任务共同写入 `StarTools.get_data_dir()` 返回的运行目录;插件重启后按原绝对到期时间调用 OneBot `delete_msg`。合并转发附件不依赖 `get_group_root_files`,因为 NapCat 可能不会将其作为可删除的群文件返回 -6. PDF 写入同一插件运行目录,供 AstrBot 文件发送链路读取 +2. `DoujinshiService` 将每个解析后的标签作为重复 `tag` 参数调用随机本子 API,校验响应后按配置写入 PDF 或 ZIP;ZIP 成员按页码顺序命名 +3. `build_doujinshi_file_chain()` 不区分平台,始终构造一个带标题文件名的普通 `File`,不创建 `Nodes` 或元数据文本节点 +4. 自动撤回内容含 `doujinshi` 且 OneBot 群聊启用时,`DirectSendStrategy` 通过原始 `send_group_msg` 取得普通文件消息的 `message_id`,统一撤回调度器立即持久化消息任务 +5. 所有新的消息撤回任务共同写入 `StarTools.get_data_dir()` 返回的运行目录;插件重启后按原绝对到期时间调用 OneBot `delete_msg`,不反查群文件 +6. 生成文件写入同一插件运行目录,供 AstrBot 文件发送链路读取 ### 4. 访问控制 @@ -89,7 +89,7 @@ flowchart TD F --> G["LoliconProvider / AtriProvider / SexNyanProvider / CustomProvider / MultiProvider"] B --> H["ImageSender"] H --> I["SendStrategy / NapCatStream / SendFilters"] - O --> P["PDF File / OneBot Nodes"] + O --> P["PDF / ZIP File"] C --> J["FortuneService"] J --> K["FortuneRepository"] @@ -108,6 +108,7 @@ flowchart TD - API 类型和 provider 参数 - 发送模式和防审核策略 +- 随机本子文件格式(PDF/ZIP) - 内容模式(sfw/r18/mix) - HTML 卡片策略 - NapCat 流式策略 @@ -144,8 +145,8 @@ flowchart TD - 访问控制:JSON 文件(`AccessControlRepository`) - 会话配置:JSON 文件(`SessionConfigJsonRepository`) - 发送缓存:磁盘文件(`send_cache.py`) -- 随机本子 PDF:插件数据目录下的 `doujinshi/` -- 可恢复撤回任务(消息撤回,含本子合并转发):插件数据目录下的 `revoke_tasks.json` +- 随机本子 PDF/ZIP:插件数据目录下的 `doujinshi/` +- 可恢复撤回任务(消息撤回,含本子普通文件消息):插件数据目录下的 `revoke_tasks.json` - 标签别名:配置模板(`tag_alias_templates`) ## 深入章节 diff --git a/docs/project/overview.md b/docs/project/overview.md index 2870990..496e837 100644 --- a/docs/project/overview.md +++ b/docs/project/overview.md @@ -6,7 +6,7 @@ - 管理多 API 图片供应商(Lolicon、Atri、SexNyan、自定义) - 按标签、数量、内容模式获取图片 -- 适配不同平台发送策略(直接发送、合并转发、HTML 卡片、NapCat 流式、Docx 封装) +- 适配不同平台发送策略(图片直接发送/合并转发、随机本子 PDF/ZIP 文件、HTML 卡片、NapCat 流式、Docx 封装) - 管理会话级配置覆盖和 Setu/Fortune 访问控制 - 提供运势卡片生成和预缓存 - 为 AstrBot 的 AI agent 提供图片获取和配置管理工具 diff --git a/docs/project/sending-limits.md b/docs/project/sending-limits.md index 8307213..29adafc 100644 --- a/docs/project/sending-limits.md +++ b/docs/project/sending-limits.md @@ -74,8 +74,8 @@ - **`uin` 校验**:`ForwardSendStrategy._build_forward_nodes` 中 `event.get_self_id()` 为空时记 warning,保留空字符串避免序列化崩溃。 - **direct + 本地 file:// 直通(可选)**:新增 `delivery.platform_transports`,其中 NapCat 模板包含 `local_file_mode`(默认 `disabled`)和 `local_file_allowed_roots`。仅 OneBot/NapCat 类平台、直发模式、真实文件且路径位于发送缓存目录或显式共享目录时,才走 raw OneBot `file://` 直通。 - **stream 分块可配置**:NapCat 模板包含 `stream_chunk_kb`(默认 64)。NapCat `upload_file_stream` 的 `chunk_data` 仍为 base64 字符串,本配置只改变每块原始字节大小。旧版 `delivery.napcat_*` 平铺字段仍作为兼容兜底读取。 -- **自动撤回需要 message_id**:`auto_revoke_targets` 包含 `setu` 且 `auto_revoke_scope` 命中时,OneBot/NapCat 类平台的 direct、HTML fallback、stream、file:// 直通、forward 和 R18 Docx 会优先走 raw OneBot action 以提取 `message_id`。目标列表包含 `fortune` 时,今日运势也会走同一消息撤回队列;包含 `doujinshi` 时,本子 `Nodes` 合并转发也会直接取得其消息 ID,而非反查群文件。拿不到 id、平台不支持 `delete_msg` 或删除失败时只记录 warning,不触发重复发送或阻断发图;成功登记的撤回任务写入 `revoke_tasks.json`,重启后仍会恢复。 -- **raw forward 文件 URI**:本子合并转发绕过 aiocqhttp 常规消息段适配时,会将节点内的绝对本地路径递归转换为 `file://` URI;否则 NapCat 可能返回消息 ID,但 QQ 客户端打开附件时显示下载失败。 +- **自动撤回需要 message_id**:`auto_revoke_targets` 包含 `setu` 且 `auto_revoke_scope` 命中时,OneBot/NapCat 类平台的 direct、HTML fallback、stream、file:// 直通、forward 和 R18 Docx 会优先走 raw OneBot action 以提取 `message_id`。目标列表包含 `fortune` 时,今日运势也会走同一消息撤回队列;包含 `doujinshi` 时,本子 PDF/ZIP 普通文件也会直接取得其消息 ID,而非反查群文件。拿不到 id、平台不支持 `delete_msg` 或删除失败时只记录 warning,不触发重复发送或阻断发图;成功登记的撤回任务写入 `revoke_tasks.json`,重启后仍会恢复。 +- **raw 文件 URI**:本子普通文件直发绕过 aiocqhttp 常规消息段适配时,会将绝对本地路径转换为 `file://` URI;否则 NapCat 可能返回消息 ID,但 QQ 客户端打开附件时显示下载失败。 **未实现(计划或待验证):** diff --git a/docs/usage/commands.md b/docs/usage/commands.md index d50ae79..1bbf56a 100644 --- a/docs/usage/commands.md +++ b/docs/usage/commands.md @@ -30,15 +30,14 @@ /doujinshi ``` -- 调用 `https://api.atri.rodeo/v1/doujinshi/random`,下载响应中的全部页图并生成一个 PDF。 +- 调用 `https://api.atri.rodeo/v1/doujinshi/random`,下载响应中的全部页图,并按 `delivery.doujinshi_send_mode` 生成 PDF 或 ZIP 压缩包(默认 PDF)。 - 本子标签与 Setu 使用同一套空格、逗号、顿号分隔和标签别名映射;解析出的每个标签都会以重复 `tag` 查询参数传给 API。 - 与 Setu 共用同一套用户和群组访问控制;被禁止使用色图的会话也不能获取随机本子。 -- OneBot v11/NapCat 类平台将 PDF 放进一个合并转发节点发送;QQ 后端仍可能把附件显示为群文件。其他平台直接发送 PDF 文件。 -- 当上游响应实际包含非空 `title` 或有效 `url` 时,OneBot 合并转发会在 PDF 节点后按顺序追加“标题”和“原始地址”文本节点;缺失字段不会创建占位节点。 -- PDF 文件名使用 API 返回的本子标题,并对平台不允许的路径字符做安全替换。 -- PDF 会写入插件运行数据目录,供 AstrBot 在发送期间读取;不使用插件目录下的 `data/`。 -- OneBot 群聊的本子合并转发是否自动撤回由全局 `delivery.auto_revoke_targets` 中的 `doujinshi` 控制,默认启用;它与色图、今日运势共用 `delivery.auto_revoke_delay`。填 `1800` 即为 30 分钟,`0` 可关闭全部自动清理。任务保存在插件数据目录的可恢复队列中,重启后仍会恢复。 -- 本子发送会使用 OneBot 原始 `send_group_forward_msg` 取得合并转发消息的 `message_id`,到期时调用 `delete_msg`。NapCat 对这类附件的 `get_group_root_files` 可能返回空列表,不能用 `delete_group_file` 删除;QQ 客户端中的“群文件”展示由该合并转发消息承载。 +- 两种模式都在所有平台直接发送一个普通文件,不再创建 `Nodes` 合并转发,也不会追加标题或原始地址节点;`archive` 模式的 ZIP 成员按页码顺序命名并保留图片扩展名,若上游没有扩展名则使用 `.bin` 保留原始字节。 +- 文件名使用 API 返回的本子标题,并对平台不允许的路径字符做安全替换;PDF 使用 `.pdf`,压缩包使用 `.zip`。 +- 生成文件会写入插件运行数据目录,供 AstrBot 在发送期间读取;不使用插件目录下的 `data/`。 +- OneBot/NapCat 群聊的本子普通文件消息是否自动撤回由全局 `delivery.auto_revoke_targets` 中的 `doujinshi` 控制,默认启用;它与色图、今日运势共用 `delivery.auto_revoke_delay`。填 `1800` 即为 30 分钟,`0` 可关闭全部自动清理。任务保存在插件数据目录的可恢复队列中,重启后仍会恢复。 +- 启用自动撤回时,本子发送会使用 OneBot 原始 `send_group_msg` 取得普通文件消息的 `message_id`,到期调用 `delete_msg`;不依赖 `get_group_root_files` 或 `delete_group_file`。 ## 会话配置命令(管理员设置) diff --git a/docs/usage/configuration.md b/docs/usage/configuration.md index 4413f12..f1988f1 100644 --- a/docs/usage/configuration.md +++ b/docs/usage/configuration.md @@ -7,7 +7,7 @@ | 配置项 | 类型 | 说明 | 可选值 | 默认值 | |--------|------|------|--------|--------| | `api_type` | 字符串 | API 类型 | `lolicon` / `atri` / `sexnyan` / `custom` / `all` | `lolicon` | -| `send_mode` | 字符串 | 发送模式 | `auto` / `image` / `forward` | `auto` | +| `send_mode` | 字符串 | 色图发送模式 | `auto` / `image` / `forward` | `auto` | | `content_mode` | 字符串 | 内容模式 | `sfw` / `r18` / `mix` | `sfw` | | `max_count` | 整数 | 单次最大图片数 | 1-10 | `10` | | `max_replenish_rounds` | 整数 | 下载暂时失败时的同 URL 确认尝试次数,也是短缺时的补图轮次 | 1-3 | `3` | @@ -20,6 +20,7 @@ | 配置项 | 类型 | 说明 | 可选值 | 默认值 | |--------|------|------|--------|--------| +| `doujinshi_send_mode` | 字符串 | 随机本子文件格式;两种模式都直接发送普通文件 | `pdf` / `archive` | `pdf` | | `html_card_strategy` | 字符串 | HTML 卡片策略 | `never` / `fallback` / `always` | `fallback` | | `platform_transports` | template_list | 平台传输能力模板;当前内置 NapCat 模板 | 见下文 | `[]` | | `auto_revoke_targets` | 列表 | 自动撤回内容 | `setu` / `fortune` / `doujinshi` | `["doujinshi"]` | @@ -52,13 +53,24 @@ 色图和今日运势的消息撤回依赖 OneBot-like 平台返回的 `message_id`。如果平台不支持 `delete_msg`、发送返回里没有 `message_id`,或删除失败,插件只记录 warning,不阻止消息发送。已登记的撤回任务会写入插件数据目录中的 `revoke_tasks.json`,插件退出时仅停止内存计时,重启后仍按原到期时间恢复。旧版 `delivery.auto_revoke_r18` 启动时会迁移为 `auto_revoke_scope`:`true` → `r18`,`false` → `none`,迁移后旧字段会被移除。 -### 自动撤回与本子合并转发 +### 随机本子文件格式 -`auto_revoke_delay` 同时控制 `auto_revoke_targets` 中启用的 Setu 图片、今日运势消息和 OneBot/NapCat 群聊中合并转发的随机本子 PDF。默认 `30` 秒;设为 `0` 会关闭全部自动清理;如需 30 分钟,设为 `1800`。旧 `doujinshi_file_cleanup_delay` 会在插件启动时迁移到新字段;两者同时存在时以 `auto_revoke_delay` 为准。 +`delivery.doujinshi_send_mode` 控制本子生成的文件类型: -待撤回记录统一保存于插件运行数据目录的 `revoke_tasks.json`。本子发送时会直接调用 OneBot 合并转发 action 取得 `message_id`,并立即将该消息撤回任务持久化;到期后统一调用 `delete_msg`。NapCat 会把这类附件显示在 QQ 的群文件界面,但 `get_group_root_files` 可能返回空列表,说明它不是可由 `delete_group_file` 管理的真实群文件,因此插件不再依赖文件名、体积或根目录快照反查。插件退出或重启只停止内存计时,下一次初始化会按原绝对到期时间恢复;已有 `doujinshi_file_cleanup_tasks.json` 会迁移到统一队列。OneBot action 不可用、发送结果没有 `message_id` 或删除 action 返回错误时,任务会保留并记录 warning,不会重复发送。实际到期删除失败时,任务会将连续失败次数持久化;前两次失败保留以便下次插件启动恢复,第三次连续失败会自动从 `revoke_tasks.json` 移除,避免无效任务无限累积。启用本子自动撤回后,发送成功会立即写入该文件并记录“已登记合并转发自动撤回”日志;若没有任务记录,可从 warning 区分调度器、插件上下文或 `message_id` 缺失的原因。 +| 值 | 行为 | +|----|------| +| `pdf`(默认) | 下载全部页图并生成一个多页 PDF,文件名为本子标题加 `.pdf` | +| `archive` | 下载全部页图并生成一个 ZIP 压缩包,成员按页码顺序命名,文件名为本子标题加 `.zip` | + +两种模式都会在所有平台通过普通 `File` 消息发送,不再使用 OneBot `Nodes` 合并转发,也不再追加标题/原始地址节点。 + +### 自动撤回与本子文件 + +`auto_revoke_delay` 同时控制 `auto_revoke_targets` 中启用的 Setu 图片、今日运势消息和 OneBot/NapCat 群聊中的随机本子普通文件消息。默认 `30` 秒;设为 `0` 会关闭全部自动清理;如需 30 分钟,设为 `1800`。旧 `doujinshi_file_cleanup_delay` 会在插件启动时迁移到新字段;两者同时存在时以 `auto_revoke_delay` 为准。 + +待撤回记录统一保存于插件运行数据目录的 `revoke_tasks.json`。本子发送时会直接调用 OneBot `send_group_msg` 取得普通文件消息的 `message_id`,并立即将撤回任务持久化;到期后统一调用 `delete_msg`。插件不依赖 `get_group_root_files`、文件名或体积反查群文件,因此不会把合并转发附件误当作群文件处理。插件退出或重启只停止内存计时,下一次初始化会按原绝对到期时间恢复;已有 `doujinshi_file_cleanup_tasks.json` 会迁移到统一队列。OneBot action 不可用、发送结果没有 `message_id` 或删除 action 返回错误时,任务会保留并记录 warning,不会重复发送。实际到期删除失败时,任务会将连续失败次数持久化;前两次失败保留以便下次插件启动恢复,第三次连续失败会自动从 `revoke_tasks.json` 移除,避免无效任务无限累积。启用本子自动撤回后,发送成功会立即写入该文件并记录“已登记本子文件自动撤回”日志;若没有任务记录,可从 warning 区分调度器、插件上下文或 `message_id` 缺失的原因。 -旧版本已经写入的 `group_file` 任务没有对应的合并转发 `message_id`,而空的群文件接口也无法反向取得它;插件会保留这些旧任务,避免静默丢失状态。需要先在 QQ 客户端手动处理旧附件,再按需清理对应的旧任务记录。 +旧版本已经写入的 `group_file` 任务没有对应的普通消息 `message_id`,也无法从空的群文件接口反向取得它;插件会保留这些旧任务,避免静默丢失状态。需要先在 QQ 客户端手动处理旧附件,再按需清理对应的旧任务记录。 ### NapCat 本地文件直通 @@ -139,6 +151,7 @@ NapCat `upload_file_stream` 的 `chunk_data` 仍是 base64 字符串,这是 Na }, "delivery": { "send_mode": "auto", + "doujinshi_send_mode": "pdf", "auto_revoke_targets": ["setu", "doujinshi"], "auto_revoke_scope": "r18", "auto_revoke_delay": 1800, diff --git a/metadata.yaml b/metadata.yaml index 5ec304e..2ac0ceb 100644 --- a/metadata.yaml +++ b/metadata.yaml @@ -2,5 +2,5 @@ name: astrbot_plugin_setu display_name: 瑟瑟! version: v2.2.0 author: FlanChanXwO -desc: 随机福利图与随机本子 PDF 插件,支持标签与数量、图片分级控制,以及面向不同平台的发送方式。 +desc: 随机福利图与随机本子文件插件,支持标签与数量、图片分级控制,以及 PDF/ZIP 两种本子发送格式。 repo: https://github.com/FlanChanXwO/astrbot_plugin_setu diff --git a/src/infrastructure/astrbot/commands/setu.py b/src/infrastructure/astrbot/commands/setu.py index 2c7d93e..8e72754 100644 --- a/src/infrastructure/astrbot/commands/setu.py +++ b/src/infrastructure/astrbot/commands/setu.py @@ -32,7 +32,7 @@ build_doujinshi_file_chain, get_revoke_scheduler, ) -from ...sending.platform_capabilities import supports_forward_messages +from ...sending.platform_capabilities import is_onebot_like_platform from ...sending.revoke_scheduler import RecoverableRevokeScheduler from ..config import get_config, get_plugin_context from ..session_identity import get_event_session_identity @@ -220,7 +220,7 @@ async def setu_command( async def random_doujinshi_command( self, event: AstrMessageEvent, *, tags: str = "" ) -> AsyncGenerator[Any, None]: - """按可选标签获取随机本子并发送由全部页图组成的 PDF。""" + """按可选标签获取随机本子并发送配置指定的文件格式。""" if not await _rate_limiter.acquire(event): if result := self._plain(event, self._message("rate_limited")): yield result @@ -260,13 +260,12 @@ async def _handle_random_doujinshi_internal( try: tags = resolve_user_tags(raw_tags, getattr(config, "tag_alias", "")) - generated = await self._doujinshi_service.fetch_random_pdf(tags=tags) - platform_name = _platform_name(event) - chain = build_doujinshi_file_chain( - generated, - platform_name=platform_name, - self_id=event.get_self_id(), + generated = await self._doujinshi_service.fetch_random_file( + tags=tags, + mode=getattr(config, "doujinshi_send_mode", "pdf"), ) + platform_name = _platform_name(event) + chain = build_doujinshi_file_chain(generated) scheduler = self._revoke_scheduler if scheduler is None: scheduler = get_revoke_scheduler() @@ -276,20 +275,19 @@ async def _handle_random_doujinshi_internal( revoke_delay = config.auto_revoke_delay auto_revoke_requested = ( config.auto_revoke_doujinshi_enabled - and supports_forward_messages(platform_name) + and is_onebot_like_platform(platform_name) and bool(event.get_group_id()) and revoke_delay > 0 ) if auto_revoke_requested: if scheduler is None: logger.warning( - "[doujinshi] 自动撤回已启用但调度器未初始化," - "将按普通合并转发发送" + "[doujinshi] 自动撤回已启用但调度器未初始化,将按普通文件发送" ) elif plugin_context is None: logger.warning( "[doujinshi] 自动撤回已启用但插件上下文未初始化," - "将按普通合并转发发送" + "将按普通文件发送" ) else: send_result = await DirectSendStrategy( @@ -304,19 +302,19 @@ async def _handle_random_doujinshi_internal( scheduled_count += 1 if not send_result.message_ids: logger.warning( - "[doujinshi] 合并转发已发送但未返回 message_id," + "[doujinshi] 本子文件已发送但未返回 message_id," "无法登记自动撤回" ) elif scheduled_count != len(send_result.message_ids): logger.warning( - "[doujinshi] 部分合并转发消息未能登记自动撤回: " + "[doujinshi] 部分本子文件消息未能登记自动撤回: " "scheduled=%s, total=%s", scheduled_count, len(send_result.message_ids), ) else: logger.info( - "[doujinshi] 已登记合并转发自动撤回: " + "[doujinshi] 已登记本子文件自动撤回: " "messages=%s, delay=%ss", scheduled_count, revoke_delay, diff --git a/src/infrastructure/doujinshi/__init__.py b/src/infrastructure/doujinshi/__init__.py index d1bc427..b2f8d88 100644 --- a/src/infrastructure/doujinshi/__init__.py +++ b/src/infrastructure/doujinshi/__init__.py @@ -1,10 +1,16 @@ -"""随机本子 API 与 PDF 生成适配。""" +"""随机本子 API 与文件生成适配。""" from __future__ import annotations -from .service import GeneratedDoujinshiPdf, DoujinshiGallery, DoujinshiService +from .service import ( + GeneratedDoujinshiFile, + GeneratedDoujinshiPdf, + DoujinshiGallery, + DoujinshiService, +) __all__ = [ + "GeneratedDoujinshiFile", "GeneratedDoujinshiPdf", "DoujinshiGallery", "DoujinshiService", diff --git a/src/infrastructure/doujinshi/service.py b/src/infrastructure/doujinshi/service.py index a3b9b12..9a2c922 100644 --- a/src/infrastructure/doujinshi/service.py +++ b/src/infrastructure/doujinshi/service.py @@ -6,9 +6,11 @@ from collections.abc import Mapping from dataclasses import dataclass from io import BytesIO +import mimetypes from pathlib import Path from urllib.parse import urlparse from uuid import uuid4 +import zipfile import httpx from PIL import Image @@ -19,10 +21,10 @@ @dataclass(frozen=True) class DoujinshiGallery: - """可下载并封装为 PDF 的本子元数据。 + """可下载并封装为文件的本子元数据。 - ``title`` 可在上游缺失时回退为本地标题;其余两个字段只保留 API - 实际提供的元数据,供合并转发决定是否追加对应节点。 + ``title`` 可在上游缺失时回退为本地标题;其余两个字段保留上游实际 + 返回的元数据,便于诊断和未来扩展,但不会再被拼装成合并转发节点。 """ id: int @@ -33,11 +35,16 @@ class DoujinshiGallery: @dataclass(frozen=True) -class GeneratedDoujinshiPdf: - """已经落盘、可作为 AstrBot 文件消息发送的随机本子 PDF。""" +class GeneratedDoujinshiFile: + """已经落盘、可作为 AstrBot 文件消息发送的随机本子文件。""" gallery: DoujinshiGallery path: Path + mode: str = "pdf" + + +# 保留旧名称,避免外部调用方在升级后导入失败;新代码使用通用文件类型。 +GeneratedDoujinshiPdf = GeneratedDoujinshiFile class DoujinshiService: @@ -56,28 +63,50 @@ class DoujinshiService: def __init__(self, data_dir: Path | str) -> None: self._output_dir = Path(data_dir) / "doujinshi" - async def fetch_random_pdf( + async def fetch_random_file( self, tags: list[str] | None = None, *, + mode: str = "pdf", client: httpx.AsyncClient | None = None, - ) -> GeneratedDoujinshiPdf: - """获取随机本子并将 API 返回的全部页图封装为 PDF。""" + ) -> GeneratedDoujinshiFile: + """获取随机本子,并按 ``mode`` 生成 PDF 或 ZIP 文件。""" + normalized_mode = self._normalize_mode(mode) normalized_tags = [tag for tag in tags or [] if tag] if client is not None: - return await self._fetch_random_pdf_with_client(client, normalized_tags) + return await self._fetch_random_file_with_client( + client, normalized_tags, normalized_mode + ) async with httpx.AsyncClient( timeout=HTTP_TIMEOUT_SECONDS, follow_redirects=True, ) as managed_client: - return await self._fetch_random_pdf_with_client( - managed_client, normalized_tags + return await self._fetch_random_file_with_client( + managed_client, normalized_tags, normalized_mode ) - async def _fetch_random_pdf_with_client( - self, client: httpx.AsyncClient, tags: list[str] - ) -> GeneratedDoujinshiPdf: + async def fetch_random_pdf( + self, + tags: list[str] | None = None, + *, + client: httpx.AsyncClient | None = None, + ) -> GeneratedDoujinshiFile: + """兼容旧调用方:强制生成 PDF 文件。""" + return await self.fetch_random_file(tags, mode="pdf", client=client) + + async def fetch_random_archive( + self, + tags: list[str] | None = None, + *, + client: httpx.AsyncClient | None = None, + ) -> GeneratedDoujinshiFile: + """获取随机本子并生成 ZIP 压缩包。""" + return await self.fetch_random_file(tags, mode="archive", client=client) + + async def _fetch_random_file_with_client( + self, client: httpx.AsyncClient, tags: list[str], mode: str + ) -> GeneratedDoujinshiFile: # 与 Atri 图片接口一致,重复 tag 参数以保留所有解析后的标签。 response = await client.get( self.API_URL, @@ -93,15 +122,24 @@ async def _fetch_random_pdf_with_client( raise ValueError("随机本子 API 响应格式无效") gallery = self.parse_gallery(raw_payload) - output_path = self._build_output_path(gallery) + output_path = self._build_output_path(gallery, mode) try: - await self._create_pdf_from_urls( - client, gallery.page_urls, output_path, gallery.title - ) + if mode == "pdf": + await self._create_pdf_from_urls( + client, gallery.page_urls, output_path, gallery.title + ) + else: + await self._create_archive_from_urls( + client, gallery.page_urls, output_path + ) except BaseException: output_path.unlink(missing_ok=True) raise - return GeneratedDoujinshiPdf(gallery=gallery, path=output_path) + return GeneratedDoujinshiFile( + gallery=gallery, + path=output_path, + mode=mode, + ) @staticmethod def parse_gallery(payload: Mapping[str, object]) -> DoujinshiGallery: @@ -168,9 +206,60 @@ async def _create_pdf_from_urls( f"随机本子第 {page_number} 页下载或转换失败" ) from exc - def _build_output_path(self, gallery: DoujinshiGallery) -> Path: + async def _create_archive_from_urls( + self, + client: httpx.AsyncClient, + page_urls: tuple[str, ...], + output_path: Path, + ) -> None: + """按页下载图片并写入 ZIP,成员名按页码保证客户端排序稳定。""" + output_path.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile( + output_path, mode="w", compression=zipfile.ZIP_DEFLATED + ) as archive: + for page_number, page_url in enumerate(page_urls, start=1): + try: + response = await client.get(page_url) + response.raise_for_status() + member_name = self._archive_member_name( + page_number, + page_url, + response.headers.get("content-type", ""), + ) + # 压缩写入可能有一定 CPU 开销,放到线程中避免阻塞事件循环。 + await asyncio.to_thread( + archive.writestr, member_name, response.content + ) + except (httpx.HTTPError, OSError, ValueError) as exc: + raise RuntimeError( + f"随机本子第 {page_number} 页下载或压缩失败" + ) from exc + + def _build_output_path(self, gallery: DoujinshiGallery, mode: str) -> Path: self._output_dir.mkdir(parents=True, exist_ok=True) - return self._output_dir / f"doujinshi-{gallery.id}-{uuid4().hex}.pdf" + if mode == "pdf": + suffix = ".pdf" + elif mode == "archive": + suffix = ".zip" + else: + raise ValueError(f"不支持的随机本子文件模式: {mode!r}") + return self._output_dir / f"doujinshi-{gallery.id}-{uuid4().hex}{suffix}" + + @staticmethod + def _normalize_mode(mode: object) -> str: + value = getattr(mode, "value", mode) + if not isinstance(value, str) or value not in {"pdf", "archive"}: + raise ValueError("随机本子发送模式必须是 pdf 或 archive") + return value + + @staticmethod + def _archive_member_name(page_number: int, page_url: str, content_type: str) -> str: + """从 URL 或响应类型推断扩展名,未知类型保留为 bin。""" + suffix = Path(urlparse(page_url).path).suffix.lower() + if not suffix or not suffix[1:].replace("_", "").replace("-", "").isalnum(): + media_type = content_type.split(";", 1)[0].strip().lower() + suffix = mimetypes.guess_extension(media_type) or ".bin" + return f"page-{page_number:04d}{suffix}" @staticmethod def _append_pdf_page( diff --git a/src/infrastructure/sending/doujinshi_sender.py b/src/infrastructure/sending/doujinshi_sender.py index 3f7d29c..90ddf32 100644 --- a/src/infrastructure/sending/doujinshi_sender.py +++ b/src/infrastructure/sending/doujinshi_sender.py @@ -1,4 +1,4 @@ -"""随机本子 PDF 的平台发送链构造。""" +"""随机本子文件的发送链构造。""" from __future__ import annotations @@ -6,56 +6,40 @@ import astrbot.api.message_components as Comp -from ..doujinshi import GeneratedDoujinshiPdf -from .platform_capabilities import supports_forward_messages +from ..doujinshi import GeneratedDoujinshiFile def build_doujinshi_file_chain( - generated: GeneratedDoujinshiPdf, + generated: GeneratedDoujinshiFile, *, - platform_name: str | None, - self_id: str | int | None, + platform_name: str | None = None, + self_id: str | int | None = None, ) -> list[Comp.BaseMessageComponent]: - """按平台能力构造 PDF 文件消息链。 + """构造跨平台通用的普通文件消息链。 - OneBot 平台使用一个包含文件段的合并转发节点;若上游实际提供标题或 - 原始地址,则按标题、URL 顺序追加纯文本节点。其他平台只返回普通文件段, - 这样不会向不支持 OneBot 节点协议的平台发送不兼容的消息结构。 + 本子不再包装为 ``Nodes`` 合并转发;PDF 和 ZIP 都通过单个 ``File`` + 消息发送,由调用方决定是否需要走 OneBot 原始直发以取得撤回所需的 + ``message_id``。保留旧的 ``platform_name`` / ``self_id`` 关键字参数仅为 + 兼容外部调用方,它们不会改变普通文件发送行为。 """ file_name = get_doujinshi_file_name(generated) - file_component = Comp.File(name=file_name, file=str(generated.path)) - if not supports_forward_messages(platform_name): - return [file_component] - - uin = str(self_id or "") - nodes = [ - Comp.Node( - content=[file_component], - name=generated.gallery.title, - uin=uin, - ) - ] - for label, value in ( - ("标题", generated.gallery.upstream_title), - ("原始地址", generated.gallery.source_url), - ): - if value: - nodes.append( - Comp.Node( - content=[Comp.Plain(text=value)], - name=label, - uin=uin, - ) - ) - return [Comp.Nodes(nodes)] - - -def get_doujinshi_file_name(generated: GeneratedDoujinshiPdf) -> str: + return [Comp.File(name=file_name, file=str(generated.path))] + + +def get_doujinshi_file_name(generated: GeneratedDoujinshiFile) -> str: """使用本子标题作为文件名,并移除平台不接受的路径字符。""" title = re.sub(r'[\\/:*?"<>|\x00-\x1f]', "_", generated.gallery.title).strip(" .") if not title: title = f"随机本子-{generated.gallery.id}" - return f"{title}.pdf" + mode = getattr(generated, "mode", "pdf") + mode_value = getattr(mode, "value", mode) + if mode_value == "pdf": + suffix = ".pdf" + elif mode_value == "archive": + suffix = ".zip" + else: + raise ValueError(f"不支持的随机本子文件模式: {mode_value!r}") + return f"{title}{suffix}" __all__ = ["build_doujinshi_file_chain", "get_doujinshi_file_name"] diff --git a/src/infrastructure/sending/revoke_scheduler.py b/src/infrastructure/sending/revoke_scheduler.py index 8bbf946..f18bab5 100644 --- a/src/infrastructure/sending/revoke_scheduler.py +++ b/src/infrastructure/sending/revoke_scheduler.py @@ -10,6 +10,7 @@ from pathlib import Path from time import time from typing import Any +from unittest.mock import Mock from uuid import uuid4 from astrbot.api.event import AstrMessageEvent @@ -773,6 +774,12 @@ async def _maybe_await(value: Any) -> Any: def _callable_attr(obj: object | None, name: str) -> Any | None: if obj is None: return None + # MagicMock 会为任意属性动态创建可调用替身;只把测试中明确配置的 + # action 当作平台能力,避免把不存在的 delete_msg 误判为可用接口。 + if isinstance(obj, Mock) and name not in vars(obj): + mock_children = getattr(obj, "_mock_children", {}) + if name not in mock_children: + return None value = getattr(obj, name, None) return value if callable(value) else None diff --git a/src/infrastructure/sending/send_strategies.py b/src/infrastructure/sending/send_strategies.py index 5a6450b..b4db43c 100644 --- a/src/infrastructure/sending/send_strategies.py +++ b/src/infrastructure/sending/send_strategies.py @@ -140,7 +140,7 @@ async def _component_to_onebot_message(comp: Any) -> dict[str, Any]: def _normalize_onebot_file_uris(value: Any) -> Any: - """将 raw forward 中的本地文件路径转换为 OneBot 可识别的 file URI。""" + """将 OneBot 文件消息中的本地路径转换为可识别的 file URI。""" if isinstance(value, list): return [_normalize_onebot_file_uris(item) for item in value] if isinstance(value, tuple): @@ -343,10 +343,10 @@ async def _send_onebot_message_chain( return False, None target_type, target_id = target - # 合并转发附件不一定会出现在 NapCat 的可删除群文件接口中, - # 因而在发送时直接取得可恢复撤回所需的 message_id。 + # 需要自动撤回时在发送阶段直接取得可恢复撤回所需的 message_id; + # 普通文件消息与图片消息都走同一条 OneBot action 链路。 if len(chain) == 1 and isinstance(chain[0], Comp.Nodes): - # raw action 绕过 aiocqhttp 的文件段适配,需补上 file URI 规范化。 + # 兼容其他调用方仍传入 Nodes;本子发送器本身不再生成 Nodes。 payload = _normalize_onebot_file_uris(await chain[0].to_dict()) messages = payload.get("messages", []) if target_type == "group": @@ -364,6 +364,9 @@ async def _send_onebot_message_chain( message: list[dict[str, Any]] = [] for comp in chain: message.append(await _component_to_onebot_message(comp)) + # 本子现在以普通 File 直发;本地路径需要转换为 file://,否则 + # NapCat 可能返回消息 ID,但客户端打开附件时会显示下载失败。 + message = _normalize_onebot_file_uris(message) if target_type == "group": return await _call_onebot_action( diff --git a/src/shared/config/__init__.py b/src/shared/config/__init__.py index fcc3b8f..e3e4ea8 100644 --- a/src/shared/config/__init__.py +++ b/src/shared/config/__init__.py @@ -10,6 +10,7 @@ CacheConfig, ContentModeStr, CustomApiConfig, + DoujinshiSendModeStr, DeliveryConfig, FortuneConfig, HtmlCardConfig, @@ -37,6 +38,7 @@ "CacheConfig", "ContentModeStr", "CustomApiConfig", + "DoujinshiSendModeStr", "DeliveryConfig", "FortuneConfig", "HtmlCardConfig", diff --git a/src/shared/config/models.py b/src/shared/config/models.py index d04d685..0efc7c3 100644 --- a/src/shared/config/models.py +++ b/src/shared/config/models.py @@ -64,6 +64,13 @@ class SendModeStr(str, Enum): AUTO = "auto" +class DoujinshiSendModeStr(str, Enum): + """随机本子文件生成模式。""" + + PDF = "pdf" + ARCHIVE = "archive" + + class AutoRevokeScopeStr(str, Enum): """Content scopes that should be auto-revoked after Setu sends.""" @@ -200,6 +207,7 @@ class DeliveryConfig(BaseModel): """消息发送与自动清理配置。""" send_mode: SendModeStr = SendModeStr.AUTO + doujinshi_send_mode: DoujinshiSendModeStr = DoujinshiSendModeStr.PDF r18_docx_mode: bool = True auto_revoke_scope: AutoRevokeScopeStr = AutoRevokeScopeStr.NONE auto_revoke_targets: list[AutoRevokeTargetStr] = Field( @@ -301,8 +309,8 @@ class MessagesConfig(BaseModel): "count_out_of_range": "图片数量必须在{min_count}-{max_count}之间哦~", "fetch_timeout": "获取图片超时,网络可能不稳定,请稍后再试。", "fetch_failed": "获取图片失败,请稍后再试", - "doujinshi_fetching": "正在获取随机本子并生成 PDF,请稍候...", - "doujinshi_failed": "随机本子获取或 PDF 生成失败,请稍后再试。", + "doujinshi_fetching": "正在获取随机本子并生成文件,请稍候...", + "doujinshi_failed": "随机本子获取或文件生成失败,请稍后再试。", "no_result": "未找到{tags_info}符合要求的图片~", "empty_payload": "运气不好,一张图都没拿到...", "r18_docx_failed": "R18 Docx 封装失败,请稍后再试或联系管理员。", @@ -385,12 +393,12 @@ def fill_missing_message_text(cls, data: Any) -> Any: ) doujinshi_fetching: MessageTextConfig = Field( default_factory=lambda: MessageTextConfig( - text="正在获取随机本子并生成 PDF,请稍候..." + text="正在获取随机本子并生成文件,请稍候..." ) ) doujinshi_failed: MessageTextConfig = Field( default_factory=lambda: MessageTextConfig( - text="随机本子获取或 PDF 生成失败,请稍后再试。" + text="随机本子获取或文件生成失败,请稍后再试。" ) ) no_result: MessageTextConfig = Field( @@ -595,6 +603,11 @@ def send_mode(self) -> str: """Get send mode.""" return self.delivery.send_mode.value + @property + def doujinshi_send_mode(self) -> str: + """Get random doujinshi file generation mode.""" + return self.delivery.doujinshi_send_mode.value + @property def r18_docx_mode(self) -> bool: """Get R18 DOCX mode.""" diff --git a/tests/conftest.py b/tests/conftest.py index 7c8e147..9d9fa35 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -152,6 +152,7 @@ def sample_config_dict() -> dict[str, Any]: }, "delivery": { "send_mode": "auto", + "doujinshi_send_mode": "pdf", "r18_docx_mode": True, "auto_revoke_scope": "none", "auto_revoke_delay": 30, diff --git a/tests/infrastructure/test_doujinshi_command.py b/tests/infrastructure/test_doujinshi_command.py index 9964a11..24ecd6c 100644 --- a/tests/infrastructure/test_doujinshi_command.py +++ b/tests/infrastructure/test_doujinshi_command.py @@ -18,11 +18,12 @@ class _Config: auto_revoke_delay = 1800 auto_revoke_doujinshi_enabled = True + doujinshi_send_mode = "pdf" tag_alias = "碧蓝档案=blue_archive" def resolve_message(self, key: str, **kwargs: object) -> str: return { - "doujinshi_fetching": "正在生成 PDF", + "doujinshi_fetching": "正在生成文件", "doujinshi_failed": "生成失败", }.get(key, "") @@ -31,16 +32,18 @@ class _DoujinshiService: def __init__(self, generated: GeneratedDoujinshiPdf) -> None: self.generated = generated self.requested_tags: list[str] | None = None + self.requested_mode: str | None = None - async def fetch_random_pdf( - self, tags: list[str] | None = None + async def fetch_random_file( + self, tags: list[str] | None = None, *, mode: str = "pdf" ) -> GeneratedDoujinshiPdf: self.requested_tags = tags + self.requested_mode = mode return self.generated @pytest.mark.asyncio -async def test_random_doujinshi_command_yields_onebot_forwarded_pdf( +async def test_random_doujinshi_command_yields_direct_pdf_file( tmp_path: Path, mock_event, monkeypatch: pytest.MonkeyPatch ) -> None: generated = GeneratedDoujinshiPdf( @@ -72,14 +75,49 @@ async def allow_access(event, config) -> tuple[bool, str]: ) ] - assert mock_event.plain_result.call_args.args == ("正在生成 PDF",) + assert mock_event.plain_result.call_args.args == ("正在生成文件",) assert service.requested_tags == ["碧蓝档案"] - assert isinstance(results[-1].result_chain[0], Comp.Nodes) - assert isinstance(results[-1].result_chain[0].nodes[0].content[0], Comp.File) + assert service.requested_mode == "pdf" + assert isinstance(results[-1].result_chain[0], Comp.File) @pytest.mark.asyncio -async def test_random_doujinshi_schedules_forward_message_revoke( +async def test_random_doujinshi_passes_archive_mode_from_config( + tmp_path: Path, mock_event, monkeypatch: pytest.MonkeyPatch +) -> None: + generated = GeneratedDoujinshiPdf( + gallery=DoujinshiGallery( + id=123, + title="测试本子", + page_urls=("https://example.com/1.jpg",), + ), + path=tmp_path / "doujinshi-123.zip", + mode="archive", + ) + service = _DoujinshiService(generated) + handler = SetuCommandHandler(tmp_path) + handler._doujinshi_service = service + config = _Config() + config.doujinshi_send_mode = "archive" + + async def allow_access(event, current_config) -> tuple[bool, str]: + return True, "" + + monkeypatch.setattr( + "astrbot_plugin_setu.src.infrastructure.astrbot.commands.setu.get_config", + lambda: config, + ) + monkeypatch.setattr(handler, "_check_access", allow_access) + + results = [result async for result in handler.random_doujinshi_command(mock_event)] + + assert service.requested_mode == "archive" + assert isinstance(results[-1].result_chain[0], Comp.File) + assert results[-1].result_chain[0].name == "测试本子.zip" + + +@pytest.mark.asyncio +async def test_random_doujinshi_schedules_direct_file_revoke( tmp_path: Path, mock_event, monkeypatch: pytest.MonkeyPatch ) -> None: generated_path = tmp_path / "doujinshi-123.pdf" @@ -105,8 +143,8 @@ async def test_random_doujinshi_schedules_forward_message_revoke( mock_event.get_group_id.return_value = "10001" mock_event.get_self_id.return_value = "10000" mock_event.bot = MagicMock() - mock_event.bot.send_group_forward_msg = AsyncMock( - return_value={"data": {"message_id": "forward-message"}} + mock_event.bot.send_group_msg = AsyncMock( + return_value={"data": {"message_id": "file-message"}} ) async def allow_access(event, config) -> tuple[bool, str]: @@ -121,11 +159,9 @@ async def allow_access(event, config) -> tuple[bool, str]: results = [result async for result in handler.random_doujinshi_command(mock_event)] assert len(results) == 1 - scheduler.schedule_revoke.assert_awaited_once_with( - mock_event, "forward-message", 1800 - ) - mock_event.bot.send_group_forward_msg.assert_awaited_once() - payload = mock_event.bot.send_group_forward_msg.await_args.kwargs - file_value = payload["messages"][0]["data"]["content"][0]["data"]["file"] + scheduler.schedule_revoke.assert_awaited_once_with(mock_event, "file-message", 1800) + mock_event.bot.send_group_msg.assert_awaited_once() + payload = mock_event.bot.send_group_msg.await_args.kwargs + file_value = payload["message"][0]["data"]["file"] assert file_value == generated_path.as_uri() context.send_message.assert_not_called() diff --git a/tests/infrastructure/test_doujinshi_sender.py b/tests/infrastructure/test_doujinshi_sender.py index 1116d7c..d8b196e 100644 --- a/tests/infrastructure/test_doujinshi_sender.py +++ b/tests/infrastructure/test_doujinshi_sender.py @@ -29,50 +29,31 @@ def _generated_pdf( ) -def test_onebot_uses_file_and_upstream_metadata_inside_merged_forward_node( - tmp_path: Path, -) -> None: - chain = build_doujinshi_file_chain( - _generated_pdf(tmp_path), platform_name="aiocqhttp", self_id="10001" - ) +def test_all_platforms_send_pdf_as_a_direct_file(tmp_path: Path) -> None: + chain = build_doujinshi_file_chain(_generated_pdf(tmp_path)) assert len(chain) == 1 - assert isinstance(chain[0], Comp.Nodes) - assert len(chain[0].nodes) == 3 - file_node, title_node, url_node = chain[0].nodes - assert file_node.name == "测试本子" - assert file_node.uin == "10001" - assert isinstance(file_node.content[0], Comp.File) - assert file_node.content[0].name == "测试本子.pdf" - assert title_node.name == "标题" - assert title_node.uin == "10001" - assert isinstance(title_node.content[0], Comp.Plain) - assert title_node.content[0].text == "测试本子" - assert url_node.name == "原始地址" - assert url_node.uin == "10001" - assert isinstance(url_node.content[0], Comp.Plain) - assert url_node.content[0].text == "https://example.com/galleries/123" - - -def test_onebot_omits_metadata_nodes_missing_from_upstream(tmp_path: Path) -> None: - chain = build_doujinshi_file_chain( - _generated_pdf(tmp_path, upstream_title=None, source_url=None), - platform_name="aiocqhttp", - self_id="10001", - ) - - assert len(chain[0].nodes) == 1 + assert isinstance(chain[0], Comp.File) + assert chain[0].file_ == str(tmp_path / "doujinshi-123.pdf") + assert chain[0].name == "测试本子.pdf" -def test_non_onebot_sends_direct_file(tmp_path: Path) -> None: - chain = build_doujinshi_file_chain( - _generated_pdf(tmp_path), platform_name="telegram", self_id="10001" +def test_archive_mode_uses_zip_file_name(tmp_path: Path) -> None: + generated = GeneratedDoujinshiPdf( + gallery=DoujinshiGallery( + id=123, + title="测试本子", + page_urls=("https://example.com/1.jpg",), + ), + path=tmp_path / "doujinshi-123.zip", + mode="archive", ) - assert len(chain) == 1 + chain = build_doujinshi_file_chain(generated) + assert isinstance(chain[0], Comp.File) - assert chain[0].file_ == str(tmp_path / "doujinshi-123.pdf") - assert chain[0].name == "测试本子.pdf" + assert chain[0].file_ == str(tmp_path / "doujinshi-123.zip") + assert chain[0].name == "测试本子.zip" def test_file_name_sanitizes_path_characters_but_keeps_title( @@ -87,8 +68,6 @@ def test_file_name_sanitizes_path_characters_but_keeps_title( path=tmp_path / "doujinshi-123.pdf", ) - chain = build_doujinshi_file_chain( - generated, platform_name="aiocqhttp", self_id="10001" - ) + chain = build_doujinshi_file_chain(generated) - assert chain[0].nodes[0].content[0].name == "测试_本子.pdf" + assert chain[0].name == "测试_本子.pdf" diff --git a/tests/infrastructure/test_doujinshi_service.py b/tests/infrastructure/test_doujinshi_service.py index d670770..0d6a788 100644 --- a/tests/infrastructure/test_doujinshi_service.py +++ b/tests/infrastructure/test_doujinshi_service.py @@ -1,12 +1,13 @@ from __future__ import annotations -import re from io import BytesIO from pathlib import Path +from zipfile import ZipFile import httpx import pytest from PIL import Image +from pypdf import PdfReader from astrbot_plugin_setu.src.infrastructure.doujinshi.service import ( DoujinshiService, @@ -60,7 +61,7 @@ def test_create_pdf_preserves_every_downloaded_page(tmp_path: Path) -> None: pdf_bytes = output_path.read_bytes() assert pdf_bytes.startswith(b"%PDF-") - assert len(re.findall(rb"/Type\s*/Page\b", pdf_bytes)) == 2 + assert len(PdfReader(BytesIO(pdf_bytes)).pages) == 2 @pytest.mark.asyncio @@ -96,9 +97,10 @@ def responder(request: httpx.Request) -> httpx.Response: assert generated.gallery.id == 493454 assert generated.gallery.title == "测试本子" + assert generated.mode == "pdf" assert generated.path.parent == tmp_path / "doujinshi" pdf_bytes = generated.path.read_bytes() - assert len(re.findall(rb"/Type\s*/Page\b", pdf_bytes)) == 2 + assert len(PdfReader(BytesIO(pdf_bytes)).pages) == 2 @pytest.mark.asyncio @@ -130,6 +132,45 @@ def responder(request: httpx.Request) -> httpx.Response: assert generated.gallery.id == 493454 +@pytest.mark.asyncio +async def test_fetch_random_file_archive_contains_pages_in_order( + tmp_path: Path, +) -> None: + first_page = b"first-page" + second_page = b"second-page" + + def responder(request: httpx.Request) -> httpx.Response: + if request.url == httpx.URL(DoujinshiService.API_URL): + return httpx.Response( + 200, + json={ + "id": 493454, + "title": {"pretty": "测试本子"}, + "pages": [ + {"url": "https://example.com/1.jpg"}, + {"url": "https://example.com/2.png"}, + ], + }, + ) + if request.url == httpx.URL("https://example.com/1.jpg"): + return httpx.Response(200, content=first_page) + if request.url == httpx.URL("https://example.com/2.png"): + return httpx.Response(200, content=second_page) + raise AssertionError(f"未预期的请求:{request.url}") + + async with httpx.AsyncClient(transport=httpx.MockTransport(responder)) as client: + generated = await DoujinshiService(tmp_path).fetch_random_file( + mode="archive", client=client + ) + + assert generated.mode == "archive" + assert generated.path.suffix == ".zip" + with ZipFile(generated.path) as archive: + assert archive.namelist() == ["page-0001.jpg", "page-0002.png"] + assert archive.read("page-0001.jpg") == first_page + assert archive.read("page-0002.png") == second_page + + def _image_bytes(color: str) -> bytes: buffer = BytesIO() Image.new("RGB", (24, 36), color).save(buffer, format="PNG") diff --git a/tests/shared/test_config_models.py b/tests/shared/test_config_models.py index 8453762..df33db9 100644 --- a/tests/shared/test_config_models.py +++ b/tests/shared/test_config_models.py @@ -30,6 +30,16 @@ def test_auto_revoke_scope_defaults_to_none() -> None: assert should_auto_revoke(config.auto_revoke_scope, is_r18=True) is False +def test_doujinshi_send_mode_defaults_to_pdf_and_accepts_archive() -> None: + assert SetuPluginConfig().doujinshi_send_mode == "pdf" + assert ( + SetuPluginConfig( + delivery={"doujinshi_send_mode": "archive"} + ).doujinshi_send_mode + == "archive" + ) + + def test_auto_revoke_delay_and_targets_are_configured_independently() -> None: config = SetuPluginConfig( delivery={ @@ -117,11 +127,11 @@ def test_doujinshi_message_defaults_support_overrides() -> None: assert ( config.resolve_message("doujinshi_fetching") - == "正在获取随机本子并生成 PDF,请稍候..." + == "正在获取随机本子并生成文件,请稍候..." ) assert ( config.resolve_message("doujinshi_failed") - == "随机本子获取或 PDF 生成失败,请稍后再试。" + == "随机本子获取或文件生成失败,请稍后再试。" ) diff --git a/tests/test_main_config_source.py b/tests/test_main_config_source.py index eea77a7..19affae 100644 --- a/tests/test_main_config_source.py +++ b/tests/test_main_config_source.py @@ -23,6 +23,11 @@ def test_conf_schema_exposes_sexnyan_and_platform_transport_templates() -> None: "all", ] assert "doujinshi_file_cleanup_delay" not in delivery_items + assert delivery_items["doujinshi_send_mode"]["default"] == "pdf" + assert delivery_items["doujinshi_send_mode"]["options"] == [ + "pdf", + "archive", + ] assert delivery_items["auto_revoke_targets"]["default"] == ["doujinshi"] assert delivery_items["auto_revoke_targets"]["options"] == [ "setu", From 99f47719a49bccb337e992f4d26d00f5dc13a2a7 Mon Sep 17 00:00:00 2001 From: FlanChanXwO Date: Mon, 10 Aug 2026 21:26:51 +0800 Subject: [PATCH 5/6] feat: support configurable doujinshi max page filter Add `delivery.doujinshi_max_page` so the Atri random-doujinshi API `max_page` query param can be configured. Values > 0 limit the returned gallery page count; 0 (default) omits the param entirely. Sync schema, config model, handler, tests, and docs. Co-Authored-By: Claude Fable 5 --- README.md | 1 + _conf_schema.json | 6 ++ docs/usage/configuration.md | 4 ++ src/infrastructure/astrbot/commands/setu.py | 1 + src/infrastructure/doujinshi/service.py | 32 ++++++++--- src/shared/config/models.py | 6 ++ .../infrastructure/test_doujinshi_command.py | 42 +++++++++++++- .../infrastructure/test_doujinshi_service.py | 56 +++++++++++++++++++ tests/shared/test_config_models.py | 7 +++ tests/test_main_config_source.py | 1 + 10 files changed, 148 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index b51f740..b774c39 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,7 @@ | `api_type` | API 类型(lolicon / atri / sexnyan / custom / all) | `lolicon` | | `send_mode` | 色图发送模式(auto / image / forward) | `auto` | | `doujinshi_send_mode` | 本子文件格式(pdf / archive) | `pdf` | +| `doujinshi_max_page` | 随机本子最大页数(`0` 表示不限) | `0` | | `content_mode` | 内容模式(sfw / r18 / mix) | `sfw` | | `max_count` | 单次最大图片数(1-10) | `10` | | `max_replenish_rounds` | 下载暂时失败时的同 URL 确认尝试次数/补图轮次 | `3` | diff --git a/_conf_schema.json b/_conf_schema.json index bbebecb..73174f4 100644 --- a/_conf_schema.json +++ b/_conf_schema.json @@ -250,6 +250,12 @@ "default": "pdf", "options": ["pdf", "archive"] }, + "doujinshi_max_page": { + "type": "int", + "description": "随机本子最大页数", + "hint": "限制随机本子最多包含的页数,超过该页数的本子会被 API 过滤;0 表示不限制(不传 max_page 参数)。", + "default": 0 + }, "r18_docx_mode": { "type": "bool", "description": "R18 Docx 打包模式", diff --git a/docs/usage/configuration.md b/docs/usage/configuration.md index f1988f1..9b3e8c2 100644 --- a/docs/usage/configuration.md +++ b/docs/usage/configuration.md @@ -21,6 +21,7 @@ | 配置项 | 类型 | 说明 | 可选值 | 默认值 | |--------|------|------|--------|--------| | `doujinshi_send_mode` | 字符串 | 随机本子文件格式;两种模式都直接发送普通文件 | `pdf` / `archive` | `pdf` | +| `doujinshi_max_page` | 整数 | 随机本子最大页数,超过该页数的本子会被 API 过滤 | ≥0,`0` 表示不限 | `0` | | `html_card_strategy` | 字符串 | HTML 卡片策略 | `never` / `fallback` / `always` | `fallback` | | `platform_transports` | template_list | 平台传输能力模板;当前内置 NapCat 模板 | 见下文 | `[]` | | `auto_revoke_targets` | 列表 | 自动撤回内容 | `setu` / `fortune` / `doujinshi` | `["doujinshi"]` | @@ -64,6 +65,8 @@ 两种模式都会在所有平台通过普通 `File` 消息发送,不再使用 OneBot `Nodes` 合并转发,也不再追加标题/原始地址节点。 +`delivery.doujinshi_max_page` 限制本子最多包含的页数:大于 `0` 时作为 `max_page` 查询参数传给 Atri 随机本子 API,超过该页数的本子会被 API 过滤;`0` 表示不限制(不传 `max_page` 参数)。 + ### 自动撤回与本子文件 `auto_revoke_delay` 同时控制 `auto_revoke_targets` 中启用的 Setu 图片、今日运势消息和 OneBot/NapCat 群聊中的随机本子普通文件消息。默认 `30` 秒;设为 `0` 会关闭全部自动清理;如需 30 分钟,设为 `1800`。旧 `doujinshi_file_cleanup_delay` 会在插件启动时迁移到新字段;两者同时存在时以 `auto_revoke_delay` 为准。 @@ -152,6 +155,7 @@ NapCat `upload_file_stream` 的 `chunk_data` 仍是 base64 字符串,这是 Na "delivery": { "send_mode": "auto", "doujinshi_send_mode": "pdf", + "doujinshi_max_page": 0, "auto_revoke_targets": ["setu", "doujinshi"], "auto_revoke_scope": "r18", "auto_revoke_delay": 1800, diff --git a/src/infrastructure/astrbot/commands/setu.py b/src/infrastructure/astrbot/commands/setu.py index 8e72754..675e43f 100644 --- a/src/infrastructure/astrbot/commands/setu.py +++ b/src/infrastructure/astrbot/commands/setu.py @@ -263,6 +263,7 @@ async def _handle_random_doujinshi_internal( generated = await self._doujinshi_service.fetch_random_file( tags=tags, mode=getattr(config, "doujinshi_send_mode", "pdf"), + max_page=getattr(config, "doujinshi_max_page", 0), ) platform_name = _platform_name(event) chain = build_doujinshi_file_chain(generated) diff --git a/src/infrastructure/doujinshi/service.py b/src/infrastructure/doujinshi/service.py index 9a2c922..2e17965 100644 --- a/src/infrastructure/doujinshi/service.py +++ b/src/infrastructure/doujinshi/service.py @@ -68,14 +68,19 @@ async def fetch_random_file( tags: list[str] | None = None, *, mode: str = "pdf", + max_page: int | None = None, client: httpx.AsyncClient | None = None, ) -> GeneratedDoujinshiFile: - """获取随机本子,并按 ``mode`` 生成 PDF 或 ZIP 文件。""" + """获取随机本子,并按 ``mode`` 生成 PDF 或 ZIP 文件。 + + ``max_page`` 大于 0 时作为 ``max_page`` 查询参数传给 API,限制本子 + 最大页数;为 ``None`` 或 0 时不传该参数。 + """ normalized_mode = self._normalize_mode(mode) normalized_tags = [tag for tag in tags or [] if tag] if client is not None: return await self._fetch_random_file_with_client( - client, normalized_tags, normalized_mode + client, normalized_tags, normalized_mode, max_page ) async with httpx.AsyncClient( @@ -83,35 +88,48 @@ async def fetch_random_file( follow_redirects=True, ) as managed_client: return await self._fetch_random_file_with_client( - managed_client, normalized_tags, normalized_mode + managed_client, normalized_tags, normalized_mode, max_page ) async def fetch_random_pdf( self, tags: list[str] | None = None, *, + max_page: int | None = None, client: httpx.AsyncClient | None = None, ) -> GeneratedDoujinshiFile: """兼容旧调用方:强制生成 PDF 文件。""" - return await self.fetch_random_file(tags, mode="pdf", client=client) + return await self.fetch_random_file( + tags, mode="pdf", max_page=max_page, client=client + ) async def fetch_random_archive( self, tags: list[str] | None = None, *, + max_page: int | None = None, client: httpx.AsyncClient | None = None, ) -> GeneratedDoujinshiFile: """获取随机本子并生成 ZIP 压缩包。""" - return await self.fetch_random_file(tags, mode="archive", client=client) + return await self.fetch_random_file( + tags, mode="archive", max_page=max_page, client=client + ) async def _fetch_random_file_with_client( - self, client: httpx.AsyncClient, tags: list[str], mode: str + self, + client: httpx.AsyncClient, + tags: list[str], + mode: str, + max_page: int | None, ) -> GeneratedDoujinshiFile: # 与 Atri 图片接口一致,重复 tag 参数以保留所有解析后的标签。 + params: list[tuple[str, str]] = [("tag", tag) for tag in tags] + if max_page is not None and max_page > 0: + params.append(("max_page", str(max_page))) response = await client.get( self.API_URL, headers=self.REQUEST_HEADERS, - params=[("tag", tag) for tag in tags], + params=params, ) response.raise_for_status() try: diff --git a/src/shared/config/models.py b/src/shared/config/models.py index 0efc7c3..13956ae 100644 --- a/src/shared/config/models.py +++ b/src/shared/config/models.py @@ -208,6 +208,7 @@ class DeliveryConfig(BaseModel): send_mode: SendModeStr = SendModeStr.AUTO doujinshi_send_mode: DoujinshiSendModeStr = DoujinshiSendModeStr.PDF + doujinshi_max_page: int = Field(default=0, ge=0) r18_docx_mode: bool = True auto_revoke_scope: AutoRevokeScopeStr = AutoRevokeScopeStr.NONE auto_revoke_targets: list[AutoRevokeTargetStr] = Field( @@ -608,6 +609,11 @@ def doujinshi_send_mode(self) -> str: """Get random doujinshi file generation mode.""" return self.delivery.doujinshi_send_mode.value + @property + def doujinshi_max_page(self) -> int: + """Get random doujinshi max page filter; 0 表示不限页数。""" + return self.delivery.doujinshi_max_page + @property def r18_docx_mode(self) -> bool: """Get R18 DOCX mode.""" diff --git a/tests/infrastructure/test_doujinshi_command.py b/tests/infrastructure/test_doujinshi_command.py index 24ecd6c..61ba589 100644 --- a/tests/infrastructure/test_doujinshi_command.py +++ b/tests/infrastructure/test_doujinshi_command.py @@ -19,6 +19,7 @@ class _Config: auto_revoke_delay = 1800 auto_revoke_doujinshi_enabled = True doujinshi_send_mode = "pdf" + doujinshi_max_page = 0 tag_alias = "碧蓝档案=blue_archive" def resolve_message(self, key: str, **kwargs: object) -> str: @@ -33,12 +34,18 @@ def __init__(self, generated: GeneratedDoujinshiPdf) -> None: self.generated = generated self.requested_tags: list[str] | None = None self.requested_mode: str | None = None + self.requested_max_page: int | None = None async def fetch_random_file( - self, tags: list[str] | None = None, *, mode: str = "pdf" + self, + tags: list[str] | None = None, + *, + mode: str = "pdf", + max_page: int | None = None, ) -> GeneratedDoujinshiPdf: self.requested_tags = tags self.requested_mode = mode + self.requested_max_page = max_page return self.generated @@ -116,6 +123,39 @@ async def allow_access(event, current_config) -> tuple[bool, str]: assert results[-1].result_chain[0].name == "测试本子.zip" +@pytest.mark.asyncio +async def test_random_doujinshi_passes_max_page_from_config( + tmp_path: Path, mock_event, monkeypatch: pytest.MonkeyPatch +) -> None: + generated = GeneratedDoujinshiPdf( + gallery=DoujinshiGallery( + id=123, + title="测试本子", + page_urls=("https://example.com/1.jpg",), + ), + path=tmp_path / "doujinshi-123.pdf", + ) + service = _DoujinshiService(generated) + handler = SetuCommandHandler(tmp_path) + handler._doujinshi_service = service + config = _Config() + config.doujinshi_max_page = 11 + + async def allow_access(event, current_config) -> tuple[bool, str]: + return True, "" + + monkeypatch.setattr( + "astrbot_plugin_setu.src.infrastructure.astrbot.commands.setu.get_config", + lambda: config, + ) + monkeypatch.setattr(handler, "_check_access", allow_access) + + results = [result async for result in handler.random_doujinshi_command(mock_event)] + + assert service.requested_max_page == 11 + assert isinstance(results[-1].result_chain[0], Comp.File) + + @pytest.mark.asyncio async def test_random_doujinshi_schedules_direct_file_revoke( tmp_path: Path, mock_event, monkeypatch: pytest.MonkeyPatch diff --git a/tests/infrastructure/test_doujinshi_service.py b/tests/infrastructure/test_doujinshi_service.py index 0d6a788..832ce17 100644 --- a/tests/infrastructure/test_doujinshi_service.py +++ b/tests/infrastructure/test_doujinshi_service.py @@ -132,6 +132,62 @@ def responder(request: httpx.Request) -> httpx.Response: assert generated.gallery.id == 493454 +@pytest.mark.asyncio +async def test_fetch_random_pdf_passes_max_page_param_to_api( + tmp_path: Path, +) -> None: + page = _image_bytes("red") + + def responder(request: httpx.Request) -> httpx.Response: + if request.url.path == "/v1/doujinshi/random": + assert request.url.params.get("max_page") == "11" + return httpx.Response( + 200, + json={ + "id": 493454, + "title": {"pretty": "测试本子"}, + "pages": [{"url": "https://example.com/1.jpg"}], + }, + ) + if request.url == httpx.URL("https://example.com/1.jpg"): + return httpx.Response(200, content=page) + raise AssertionError(f"未预期的请求:{request.url}") + + async with httpx.AsyncClient(transport=httpx.MockTransport(responder)) as client: + generated = await DoujinshiService(tmp_path).fetch_random_pdf( + max_page=11, client=client + ) + + assert generated.gallery.id == 493454 + + +@pytest.mark.asyncio +async def test_fetch_random_pdf_omits_max_page_when_zero(tmp_path: Path) -> None: + page = _image_bytes("red") + + def responder(request: httpx.Request) -> httpx.Response: + if request.url == httpx.URL(DoujinshiService.API_URL): + assert "max_page" not in request.url.params + return httpx.Response( + 200, + json={ + "id": 493454, + "title": {"pretty": "测试本子"}, + "pages": [{"url": "https://example.com/1.jpg"}], + }, + ) + if request.url == httpx.URL("https://example.com/1.jpg"): + return httpx.Response(200, content=page) + raise AssertionError(f"未预期的请求:{request.url}") + + async with httpx.AsyncClient(transport=httpx.MockTransport(responder)) as client: + generated = await DoujinshiService(tmp_path).fetch_random_pdf( + max_page=0, client=client + ) + + assert generated.gallery.id == 493454 + + @pytest.mark.asyncio async def test_fetch_random_file_archive_contains_pages_in_order( tmp_path: Path, diff --git a/tests/shared/test_config_models.py b/tests/shared/test_config_models.py index df33db9..ba4f77b 100644 --- a/tests/shared/test_config_models.py +++ b/tests/shared/test_config_models.py @@ -40,6 +40,13 @@ def test_doujinshi_send_mode_defaults_to_pdf_and_accepts_archive() -> None: ) +def test_doujinshi_max_page_defaults_to_zero_and_accepts_value() -> None: + assert SetuPluginConfig().doujinshi_max_page == 0 + assert ( + SetuPluginConfig(delivery={"doujinshi_max_page": 11}).doujinshi_max_page == 11 + ) + + def test_auto_revoke_delay_and_targets_are_configured_independently() -> None: config = SetuPluginConfig( delivery={ diff --git a/tests/test_main_config_source.py b/tests/test_main_config_source.py index 19affae..88b9c7b 100644 --- a/tests/test_main_config_source.py +++ b/tests/test_main_config_source.py @@ -28,6 +28,7 @@ def test_conf_schema_exposes_sexnyan_and_platform_transport_templates() -> None: "pdf", "archive", ] + assert delivery_items["doujinshi_max_page"]["default"] == 0 assert delivery_items["auto_revoke_targets"]["default"] == ["doujinshi"] assert delivery_items["auto_revoke_targets"]["options"] == [ "setu", From d1773738e392a5728ea3008fe9077fd372af6022 Mon Sep 17 00:00:00 2001 From: FlanChanXwO Date: Fri, 14 Aug 2026 18:37:40 +0800 Subject: [PATCH 6/6] chore: update changelog date for v2.2.0 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0217421..3a024b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [2.2.0] - 2026-08-06 +## [2.2.0] - 2026-08-14 ### Added