feat: complete bot and web reliability enhancement plan

This commit is contained in:
hicocos committed 2026-08-24 07:17:08 +08:00
1 parent 6b2ee50a9e
commit 28d229f8c9
122 files changed
+9173 -2688

No files matched your search

+3 -1
View File
@@ -35,7 +35,9 @@ DOMAIN=cloud.example.com
# =============================================================================
# Telegram Bot 基础配置。只要填这三项,Bot 就可以收文件、管理任务、调用 yt-dlp。
# TELEGRAM_API_ID / TELEGRAM_API_HASH 也会被账号级下载器复用,不需要再配置 USER_API_ID / USER_API_HASH。
# 生产 Telegram 部署建议设 TELEGRAM_REQUIRED=true;凭证无效或 Bot 启动失败时 /readyz 返回 503。
# false 时 Web/API 继续运行,但 /readyz 和管理页会暴露 degraded 的 Bot component status。
TELEGRAM_REQUIRED=true
TELEGRAM_BOT_TOKEN=
TELEGRAM_API_ID=
TELEGRAM_API_HASH=
@@ -0,0 +1,772 @@
# TG Vault Bot 实用能力增强与 Web 缺陷修复实施计划
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
**Goal:** 在不破坏现有文件、任务、存储与恢复协议的前提下,先修复审查确认的正确性和部署基线问题,再把 Telegram Bot 扩展成可日常使用的“文件收件箱 + 任务中心 + 频道归档助手”,并补齐 Web 端关键逻辑、交互和可访问性缺陷。
**Architecture:** 保留现有 Express + PostgreSQL + GramJS + React 架构,继续以 PostgreSQL 作为持久任务、订阅、会话和对账事实源;将 Bot 的命令分发、会话状态、展示层和领域服务逐步拆开。所有会产生外部副作用的功能必须固化存储目标、持有租约、写入对账证据,并通过一次性确认执行危险操作。
**Tech Stack:** Node.js 20、TypeScript、Express 4、PostgreSQL、GramJS、React 19、Vite 7、Docker Compose、Nginx、node:test/tsx。
---
## 1. 审查基线
- 审查时间:2026-08-24(CST, UTC+08:00)
- 仓库:`/www/wwwroot/tg-vault`
- 审查基线:`main` / `6b2ee50a9e4aed7806f9720505dad5bd5eb60efb`
- 规模:315 个非依赖文件,约 32,362 行代码;TypeScript/TSX 为主。
- 当前工作树在审查前已有 10 个 `docs/superpowers/**` 文件处于删除状态;这是已有用户工作,本计划不得恢复、覆盖或顺带提交这些删除。
- 本轮只做审查和文档,不实现业务代码、不重启生产容器、不向 Telegram 发送测试消息。
### 1.1 已完成的无损验证
| 检查 | 真实结果 |
|---|---|
| Backend tests | `220 passed, 0 failed` |
| Backend typecheck | 通过 |
| Backend build | 通过,`dist/index.js` 约 909.7 KiB |
| Frontend tests | `68 passed, 0 failed` |
| Frontend lint | 通过 |
| Frontend build | 通过 |
| Compose config | `docker compose config --quiet` 通过 |
| 运行状态 | frontend/backend/postgres 均 healthy |
| HTTP 探针 | `/livez=200`、`/readyz=200`、前端根页面 `200` |
| 数据一致性抽查 | 当前无 pending Telegram/yt-dlp/chunk reconciliation |
| Backend npm audit | **失败:2 个 high transitive advisories** |
| Frontend npm audit | 0 vulnerabilities |
### 1.2 证据等级
- **已验证缺陷**:通过可重复命令、实际响应、哈希或确定性源码数据流证实。
- **静态高可信风险**:源码路径明确,但未在生产账号/有状态页面执行破坏性复现。
- **产品增强**:不是现有 bug,按用户价值和实现风险排期。
---
## 2. 当前 Telegram Bot 能力盘点
现有 Bot 已经不是简单上传脚本,具备较好的安全和持久任务基础:
1. **认证与安全**
- `/start` PIN 键盘、允许用户列表、错误锁定、TOTP 2FA。
- 已认证用户持久化至 `telegram_auth`。
- 危险删除、任务取消、订阅取消有二次确认或一次性令牌。
2. **文件接收与归档**
- 私聊/群聊发送文件、相册聚合、文件命名、自动目录规则。
- `/p`、`/ps`、`/pc` 与 `/path_rules`。
- 重复文件策略、临时磁盘水位、并发控制。
3. **任务中心**
- `/tasks`、任务详情、暂停、继续、取消、失败重试。
- 普通 Bot 任务、频道任务、yt-dlp 任务有统一展示适配层。
4. **频道/群组能力**
- 按日期、标签抓取;可包含评论区文件。
- 订阅同步、专属目录、订阅健康信息、恢复与游标控制。
5. **yt-dlp**
- 单 URL 下载、持久队列、阶段进度、取消、重试和对账。
6. **存储**
- 本地、OneDrive、Google Drive、S3、OSS、WebDAV。
- `/storage`、`/storage_switch`、存储冷却与配额保护。
这些能力应被保留;后续增强不应重新发明任务队列、存储写入或确认协议。
---
## 3. 审查发现与优先级
### 3.1 优先级定义
- **P0**:可能把文件写到错误目标、造成安全边界错误,或当前发布基线不可证明;增加功能前必须解决。
- **P1**:明显影响日常正确性、Bot 可用性或关键交互;首个迭代解决。
- **P2**:重要体验、维护性和可访问性;主功能完成前解决。
- **P3**:优化项,可进入后续小版本。
### 3.2 Telegram Bot 已验证缺陷 / 风险
#### BOT-01 · P0 · 频道向导确认的存储快照会被当前全局目标覆盖
**证据:**
- `backend/src/services/telegramBot.ts:619-654` 在向导确认前记录 `targetProvider/targetAccountId`。
- `backend/src/services/telegramBot.ts:600-611` 将快照传入 enqueue options。
- `backend/src/services/telegramChannelJobs.ts:1458-1469`、`1497-1512` 把 options 放入 job params。
- 但 `backend/src/services/telegramChannelJobs.ts:631-637` 的 `createJob()` 先展开 params,再无条件用 `storageManager.getActiveTarget()` 覆盖 `storageProvider/storageAccountId`。
- 静态紧凑复现已确认“supplied target overwritten=true”。
**影响:** 用户在确认卡看到存储 A;如果确认期间系统默认被切换,持久任务可能实际锁定并写入 B。多用户或 Web/Bot 同时切换默认存储时风险更高。
**修复原则:** `createJob` 必须消费调用方提供的不可变 `StorageTargetSnapshot`,不得再次读取全局默认;仅没有明确快照的旧入口才在 admission 时获取一次默认值。
#### BOT-02 · P1 · 日期解析接受不存在的日期并自动滚动
**证据:**
- `backend/src/services/telegramBot.ts:350-352` 只验证 `YYYY-MM-DD` 形状。
- `backend/src/services/telegramChannelJobs.ts:612-628` 直接使用 `Date.UTC`,没有反向核对年月日。
- 实际执行:`2026-02-29 => 2026-03-01`,`2026-99-99 => 2034-06-07`。
**影响:** 用户输入错误日期时不会立刻收到错误,而可能扫描完全不同的时间段。
**修复原则:** 严格解析并把 UTC 年/月/日反向比较;校验开始日期不晚于结束日期;大范围扫描显示天数和二次确认。
#### BOT-03 · P1 · 频道向导仅按 userId 存状态,未绑定聊天
**证据:**
- `backend/src/services/telegramBot.ts:133` 使用 `Map<number, TelegramWizardState>`。
- `startTelegramWizard` / `handleTelegramWizardMessage` 以 `senderId` 读写,没有 chat key、起始消息或过期时间。
**影响:** 同一授权用户在私聊和群聊同时操作时,A 聊天启动的向导可被 B 聊天的普通文本推进;确认和反馈可能出现在错误会话。
**修复原则:** 状态 key 至少绑定 `userId + canonicalChatId`,并记录 origin message、创建时间、过期时间;新向导显式替换同聊天旧向导,不能覆盖另一聊天状态。
#### BOT-04 · P1 · Web 修改允许列表后,广播缓存未立即收敛
**证据:**
- `backend/src/routes/storage.ts:291-304` 只更新 `telegram_allowed_user_ids`。
- `backend/src/services/telegramState.ts:19` 的 `authenticatedUsers` 没有在该路由执行后统一重载/剔除。
- 普通命令在下一次 `isAuthenticatedAsync()` 时会安全撤销,但 `backend/src/services/telegramBot.ts:2020-2024` 的安全广播和 `1338-1344` 的清理通知直接遍历缓存。
**影响:** 已从允许列表移除、但尚未再次与 Bot 交互的用户仍可能收到登录安全通知或维护通知。
**修复原则:** 保存允许列表和认证缓存收敛应成为一个原子服务;广播收件人必须重新与当前 allowlist 求交集。
#### BOT-05 · P1 · Bot 初始化失败被吞掉,Web readiness 仍可能显示 ready
**证据:**
- `backend/src/services/telegramBot.ts:2008-2010` 捕获启动异常后仅记录日志,不向调用方抛出。
- `backend/src/index.ts:187-193` 等待 `initTelegramBot()` 后直接把应用标记 ready。
**影响:** Telegram 凭证已配置但失效时,Web/API 健康,Bot 实际不可用;监控无法区分“Bot 未启用”和“Bot 配置失败”。
**修复原则:** 输出结构化 Bot component health;新增 `TELEGRAM_REQUIRED` 策略。required 时初始化失败阻止 readiness;optional 时 Web 可运行,但 `/readyz`/管理页必须明确标记 degraded 和恢复动作。
#### BOT-06 · P2 · “更多能力”固定截断 12 项,10 个命令不可达
**证据:**
- `backend/src/services/telegramBot.ts:52-58` 使用 `visible.slice(0, 12)`,无分页。
- 当前 registry 中可展示 22 项;实测隐藏:`task_resume`、`task_cancel`、`tg_retry`、`stop_tasks`、`download_workers`、`file_concurrency`、`duplicate_mode`、`cleanup_settings`、`tg_subs`、`tg_unsub`。
**影响:** 首页宣称“更多能力”,但近半功能只能知道命令后手工输入。
**修复原则:** 按“文件 / 任务 / 频道 / 设置 / 安全”分组或分页;按钮应直接打开对应面板,而不是只回复“请发送命令”。
#### BOT-07 · P2 · 临时会话 Map 缺少统一 TTL/清理
**证据:**
- `telegramWizardStates`、`passwordInputState`、`userStates`、若干 pending confirmation Map 分散在 `telegramBot.ts`、`telegramCommands.ts`。
- 部分确认有 TTL,但向导/PIN/2FA 输入状态没有统一过期策略。
**影响:** 长期运行后可能保留僵尸交互;用户数和失败操作多时产生内存与误路由风险。
**修复原则:** 建立一个有 TTL、作用域和容量上限的 interaction store;所有交互都必须可取消、可过期、可在重启后明确失效。
### 3.3 Web 已验证缺陷 / 风险
#### WEB-01 · P1 · 重命名失败仍调用浏览器原生 alert,现有测试漏检
**证据:**
- `frontend/src/App.tsx:849-883` 两处使用裸 `alert(...)`。
- `frontend/src/services/nativeDialogContract.test.ts:17` 只匹配 `window.alert/window.confirm/window.prompt`。
- 静态紧凑检查对 `App.tsx` 返回 FAIL。
**影响:** 与产品对话框风格不一致、不可控制焦点/本地化;测试名称声称覆盖全部产品流程但实际是假阳性。
#### WEB-02 · P1 · 频道任务 stage 把 `done` 误判为 `scanning`
**证据:**
- `backend/src/services/telegramChannelJobs.ts` 使用 `scan_status='done'`。
- `backend/src/routes/tasks.ts:103` 却判断 `row.scan_status !== 'completed' ? 'scanning' : 'downloading'`。
**影响:** Web 任务中心可显示“已完成 + 扫描消息”的矛盾状态。
**修复原则:** 建立共享的 channel job → unified task mapper,集中映射 `pending/scanning/done/cancelled`,由 Bot 和 Web 共用纯函数测试。
#### WEB-03 · P2 · 搜索变化可能触发两条重复列表请求
**证据:**
- `frontend/src/App.tsx:278-331` 的 `loadFiles` 随 query options 改变。
- `390-397` 和 `399-404` 两个 effect 都依赖 `loadFiles` 并调用它。
- `LatestRequest` 会中止旧 generation,因此一般不提交旧数据,但仍产生重复请求、loading 抖动和数据库压力。
**修复原则:** 合并为一个数据加载 effect;搜索输入做 200–300 ms debounce;刷新使用独立 generation,不通过重复 effect 触发。
#### WEB-04 · P2 · 预览内下载失败只写 console,用户无反馈
**证据:**
- `frontend/src/components/ui/PreviewModal.tsx:49-54`、`249-256` 捕获下载错误后只 `console.error`。
**影响:** 点击下载无反应时用户无法知道是登录过期、云盘限额、源文件删除还是浏览器阻止。
**修复原则:** 复用统一通知组件和媒体状态分类;保留重试、复制错误 ID/请求 ID。
#### WEB-05 · P2 · 模态框焦点和语义不统一
**证据:**
- `PreviewModal` 主覆盖层没有 `role="dialog"/aria-modal`,只有内层详情卡具备语义。
- 顶部“查看原始文件 / 下载 / 关闭”部分图标按钮缺少一致 `aria-label`。
- `ConfirmDialog`、`CreateFolderModal`、`RenameModal`、`TasksPage` 内联对话框没有统一焦点陷阱、Escape、焦点归还和异步提交锁。
- `frontend/index.html:2` 固定 `lang="en"`,运行语言切换没有同步 document language。
**修复原则:** 建立一个共享 Dialog primitive,并以键盘/屏幕阅读器 E2E 验收。
### 3.4 部署、依赖与交付问题
#### OPS-01 · P0 · 当前运行前端与当前源码构建不一致
**证据:**
- 当前运行容器引用 `/assets/index-4Hc25KGO.js`。
- 当前源码构建输出 `/assets/index-CNfpF-0L.js`。
- 容器 `index.html` 与本机构建 `index.html` SHA-256 不同。
- Backend 运行 bundle 与本次本地 build 哈希一致,但镜像仍标记 `tg-vault-backend:v2.0.0`,包内版本为 `2.0.1`。
**影响:** 直接在生产页面验证源码修复会得到错误结论;回滚与审计无法只凭镜像标签判断实际代码。
#### OPS-02 · P1 · Backend 本地质量门当前被 2 个 high 依赖告警阻断
- `ip-address@10.2.0`:多项 SSRF / special-use address classification advisory。
- `brace-expansion@2.1.2`:DoS advisory。
- 路径分别来自 `express-rate-limit`/`telegram`/`socks` 和 `webdav`/`minimatch`。
必须升级 lockfile 并运行网络安全回归,而不是简单忽略 audit。
#### OPS-03 · P1 · `deploy/install.sh` 生成的 `.env` 缺少 Compose 必填项
**证据:**
- `deploy/install.sh:16-24` 未写 `IMAGE_VERSION`、`OAUTH_CALLBACK_BASE_URL`、`OAUTH_FRONTEND_ORIGIN`。
- `docker-compose.yml:6,38` 要求 `IMAGE_VERSION` 必须存在。
**影响:** 新用户按脚本编辑域名后再次运行,可能在 `docker compose config` 直接失败;OAuth 配置也不完整。
#### OPS-04 · P2 · 首装 SQL 与运行时 schema 双源漂移
- 根 `init.sql` 只有 4 张基础表(88 行)。
- `backend/src/db/schema.sql` 有 19 张业务表及大量 `ALTER`(498 行)。
- 实际升级依赖 Backend 启动时执行后者。
现状可运行,但迁移来源不清晰,增加新 Bot 表时容易只改一边。
---
## 4. 目标产品形态
### 4.1 Bot 首页
首页不是“命令大全”,而是 6 个直接可操作入口:
1. **上传与保存位置**
2. **文件浏览 / 搜索**
3. **任务中心**
4. **频道归档 / 订阅**
5. **链接下载**
6. **存储与系统状态**
所有二级页面都提供“返回首页、刷新、取消当前向导”,不要求用户记忆命令。
### 4.2 最有价值的新增能力
| 优先级 | 功能 | 用户价值 |
|---|---|---|
| P1 | 每聊天/每会话存储目标 | 不再为一次上传改全局默认;避免多人互相影响 |
| P1 | `/find` 文件搜索与操作卡 | 在 Telegram 内按名字、类型、目录、时间找到文件并复制 ID、分享、收藏、移动、删除 |
| P1 | 可操作上传回执 | 成功后显示目标、目录、文件 ID;提供“查看文件、复制 ID、删除、重试失败项” |
| P1 | Bot `/status` 诊断面板 | 一眼看到 Bot、用户账号下载器、数据库、当前存储、配额、磁盘、队列、订阅健康 |
| P1 | 订阅运行中心 | 立即同步、暂停/恢复、从现在/上次游标/指定日期补抓、固定目标、失败提醒 |
| P2 | 智能链接下载向导 | 直接粘贴 URL 后预览标题、质量、音频/视频、目标目录,再确认提交 |
| P2 | 通知偏好与安静时段 | 避免批量任务刷屏;失败即时提醒、成功可汇总 |
| P2 | 用户/聊天偏好 | 语言、时区、默认目录、默认目标、通知方式按用户或聊天隔离 |
| P3 | playlist/range 下载 | 对播放列表选择范围、最大条目和预算,避免误下载整个列表 |
---
## 5. 实施阶段
## Phase 0 — 建立可证明的发布基线(先做,预计 1–2 个工程日)
### Task 0.1:隔离当前工作树
**Files:** 无业务文件修改。
1. 记录 `git status --short --branch`。
2. 不恢复现有 10 个删除文件。
3. 推荐在独立 worktree/分支开发,或先由仓库所有者决定这些删除是否属于下一提交。
4. 保存当前生产镜像 ID、数据库/文件卷备份和 Compose 配置快照。
**验收:** 新分支只包含计划内改动;`git diff --name-status` 不混入旧文档删除。
### Task 0.2:修复依赖质量门
**Files:**
- Modify: `backend/package.json`
- Modify: `backend/package-lock.json`
- Test: `backend/src/utils/networkSecurity.test.ts`(如需新增)
- Test: `backend/src/services/webdavTimeout.test.ts`
1. 在临时分支执行 `npm audit fix --package-lock-only` 或精确升级上游包。
2. 明确解析 `ip-address` 与 `brace-expansion` 的最终版本和依赖路径。
3. 加强 `assertPublicHttpUrl/assertPublicStorageEndpoint` 对 IPv4 leading zero、CIDR suffix、IPv4-mapped IPv6、NAT64、重绑定结果的测试。
4. 运行全量 backend tests/typecheck/build/audit。
**验收:** `npm audit --omit=dev --audit-level=high` 退出 0;网络边界测试通过。
### Task 0.3:让构建产物可追溯
**Files:**
- Modify: `deploy/install.sh`
- Modify: `deploy/DEPLOY.md`
- Modify: `docker-compose.yml`(只在需要时)
- Test: `backend/src/services/reproducibleBuildContract.test.ts`
1. 安装脚本生成完整必填配置:`IMAGE_VERSION`、OAuth origins。
2. 本地/CI build 必须显式传 `SOURCE_REVISION` 和 `SOURCE_VERSION`。
3. 发布标签、包版本、OCI label 三者一致。
4. 部署后比较运行容器与预期前端入口 asset、backend bundle revision。
**验收:** `docker inspect` 能得到真实 commit/version;运行页面加载的新 asset 与 staging build 一致。
---
## Phase 1 — Bot 正确性与安全边界(预计 3–5 个工程日)
### Task 1.1:修复频道任务存储目标快照
**Files:**
- Modify: `backend/src/services/telegramChannelJobs.ts:631-655, 1449-1514`
- Modify: `backend/src/services/telegramBot.ts:591-654`
- Test: `backend/src/services/telegramBatchConfirmation.test.ts`
- Test: `backend/src/services/storageTargetSnapshot.test.ts`
**接口建议:**
```ts
async function createJob(input: {
userId: number;
chatId?: string;
kind: string;
source: string;
params: Record<string, unknown>;
target: StorageTargetSnapshot;
}): Promise<string>
```
**TDD 步骤:**
1. 写失败测试:向导快照为 account A;在 create 前把 active target 切到 B;断言持久 job 仍为 A,锁也作用于 A。
2. 运行 focused test,确认 RED。
3. 删除 `createJob()` 内第二次读取 active target 的行为。
4. 对 direct command 入口在 admission 点显式捕获一次 target。
5. 对恢复路径断言只读取持久 params,不读取当前默认。
6. 全量测试。
**验收:** 确认卡目标、job params、operation lease、文件 `storage_account_id` 四者一致。
### Task 1.2:严格日期与范围校验
**Files:**
- Modify: `backend/src/services/telegramChannelJobs.ts:612-629`
- Modify: `backend/src/services/telegramBot.ts:326-347, 631-654`
- Create/Test: `backend/src/services/telegramDateRange.test.ts`
1. `parseDateOnly` 校验 round-trip 年月日。
2. 拒绝不存在日期、结束早于开始、空范围。
3. 在确认卡显示总天数;超过建议阈值显示风险提示。
4. direct command 与向导共用同一个 parser。
**验收样例:**
- `2026-02-29` → 拒绝。
- `2024-02-29` → 接受。
- `2026-99-99` → 拒绝。
- 结束早于开始 → 拒绝,不创建 job。
### Task 1.3:建立 chat-bound interaction store
**Files:**
- Create: `backend/src/services/telegramInteractionState.ts`
- Create/Test: `backend/src/services/telegramInteractionState.test.ts`
- Modify: `backend/src/services/telegramBot.ts`
- Modify: `backend/src/services/telegramCommands.ts`
**状态至少包含:** `userId`、`chatKey`、`kind`、`step`、`originMessageId`、`createdAt`、`expiresAt`。
1. 先迁移频道下载/订阅向导。
2. 再迁移 PIN、TOTP 和路径输入提示。
3. 默认 TTL 15 分钟;容量有上限;定期清理。
4. 所有向导支持“取消”和“重新开始”;过期时给明确提示。
5. callback 必须绑定用户、聊天、消息和动作。
**验收:** A 聊天的文本不能推进 B 聊天状态;重启后旧按钮返回“已失效,请重新打开”,不静默失败。
### Task 1.4:允许列表、认证缓存与广播收敛
**Files:**
- Modify: `backend/src/utils/authSettings.ts`
- Modify: `backend/src/services/telegramState.ts`
- Modify: `backend/src/routes/storage.ts:291-309`
- Modify: `backend/src/services/telegramBot.ts:2013-2027`
- Test: `backend/src/services/telegramAuthAllowlist.test.ts`
1. 创建 `setTelegramAllowedUsersAndReconcile()`。
2. 写入 allowlist 后立即从 cache/DB auth 中剔除移除用户。
3. 广播前重新读取有效收件人交集。
4. Web 返回 added/removed/revoked 数量,并提示移除立即生效。
5. 增加 Bot `/logout` 或“撤销本设备 Bot 认证”。
**验收:** 被移除用户不能执行命令,也不再收到安全/维护广播。
### Task 1.5:Bot component health 与可观测性
**Files:**
- Create: `backend/src/services/telegramBotStatus.ts`
- Modify: `backend/src/services/telegramBot.ts`
- Modify: `backend/src/index.ts:179-193`
- Modify: `backend/src/routes/storage.ts`
- Modify: `.env.example`, `docker-compose.yml`
- Test: `backend/src/services/telegramUserClientStatus.test.ts`
- Create/Test: `backend/src/services/telegramBotReadiness.test.ts`
1. 状态:`not_configured/starting/ready/reconnecting/auth_failed/error/stopped`。
2. 记录 `checkedAt/lastConnectedAt/lastError/action`,不包含 token/session。
3. 增加 `TELEGRAM_REQUIRED`;required 时失败阻止 ready,optional 时暴露 degraded。
4. `/status` 和 Web Telegram 设置页消费同一状态。
5. 把 TIMEOUT/reconnect 转为计数和最近恢复时间,不刷大量原始日志。
**验收:** 配置错误不会显示“Bot ready”;临时重连恢复后状态自动回 ready。
---
## Phase 2 — Bot 实用功能第一批(预计 5–8 个工程日)
### Task 2.1:命令 dispatcher 与可分页首页
**Files:**
- Modify: `backend/src/utils/telegramCommandRegistry.ts`
- Create: `backend/src/services/telegramCommandDispatcher.ts`
- Create/Test: `backend/src/services/telegramCommandDispatcher.test.ts`
- Modify: `backend/src/services/telegramBot.ts`
1. registry 增加 handler key、权限、feature requirement、菜单分组。
2. dispatcher 统一 alias、认证、限流、错误消息和 tracing。
3. 首页按类别展示,支持翻页;不再 `slice(0,12)`。
4. 按钮直接调用面板 handler,而非回复“请发送 /xxx”。
5. 保持所有旧命令 alias 兼容。
**验收:** registry 中所有 `help=true` 的命令均可从首页在不记命令的情况下到达;callback data < 64 bytes。
### Task 2.2:每聊天/下一次存储目标
**Files:**
- Create: `backend/src/utils/telegramTargetStateStore.ts`
- Create/Test: `backend/src/utils/telegramTargetStateStore.test.ts`
- Modify: `backend/src/db/schema.sql`
- Modify: `backend/src/services/telegramCommands.ts`
- Modify: `backend/src/services/telegramUpload.ts`
- Modify: `backend/src/services/ytDlpDownload.ts`
- Modify: `backend/src/services/telegramChannelJobs.ts`
**命令/面板:** `/target`、下一次、当前聊天持续、清除、管理员全局默认。
**核心规则:**
- “下一次”必须像 `/p` 一样原子消费。
- 普通文件、相册、频道任务、yt-dlp 在 admission 时固化同一个 target snapshot。
- 切换全局默认只影响没有 chat override 的后续任务。
- 删除存储账户前检查 target state 引用。
**验收:** 两个聊天可同时使用不同账户;切换全局默认不改变已排队任务。
### Task 2.3:Telegram 文件浏览 / 搜索 / 操作卡
**Files:**
- Refactor/Create: `backend/src/services/fileReadModel.ts`
- Modify: `backend/src/services/fileQuery.ts`
- Create: `backend/src/services/telegramFileBrowser.ts`
- Create/Test: `backend/src/services/telegramFileBrowser.test.ts`
- Modify: `backend/src/utils/telegramCommandRegistry.ts`
**功能:**
- `/find <关键词>`、按类型、收藏、目录、日期筛选。
- keyset 分页,不使用无限 OFFSET。
- 详情卡:名称、大小、来源、目录、创建时间、短 ID。
- 操作:复制 ID、收藏/取消、生成 Web/签名链接、移动/重命名、删除确认。
- 所有 provider capability 由后端 contract 决定。
**验收:** 201+ 文件分页无重复/遗漏;每个危险操作绑定 actor/chat/message/object/TTL。
### Task 2.4:可操作上传回执
**Files:**
- Modify: `backend/src/utils/telegramMessages.ts`
- Modify: `backend/src/services/telegramUpload.ts`
- Modify: `backend/src/services/telegramTaskCenter.ts`
- Test: `backend/src/services/telegramProgressPresentation.test.ts`
1. 入队后 1 秒内给单一状态卡。
2. 成功回执显示实际存储、目录、文件 ID、duplicate outcome。
3. 批量任务只编辑一张卡,失败项可“重试失败 / 查看失败明细”。
4. 提供“搜索同目录、复制 ID、删除该文件”快捷按钮。
5. 9+ 文件继续静默模式,不刷屏。
**验收:** 用户无需打开日志即可知道文件是否保存、保存到哪里、失败后做什么。
### Task 2.5:Bot `/status` 诊断面板
**Files:**
- Create: `backend/src/services/telegramStatusPanel.ts`
- Modify: `backend/src/services/telegramCommands.ts`
- Modify: `backend/src/utils/telegramCommandRegistry.ts`
- Test: `backend/src/services/telegramStatusPanel.test.ts`
显示:Bot 连接、账号级下载器、当前 target、远端 probe/cooldown、临时磁盘、队列、失败任务、订阅最近扫描、待对账数量。
**安全:** 不显示绝对路径、凭证、token、完整 provider error;提供 request/operation ID。
---
## Phase 3 — Bot 实用功能第二批(预计 5–8 个工程日)
### Task 3.1:智能 yt-dlp 向导
**Files:**
- Modify: `backend/src/services/ytDlpDownload.ts`
- Create: `backend/src/services/ytDlpProbe.ts`
- Create/Test: `backend/src/services/ytDlpProbe.test.ts`
- Modify: `backend/src/services/telegramBot.ts`
1. 普通粘贴单一公网 URL 时提示“作为链接下载”,不自动执行。
2. 用受限 `yt-dlp --dump-single-json --skip-download` 预取标题/时长/站点;设置超时、输出上限和 AbortSignal。
3. 选项:最佳视频、音频、目标目录、target snapshot。
4. playlist 默认拒绝;用户明确开启后要求范围和最大条目。
5. Cookie 只能在 Web 端加密配置,绝不通过聊天文本输入或回显。
6. 继续保留 `--` 参数边界、SSRF 校验、持久执行 generation/lease。
**验收:** 无确认不下载;超大播放列表不能误触;取消可终止子进程并清理目录。
### Task 3.2:订阅运行中心
**Files:**
- Modify: `backend/src/services/telegramChannelJobs.ts`
- Modify: `backend/src/services/telegramSubscriptionManagement.ts`
- Modify: `backend/src/db/schema.sql`
- Modify: `backend/src/services/telegramBot.ts`
- Test: `backend/src/services/telegramSubscriptionManagement.test.ts`
新增:立即同步、暂停、恢复、修改 target、从现在开始、按日期补抓、查看最近结果、失败项重试。
订阅 target 必须有明确模式:
- `follow_global`:每次扫描 admission 时快照当前默认。
- `fixed`:固定 provider/account,删除账户时阻止或要求迁移。
**验收:** 每个订阅能回答“上次何时成功、下次何时扫描、为什么暂停、文件写到哪里”。
### Task 3.3:通知偏好、安静时段与摘要
**Files:**
- Create: `backend/src/services/telegramNotificationPreferences.ts`
- Create/Test: `backend/src/services/telegramNotificationPreferences.test.ts`
- Modify: `backend/src/db/schema.sql`
- Modify: `backend/src/services/telegramBot.ts`
支持按用户/聊天设置:失败即时、成功即时/摘要、安全通知、订阅摘要、时区、quiet hours。
**验收:** 安全告警不被静音;普通成功消息按策略汇总;跨时区测试使用固定时钟。
---
## Phase 4 — Web 逻辑与交互修复(预计 3–5 个工程日)
### Task 4.1:消除原生对话框与测试盲点
**Files:**
- Modify: `frontend/src/App.tsx:849-883`
- Modify: `frontend/src/services/nativeDialogContract.test.ts`
- Modify: `frontend/src/components/ui/RenameModal.tsx`
1. 使用 Notification/产品 Dialog 显示重命名错误。
2. 测试同时拒绝 `alert(`、`window.alert(`、`globalThis.alert(` 及 confirm/prompt。
3. 重命名提交期间禁用按钮,错误保留输入与焦点。
### Task 4.2:统一任务状态 mapper
**Files:**
- Create: `backend/src/services/unifiedTaskMapper.ts`
- Create/Test: `backend/src/services/unifiedTaskMapper.test.ts`
- Modify: `backend/src/routes/tasks.ts`
- Modify: `backend/src/services/telegramTaskCenter.ts`
验收 `scan_status=done` 不显示 scanning;API 的 `total` 表示真实过滤总数,另提供 `returned` 或分页游标。
### Task 4.3:合并列表请求与搜索 debounce
**Files:**
- Create: `frontend/src/hooks/useFileQuery.ts`
- Modify: `frontend/src/App.tsx:194-404`
- Test: `frontend/src/services/latestRequest.test.ts`
- Create/Test: `frontend/src/services/fileQueryEffect.test.ts`
**验收:** 每次 settled query 只产生一组 files + aggregation 请求;快速输入只提交最后查询;刷新不重置当前列表直到新结果成功。
### Task 4.4:统一用户可见错误
**Files:**
- Modify: `frontend/src/components/ui/PreviewModal.tsx`
- Modify: `frontend/src/components/ui/FileCard.tsx`
- Modify: `frontend/src/services/api.ts`
下载、复制、打开原文件、分享失败都显示可操作通知;401 统一退出,429 显示 retry time,410 显示源已删除,503 显示稍后重试和 request ID。
### Task 4.5:共享 Dialog 与可访问性
**Files:**
- Create: `frontend/src/components/ui/Dialog.tsx`
- Modify: `ConfirmDialog.tsx`, `CreateFolderModal.tsx`, `RenameModal.tsx`, `MoveModal.tsx`, `PreviewModal.tsx`, `TasksPage.tsx`, `SettingsPage.tsx`
- Modify: `frontend/src/i18n.ts`
- Test: component/E2E accessibility tests
**验收:**
- Tab 不离开 dialog;Escape 关闭允许关闭的 dialog;关闭后焦点回到触发器。
- icon button 有 accessible name。
- `document.documentElement.lang` 随语言切换。
- `prefers-reduced-motion` 生效。
- 移动端 360px 无横向裁切。
---
## Phase 5 — 结构收敛与发布(预计 2–3 个工程日)
### Task 5.1:拆分超大模块但不重写领域协议
建议目标:
```text
backend/src/bot/
├── connection.ts
├── dispatcher.ts
├── context.ts
├── interactions.ts
├── callbacks/
├── commands/
│ ├── home.ts
│ ├── files.ts
│ ├── tasks.ts
│ ├── channels.ts
│ ├── storage.ts
│ └── security.ts
└── presentation/
```
- `telegramUpload.ts` 保留下载/存储领域逻辑,逐步抽出 presentation 和 in-memory tracking。
- `telegramChannelJobs.ts` 保留持久 job/lease/cursor 协议,抽出纯 mapper 和扫描器。
- 每次仅移动一个边界并保持 tests green;禁止一次大爆炸重写。
### Task 5.2:迁移单一来源
1. 新增版本化 migration ledger(例如 `schema_migrations`)。
2. `init.sql` 只负责调用/镜像 canonical schema,或从构建时同一源生成。
3. 所有 migration 使用 expand → backfill → contract;旧镜像至少能读取新 schema。
4. migrations 在 staging 的生产备份副本上演练。
### Task 5.3:发布候选验证
必须全部通过:
```bash
cd backend
npm ci
npm audit --omit=dev --audit-level=high
npm test
npm run typecheck
npm run build
cd ../frontend
npm ci
npm audit --omit=dev --audit-level=high
npm test
npm run lint
npm run build
cd ..
docker compose config --quiet
bash deploy/backup-restore.test.sh
python3 backend/scripts/verify_db_optimizations.py
python3 backend/scripts/verify_private_invite_support.py
python3 backend/scripts/verify_telegram_task_queue_status.py
```
发布前还要在**独立测试 Bot token、测试 Telegram 账号、隔离数据库/卷**上跑手工 smoke,不能让 staging 和生产同时使用同一个 Bot token/session。
---
## 6. 验收矩阵
| 场景 | 预期 |
|---|---|
| 确认频道任务后切换全局存储 | 任务仍写入确认卡显示的目标 |
| 两个聊天同时启动不同向导 | 状态互不推进/覆盖 |
| 输入 2026-99-99 | 立即拒绝,不创建任务 |
| 从 Web allowlist 移除用户 | 命令立即拒绝,广播也不再发送 |
| Bot token 无效且 required | `/readyz=503`,给恢复动作 |
| Bot optional 且连接失败 | Web 可用,但状态明确 degraded |
| “更多能力”页面 | 22 项都可到达,不截断 |
| 单聊设置 target A、群聊 target B | 并行任务分别写入 A/B |
| 搜索 201+ 文件 | keyset 分页无重复/遗漏 |
| 相册 20 项,3 项失败 | 一张状态卡;可只重试 3 项 |
| yt-dlp 粘贴 playlist | 默认只预览并要求范围,不自动全量下载 |
| Web 快速输入 10 个字符 | 只提交最后稳定 query;无旧数据覆盖 |
| 频道 job `scan_status=done` | Web 不显示“扫描中” |
| 键盘操作所有 modal | 焦点受控、Escape/返回一致 |
| Backend 重启 | 持久频道/yt-dlp 恢复;普通 in-memory 任务明确标 interrupted |
| 写入后 DB 失败 | 有 reconciliation 证据,不宣称成功 |
---
## 7. 风险与取舍
1. **不要把所有普通 Bot 上传立刻改成完全数据库队列。** 先保留现有 transfer snapshot + in-memory execution,避免一次性重写高风险下载路径。
2. **不要让 per-chat target 复用全局 `storageManager.switchAccount()`。** override 是 admission 输入,不是修改系统默认。
3. **不要为了“人性化”发送更多消息。** 默认编辑一张状态卡,批量成功做摘要,失败才突出提醒。
4. **不要在 Telegram 收 Cookie、密码或云盘凭证。** 敏感配置只经 Web、加密存储并有审计。
5. **不要仅依赖静态正则测试。** 对 dispatcher、mapper、state store 写纯行为测试;对 Web 关键旅程增加真实浏览器 E2E。
6. **不要用同一个 Telegram Bot token 做蓝绿双实例。** 没有 update ownership 设计前,只允许单 active Bot consumer。
---
## 8. 产品决策(建议默认值)
| 问题 | 建议默认 |
|---|---|
| Bot 是否只支持单一管理员 | 保留单 Web 管理员,但允许多 Telegram 用户;新增 admin/member 角色后再开放全局设置 |
| 群聊是否允许向导 | 允许,但必须 chat-bound;危险操作只允许发起者完成 |
| 存储选择作用域 | 默认“当前聊天会话”,管理员可显式修改系统默认 |
| 订阅 target | 默认 follow-global;用户可改 fixed,并在确认卡明确展示 |
| Bot readiness | 生产 Telegram 部署设 `TELEGRAM_REQUIRED=true` |
| 2FA | 短期保留全局 TOTP;多用户正式化时迁移为每用户 TOTP/设备凭据 |
| 成功通知 | 单文件即时,批量/订阅摘要;失败即时 |
| playlist | 默认禁用;明确确认范围和最大条目后启用 |
---
## 9. 完成定义
“完成”不是代码合并,而是同时满足:
- 所有 P0/P1 缺陷有回归测试并修复。
- 新功能从 Bot 首页可发现,不要求阅读 README 才会用。
- 存储目标、权限、危险确认、租约、对账不变量不退化。
- Backend/Frontend tests、lint、typecheck、build、audit 全绿。
- 独立 staging Bot 完成验收矩阵。
- 有一致备份、显式镜像版本、source revision 和经过演练的回滚步骤。
- 生产部署后验证运行前端 asset、backend image label、`/readyz`、Bot `/status` 与关键命令。
@@ -0,0 +1,647 @@
# TG Vault 开发与运维交接步骤
**项目目录:** `/www/wwwroot/tg-vault`
**交接基线:** `main` / `6b2ee50a9e4aed7806f9720505dad5bd5eb60efb`
**交接日期:** 2026-08-24(CST, UTC+08:00)
**配套计划:** `.hermes/plans/2026-08-24_000159-tg-vault-bot-enhancement-plan.md`
> 本文用于让下一位开发者或 Agent 在不猜测、不破坏生产数据的情况下接手。本文不包含任何 `.env` 值、Telegram token、session 内容、数据库密码或存储凭证。
---
## 1. 接手前必须知道的事实
### 1.1 当前仓库状态
- Git 分支:`main`,与 `origin/main` 同步。
- 审查开始前已有 10 个 `docs/superpowers/**` 文件被删除且未提交。
- 这些删除不是本轮审查产生的;**不要恢复、覆盖、格式化或混入功能提交**。
- 本轮新增两份文档位于 `.hermes/plans/`;该目录此前不存在。
- `frontend/dist/` 被忽略;本地 build 会覆盖它,但不应提交。
- `backend/dist/index.js` 被显式跟踪;修改 backend 源码后是否提交 bundle 必须遵循仓库现有发布约定,不要自行假定。
首次接手先运行:
```bash
cd /www/wwwroot/tg-vault
git status --short --branch
git log -5 --oneline --decorate
git diff --name-status
```
**期望:** 除 `.hermes/plans` 新文档外,看到既有的 10 个删除;不要执行 `git reset --hard`、`git clean -fd` 或盲目 stash。
### 1.2 当前生产拓扑
```text
互联网用户 / Telegram
│
├── 宿主机 Nginx/面板(HTTPS,不在 Compose 内)
│ ├── Web 域名 → 127.0.0.1:47832
│ └── API 域名 → 127.0.0.1:51947
│
└── Docker Compose: tg-vault
├── frontend: nginx 静态 React SPA
├── backend: Node/Express + GramJS Bot + 用户账号 client
└── postgres: PostgreSQL
Named volumes:
tg-vault_file-storage → /data
tg-vault_postgres-data → PostgreSQL data
```
Compose 只含 `frontend`、`backend`、`postgres`;不要在 Compose 中寻找 Certbot 或宿主机 Nginx。
### 1.3 当前真实运行状态(交接时快照)
- `tg-vault-frontend`:healthy,镜像标签 `tg-vault-frontend:v2.0.0`
- `tg-vault-backend`:healthy,镜像标签 `tg-vault-backend:v2.0.0`
- `tg-vault-postgres`:healthy
- 包内前后端版本:`2.0.1`
- `/livez=200`、`/readyz=200`
- Telegram Bot 配置存在;用户账号 session 文件存在且权限为 `0600`。
- 当前生产数据库抽查:1 个 Telegram auth 用户、2 个已手动取消订阅、3 个历史频道 job、321 个成功下载 item;无 pending reconciliation。
- OCI labels 当前为 `revision=unknown`、`version=worktree`,镜像标签与包版本不一致。
- 当前运行 frontend asset 是 `index-4Hc25KGO.js`;当前源码本地 build 产出 `index-CNfpF-0L.js`,两者不一致。
这些值是接手时快照,不要写入永久配置;部署前重新查询。
---
## 2. 代码地图
### 2.1 Backend 入口与基础设施
| 文件 | 职责 |
|---|---|
| `backend/src/index.ts` | Express、CORS/Origin、Helmet、路由挂载、初始化顺序、readiness、停机 |
| `backend/src/db/index.ts` | PostgreSQL pool、启动 schema 执行、SQL 日志 |
| `backend/src/db/schema.sql` | 当前 canonical 运行时 schema(19 张主要表) |
| `init.sql` | PostgreSQL 首次创建卷时执行的旧基础 schema(只有 4 张表) |
| `backend/src/utils/runtimeConfig.ts` | 环境变量验证与脱敏摘要 |
| `backend/src/utils/secretStore.ts` | `/data/secrets` 持久密钥 |
| `backend/src/utils/credentialCrypto.ts` | 第三方凭证加解密 |
### 2.2 Telegram Bot 核心
| 文件 | 职责 / 注意事项 |
|---|---|
| `backend/src/services/telegramBot.ts` | 连接、消息/回调路由、PIN/2FA、频道向导、订阅 UI;约 2030 行,后续应拆分 |
| `backend/src/services/telegramCommands.ts` | 文件/存储/任务/路径/危险操作命令;约 1886 行 |
| `backend/src/utils/telegramCommandRegistry.ts` | 菜单、help、alias 单一注册表 |
| `backend/src/utils/telegramMessages.ts` | 大部分 Bot 文案和进度展示 |
| `backend/src/services/telegramState.ts` | 已认证用户、PIN/2FA 内存状态 |
| `backend/src/services/telegramUpload.ts` | 普通文件、相册、下载队列、磁盘保护、存储写入/补偿;约 3040 行 |
| `backend/src/services/downloadTaskQueue.ts` | 普通下载的任务组调度器 |
| `backend/src/services/telegramTaskCenter.ts` | Telegram `/tasks` 纯展示/按钮模型 |
| `backend/src/services/transferTasks.ts` | ordinary Bot / yt-dlp 持久任务快照 |
| `backend/src/services/telegramChannelJobs.ts` | 日期/标签/订阅 job、child lease、恢复、游标、对账;约 2168 行 |
| `backend/src/services/telegramUserClient.ts` | 账号级 Telegram client/session |
| `backend/src/services/telegramUserClientStatus.ts` | 用户 client 可操作状态 |
| `backend/src/services/ytDlpDownload.ts` | yt-dlp 持久队列、子进程、上传、通知 |
| `backend/src/utils/telegramPathSettings.ts` | `/p`、`/ps`、最近目录、path panel |
| `backend/src/utils/telegramPathStateStore.ts` | `telegram_path_states` 持久协议 |
### 2.3 存储与一致性
| 文件 | 职责 |
|---|---|
| `backend/src/services/storage.ts` | 所有 provider 与 StorageManager |
| `backend/src/services/storageAccountLifecycle.ts` | 账户切换/删除锁和引用检查 |
| `backend/src/services/storageAccountOperation.ts` | provider 外部操作租约 |
| `backend/src/services/storageWrite.ts` | 永久写入和补偿 |
| `backend/src/services/storageTargetReadiness.ts` | active target fail-closed |
| `backend/src/services/telegramWriteReconciliation.ts` | Telegram 外部写对账 |
| `backend/src/services/chunkUploadReconciliation.ts` | 分块完成写入对账 |
| `backend/src/services/ytDlpExecution.ts` | yt-dlp generation/lease/journal |
### 2.4 Web 前端
| 文件 | 职责 / 注意事项 |
|---|---|
| `frontend/src/App.tsx` | 登录状态、路由、文件查询、上传队列、批量操作;约 1726 行 |
| `frontend/src/services/api.ts` | 全部 API client、上传协议;约 1207 行 |
| `frontend/src/components/pages/SettingsPage.tsx` | 设置/OAuth/安全/Telegram;约 2197 行 |
| `frontend/src/components/pages/TasksPage.tsx` | 统一任务中心与 5 秒轮询 |
| `frontend/src/components/ui/PreviewModal.tsx` | 图片/音视频预览、导航、下载 |
| `frontend/src/services/appRoute.ts` | Browser History 路由 |
| `frontend/src/services/latestRequest.ts` | 列表请求 generation/abort |
| `frontend/src/services/boundedUploadQueue.ts` | 有界上传队列 |
| `frontend/src/services/chunkUploadProtocol.ts` | 分块上传契约 |
---
## 3. 关键数据流
### 3.1 普通 Bot 文件 / 相册
```text
Telegram update
→ telegramBot.ts 认证/限流
→ telegramUpload.handleFileUpload
→ 固化 storage target + 解析 path rule
→ admitOrdinaryTransferTask (transfer_tasks)
→ DownloadTaskQueue
→ GramJS 下载到 /data 临时文件
→ storage operation lease
→ provider.saveFile
→ files 索引
→ 任务结算 / 失败补偿 / 回执
```
**不变量:** provider、account ID、folder 必须在 admission 时固化;外部对象与 `files` 索引不能只成功一边。
### 3.2 频道日期/标签/订阅
```text
Bot 向导/命令
→ resolve source + allowlist
→ telegram_background_jobs
→ 分段扫描
→ telegram_download_items
→ child claim + lease token + heartbeat
→ telegramUpload.downloadTelegramChannelRange
→ telegram_write_reconciliations journal
→ provider + files index
→ child settlement
→ parent final CAS
→ 订阅 cursor 单调推进(仅 subscription)
```
**不变量:** 旧 lease worker 不得结算新 lease;失败边界之前才可推进订阅 cursor;取消和暂停语义不同。
### 3.3 yt-dlp
```text
/ytdlp URL
→ public HTTP URL 校验
→ 固化 storage target
→ transfer_tasks(ytdlp)
→ PersistentYtDlpQueue
→ claim generation + lease
→ spawn yt-dlp(参数数组 + '--')
→ 选择主输出文件
→ ytdlp_write_reconciliations
→ provider + files index
→ settle / notify / cleanup
```
### 3.4 Web 上传
```text
React UploadZone
→ 小文件 XHR / 大文件 chunk protocol
→ target snapshot
→ chunk_upload_sessions + chunk metadata
→ completion CAS/heartbeat
→ provider + files index + compensation
→ TasksPage / recovered upload UI
```
---
## 4. 不可破坏的不变量
开发任何 Bot 新功能前必须阅读并保留:
1. **存储目标快照**:任务提交后不随全局 storage switch 改变。
2. **Storage account lease**:有外部副作用的操作持有租约,账户删除必须阻塞。
3. **Child lease/generation**:旧 worker 不能结算或覆盖新执行。
4. **Write-ahead reconciliation**:外部对象和 DB 索引不确定时留下持久证据。
5. **Pause vs cancel**:暂停阻止新 claim,活动文件可完成;取消才硬中断/补偿。
6. **危险操作确认**:绑定 actor/session、action、object、chat/message、TTL、一次消费。
7. **订阅 cursor**:只能在 parent finalization 同一事务内单调推进。
8. **Cookie auth**:Web 使用 HttpOnly cookie,不把 token 放回 localStorage。
9. **Secrets**:token/session/凭证不写日志、不回显、不进入 Bot 文本。
10. **当前工作树**:不把用户已有删除混入修复提交。
相关回归测试已经覆盖很多协议;不要因为“代码复杂”而绕过服务直接写 SQL/provider。
---
## 5. 已确认问题清单(接手顺序)
### 必须先解决(P0/P1)
1. **频道 job target 被覆盖**
- `telegramChannelJobs.ts:631-637`
- 详见实施计划 BOT-01。
2. **运行前端与当前源码 build 不一致;镜像不可追溯**
- 部署前先建立可证明 baseline。
3. **Backend `npm audit` 有 2 个 high transitive advisory**
- `ip-address@10.2.0`、`brace-expansion@2.1.2`。
4. **日期 parser 接受非法日期**
- `telegramChannelJobs.ts:612-628`。
5. **Bot 向导只按 userId,不按聊天作用域**
- `telegramBot.ts:133`。
6. **Web allowlist 更新不立即收敛认证广播缓存**
- `storage.ts:291-304` / `telegramState.ts` / `sendSecurityNotification`。
7. **Bot 初始化失败被吞,readiness 无法反映 Bot 失败**
- `telegramBot.ts:2008-2010` / `index.ts:187-193`。
8. **Web 裸 alert + 测试假阳性**
- `App.tsx:849-883` / `nativeDialogContract.test.ts:17`。
9. **统一任务 channel stage 映射错误**
- `routes/tasks.ts:103`;真实值是 `scan_status='done'`。
10. **install.sh 缺 Compose 必填变量**
- `deploy/install.sh:16-24`。
### 重要但可后置(P2)
- Bot “更多”只显示 12/22 命令。
- 临时 interaction maps 无统一 TTL。
- Web 重复列表请求。
- Preview 下载失败无用户反馈。
- Dialog/键盘/ARIA 不统一。
- `init.sql` 与 runtime schema 双源漂移。
---
## 6. 推荐开发流程
### 6.1 建独立 worktree(推荐)
当前主目录有用户删除,建议不要直接在这里开发:
```bash
cd /www/wwwroot/tg-vault
git fetch origin
git worktree add ../tg-vault-bot-next -b feat/bot-next main
cd ../tg-vault-bot-next
```
如果因环境原因必须在主目录开发,先由仓库所有者明确处理已有删除,然后按任务小提交;不要自动 stash/恢复。
### 6.2 每个缺陷遵循 RED → GREEN → REFACTOR
以 target snapshot 为例:
1. 在 `telegramBatchConfirmation.test.ts` 写能真实失败的行为测试。
2. focused 运行,确认测试因“快照被 B 覆盖”失败。
3. 最小修改 `createJob` 接口,去掉第二次读取 active target。
4. focused test 变绿。
5. 运行所有相关测试 + 全量 suite。
6. 独立 reviewer 检查 spec compliance 和 code quality。
7. 一个问题一个提交。
### 6.3 推荐提交拆分
```text
fix(bot): preserve confirmed storage target for channel jobs
fix(bot): validate Telegram date ranges strictly
fix(bot): bind interaction state to chat with TTL
fix(auth): reconcile Telegram allowlist and authenticated cache
feat(bot): expose component health and status panel
feat(bot): add scoped storage targets
feat(bot): add file browser and actionable receipts
fix(web): unify dialogs and task status mapping
chore(release): make local images traceable and version-aligned
```
不要把格式化、超大模块拆分和功能修复混为同一提交。
---
## 7. 本地验证命令
### 7.1 Backend
```bash
cd /www/wwwroot/tg-vault/backend
npm ci
npm audit --omit=dev --audit-level=high
npm test
npm run typecheck
npm run build
```
交接时真实基线:220 tests 通过;audit 当前因 2 high 退出 1。
Focused 示例:
```bash
npm run test:queue
npx tsx --test src/services/telegramBatchConfirmation.test.ts
npx tsx --test src/services/telegramAuthAllowlist.test.ts
npx tsx --test src/services/telegramTaskCenter.test.ts
npx tsx --test src/services/telegramSubscriptionManagement.test.ts
```
### 7.2 Frontend
```bash
cd /www/wwwroot/tg-vault/frontend
npm ci
npm audit --omit=dev --audit-level=high
npm test
npm run lint
npm run build
```
交接时真实基线:68 tests、lint、build 通过;audit 0 vulnerabilities。
### 7.3 静态运维检查
```bash
cd /www/wwwroot/tg-vault
docker compose config --quiet
bash deploy/backup-restore.test.sh
python3 backend/scripts/verify_db_optimizations.py
python3 backend/scripts/verify_private_invite_support.py
python3 backend/scripts/verify_large_batch_optimizations.py
python3 backend/scripts/verify_telegram_task_queue_status.py
git diff --check
```
注意:`backup-restore.test.sh` 使用 fake docker harness,不写生产卷;真正执行 `deploy/backup.sh` 会短暂停 backend,必须安排维护窗口。
---
## 8. Staging 设计
### 8.1 必须隔离的资源
- 单独 Telegram Bot token。
- 单独 Telegram 用户测试账号和 session。
- 单独 Compose project name、Postgres volume、file storage volume。
- 单独域名或本地端口。
- 测试用 S3/WebDAV/云盘目录或账户。
**严禁:** staging 与 production 同时消费同一个 Bot token 或同一 Telegram user session。
### 8.2 建议启动方式
使用独立目录/`.env.staging` 和不同项目名:
```bash
COMPOSE_PROJECT_NAME=tg-vault-staging \
docker compose --env-file .env.staging up -d --build
```
如果要改端口,用 overlay compose 文件,不直接修改生产 compose。
### 8.3 Staging smoke 清单
1. 首次认证、错误 PIN、锁定、2FA。
2. 单文件、相册、9+ 文件静默批量。
3. `/p`、`/ps`、per-chat target。
4. A/B 聊天并发向导隔离。
5. task pause/resume/cancel/retry。
6. 日期/标签任务、非法日期、范围确认。
7. 订阅立即同步、暂停、恢复、游标不重复。
8. yt-dlp preview/confirm/cancel/retry。
9. 存储切换期间已排队任务目标不变。
10. 模拟 provider 失败、quota cooldown、Bot reconnect。
11. Web 列表、搜索、预览、上传恢复、任务状态、OAuth。
12. 重启 backend 后检查持久任务和 reconciliation。
---
## 9. 数据库与迁移交接
### 9.1 当前迁移机制
- 新 Postgres volume 先执行根 `init.sql`。
- Backend 启动后读取打包进镜像的 `dist/schema.sql`,逐句执行 `CREATE IF NOT EXISTS` / `ALTER`。
- `backend/src/db/index.ts:51-149` 是实际升级路径。
- 当前没有 migration version ledger;schema 变更是累计幂等 SQL。
### 9.2 增加表/列的安全步骤
1. 先写 migration contract test。
2. 只做向后兼容 expand:nullable/default 列、新表、新索引。
3. 在代码里兼容旧值/空值。
4. 用生产备份的隔离恢复副本演练启动。
5. 部署新 backend,观察 backfill/锁时间。
6. 后续版本才做 contract(删除旧列/约束),不要同版完成。
7. 同步 canonical schema 和首装路径,最终消除双源。
### 9.3 Bot 新功能建议表
- `telegram_interaction_states`:仅在确需跨重启向导时;否则有 TTL 内存 store 即可,YAGNI。
- `telegram_target_states`:chat/user + mode(next/session) + provider/account + expiry。
- `telegram_notification_preferences`:user/chat + timezone + quiet hours + event flags。
- 订阅表增加 target mode/provider/account(如实施 fixed/follow-global)。
所有 account foreign key 都要考虑 `ON DELETE` 语义和 storage account deletion reference check。
---
## 10. 部署步骤
### 10.1 部署前
1. 所有质量门通过(含 audit)。
2. 确认工作树只含计划改动。
3. 生成可追溯版本:建议 `v2.1.0-rc.N` 或明确 commit-based tag。
4. 设置:
```bash
export SOURCE_REVISION=$(git rev-parse HEAD)
export SOURCE_VERSION=<release-version>
```
5. 运行协调备份(会停 backend):
```bash
cd /www/wwwroot/tg-vault
BACKUP_DIR=/安全且空间充足的路径 ./deploy/backup.sh
```
6. 校验备份:
```bash
./deploy/restore-verify.sh /安全路径/<backup-directory>
```
7. 保存旧镜像 digest 和当前 Compose config。
### 10.2 构建镜像
不要继续用与包版本不一致的 `v2.0.0` 标签。示例:
```bash
cd /www/wwwroot/tg-vault
set -a
source .env
set +a
export IMAGE_VERSION=v2.1.0
export SOURCE_REVISION=$(git rev-parse HEAD)
export SOURCE_VERSION=$IMAGE_VERSION
docker compose build --pull backend frontend
```
确保 `.env` 中 `IMAGE_VERSION` 与要部署的一致;`VITE_API_URL` 是 build-time 值,修改后必须重建 frontend。
### 10.3 滚动范围
当前 Bot 设计没有多实例 update ownership,不做双 backend 蓝绿同时在线。建议:
```bash
docker compose up -d --no-deps --force-recreate backend
# backend healthy 后
docker compose up -d --no-deps --force-recreate frontend
```
若 schema 改动需要初始化,先 backend;frontend 在 backend ready 后切换。
### 10.4 部署后验证
```bash
docker compose ps
curl -fsS http://127.0.0.1:51947/livez
curl -fsS http://127.0.0.1:51947/readyz
curl -fsSI http://127.0.0.1:47832/
docker compose logs --tail=200 backend frontend postgres
```
再验证:
```bash
docker inspect --format='{{index .Config.Labels "org.opencontainers.image.revision"}} {{index .Config.Labels "org.opencontainers.image.version"}}' tg-vault-backend
docker inspect --format='{{index .Config.Labels "org.opencontainers.image.revision"}} {{index .Config.Labels "org.opencontainers.image.version"}}' tg-vault-frontend
```
检查前端 `index.html` 引用的入口 asset 与本次 build manifest 一致;不能只看到 200 就宣称部署成功。
### 10.5 Bot 生产 smoke(无破坏)
使用已授权测试用户:
- `/start`
- `/help`
- `/status`(实现后)
- `/storage`
- `/tasks`
- `/find`(实现后)
不要在生产 smoke 中执行清空本地文件、删除账户、批量频道抓取或真实大文件下载。
---
## 11. 回滚步骤
### 11.1 代码/镜像回滚
前提:schema 变更保持向后兼容。
1. 记录失败时间和 request/operation ID。
2. 停止新的功能入口(feature flag 或关闭 Bot required feature)。
3. 把 `IMAGE_VERSION` 改回已记录的旧版本/digest。
4. 只重建/recreate backend/frontend,不删除卷:
```bash
docker compose up -d --no-deps --force-recreate backend frontend
```
5. 验证 `/readyz`、旧前端 asset、Bot 基础命令。
### 11.2 数据回滚
只有以下情况才恢复 DB/文件卷:
- schema/data migration 不可向后读取;
- 已确认写入损坏;
- 运维负责人批准维护窗口。
恢复必须在隔离环境先验证,且数据库 dump 与 `/data` 必须来自同一个 `backend-stopped` 备份目录。**绝不执行 `docker compose down -v`。**
### 11.3 外部存储副作用
数据库回滚不会自动回滚云端对象。先查:
- `telegram_write_reconciliations`
- `ytdlp_write_reconciliations`
- `chunk_upload_reconciliations`
- `storage_account_leases`
待对账记录不为 0 时,不要盲目重试/删除;按 provider/object path/file ID 逐条核验。
---
## 12. 监控与告警建议
### 必需探针
- `/livez`:进程。
- `/readyz`:DB、schema、storage、security。
- 新增 Bot component health:连接状态、最近 update、重连次数、last error。
- 用户账号 client health。
### 数据库告警查询(只读)
```sql
SELECT COUNT(*) FROM telegram_write_reconciliations WHERE status='pending';
SELECT COUNT(*) FROM ytdlp_write_reconciliations WHERE status='pending';
SELECT COUNT(*) FROM chunk_upload_reconciliations WHERE status='pending';
SELECT COUNT(*) FROM storage_account_leases WHERE released_at IS NULL AND expires_at < NOW();
SELECT status, COUNT(*) FROM telegram_background_jobs GROUP BY status;
SELECT status, COUNT(*) FROM transfer_tasks GROUP BY status;
```
建议告警:
- pending reconciliation > 0 持续 10 分钟。
- expired unreleased lease > 0。
- Bot 非 ready 持续 5 分钟。
- 订阅连续失败 N 次。
- 临时磁盘低于水位。
- backend 容器重启循环。
- npm/镜像扫描出现 high/critical。
---
## 13. 已运行验证记录
交接文档生成前实际运行并观察:
```text
Backend tests: 220 pass / 0 fail
Backend typecheck: pass
Backend build: pass
Frontend tests: 68 pass / 0 fail
Frontend lint: pass
Frontend build: pass
Compose config: pass
/livez: 200
/readyz: 200
Frontend root: 200
Backend audit: 2 high (需修复)
Frontend audit: 0
```
还执行了源码静态紧凑复现,确认:
- 频道确认 target 会在 `createJob` 被 active target 覆盖。
- `parseDateOnly` 会把非法日期滚动到未来合法日期。
- Web allowlist 路由未立即 reconcile auth cache。
- Bot wizard 没有 chat binding。
- App 仍有裸 `alert()`,而现有测试没有匹配到。
- 当前生产 frontend asset 与当前源码 build 不一致。
未执行:
- 登录生产 Web。
- 输入/猜测密码或 2FA。
- 发送 Telegram 消息。
- 删除/修改生产数据。
- 重启或重建生产容器。
---
## 14. 接手完成检查表
- [ ] 已阅读配套实施计划。
- [ ] 已确认现有 10 个删除文件的归属。
- [ ] 已使用独立分支/worktree。
- [ ] 已保存旧镜像 ID、版本、revision 和一致备份。
- [ ] 已先修 P0/P1,而不是直接添加漂亮菜单。
- [ ] 每个缺陷都有 RED-capable regression test。
- [ ] 未绕过 storage lease、task lease、reconciliation、confirmation。
- [ ] 未让 staging/production 共用 Bot token/session。
- [ ] Audit、test、typecheck、lint、build 全绿。
- [ ] 在隔离 staging 完成 Bot + Web 验收矩阵。
- [ ] 部署产物能从 OCI label 追到 commit。
- [ ] 部署后确认实际 frontend asset 和 backend bundle,而非只看容器 healthy。
- [ ] 回滚命令、旧镜像和备份路径在变更单中已记录。
+2671 -958
View File
File diff suppressed because it is too large. Load diff
+7 -6
View File
@@ -2265,9 +2265,10 @@
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
@@ -3934,9 +3935,9 @@
}
},
"node_modules/ip-address": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz",
"integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==",
"license": "MIT",
"engines": {
"node": ">= 12"
+3 -1
View File
@@ -9,7 +9,9 @@
"typecheck": "tsc --noEmit --pretty false",
"build": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --packages=external && esbuild src/scripts/login_telegram_user.ts --bundle --platform=node --format=esm --outfile=dist/scripts/login_telegram_user.js --packages=external",
"start": "node dist/index.js",
"login:telegram-user": "node dist/scripts/login_telegram_user.js"
"login:telegram-user": "node dist/scripts/login_telegram_user.js",
"schema:generate-init": "python3 scripts/generate_init_sql.py",
"schema:check": "python3 scripts/generate_init_sql.py --check"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1075.0",
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
root = Path(__file__).resolve().parents[2]
schema = root / 'backend' / 'src' / 'db' / 'schema.sql'
init = root / 'init.sql'
expected = '-- GENERATED FROM backend/src/db/schema.sql; DO NOT EDIT init.sql DIRECTLY.\n' + schema.read_text()
if '--check' in sys.argv:
if not init.exists() or init.read_text() != expected:
print('init.sql is not synchronized with canonical schema.sql', file=sys.stderr)
raise SystemExit(1)
else:
init.write_text(expected)
print(f'generated {init}')
+1 -1
View File
@@ -17,7 +17,7 @@ checks = [
('idx_files_account_folder_created', schema),
('idx_files_source_fav_created', schema),
('idx_files_source_folder_created', schema),
('ensureFilesPerformanceIndexes', db_index),
('CANONICAL_SCHEMA_VERSION', db_index),
('buildFilePageQuery', file_query),
('id, name, stored_name, type, mime_type, size', file_query),
('(created_at, id)', file_query),
@@ -4,7 +4,7 @@ from pathlib import Path
root = Path(__file__).resolve().parents[1]
jobs = (root / 'src/services/telegramChannelJobs.ts').read_text()
schema = (root / 'src/db/schema.sql').read_text()
bot = (root / 'src/services/telegramBot.ts').read_text()
presentation = (root / 'src/bot/presentation/subscription.ts').read_text()
date_download_block = jobs[jobs.index('export async function enqueueTelegramDateDownload'):jobs.index('async function getMessagesByHashtag')]
tag_download_block = jobs[jobs.index('export async function enqueueTelegramTagDownload'):jobs.index('export async function listTelegramActiveTaskQueues')]
@@ -17,7 +17,7 @@ checks = [
('tag download resolves private invites before job creation', 'resolveTelegramSource(userClient, sourceInput)' in tag_download_block and 'const source = resolved.source' in tag_download_block),
('stable peer persistence', 'source_original' in schema and 'source_type' in schema and 'disabled_reason' in schema),
('subscription pause on inaccessible source', 'pauseTelegramSubscriptionForError' in jobs and 'enabled = false' in jobs and 'disabled_reason' in jobs),
('subscription list warning', 'disabled_reason' in bot and 'listTelegramSubscriptions(userId, true)' in bot),
('subscription list warning', 'disabled_reason' in presentation and '上次扫描' in presentation),
('explicit not joined wording', '尚未加入这个私密频道/群' in jobs),
('expired/invalid invite wording', '邀请链接已过期' in jobs and '邀请链接无效' in jobs),
]
+15
View File
@@ -0,0 +1,15 @@
import type { Api } from 'telegram';
import { canonicalTelegramChatKey, telegramChatKeyFromPeerParts } from '../utils/telegramChatKey.js';
export function messageChatKey(message: Api.Message, senderId: number): string {
return canonicalTelegramChatKey(message.chatId?.toString() || senderId);
}
export function callbackChatKey(update: Api.UpdateBotCallbackQuery, userId: number): string {
return telegramChatKeyFromPeerParts(update.peer as any, userId);
}
export function telegramSubscriptionPeerKey(peer: any): string {
const value = peer?.userId || peer?.chatId || peer?.channelId;
return String(value?.toString?.() || value || peer?.toString?.() || '').replace(/^-100/, '').replace(/^-/, '');
}
@@ -0,0 +1,37 @@
export function buildSubscriptionDisplayLines(row: any, index: number): string {
const status = row.enabled ? '✅' : '⏸️';
const sourceLine = row.source_original && row.source_original !== row.source
? ` ${row.source_original} → ${row.source} · last_id=${row.last_message_id || 0}`
: ` ${row.source} · last_id=${row.last_message_id || 0}`;
return [
`${index + 1}. ${status} ${row.title || row.source_original || row.source}`,
sourceLine,
row.folder_override ? ` 📁 专属目录:${row.folder_override}` : ' 📁 保存策略:默认自动分类',
` 🎯 Target:${row.target_mode === 'fixed' ? `${row.target_provider} / ${row.target_account_id || 'local'}(固定)` : '跟随系统默认(每次 admission 快照)'}`,
row.last_scan_at ? ` 🔎 上次扫描:${new Date(row.last_scan_at).toLocaleString('zh-CN', { hour12: false })}` : ' 🔎 尚未扫描',
row.last_success_at ? ` ✅ 上次成功:${new Date(row.last_success_at).toLocaleString('zh-CN', { hour12: false })}` : null,
row.next_scan_at ? ` ⏭️ 下次扫描约:${new Date(row.next_scan_at).toLocaleString('zh-CN', { hour12: false })}` : null,
row.last_result ? ` 📊 最近结果:${row.last_result.status || 'unknown'}${row.last_result.found !== undefined ? `,发现 ${row.last_result.found}` : ''}${row.last_result.failed ? `,失败 ${row.last_result.failed}` : ''}` : null,
row.last_error ? ` ⚠️ 最近错误:${row.last_error}` : null,
!row.enabled && row.disabled_reason ? ` ⚠️ ${row.disabled_reason}` : null,
!row.enabled && row.disabled_at ? ` 暂停时间:${new Date(row.disabled_at).toLocaleString('zh-CN', { hour12: false })}` : null,
].filter(Boolean).join('\n');
}
export function buildSubscriptionManagePanel(rows: any[], page: { page: number; totalPages: number; startIndex: number; visibleRows: any[] }): string {
return [
'📡 **频道订阅管理**',
...(page.totalPages > 1 ? [`第 ${page.page + 1}/${page.totalPages} 页 · 共 ${rows.length} 个订阅`] : []),
'',
page.visibleRows.length > 0
? page.visibleRows.map((row, index) => buildSubscriptionDisplayLines(row, page.startIndex + index)).join('\n')
: '当前没有订阅。',
'',
rows.length > 0 ? '可使用操作按钮同步、暂停、修改目标、补抓或重试;已保存文件不会随取消订阅删除。' : '回复频道用户名或链接可新增订阅。',
'回复频道用户名或链接也可新增订阅。',
'例如:`@channel_username`、`https://t.me/channel_username` 或已加入的 `https://t.me/+hash` 私密链接',
'',
'新增订阅时可指定独立目录,不会改变全局 /path_rules。',
'发送“取消”可退出。',
].join('\n');
}
+11 -64
View File
@@ -4,6 +4,7 @@ import path from 'path';
import dotenv from 'dotenv';
import { fileURLToPath } from 'url';
import { buildSqlLogEvent, parseSqlLoggingConfig, shouldLogSqlQuery, sqlOperation } from '../utils/dbLogging.js';
import crypto from 'node:crypto';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -17,35 +18,16 @@ export const pool = new Pool({
});
let initializationPromise: Promise<void> | null = null;
const CANONICAL_SCHEMA_VERSION = 2026082401;
async function ensureFavoritesColumn() {
try {
await pool.query(`ALTER TABLE files ADD COLUMN IF NOT EXISTS is_favorite BOOLEAN DEFAULT false`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_files_is_favorite ON files(is_favorite)`);
} catch (err: any) {
if (err?.code === '42P01') {
return;
}
console.error('❌ 数据库迁移失败 (收藏字段):', err);
throw err;
}
}
async function ensureFilesPerformanceIndexes() {
try {
await pool.query(`CREATE INDEX IF NOT EXISTS idx_files_account_created ON files(storage_account_id, created_at DESC, id DESC)`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_files_source_created ON files(source, created_at DESC, id DESC)`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_files_account_fav_created ON files(storage_account_id, is_favorite, created_at DESC, id DESC)`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_files_source_fav_created ON files(source, is_favorite, created_at DESC, id DESC)`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_files_account_folder_created ON files(storage_account_id, folder, created_at DESC, id DESC)`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_files_source_folder_created ON files(source, folder, created_at DESC, id DESC)`);
} catch (err: any) {
if (err?.code === '42P01') {
return;
}
console.error('❌ 数据库迁移失败 (文件列表性能索引):', err);
throw err;
}
async function applyCanonicalSchemaMigration(schemaSql: string): Promise<void> {
const checksum = crypto.createHash('sha256').update(schemaSql).digest('hex');
await pool.query(
`INSERT INTO schema_migrations (version, name, checksum)
VALUES ($1, $2, $3)
ON CONFLICT (version) DO UPDATE SET checksum = EXCLUDED.checksum`,
[CANONICAL_SCHEMA_VERSION, 'canonical-expand-schema', checksum],
);
}
// 自动初始化数据库表结构
@@ -100,42 +82,7 @@ async function initializeDatabase() {
}
}
await ensureFavoritesColumn();
await ensureFilesPerformanceIndexes();
await pool.query(`ALTER TABLE files ADD COLUMN IF NOT EXISTS preview_path VARCHAR(500)`);
await pool.query(`ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS key_hash VARCHAR(64)`);
await pool.query(`ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS last_used_at TIMESTAMPTZ`);
await pool.query(`ALTER TABLE telegram_channel_subscriptions ADD COLUMN IF NOT EXISTS source_original TEXT`);
await pool.query(`ALTER TABLE telegram_channel_subscriptions ADD COLUMN IF NOT EXISTS source_type TEXT DEFAULT 'public'`);
await pool.query(`ALTER TABLE telegram_channel_subscriptions ADD COLUMN IF NOT EXISTS disabled_reason TEXT`);
await pool.query(`ALTER TABLE telegram_channel_subscriptions ADD COLUMN IF NOT EXISTS disabled_at TIMESTAMPTZ`);
await pool.query(`CREATE TABLE IF NOT EXISTS web_sessions (
token_hash VARCHAR(64) PRIMARY KEY,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
)`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_web_sessions_expires ON web_sessions(expires_at)`);
await pool.query(`CREATE TABLE IF NOT EXISTS task_center_dismissals (
source_type VARCHAR(30) NOT NULL,
task_id VARCHAR(128) NOT NULL,
task_updated_at TIMESTAMPTZ NOT NULL,
dismissed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (source_type, task_id)
)`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_task_center_dismissals_version ON task_center_dismissals(source_type, task_id, task_updated_at)`);
await pool.query(`CREATE TABLE IF NOT EXISTS storage_account_cooldowns (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
storage_account_id UUID REFERENCES storage_accounts(id) ON DELETE CASCADE,
provider VARCHAR(50) NOT NULL,
reason VARCHAR(100) NOT NULL,
cooldown_until TIMESTAMPTZ NOT NULL,
last_error TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(storage_account_id, provider, reason)
)`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_storage_account_cooldowns_until ON storage_account_cooldowns(cooldown_until)`);
await pool.query(`CREATE UNIQUE INDEX IF NOT EXISTS idx_api_keys_key_hash ON api_keys(key_hash) WHERE key_hash IS NOT NULL`);
await applyCanonicalSchemaMigration(schemaSql);
console.log('✅ 数据库表结构初始化完成');
} catch (err: any) {
+58
View File
@@ -1,3 +1,11 @@
-- Canonical migration ledger. Version 2026082401 is the idempotent expand schema below.
CREATE TABLE IF NOT EXISTS schema_migrations (
version BIGINT PRIMARY KEY,
name TEXT NOT NULL,
checksum VARCHAR(64),
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- TG Vault 数据库表结构
-- 启用 UUID 扩展
@@ -327,6 +335,25 @@ CREATE OR REPLACE TRIGGER telegram_path_states_updated_at
FOR EACH ROW
EXECUTE FUNCTION update_updated_at();
-- 每聊天的下一次/会话级存储目标;不会切换系统全局默认。
CREATE TABLE IF NOT EXISTS telegram_target_states (
chat_id TEXT NOT NULL,
mode VARCHAR(10) NOT NULL CHECK (mode IN ('once', 'session')),
provider VARCHAR(50) NOT NULL,
account_id UUID REFERENCES storage_accounts(id) ON DELETE RESTRICT,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (chat_id, mode),
CHECK ((provider = 'local' AND account_id IS NULL) OR (provider <> 'local' AND account_id IS NOT NULL))
);
CREATE INDEX IF NOT EXISTS idx_telegram_target_states_expiry ON telegram_target_states(expires_at);
CREATE INDEX IF NOT EXISTS idx_telegram_target_states_account ON telegram_target_states(account_id) WHERE account_id IS NOT NULL;
CREATE OR REPLACE TRIGGER telegram_target_states_updated_at
BEFORE UPDATE ON telegram_target_states
FOR EACH ROW
EXECUTE FUNCTION update_updated_at();
-- Telegram 频道订阅表
CREATE TABLE IF NOT EXISTS telegram_channel_subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -357,12 +384,43 @@ ALTER TABLE telegram_channel_subscriptions ADD COLUMN IF NOT EXISTS last_scan_at
ALTER TABLE telegram_channel_subscriptions ADD COLUMN IF NOT EXISTS last_success_at TIMESTAMPTZ;
ALTER TABLE telegram_channel_subscriptions ADD COLUMN IF NOT EXISTS last_error TEXT;
ALTER TABLE telegram_channel_subscriptions ADD COLUMN IF NOT EXISTS last_result JSONB;
ALTER TABLE telegram_channel_subscriptions ADD COLUMN IF NOT EXISTS next_scan_at TIMESTAMPTZ;
ALTER TABLE telegram_channel_subscriptions ADD COLUMN IF NOT EXISTS target_mode VARCHAR(20) NOT NULL DEFAULT 'follow_global';
ALTER TABLE telegram_channel_subscriptions ADD COLUMN IF NOT EXISTS target_provider VARCHAR(50);
ALTER TABLE telegram_channel_subscriptions ADD COLUMN IF NOT EXISTS target_account_id UUID REFERENCES storage_accounts(id) ON DELETE RESTRICT;
DO $$ BEGIN
ALTER TABLE telegram_channel_subscriptions ADD CONSTRAINT telegram_subscription_target_mode_check CHECK (target_mode IN ('follow_global', 'fixed'));
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
CREATE INDEX IF NOT EXISTS idx_tg_channel_subscriptions_target_account ON telegram_channel_subscriptions(target_account_id) WHERE target_account_id IS NOT NULL;
CREATE OR REPLACE TRIGGER telegram_channel_subscriptions_updated_at
BEFORE UPDATE ON telegram_channel_subscriptions
FOR EACH ROW
EXECUTE FUNCTION update_updated_at();
CREATE TABLE IF NOT EXISTS telegram_notification_preferences (
user_id BIGINT NOT NULL,
chat_id TEXT NOT NULL,
preferences JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, chat_id)
);
CREATE OR REPLACE TRIGGER telegram_notification_preferences_updated_at
BEFORE UPDATE ON telegram_notification_preferences
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TABLE IF NOT EXISTS telegram_notification_digest (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id BIGINT NOT NULL,
chat_id TEXT NOT NULL,
kind VARCHAR(30) NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
delivered_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_telegram_notification_digest_pending ON telegram_notification_digest(user_id, chat_id, created_at) WHERE delivered_at IS NULL;
-- Telegram 后台任务表(用于重启后可见、可追踪)
CREATE TABLE IF NOT EXISTS telegram_background_jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+18 -2
View File
@@ -13,6 +13,7 @@ import tasksRouter from './routes/tasks.js';
import authRouter, { requireAuth } from './routes/auth.js';
import { requireAuthOrSignedUrl } from './middleware/signedUrl.js';
import { initTelegramBot } from './services/telegramBot.js';
import { getTelegramBotStatus, markTelegramBotError, telegramBotBlocksReadiness } from './services/telegramBotStatus.js';
import { initTelegramUserClient, isTelegramUserClientReady } from './services/telegramUserClient.js';
import { isInitialSetupRequired } from './utils/authSettings.js';
import { get2FAReadiness } from './utils/security.js';
@@ -158,7 +159,15 @@ app.get('/readyz', async (_req, res) => {
await storageManager.assertReady();
const twoFactor = await get2FAReadiness();
if (!twoFactor.ready) throw new Error('2FA 密钥不可读取');
res.json({ status: 'ready', timestamp: new Date().toISOString() });
const bot = getTelegramBotStatus();
if (telegramBotBlocksReadiness(bot)) {
throw new Error(`Telegram Bot 未就绪:${bot.status}${bot.action ? `;${bot.action}` : ''}`);
}
res.json({
status: bot.degraded ? 'degraded' : 'ready',
timestamp: new Date().toISOString(),
components: { telegramBot: bot },
});
} catch (error) {
res.status(503).json({ status: 'not_ready', error: error instanceof Error ? error.message : String(error) });
}
@@ -186,7 +195,14 @@ async function initializeApplication(): Promise<void> {
if (!twoFactor.ready) throw new Error('2FA 已启用但密钥不可读取');
if (telegramEnabled) {
await initTelegramUserClient();
await initTelegramBot();
try {
await initTelegramBot();
} catch (error) {
if (/^(1|true|yes|on)$/i.test(process.env.TELEGRAM_REQUIRED || 'false')) throw error;
const message = error instanceof Error ? error.message : String(error);
markTelegramBotError('error', message, '检查 Telegram 凭证/网络并重启后端');
console.warn('Telegram Bot 可选组件启动失败,应用以 degraded 状态继续:', message);
}
}
await initializeYtDlpQueue();
applicationReady = true;
+14 -3
View File
@@ -8,7 +8,7 @@ import fs from 'fs';
import axios from 'axios';
import crypto from 'crypto';
import { getSetting, setSetting } from '../utils/settings.js';
import { getConfiguredTelegramAllowedUsers, parseTelegramAllowedUserIds, setTelegramAllowedUsers } from '../utils/authSettings.js';
import { getConfiguredTelegramAllowedUsers, parseTelegramAllowedUserIds, setTelegramAllowedUsersAndReconcile } from '../utils/authSettings.js';
import { getTelegramUserSessionFilePath, isTelegramUserClientReady } from '../services/telegramUserClient.js';
import { assertPublicStorageEndpoint } from '../utils/networkSecurity.js';
import { getCurrentStorageScope } from '../utils/fileScope.js';
@@ -20,6 +20,7 @@ import { logOperationalEvent } from '../services/operationalEvents.js';
import { webDestructiveConfirmationStore } from '../services/webDestructiveConfirmation.js';
import { StorageProbeError } from '../services/storage.js';
import { getTelegramUserClientStatus } from '../services/telegramUserClientStatus.js';
import { getTelegramBotStatus } from '../services/telegramBotStatus.js';
import { maintenanceImpact } from '../utils/maintenanceActions.js';
import { buildStorageCapabilities, buildStorageStatsPayload } from '../utils/storageProductContract.js';
import { buildAdvancedSettings, normalizeAdvancedSettingsPatch } from '../utils/advancedSettings.js';
@@ -223,6 +224,7 @@ router.get('/config', requireAuth, async (req: Request, res: Response) => {
telegramUserDownloadEnabled: telegramUserDownloadEnabled === 'true',
telegramUserSessionReady,
telegramUserClientStatus: getTelegramUserClientStatus(),
telegramBotStatus: getTelegramBotStatus(),
telegramAllowedUserIds,
telegramAllowedUserIdsFromEnv,
});
@@ -300,8 +302,17 @@ router.post('/config/telegram-allowed-users', requireAuth, async (req: Request,
if (userIds.length === 0) {
return res.status(400).json({ error: '请至少填写一个 Telegram user id' });
}
const saved = await setTelegramAllowedUsers(userIds);
res.json({ success: true, userIds: saved });
const reconciliation = await setTelegramAllowedUsersAndReconcile(userIds);
res.json({
success: true,
userIds: reconciliation.allowed,
added: reconciliation.added.length,
removed: reconciliation.removed.length,
revoked: reconciliation.revoked.length,
message: reconciliation.revoked.length > 0
? '允许列表已保存;移除用户的 Bot 认证已立即撤销。'
: '允许列表已保存。',
});
} catch (error) {
console.error('更新 Telegram 允许用户列表失败:', error);
res.status(500).json({ error: '更新 Telegram 允许用户列表失败' });
+7 -81
View File
@@ -9,29 +9,12 @@ import { cancelYtDlpTask, retryYtDlpTask } from '../services/ytDlpDownload.js';
import { cancelDownloadTaskGroup, retryFailedDownloadTasks, cancelChannelExecutionGroup } from '../services/telegramUpload.js';
import { cancelTelegramBackgroundJob, retryTelegramBackgroundJob } from '../services/telegramChannelJobs.js';
import { filterDismissedTasks, isTaskDismissible, loadTaskCenterDismissals, saveTaskCenterDismissals } from '../services/taskCenterDismissals.js';
import { mapTelegramChannelJob, mapTransferTask } from '../services/unifiedTaskMapper.js';
import crypto from 'node:crypto';
const router = Router();
const CHUNK_DIR = process.env.CHUNK_DIR || './data/chunks';
function parseJsonObject(value: unknown): Record<string, any> {
if (value && typeof value === 'object' && !Array.isArray(value)) return value as Record<string, any>;
if (typeof value !== 'string') return {};
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
} catch {
return {};
}
}
function channelStatus(status: string): string {
if (status === 'queued') return 'pending';
if (status === 'cooling') return 'waiting';
if (status === 'completed_with_errors') return 'failed';
return status;
}
async function collectUnifiedTasks(limit: number): Promise<any[]> {
const [transfers, channels, chunks, subscriptions, accounts] = await Promise.all([
listTransferTasks({ limit }),
@@ -62,66 +45,8 @@ async function collectUnifiedTasks(limit: number): Promise<any[]> {
query('SELECT id, name FROM storage_accounts'),
]);
const accountNames = new Map(accounts.rows.map(row => [String(row.id), String(row.name)]));
const tasks: any[] = transfers.map(task => ({
id: task.id,
sourceType: task.sourceType,
kind: task.kind,
title: task.title,
status: task.status,
stage: task.stage,
progress: task.progress,
ownerUserId: task.ownerUserId,
chatId: task.chatId,
source: task.source,
target: {
provider: task.targetProvider,
accountId: task.targetAccountId,
accountName: task.targetAccountId ? accountNames.get(task.targetAccountId) || null : (task.targetProvider === 'local' ? '服务器本地目录' : null),
folder: task.targetFolder,
},
counts: { total: task.totalItems, completed: task.completedItems, failed: task.failedItems },
bytes: { total: task.totalBytes, transferred: task.transferredBytes },
detail: task.payload,
error: task.error,
retryable: task.retryable,
cancellable: ['pending', 'running', 'paused'].includes(task.status),
createdAt: task.createdAt,
updatedAt: task.updatedAt,
finishedAt: task.finishedAt,
}));
for (const row of channels.rows) {
const params = parseJsonObject(row.params);
const total = Math.max(Number(row.total_count || 0), Number(row.item_count || 0));
const completed = Number(row.completed_items || 0);
const failed = Number(row.failed_items || 0);
tasks.push({
id: String(row.id),
sourceType: 'telegram_channel',
kind: String(row.kind),
title: row.source || 'Telegram 频道任务',
status: channelStatus(String(row.status)),
stage: row.scan_status !== 'completed' ? 'scanning' : 'downloading',
progress: total > 0 ? Math.min(100, ((completed + failed + Number(row.skipped_count || 0)) / total) * 100) : 0,
ownerUserId: Number(row.user_id),
chatId: row.chat_id == null ? null : String(row.chat_id),
source: row.source,
target: {
provider: params.storageProvider || null,
accountId: params.storageAccountId || null,
accountName: params.storageAccountId ? accountNames.get(String(params.storageAccountId)) || null : (params.storageProvider === 'local' ? '服务器本地目录' : null),
folder: params.folderOverride || null,
},
counts: { total, completed, failed },
bytes: { total: Number(row.total_bytes || 0), transferred: 0 },
detail: { scanStatus: row.scan_status, downloadStatus: row.download_status, skipped: Number(row.skipped_count || 0) },
error: row.error,
retryable: ['failed', 'completed_with_errors'].includes(String(row.status)),
cancellable: ['queued', 'running', 'paused', 'cooling'].includes(String(row.status)),
createdAt: row.created_at,
updatedAt: row.updated_at,
finishedAt: row.finished_at,
});
}
const tasks: any[] = transfers.map(task => mapTransferTask(task, accountNames));
for (const row of channels.rows) tasks.push(mapTelegramChannelJob(row, accountNames));
for (const row of chunks.rows) {
const total = Number(row.total_size || 0);
const received = Number(row.received_bytes || 0);
@@ -191,9 +116,10 @@ router.get('/', requireAuth, async (req: Request, res: Response) => {
const filtered = tasks
.filter(task => !source || task.sourceType === source)
.filter(task => !status || task.status === status)
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
.slice(0, limit);
res.json({ tasks: filtered, total: filtered.length, generatedAt: new Date().toISOString() });
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
const total = filtered.length;
const page = filtered.slice(0, limit);
res.json({ tasks: page, total, returned: page.length, generatedAt: new Date().toISOString() });
} catch (error) {
console.error('获取统一任务列表失败:', error);
res.status(500).json({ error: '获取任务列表失败' });
@@ -0,0 +1,30 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';
const routes = fs.readFileSync(new URL('./tasks.ts', import.meta.url), 'utf8');
const taskCenter = fs.readFileSync(new URL('../services/telegramTaskCenter.ts', import.meta.url), 'utf8');
function routeBlock(start: string, end: string): string {
const startIndex = routes.indexOf(start);
const endIndex = routes.indexOf(end, startIndex + start.length);
assert(startIndex >= 0, `missing route block start: ${start}`);
assert(endIndex > startIndex, `missing route block end: ${end}`);
return routes.slice(startIndex, endIndex);
}
test('Web and Telegram channel views consume the shared channel mapper', () => {
assert.match(routes, /mapTelegramChannelJob/);
assert.match(taskCenter, /telegramChannelJobTaskState/);
assert.doesNotMatch(routes, /row\.scan_status !== 'completed'/);
});
test('task list reports filtered total separately from returned page size', () => {
const listRoute = routeBlock("router.get('/', requireAuth", "router.post('/dismissals/prepare'");
assert.match(listRoute, /const total = filtered\.length/);
assert.match(listRoute, /const page = filtered\.slice\(0, limit\)/);
assert.match(listRoute, /tasks: page/);
assert.match(listRoute, /total/);
assert.match(listRoute, /returned: page\.length/);
assert.doesNotMatch(listRoute, /total: filtered\.length/);
});
@@ -0,0 +1,22 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';
const bot = fs.readFileSync(new URL('./telegramBot.ts', import.meta.url), 'utf8');
const jobs = fs.readFileSync(new URL('./telegramChannelJobs.ts', import.meta.url), 'utf8');
const context = fs.readFileSync(new URL('../bot/context.ts', import.meta.url), 'utf8');
const presentation = fs.readFileSync(new URL('../bot/presentation/subscription.ts', import.meta.url), 'utf8');
test('Bot context and subscription presentation are extracted behind stable boundaries', () => {
assert.match(context, /messageChatKey/);
assert.match(context, /callbackChatKey/);
assert.match(presentation, /buildSubscriptionDisplayLines/);
assert.match(presentation, /buildSubscriptionManagePanel/);
});
test('large modules consume extracted boundaries while retaining domain protocols', () => {
assert.match(bot, /from '\.\.\/bot\/context\.js'/);
assert.match(bot, /from '\.\.\/bot\/presentation\/subscription\.js'/);
assert.match(jobs, /finalizeSubscriptionJobInTransaction/);
assert.match(jobs, /claimTelegramWriteReconciliations/);
});
+12
View File
@@ -16,6 +16,7 @@ test('normalizes validated global query options and rejects invalid values', ()
sort: 'name', direction: 'asc', limit: '999',
}), {
q: 'holiday', type: 'image', folder: null, favorite: true,
after: null, before: null,
sort: 'name', direction: 'asc', limit: 500, cursor: null,
});
@@ -24,6 +25,17 @@ test('normalizes validated global query options and rejects invalid values', ()
assert.throws(() => normalizeFileQuery({ sort: 'size' }), /sort/);
assert.throws(() => normalizeFileQuery({ direction: 'sideways' }), /direction/);
assert.throws(() => normalizeFileQuery({ q: 'x'.repeat(201) }), /q/);
assert.throws(() => normalizeFileQuery({ after: 'not-a-date' }), /after/);
assert.throws(() => normalizeFileQuery({ before: 'not-a-date' }), /before/);
});
test('date filters use parameterized inclusive bounds', () => {
const options = normalizeFileQuery({ after: '2026-08-01', before: '2026-08-24' });
const built = buildFilePageQuery(localScope, options);
assert.match(built.text, /created_at >= \$2::timestamptz/);
assert.match(built.text, /created_at <= \$3::timestamptz/);
assert.equal(built.params[1], '2026-08-01T00:00:00.000Z');
assert.equal(built.params[2], '2026-08-24T23:59:59.999Z');
});
test('builds scoped search, type, root-folder and favorite predicates with deterministic name keyset', () => {
+28
View File
@@ -7,6 +7,8 @@ export interface NormalizedFileQuery {
type: 'image' | 'video' | 'audio' | 'document' | 'other' | 'media' | null;
folder: string | null | undefined;
favorite: boolean | null;
after: string | null;
before: string | null;
sort: FileQuerySort;
direction: FileQueryDirection;
limit: number;
@@ -35,6 +37,22 @@ function single(value: unknown): string | undefined {
return typeof value === 'string' ? value : undefined;
}
function normalizeDateBound(value: unknown, field: 'after' | 'before'): string | null {
const raw = single(value)?.trim();
if (!raw) return null;
const dateOnly = raw.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (dateOnly) {
const [, year, month, day] = dateOnly;
const timestamp = Date.UTC(Number(year), Number(month) - 1, Number(day), field === 'before' ? 23 : 0, field === 'before' ? 59 : 0, field === 'before' ? 59 : 0, field === 'before' ? 999 : 0);
const parsed = new Date(timestamp);
if (parsed.getUTCFullYear() !== Number(year) || parsed.getUTCMonth() !== Number(month) - 1 || parsed.getUTCDate() !== Number(day)) throw new Error(`invalid ${field}`);
return parsed.toISOString();
}
const timestamp = Date.parse(raw);
if (!Number.isFinite(timestamp)) throw new Error(`invalid ${field}`);
return new Date(timestamp).toISOString();
}
export function normalizeFileQuery(input: Record<string, unknown>): NormalizedFileQuery {
const rawQ = single(input.q)?.trim() || null;
if (rawQ && rawQ.length > 200) throw new Error('q is too long');
@@ -66,6 +84,8 @@ export function normalizeFileQuery(input: Record<string, unknown>): NormalizedFi
type: rawType as NormalizedFileQuery['type'],
folder,
favorite,
after: normalizeDateBound(input.after, 'after'),
before: normalizeDateBound(input.before, 'before'),
sort: rawSort as FileQuerySort,
direction: rawDirection as FileQueryDirection,
limit,
@@ -136,6 +156,14 @@ function addFilters(options: NormalizedFileQuery, where: string[], params: unkno
params.push(options.favorite);
where.push(`${prefix}is_favorite = $${params.length}`);
}
if (options.after) {
params.push(options.after);
where.push(`${prefix}created_at >= $${params.length}::timestamptz`);
}
if (options.before) {
params.push(options.before);
where.push(`${prefix}created_at <= $${params.length}::timestamptz`);
}
}
export function buildFilePageQuery(scope: FileQueryScope, options: NormalizedFileQuery): BuiltQuery {
@@ -0,0 +1,26 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';
const db = fs.readFileSync(new URL('../db/index.ts', import.meta.url), 'utf8');
const schema = fs.readFileSync(new URL('../db/schema.sql', import.meta.url), 'utf8');
const init = fs.readFileSync(new URL('../../../init.sql', import.meta.url), 'utf8');
test('database startup records a canonical schema migration ledger', () => {
assert.match(schema, /CREATE TABLE IF NOT EXISTS schema_migrations/);
assert.match(db, /applyCanonicalSchemaMigration/);
assert.match(db, /INSERT INTO schema_migrations/);
assert.match(db, /CANONICAL_SCHEMA_VERSION/);
});
test('first-install init is generated from the same canonical schema source', () => {
assert.match(init, /GENERATED FROM backend\/src\/db\/schema\.sql/);
assert.match(init, /schema_migrations/);
assert.match(init, /telegram_notification_preferences/);
});
test('legacy ad-hoc startup migrations no longer duplicate schema ownership', () => {
const initializer = db.slice(db.indexOf('async function initializeDatabase'), db.indexOf('export function ensureDatabaseInitialized'));
assert.doesNotMatch(initializer, /ALTER TABLE files ADD COLUMN/);
assert.doesNotMatch(initializer, /CREATE TABLE IF NOT EXISTS web_sessions/);
});
@@ -8,6 +8,13 @@ const compose = fs.readFileSync(new URL('../../../docker-compose.yml', import.me
const workflow = fs.readFileSync(new URL('../../../.github/workflows/docker-publish.yml', import.meta.url), 'utf8');
const backendPackage = JSON.parse(fs.readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
const frontendPackage = JSON.parse(fs.readFileSync(new URL('../../../frontend/package.json', import.meta.url), 'utf8'));
const installScript = fs.readFileSync(new URL('../../../deploy/install.sh', import.meta.url), 'utf8');
const deployGuide = fs.readFileSync(new URL('../../../deploy/DEPLOY.md', import.meta.url), 'utf8');
function assertRequiredBuildMetadata(source: string): void {
assert.match(source, /required_keys=.*SOURCE_REVISION.*SOURCE_VERSION/);
assert.match(source, /\.env 缺少必填项/);
}
test('release images use locked dependencies, pinned bases, verified yt-dlp and source labels', () => {
assert.equal(backendPackage.version, '2.0.1');
@@ -35,4 +42,17 @@ test('release images use locked dependencies, pinned bases, verified yt-dlp and
assert.ok(actionRefs.every(ref => /@[0-9a-f]{40}$/.test(ref)));
assert.match(workflow, /Generate CycloneDX SBOM release assets/);
assert.match(workflow, /actions\/upload-artifact@[0-9a-f]{40}/);
});
test('local release builds fail closed without traceable source metadata', () => {
assert.equal((compose.match(/\$\{SOURCE_REVISION:\?SOURCE_REVISION is required\}/g) || []).length, 2);
assert.equal((compose.match(/\$\{SOURCE_VERSION:\?SOURCE_VERSION is required\}/g) || []).length, 2);
assert.doesNotMatch(compose, /SOURCE_REVISION:-unknown/);
assert.doesNotMatch(compose, /SOURCE_VERSION:-worktree/);
assertRequiredBuildMetadata(installScript);
assert.match(installScript, /IMAGE_VERSION=/);
assert.match(installScript, /OAUTH_CALLBACK_BASE_URL=/);
assert.match(installScript, /OAUTH_FRONTEND_ORIGIN=/);
assert.match(deployGuide, /docker inspect/);
assert.match(deployGuide, /assets\//);
});
@@ -0,0 +1,30 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { ScopedInteractionMap } from './scopedInteractionMap.js';
test('scoped map expires entries and enforces capacity', () => {
let now = 0;
const map = new ScopedInteractionMap<string, { value: string }>({ ttlMs: 1_000, maxEntries: 2, now: () => now });
map.set('a', { value: 'a' });
now = 1;
map.set('b', { value: 'b' });
now = 2;
map.set('c', { value: 'c' });
assert.equal(map.get('a'), undefined);
assert.equal(map.size, 2);
now = 1_001;
assert.equal(map.get('b'), undefined);
assert.equal(map.get('c')?.value, 'c');
});
test('touch refreshes TTL while delete and cleanup are explicit', () => {
let now = 10;
const map = new ScopedInteractionMap<number, string>({ ttlMs: 100, maxEntries: 2, now: () => now });
map.set(1, 'pin');
now = 90;
assert.equal(map.touch(1), true);
now = 111;
assert.equal(map.get(1), 'pin');
assert.equal(map.delete(1), true);
assert.equal(map.cleanup(), 0);
});
@@ -0,0 +1,109 @@
export interface ScopedInteractionMapOptions {
ttlMs?: number;
maxEntries?: number;
now?: () => number;
}
interface Entry<V> {
value: V;
createdAt: number;
expiresAt: number;
}
export class ScopedInteractionMap<K, V> {
private readonly entries = new Map<K, Entry<V>>();
private readonly ttlMs: number;
private readonly maxEntries: number;
private readonly now: () => number;
constructor(options: ScopedInteractionMapOptions = {}) {
this.ttlMs = Math.max(1, options.ttlMs ?? 15 * 60_000);
this.maxEntries = Math.max(1, options.maxEntries ?? 1_000);
this.now = options.now ?? Date.now;
}
private evictForCapacity(): void {
this.cleanup();
while (this.entries.size >= this.maxEntries) {
let oldest: K | undefined;
let oldestTime = Number.POSITIVE_INFINITY;
for (const [key, entry] of this.entries) {
if (entry.createdAt < oldestTime) {
oldest = key;
oldestTime = entry.createdAt;
}
}
if (oldest === undefined) break;
this.entries.delete(oldest);
}
}
set(key: K, value: V): this {
const now = this.now();
if (!this.entries.has(key)) this.evictForCapacity();
this.entries.set(key, { value, createdAt: now, expiresAt: now + this.ttlMs });
return this;
}
get(key: K): V | undefined {
const entry = this.entries.get(key);
if (!entry) return undefined;
if (entry.expiresAt <= this.now()) {
this.entries.delete(key);
return undefined;
}
return entry.value;
}
has(key: K): boolean {
return this.get(key) !== undefined;
}
touch(key: K): boolean {
const value = this.get(key);
if (value === undefined) return false;
const entry = this.entries.get(key)!;
entry.expiresAt = this.now() + this.ttlMs;
return true;
}
delete(key: K): boolean {
return this.entries.delete(key);
}
keys(): IterableIterator<K> {
this.cleanup();
return this.entries.keys();
}
values(): IterableIterator<V> {
this.cleanup();
return Array.from(this.entries.values(), entry => entry.value).values();
}
entriesIterator(): IterableIterator<[K, V]> {
this.cleanup();
return Array.from(this.entries, ([key, entry]) => [key, entry.value] as [K, V]).values();
}
cleanup(): number {
const now = this.now();
let removed = 0;
for (const [key, entry] of this.entries) {
if (entry.expiresAt <= now) {
this.entries.delete(key);
removed += 1;
}
}
return removed;
}
clear(): void {
this.entries.clear();
}
get size(): number {
this.cleanup();
return this.entries.size;
}
}
@@ -32,6 +32,8 @@ test('delete locks account, rechecks every resumable task/upload reference, then
{ rows: [] },
{ rows: [] },
{ rows: [] },
{ rows: [] },
{ rows: [] },
{ rowCount: 3 },
{ rows: [{ id: 'account' }], rowCount: 1 },
]);
@@ -39,18 +41,47 @@ test('delete locks account, rechecks every resumable task/upload reference, then
assert.deepEqual(result, { id: 'account', name: 'A', type: 's3', deletedFiles: 3 });
assert.match(client.calls[0].text, /FOR UPDATE/);
assert.match(client.calls[1].text, /telegram_background_jobs/);
assert.match(client.calls[2].text, /transfer_tasks/);
assert.match(client.calls[2].text, /retryable = true/);
assert.match(client.calls[3].text, /storage_account_leases/);
assert.match(client.calls[4].text, /telegram_write_reconciliations/);
assert.match(client.calls[4].text, /chunk_upload_reconciliations/);
assert.match(client.calls[4].text, /ytdlp_write_reconciliations/);
assert.match(client.calls[5].text, /chunk_upload_sessions/);
assert.match(client.calls[5].text, /status IN \('open', 'completing', 'failed'\)/);
assert.match(client.calls[6].text, /target_account_id = NULL/);
assert.match(client.calls[6].text, /status IN \('completed', 'cancelled'\)/);
assert.match(client.calls[7].text, /DELETE FROM files/);
assert.match(client.calls[8].text, /DELETE FROM storage_accounts/);
assert.match(client.calls[2].text, /telegram_target_states/);
assert.match(client.calls[3].text, /telegram_channel_subscriptions/);
assert.match(client.calls[4].text, /transfer_tasks/);
assert.match(client.calls[4].text, /retryable = true/);
assert.match(client.calls[5].text, /storage_account_leases/);
assert.match(client.calls[6].text, /telegram_write_reconciliations/);
assert.match(client.calls[6].text, /chunk_upload_reconciliations/);
assert.match(client.calls[6].text, /ytdlp_write_reconciliations/);
assert.match(client.calls[7].text, /chunk_upload_sessions/);
assert.match(client.calls[7].text, /status IN \('open', 'completing', 'failed'\)/);
assert.match(client.calls[8].text, /target_account_id = NULL/);
assert.match(client.calls[8].text, /status IN \('completed', 'cancelled'\)/);
assert.match(client.calls[9].text, /DELETE FROM files/);
assert.match(client.calls[10].text, /DELETE FROM storage_accounts/);
});
test('delete refuses a storage account referenced by a chat target override', async () => {
const client = new ScriptedClient([
{ rows: [{ id: 'account', name: 'A', type: 's3', is_active: false }] },
{ rows: [] },
{ rows: [{ chat_id: 'chat-a' }] },
]);
await assert.rejects(
deleteStorageAccountWithClient(client as any, 'account'),
(error: unknown) => error instanceof StorageAccountConflictError && error.kind === 'job',
);
assert.match(client.calls[2].text, /telegram_target_states/);
});
test('delete refuses a storage account referenced by a fixed subscription target', async () => {
const client = new ScriptedClient([
{ rows: [{ id: 'account', name: 'A', type: 's3', is_active: false }] },
{ rows: [] },
{ rows: [] },
{ rows: [{ id: 'subscription' }] },
]);
await assert.rejects(
deleteStorageAccountWithClient(client as any, 'account'),
(error: unknown) => error instanceof StorageAccountConflictError && error.kind === 'job',
);
assert.match(client.calls[3].text, /telegram_channel_subscriptions/);
});
test('delete refuses an account referenced by an upload lease before deleting files', async () => {
@@ -58,6 +89,8 @@ test('delete refuses an account referenced by an upload lease before deleting fi
{ rows: [{ id: 'account', name: 'A', type: 's3', is_active: false }] },
{ rows: [] },
{ rows: [] },
{ rows: [] },
{ rows: [] },
{ rows: [{ id: 'lease' }] },
]);
await assert.rejects(
@@ -75,6 +108,8 @@ test('delete refuses an account referenced by an open, completing, or failed res
{ rows: [] },
{ rows: [] },
{ rows: [] },
{ rows: [] },
{ rows: [] },
{ rows: [{ upload_id: 'upload', status }] },
]);
await assert.rejects(
@@ -95,6 +130,8 @@ test('delete refuses an account referenced by an unfinished or retryable transfe
const client = new ScriptedClient([
{ rows: [{ id: 'account', name: 'A', type: 's3', is_active: false }] },
{ rows: [] },
{ rows: [] },
{ rows: [] },
{ rows: [task] },
]);
await assert.rejects(
@@ -114,6 +151,8 @@ test('delete failure after file delete is surfaced so caller transaction can rol
{ rows: [] },
{ rows: [] },
{ rows: [] },
{ rows: [] },
{ rows: [] },
{ rowCount: 2 },
{ error: new Error('account delete failed') },
]);
@@ -93,6 +93,22 @@ export async function deleteStorageAccountWithClient(client: LifecycleClient, ac
);
if (taskReference.rows.length > 0) throw new StorageAccountConflictError('job');
const targetStateReference = await client.query(
`SELECT chat_id FROM telegram_target_states
WHERE account_id = $1 AND expires_at > NOW()
LIMIT 1 FOR UPDATE`,
[accountId],
);
if (targetStateReference.rows.length > 0) throw new StorageAccountConflictError('job');
const subscriptionTargetReference = await client.query(
`SELECT id FROM telegram_channel_subscriptions
WHERE target_mode = 'fixed' AND target_account_id = $1
LIMIT 1 FOR UPDATE`,
[accountId],
);
if (subscriptionTargetReference.rows.length > 0) throw new StorageAccountConflictError('job');
const transferReference = await client.query(
`SELECT id FROM transfer_tasks
WHERE target_account_id = $1
@@ -20,8 +20,9 @@ test('Telegram, yt-dlp, web and chunk permanent writes all hold account operatio
test('Telegram job target snapshot and storage switch participate in account row locking transactions', () => {
const jobs = read('./telegramChannelJobs.ts');
const storage = read('./storage.ts');
assert.match(jobs, /await lockStorageAccountForUse\(client, target\.accountId\)/);
assert.match(jobs, /INSERT INTO telegram_background_jobs[\s\S]*await client\.query\('COMMIT'\)/);
assert.match(jobs, /persistChannelJobAdmission\(input/);
assert.match(jobs, /lockStorageAccount:\s*\(client, accountId\)\s*=>\s*lockStorageAccountForUse/);
assert.match(jobs, /withTransaction:[\s\S]*await client\.query\('BEGIN'\)[\s\S]*await client\.query\('COMMIT'\)/);
assert.match(storage, /switchStorageToLocalWithClient/);
assert.match(storage, /switchStorageAccountWithClient/);
assert.match(storage, /await client\.query\('BEGIN'\)[\s\S]*await client\.query\('COMMIT'\)/);
@@ -0,0 +1,46 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { reconcileTelegramAllowedUsers } from './telegramState.js';
function fakeQuery(authenticatedIds: number[]) {
const calls: Array<{ sql: string; params?: unknown[] }> = [];
const query = async (sql: string, params?: unknown[]) => {
calls.push({ sql, params });
if (/SELECT user_id FROM telegram_auth/.test(sql)) {
return { rows: authenticatedIds.map(user_id => ({ user_id })), rowCount: authenticatedIds.length } as any;
}
if (/DELETE FROM telegram_auth/.test(sql)) {
const keep = new Set((params?.[0] as number[]) || []);
const revoked = authenticatedIds.filter(id => !keep.has(id));
return { rows: revoked.map(user_id => ({ user_id })), rowCount: revoked.length } as any;
}
throw new Error(`unexpected SQL: ${sql}`);
};
return { query, calls };
}
test('allowlist reconciliation atomically revokes removed DB and cache auth users', async () => {
const cache = new Map<number, { authenticatedAt: Date }>([
[1, { authenticatedAt: new Date(0) }],
[2, { authenticatedAt: new Date(0) }],
[3, { authenticatedAt: new Date(0) }],
]);
const db = fakeQuery([1, 2, 3]);
const result = await reconcileTelegramAllowedUsers([2, 4], { query: db.query as any, cache });
assert.deepEqual(result.added, [4]);
assert.deepEqual(result.removed, [1, 3]);
assert.deepEqual(result.revoked, [1, 3]);
assert.deepEqual([...cache.keys()], [2]);
assert.match(db.calls[1].sql, /DELETE FROM telegram_auth/);
});
test('broadcast recipients are always the current allowlist and authenticated-cache intersection', async () => {
const cache = new Map<number, { authenticatedAt: Date }>([
[2, { authenticatedAt: new Date(0) }],
[3, { authenticatedAt: new Date(0) }],
]);
const db = fakeQuery([2, 3]);
const result = await reconcileTelegramAllowedUsers([2, 4], { query: db.query as any, cache });
assert.deepEqual(result.recipients, [2]);
});
@@ -7,7 +7,17 @@ assert.equal(canTelegramUserAuthenticate(7, [7]), true);
assert.equal(canTelegramUserAuthenticate(8, [7]), false);
const source = fs.readFileSync(new URL('./telegramBot.ts', import.meta.url), 'utf8');
const storageRoute = fs.readFileSync(new URL('../routes/storage.ts', import.meta.url), 'utf8');
const commandRegistry = fs.readFileSync(new URL('../utils/telegramCommandRegistry.ts', import.meta.url), 'utf8');
assert.doesNotMatch(source, /Received text from.*\$\{text\}/);
assert.doesNotMatch(source, /command received.*\$\{text\}/);
assert.match(source, /reconcileTelegramAllowedUsers\(allowedUsers\)/);
assert.doesNotMatch(source, /for \(const userId of authenticatedUsers\.keys\(\)\)/);
assert.match(source, /text === '\/logout'/);
assert.match(source, /await revokeAuthenticatedUser\(senderId\)/);
assert.match(commandRegistry, /command: 'logout'/);
assert.match(storageRoute, /added: reconciliation\.added\.length/);
assert.match(storageRoute, /removed: reconciliation\.removed\.length/);
assert.match(storageRoute, /revoked: reconciliation\.revoked\.length/);
console.log('telegram authentication allowlist ok');
@@ -10,11 +10,17 @@ test('date and tag wizards require a final scope and target confirmation', () =>
assert.match(bot, /请确认任务范围/);
assert.match(bot, /固定存储/);
assert.match(bot, /估计量将在扫描中实时更新/);
assert.match(bot, /共 \$\{state\.dayCount/);
assert.match(bot, /范围较大/);
assert.match(bot, /parseTelegramDateRange\(state\.startDate!, input\)/);
assert.match(bot, /发送 `确认` 开始/);
});
test('confirmed channel jobs persist the snapshotted storage target', () => {
assert.match(bot, /targetProvider: state\.targetProvider/);
assert.match(jobs, /storageProvider: options\.targetProvider/);
assert.match(jobs, /storageAccountId: options\.targetAccountId/);
test('confirmed channel jobs pass an immutable target snapshot to admission', () => {
assert.match(bot, /state\.target = target/);
assert.match(bot, /target: state\.target/);
assert.match(jobs, /resolveChannelJobTargetSnapshot\(options\.target/);
assert.match(jobs, /target,/);
assert.doesNotMatch(jobs, /storageProvider: options\.targetProvider/);
assert.doesNotMatch(jobs, /storageAccountId: options\.targetAccountId/);
});
+449 -90
View File
@@ -5,12 +5,15 @@ import { Raw } from 'telegram/events/index.js';
import fs from 'fs';
import path from 'path';
import crypto from 'crypto';
import { storageManager } from '../services/storage.js';
import { authenticatedUsers, passwordInputState, isAuthenticatedAsync, loadAuthenticatedUsers, persistAuthenticatedUser, userStates, TelegramUserState } from './telegramState.js';
import { storageManager, type StorageTargetSnapshot } from '../services/storage.js';
import { passwordInputState, isAuthenticatedAsync, loadAuthenticatedUsers, persistAuthenticatedUser, revokeAuthenticatedUser, reconcileTelegramAllowedUsers, userStates, TelegramUserState } from './telegramState.js';
import { is2FAEnabled, generateOTPAuthUrl, verifyTOTP, activate2FA } from '../utils/security.js';
import { handleStart, handleHelp, handleStorage, handleStorageSwitch, handleStorageSwitchCallback, handleList, handleDelete, handleDeleteConfirmCallback, handleTasks, handleTaskCenterCallback, handleBulkTaskCancelCallback, handleStopTasks, handlePauseTasks, handleResumeTasks, handleCancelTask, handleChannelTaskQueueCallback, handleRetryFailedTasks, handleDownloadWorkers, handleDownloadWorkersCallback, handleFileConcurrency, handleFileConcurrencyCallback, handleStorageCleanupCallback, handlePathRules, handlePathOnce, handlePathSession, handlePathClear, handlePathRulesCallback, handleDuplicateMode, handleDuplicateModeCallback, handleCleanupSettings, handleCleanupSettingsCallback } from './telegramCommands.js';
import { handleFileUpload, handleCleanupCallback, pauseDownloadTasks, resumeDownloadTasks, resolveTaskChatIdForControl, refreshSilentProgress, cancelSilentTask, canControlTask, loadFileDownloadConcurrencySetting } from './telegramUpload.js';
import { handleStart, handleHelp, handleNotifications, handleStatus, handleStorage, handleStorageSwitch, handleStorageSwitchCallback, handleTarget, handleFind, handleList, handleDelete, handleDeleteConfirmCallback, handleTelegramFileBrowserCallback, applyPendingTelegramFileMutation, handleTasks, handleTaskCenterCallback, handleBulkTaskCancelCallback, handleStopTasks, handlePauseTasks, handleResumeTasks, handleCancelTask, handleChannelTaskQueueCallback, handleRetryFailedTasks, handleDownloadWorkers, handleDownloadWorkersCallback, handleFileConcurrency, handleFileConcurrencyCallback, handleStorageCleanupCallback, handlePathRules, handlePathOnce, handlePathSession, handlePathClear, handlePathRulesCallback, handleDuplicateMode, handleDuplicateModeCallback, handleCleanupSettings, handleCleanupSettingsCallback } from './telegramCommands.js';
import { handleFileUpload, handleCleanupCallback, pauseDownloadTasks, resumeDownloadTasks, resolveTaskChatIdForControl, refreshSilentProgress, cancelSilentTask, canControlTask, listFailedDownloadTaskDetails, retryFailedDownloadTasks, loadFileDownloadConcurrencySetting } from './telegramUpload.js';
import { handleYtDlpCommand, setYtDlpNotifier } from './ytDlpDownload.js';
import { runYtDlpProbe, type YtDlpProbeResult } from './ytDlpProbe.js';
import { YtDlpConfirmationStore } from './ytDlpConfirmation.js';
import { enqueueTelegramNotification, flushTelegramNotificationDigest, resolveNotificationOwnerUserId } from './telegramNotificationDelivery.js';
import {
enqueueTelegramDateDownload,
enqueueTelegramTagDownload,
@@ -22,6 +25,11 @@ import {
subscribeTelegramChannel,
unsubscribeTelegramChannel,
updateTelegramSubscriptionFolder,
setTelegramSubscriptionEnabled,
updateTelegramSubscriptionTarget,
setTelegramSubscriptionFromNow,
requestTelegramSubscriptionSync,
retryTelegramBackgroundJob,
TELEGRAM_COMMENTS_MAX_PER_POST,
} from './telegramChannelJobs.js';
import { cleanupOrphanFiles, isAutoCleanupEnabled, startPeriodicCleanup } from './orphanCleanup.js';
@@ -29,10 +37,23 @@ import { MSG, buildStartPrompt, buildAuthSuccess, build2FASetupCaption, buildCle
import { query } from '../db/index.js';
import { getConfiguredTelegramAllowedUsers, addTelegramAllowedUser, countAuthenticatedTelegramUsers, shouldAutoAllowFirstTelegramUser, verifyTelegramPin } from '../utils/authSettings.js';
import { assertPublicHttpUrl } from '../utils/networkSecurity.js';
import { consumeOrGetTelegramTargetState } from '../utils/telegramTargetStateStore.js';
import { BOT_COMMANDS, buildBotCommandMenu, normalizeBotCommandText } from '../utils/telegramCommandRegistry.js';
import { buildCommandHomePage } from './telegramCommandDispatcher.js';
import { rememberRecentTelegramPathPersistent, buildPathPreviewLine, applyPendingTelegramPathInputPersistent, getPendingTelegramPathInput, clearPendingTelegramPathInput } from '../utils/telegramPathSettings.js';
import { isTelegramSubscriptionVisibleInManagement } from './telegramSubscriptionVisibility.js';
import { buildTelegramSubscriptionPage, parseTelegramSubscriptionCallback } from './telegramSubscriptionManagement.js';
import { buildTelegramSubscriptionPage, buildSubscriptionOperations, parseTelegramSubscriptionCallback } from './telegramSubscriptionManagement.js';
import { parseDateOnlyStrict, parseTelegramDateRange } from './telegramDateRange.js';
import { TelegramInteractionStore } from './telegramInteractionState.js';
import { messageChatKey, callbackChatKey, telegramSubscriptionPeerKey } from '../bot/context.js';
import { buildSubscriptionDisplayLines, buildSubscriptionManagePanel as buildSubscriptionManagePanelText } from '../bot/presentation/subscription.js';
import {
classifyTelegramBotStartupError,
markTelegramBotError,
markTelegramBotReady,
markTelegramBotStarting,
resetTelegramBotStatus,
} from './telegramBotStatus.js';
function buildBotStartKeyboard(): Api.ReplyInlineMarkup {
return new Api.ReplyInlineMarkup({
@@ -49,26 +70,73 @@ function buildBotStartKeyboard(): Api.ReplyInlineMarkup {
});
}
function buildBotMoreKeyboard(): Api.ReplyInlineMarkup {
const visible = BOT_COMMANDS.filter(command => command.help && !['start', 'tasks', 'storage', 'help'].includes(command.command));
function homePageKeyboard(requestedPage: number): Api.ReplyInlineMarkup {
const page = buildCommandHomePage(requestedPage);
return new Api.ReplyInlineMarkup({
rows: visible.slice(0, 12).map(command => new Api.KeyboardButtonRow({
buttons: [new Api.KeyboardButtonCallback({ text: `/${command.command} · ${command.description}`, data: Buffer.from(`home_cmd_${command.command}`) })],
rows: page.buttons.map(row => new Api.KeyboardButtonRow({
buttons: row.map(button => new Api.KeyboardButtonCallback({ text: button.text, data: Buffer.from(button.data) })),
})),
});
}
function homePageText(requestedPage: number): string {
const page = buildCommandHomePage(requestedPage);
const labels: Record<string, string> = { main: '常用入口', files: '文件与保存位置', channels: '频道与订阅', settings: '任务与系统设置', security: '安全' };
return [`☰ **${labels[page.category] || page.category}**`, `第 ${page.page + 1}/${page.totalPages} 页`, '', '点击按钮可直接打开对应功能。'].join('\n');
}
async function handleBotHomeCallback(update: Api.UpdateBotCallbackQuery, data: string): Promise<void> {
const userId = update.userId.toJSNumber();
if (!(await isAuthenticatedAsync(userId))) return;
if (data === 'home_tasks') return handleTasks(await client!.getMessages(update.peer, { ids: Number(update.msgId) }).then(messages => messages[0] as Api.Message));
if (data === 'home_storage') return handleStorage(await client!.getMessages(update.peer, { ids: Number(update.msgId) }).then(messages => messages[0] as Api.Message));
const text = data === 'home_upload'
? '📤 直接发送或转发任意文件即可上传。\n\n使用 /path_rules 选择保存位置,使用 /tasks 查看进度。'
: data === 'home_more'
? '☰ **更多能力**\n\n请选择下方命令,或发送 /help 查看完整说明。'
: `请发送 /${data.replace(/^home_cmd_/, '')} 使用此能力。`;
await client!.editMessage(update.peer, { message: Number(update.msgId), text, buttons: data === 'home_more' ? buildBotMoreKeyboard() : buildBotStartKeyboard() });
const currentMessage = async () => client!.getMessages(update.peer, { ids: Number(update.msgId) }).then(messages => messages[0] as Api.Message);
if (data === 'home_tasks' || data === 'home_open_tasks') return handleTasks(await currentMessage());
if (data === 'home_storage' || data === 'home_open_storage') return handleStorage(await currentMessage());
if (data === 'home_upload') {
await client!.editMessage(update.peer, { message: Number(update.msgId), text: '📤 直接发送或转发任意文件即可上传。\n\n使用 /path_rules 选择保存位置,使用 /tasks 查看进度。', buttons: buildBotStartKeyboard() });
return;
}
const pageMatch = data.match(/^home_page_(\d+)$/);
if (data === 'home_more' || pageMatch) {
const page = pageMatch ? Number(pageMatch[1]) : 0;
await client!.editMessage(update.peer, { message: Number(update.msgId), text: homePageText(page), buttons: homePageKeyboard(page) });
return;
}
const openMatch = data.match(/^home_open_([a-z0-9_]+)$/);
if (!openMatch) return;
const command = openMatch[1];
const message = await currentMessage();
if (command === 'storage_switch') return handleStorageSwitch(message);
if (command === 'target') return handleTarget(message, []);
if (command === 'path_rules') return handlePathRules(message);
if (command === 'tg_download') return startTelegramWizard(message, userId, 'tg_download');
if (command === 'tg_sub') return startTelegramWizard(message, userId, 'tg_sub_manage');
if (command === 'tg_subs') {
const rows = await listManageableTelegramSubscriptions(userId);
await message.reply({ message: formatSubscriptionList(rows) });
return;
}
if (command === 'list') return handleList(message, []);
if (command === 'find') return handleFind(message, []);
if (command === 'status') return handleStatus(message);
if (command === 'notifications') return handleNotifications(message, []);
if (command === 'tasks') return handleTasks(message);
if (command === 'task_pause') return handlePauseTasks(message, []);
if (command === 'task_resume') return handleResumeTasks(message, []);
if (command === 'stop_tasks') return handleStopTasks(message);
if (command === 'tg_retry') return handleRetryFailedTasks(message, []);
if (command === 'download_workers') return handleDownloadWorkers(message);
if (command === 'file_concurrency') return handleFileConcurrency(message);
if (command === 'pc') return handlePathClear(message);
if (command === 'duplicate_mode') return handleDuplicateMode(message);
if (command === 'cleanup_settings') return handleCleanupSettings(message);
if (command === 'help') return handleHelp(message);
if (command === 'logout') {
await client!.sendMessage(update.peer, { message: '发送 /logout 可立即撤销当前 Bot 认证。' });
return;
}
const definition = BOT_COMMANDS.find(item => item.command === command);
await client!.sendMessage(update.peer, { message: `/${command}${definition?.usage ? ` ${definition.usage}` : ''}` });
}
// Session File Path
@@ -87,6 +155,9 @@ interface TelegramWizardState {
startDate?: string;
tag?: string;
endDate?: string;
dayCount?: number;
requiresLargeRangeConfirmation?: boolean;
target?: StorageTargetSnapshot;
targetProvider?: string;
targetAccountId?: string | null;
targetAccountName?: string;
@@ -130,7 +201,46 @@ function buildTelegramCommentsKeyboard(): Api.ReplyInlineMarkup {
});
}
const telegramWizardStates = new Map<number, TelegramWizardState>();
const TELEGRAM_INTERACTION_TTL_MS = Math.max(60_000, parseInt(process.env.TELEGRAM_INTERACTION_TTL_MS || '900000', 10) || 900_000);
const TELEGRAM_INTERACTION_MAX_ENTRIES = Math.max(10, parseInt(process.env.TELEGRAM_INTERACTION_MAX_ENTRIES || '1000', 10) || 1_000);
const telegramWizardStates = new TelegramInteractionStore<TelegramWizardState>({
ttlMs: TELEGRAM_INTERACTION_TTL_MS,
maxEntries: TELEGRAM_INTERACTION_MAX_ENTRIES,
});
const ytDlpConfirmations = new YtDlpConfirmationStore({
ttlMs: Math.min(TELEGRAM_INTERACTION_TTL_MS, 5 * 60_000),
maxEntries: Math.min(TELEGRAM_INTERACTION_MAX_ENTRIES, 500),
});
function putTelegramWizardState(
userId: number,
chatKey: string,
state: TelegramWizardState,
originMessageId?: number,
): void {
telegramWizardStates.set({
userId,
chatKey,
kind: state.kind,
step: state.step,
originMessageId,
value: state,
});
}
function refreshTelegramWizardState(
userId: number,
chatKey: string,
state: TelegramWizardState,
originMessageId?: number,
): void {
telegramWizardStates.update(userId, chatKey, {
kind: state.kind,
step: state.step,
value: state,
originMessageId,
});
}
const pendingSubscriptionCancels = new Map<string, {
userId: number;
peerKey: string;
@@ -303,7 +413,14 @@ function buildTelegramWizardPrompt(state: TelegramWizardState): string {
if (state.step === 'confirm') {
const range = state.kind === 'tg_tag' ? `标签:#${state.tag}` : `日期:${state.startDate} → ${state.endDate}`;
const dateRangeLines = state.kind === 'tg_date'
? [
`📅 共 ${state.dayCount || 0} 天(含首尾日期)`,
state.requiresLargeRangeConfirmation ? '⚠️ 范围较大:确认后将执行分段扫描,请核对日期和存储目标。' : null,
]
: [];
return [title, '', '请确认任务范围:', `📍 频道:${state.source}`, `🔎 ${range}`,
...dateRangeLines,
`💬 评论区:${state.includeComments ? `包含(每帖最多 ${state.commentsMaxPerPost} 条)` : '不包含'}`,
`📁 目录:${state.customFolder || '默认自动分类'}`,
`☁️ 固定存储:${state.targetProvider || '当前存储'} / ${state.targetAccountName || state.targetAccountId || '当前账户'}`,
@@ -348,7 +465,12 @@ function buildTelegramWizardPrompt(state: TelegramWizardState): string {
}
function isDateOnly(text: string): boolean {
return /^\d{4}-\d{2}-\d{2}$/.test(text.trim());
try {
parseDateOnlyStrict(text.trim());
return true;
} catch {
return false;
}
}
@@ -439,26 +561,38 @@ async function replyWithJobResult(statusMessage: Api.Message, fallbackMessage: A
async function startTelegramWizard(message: Api.Message, senderId: number, kind: TelegramWizardKind): Promise<void> {
const state: TelegramWizardState = { kind, step: kind === 'tg_download' ? 'mode' : 'source' };
telegramWizardStates.set(senderId, state);
const chatKey = messageChatKey(message, senderId);
let originMessageId: number | undefined;
if (kind === 'tg_sub_manage') {
const rows = await listManageableTelegramSubscriptions(senderId);
await message.reply({ message: buildSubscriptionManagePanel(rows, 0), buttons: buildSubscriptionActionKeyboard(rows, 0) });
const reply = await message.reply({ message: buildSubscriptionManagePanel(rows, 0), buttons: buildSubscriptionActionKeyboard(rows, 0) });
originMessageId = (reply as Api.Message).id;
putTelegramWizardState(senderId, chatKey, state, originMessageId);
return;
}
await message.reply({
const reply = await message.reply({
message: buildTelegramWizardPrompt(state),
buttons: kind === 'tg_download' ? buildTelegramDownloadModeKeyboard() : undefined,
});
originMessageId = (reply as Api.Message).id;
putTelegramWizardState(senderId, chatKey, state, originMessageId);
}
async function handleTelegramWizardMessage(message: Api.Message, senderId: number, text: string): Promise<boolean> {
const state = telegramWizardStates.get(senderId);
if (!state) return false;
const chatKey = messageChatKey(message, senderId);
const lookup = telegramWizardStates.lookup(senderId, chatKey);
if (lookup.status === 'expired') {
await message.reply({ message: '⌛ 当前向导已过期,请重新打开。' });
return true;
}
if (lookup.status === 'missing') return false;
const state = lookup.record.value;
const input = text.trim();
if (!input) return true;
refreshTelegramWizardState(senderId, chatKey, state, lookup.record.originMessageId);
if (isCancelInput(input)) {
telegramWizardStates.delete(senderId);
telegramWizardStates.delete(senderId, chatKey);
await message.reply({ message: '已取消 Telegram 频道操作向导。' });
return true;
}
@@ -501,7 +635,7 @@ async function handleTelegramWizardMessage(message: Api.Message, senderId: numbe
await message.reply({ message: '❌ 没有这个序号,请回复列表中的序号,或发送频道用户名/链接来新增订阅。' });
return true;
}
telegramWizardStates.delete(senderId);
telegramWizardStates.delete(senderId, chatKey);
await sendSubscriptionCancelConfirmation(message, senderId, target, 0);
return true;
}
@@ -534,7 +668,7 @@ async function handleTelegramWizardMessage(message: Api.Message, senderId: numbe
}
if (state.kind === 'tg_sub_manage') {
telegramWizardStates.delete(senderId);
telegramWizardStates.delete(senderId, chatKey);
try {
if (state.subscriptionId) {
const sub = await updateTelegramSubscriptionFolder(senderId, state.subscriptionId, state.customFolder || null);
@@ -593,21 +727,21 @@ async function handleTelegramWizardMessage(message: Api.Message, senderId: numbe
await message.reply({ message: '请发送 `确认` 开始任务,或发送 `取消` 放弃。' });
return true;
}
telegramWizardStates.delete(senderId);
telegramWizardStates.delete(senderId, chatKey);
try {
if (state.kind === 'tg_tag') {
const queuedMsg = await message.reply({ message: `⏳ 已确认,开始后台扫描 ${state.source} 中带有 ${state.tag?.startsWith('#') ? state.tag : `#${state.tag}`} 的媒体消息…` });
await replyWithJobResult(queuedMsg as Api.Message, message, enqueueTelegramTagDownload(client!, message, senderId, state.source!, state.tag!, state.customFolder, {
includeComments: Boolean(state.includeComments), commentsMaxPerPost: state.commentsMaxPerPost || TELEGRAM_COMMENTS_MAX_PER_POST,
onScanComplete: summary => updateScanStatusMessage(queuedMsg as Api.Message, summary), onProgress: summary => updateJobProgressMessage(queuedMsg as Api.Message, summary),
targetProvider: state.targetProvider, targetAccountId: state.targetAccountId,
target: state.target,
}), 'tag');
} else {
const queuedMsg = await message.reply({ message: `⏳ 已确认,开始后台扫描 ${state.source}:${state.startDate} → ${state.endDate}…` });
await replyWithJobResult(queuedMsg as Api.Message, message, enqueueTelegramDateDownload(client!, message, senderId, state.source!, state.startDate!, state.endDate!, state.customFolder, {
includeComments: Boolean(state.includeComments), commentsMaxPerPost: state.commentsMaxPerPost || TELEGRAM_COMMENTS_MAX_PER_POST,
onScanComplete: summary => updateScanStatusMessage(queuedMsg as Api.Message, summary), onProgress: summary => updateJobProgressMessage(queuedMsg as Api.Message, summary),
targetProvider: state.targetProvider, targetAccountId: state.targetAccountId,
target: state.target,
}), 'date');
}
} catch (error) {
@@ -618,8 +752,12 @@ async function handleTelegramWizardMessage(message: Api.Message, senderId: numbe
if (state.step === 'tag') {
state.tag = input;
const target = storageManager.getActiveTarget();
const selectedTarget = await consumeOrGetTelegramTargetState(chatKey);
const target = selectedTarget
? storageManager.getTarget(selectedTarget.provider, selectedTarget.accountId)
: storageManager.getActiveTarget();
const accounts = await storageManager.getAccounts();
state.target = target;
state.targetProvider = target.provider.name;
state.targetAccountId = target.accountId;
state.targetAccountName = accounts.find(account => String(account.id) === String(target.accountId || ''))?.name || (target.provider.name === 'local' ? '服务器本地目录' : undefined);
@@ -644,9 +782,23 @@ async function handleTelegramWizardMessage(message: Api.Message, senderId: numbe
return true;
}
let dateRange;
try {
dateRange = parseTelegramDateRange(state.startDate!, input);
} catch (error) {
await message.reply({ message: `❌ ${error instanceof Error ? error.message : '日期范围无效'}` });
return true;
}
state.endDate = input;
const target = storageManager.getActiveTarget();
state.dayCount = dateRange.dayCount;
state.requiresLargeRangeConfirmation = dateRange.requiresLargeRangeConfirmation;
const selectedTarget = await consumeOrGetTelegramTargetState(chatKey);
const target = selectedTarget
? storageManager.getTarget(selectedTarget.provider, selectedTarget.accountId)
: storageManager.getActiveTarget();
const accounts = await storageManager.getAccounts();
state.target = target;
state.targetProvider = target.provider.name;
state.targetAccountId = target.accountId;
state.targetAccountName = accounts.find(account => String(account.id) === String(target.accountId || ''))?.name || (target.provider.name === 'local' ? '服务器本地目录' : undefined);
@@ -667,6 +819,12 @@ function buildSubscriptionActionKeyboard(rows: any[], requestedPage = 0): Api.Re
new Api.KeyboardButtonRow({
buttons: [new Api.KeyboardButtonCallback({ text: `${page.startIndex + localIndex + 1}. ${row.title || row.source}`, data: Buffer.from(`tsub_view_${row.id}_${page.page}`) })],
}),
...[0, 2, 5].map((start, index, starts) => new Api.KeyboardButtonRow({
buttons: buildSubscriptionOperations(row).slice(start, starts[index + 1]).map(operation => new Api.KeyboardButtonCallback({
text: operation.label,
data: Buffer.from(`tsub_${operation.action}_${row.id}_${page.page}`),
})),
})),
new Api.KeyboardButtonRow({
buttons: [
new Api.KeyboardButtonCallback({ text: '✏️ 修改专属目录', data: Buffer.from(`tsub_folder_${row.id}_${page.page}`) }),
@@ -684,43 +842,8 @@ function buildSubscriptionActionKeyboard(rows: any[], requestedPage = 0): Api.Re
});
}
function buildSubscriptionDisplayLines(row: any, index: number): string {
const status = row.enabled ? '✅' : '⏸️';
const sourceLine = row.source_original && row.source_original !== row.source
? ` ${row.source_original} → ${row.source} · last_id=${row.last_message_id || 0}`
: ` ${row.source} · last_id=${row.last_message_id || 0}`;
return [
`${index + 1}. ${status} ${row.title || row.source_original || row.source}`,
sourceLine,
row.folder_override ? ` 📁 专属目录:${row.folder_override}` : ' 📁 保存策略:默认自动分类',
row.last_scan_at ? ` 🔎 上次扫描:${new Date(row.last_scan_at).toLocaleString('zh-CN', { hour12: false })}` : ' 🔎 尚未扫描',
row.last_success_at ? ` ✅ 上次成功:${new Date(row.last_success_at).toLocaleString('zh-CN', { hour12: false })}` : null,
row.next_scan_at ? ` ⏭️ 下次扫描约:${new Date(row.next_scan_at).toLocaleString('zh-CN', { hour12: false })}` : null,
row.last_result ? ` 📊 最近结果:${row.last_result.status || 'unknown'}${row.last_result.found !== undefined ? `,发现 ${row.last_result.found}` : ''}${row.last_result.failed ? `,失败 ${row.last_result.failed}` : ''}` : null,
row.last_error ? ` ⚠️ 最近错误:${row.last_error}` : null,
!row.enabled && row.disabled_reason ? ` ⚠️ ${row.disabled_reason}` : null,
!row.enabled && row.disabled_at ? ` 暂停时间:${new Date(row.disabled_at).toLocaleString('zh-CN', { hour12: false })}` : null,
].filter(Boolean).join('\n');
}
function buildSubscriptionManagePanel(rows: any[], requestedPage = 0): string {
const page = buildTelegramSubscriptionPage(rows, requestedPage);
return [
'📡 **频道订阅管理**',
...(page.totalPages > 1 ? [`第 ${page.page + 1}/${page.totalPages} 页 · 共 ${rows.length} 个订阅`] : []),
'',
page.visibleRows.length > 0
? page.visibleRows.map((row, index) => buildSubscriptionDisplayLines(row, page.startIndex + index)).join('\n')
: '当前没有订阅。',
'',
rows.length > 0 ? '可直接点击订阅下方按钮修改/清除专属目录或取消订阅;系统自动暂停的订阅会保留提醒,重新添加同一来源可恢复。' : '回复频道用户名或链接可新增订阅。',
'回复频道用户名或链接也可新增订阅。',
'例如:`@channel_username`、`https://t.me/channel_username` 或已加入的 `https://t.me/+hash` 私密链接',
'',
'新增订阅时会询问是否为本订阅单独指定保存目录;该目录只影响这个订阅,不会改变全局 /path_rules。',
'',
'发送“取消”可退出。',
].join('\n');
return buildSubscriptionManagePanelText(rows, buildTelegramSubscriptionPage(rows, requestedPage));
}
function formatSubscriptionList(rows: any[]): string {
@@ -735,11 +858,6 @@ function formatSubscriptionList(rows: any[]): string {
].join('\n');
}
function telegramSubscriptionPeerKey(peer: any): string {
const value = peer?.userId || peer?.chatId || peer?.channelId;
return String(value?.toString?.() || value || peer?.toString?.() || '').replace(/^-100/, '').replace(/^-/, '');
}
function buildSubscriptionCancelConfirm(target: any, token: string): { text: string; buttons: Api.ReplyInlineMarkup } {
return {
text: [
@@ -847,7 +965,69 @@ function generatePasswordKeyboard(currentLength: number): Api.ReplyInlineMarkup
});
}
// Handle Password Callback
function buildYtDlpPreviewText(metadata: YtDlpProbeResult, target: StorageTargetSnapshot, folder: string): string {
const duration = metadata.durationSeconds == null ? '未知' : `${Math.floor(metadata.durationSeconds / 60)}:${String(Math.floor(metadata.durationSeconds % 60)).padStart(2, '0')}`;
return [
'🔗 **作为链接下载?**',
'',
`标题:${metadata.title}`,
`站点:${metadata.site}`,
`时长:${duration}`,
`目标:${target.provider.name} / ${target.accountId || 'local'}`,
`目录:${folder}`,
'',
'请选择格式;没有确认不会创建下载任务。Cookie 只能在 Web 端加密配置,聊天中不会接收。',
].join('\n');
}
function buildYtDlpConfirmKeyboard(token: string): Api.ReplyInlineMarkup {
return new Api.ReplyInlineMarkup({ rows: [
new Api.KeyboardButtonRow({ buttons: [
new Api.KeyboardButtonCallback({ text: '最佳视频', data: Buffer.from(`yp_best_${token}`) }),
new Api.KeyboardButtonCallback({ text: '仅音频', data: Buffer.from(`yp_audio_${token}`) }),
] }),
new Api.KeyboardButtonRow({ buttons: [new Api.KeyboardButtonCallback({ text: '取消', data: Buffer.from(`yp_cancel_${token}`) })] }),
] });
}
async function createYtDlpPreview(message: Api.Message, userId: number, url: string): Promise<void> {
await assertPublicHttpUrl(url);
const targetSelection = await consumeOrGetTelegramTargetState(messageChatKey(message, userId));
const target = targetSelection ? storageManager.getTarget(targetSelection.provider, targetSelection.accountId) : storageManager.getActiveTarget();
const metadata = await runYtDlpProbe(url);
const folder = 'ytdlp';
const preview = await message.reply({ message: buildYtDlpPreviewText(metadata, target, folder) }) as Api.Message;
const issued = ytDlpConfirmations.issue({ actorId: userId, chatKey: messageChatKey(message, userId), messageId: preview.id, url, metadata, target, format: 'best', folder });
await preview.edit({ text: preview.message, buttons: buildYtDlpConfirmKeyboard(issued.token) });
}
async function handleYtDlpPreviewCallback(update: Api.UpdateBotCallbackQuery, data: string): Promise<void> {
if (!client) return;
const match = data.match(/^yp_(best|audio|cancel)_([A-Za-z0-9_-]+)$/);
if (!match) return;
const [, action, token] = match;
const actorId = update.userId.toJSNumber();
const binding = { actorId, chatKey: callbackChatKey(update, actorId), messageId: Number(update.msgId) };
if (action === 'cancel') {
const cancelled = ytDlpConfirmations.cancel(token, binding);
await client.editMessage(update.peer, { message: update.msgId, text: cancelled ? '已取消链接下载,未创建任务。' : '确认已失效,请重新发送链接。' });
await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: cancelled ? '已取消' : '已失效', alert: !cancelled }));
return;
}
const consumed = ytDlpConfirmations.consume(token, binding);
if (consumed.status !== 'ok') {
await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: '确认已失效或不属于当前聊天', alert: true }));
return;
}
const currentMessage = (await client.getMessages(update.peer, { ids: [Number(update.msgId)] }))[0] as Api.Message;
await handleYtDlpCommand(currentMessage, consumed.value.url, consumed.value.target, {
format: action === 'audio' ? 'audio' : 'best', folder: consumed.value.folder, metadata: consumed.value.metadata as unknown as Record<string, unknown>,
});
await client.editMessage(update.peer, { message: update.msgId, text: `${buildYtDlpPreviewText(consumed.value.metadata, consumed.value.target, consumed.value.folder)}\n\n✅ 已确认并提交。` });
await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: '任务已提交' }));
}
export function canTelegramUserAuthenticate(userId: number, allowedUsers: number[]): boolean {
return allowedUsers.length > 0 && allowedUsers.includes(userId);
}
@@ -1017,6 +1197,33 @@ async function handleCleanupButtonCallback(update: Api.UpdateBotCallbackQuery, c
}
}
async function handleUploadReceiptCallback(update: Api.UpdateBotCallbackQuery, data: string): Promise<void> {
if (!client) return;
const userId = update.userId.toJSNumber();
if (!(await isAuthenticatedAsync(userId))) {
await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: MSG.AUTH_REQUIRED, alert: true }));
return;
}
const match = data.match(/^receipt_(retry|failures)_([A-Za-z0-9_-]+)$/);
if (!match) return;
const [, action, taskId] = match;
const chatId = resolveTaskChatIdForControl(taskId);
const callbackChatId = callbackChatKey(update, userId);
if (!chatId || callbackChatId !== messageChatKey(({ chatId } as unknown) as Api.Message, userId) || !canControlTask(taskId, chatId, userId)) {
await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: '任务卡已失效或不属于当前聊天', alert: true }));
return;
}
if (action === 'retry') {
const result = await retryFailedDownloadTasks(50, taskId, chatId, userId);
await refreshSilentProgress(client, update.peer, userId);
await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: result.retried > 0 ? `已重试 ${result.retried} 项` : '没有可重试失败项' }));
return;
}
const details = listFailedDownloadTaskDetails(taskId, chatId, userId);
await client.sendMessage(update.peer, { message: ['❌ **失败明细**', '', ...(details.length ? details.slice(0, 30).map(item => `• ${item}`) : ['失败记录已清理或任务已重试。'])].join('\n') });
await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: '已发送失败明细' }));
}
async function handleTaskQueueCallback(update: Api.UpdateBotCallbackQuery, data: string): Promise<void> {
if (!client) return;
const userId = update.userId.toJSNumber();
@@ -1079,17 +1286,31 @@ async function handleTelegramDownloadModeCallback(update: Api.UpdateBotCallbackQ
await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: MSG.AUTH_REQUIRED, alert: true }));
return;
}
const chatKey = callbackChatKey(update, userId);
const action = data.replace(/^tgd_/, '');
const validation = telegramWizardStates.validateCallback({
userId,
chatKey,
messageId: Number(update.msgId),
action,
allowedActions: ['cancel', 'mode_date', 'mode_tag', 'comments_on', 'comments_off'],
});
if (!validation.ok) {
await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: '向导已失效,请重新打开', alert: true }));
return;
}
const record = telegramWizardStates.get(userId, chatKey)!;
const state = record.value;
if (data === 'tgd_cancel') {
telegramWizardStates.delete(userId);
telegramWizardStates.delete(userId, chatKey);
await client.editMessage(update.peer, { message: update.msgId, text: '已取消频道文件下载向导。' });
await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: '已取消' }));
return;
}
const state = telegramWizardStates.get(userId) || { kind: 'tg_download' as TelegramWizardKind, step: 'mode' as TelegramWizardStep };
if (data === 'tgd_mode_date') {
state.kind = 'tg_date';
state.step = 'source';
telegramWizardStates.set(userId, state);
putTelegramWizardState(userId, chatKey, state, record.originMessageId);
await client.editMessage(update.peer, { message: update.msgId, text: buildTelegramWizardPrompt(state) });
await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: '按日期下载' }));
return;
@@ -1097,7 +1318,7 @@ async function handleTelegramDownloadModeCallback(update: Api.UpdateBotCallbackQ
if (data === 'tgd_mode_tag') {
state.kind = 'tg_tag';
state.step = 'source';
telegramWizardStates.set(userId, state);
putTelegramWizardState(userId, chatKey, state, record.originMessageId);
await client.editMessage(update.peer, { message: update.msgId, text: buildTelegramWizardPrompt(state) });
await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: '按标签下载' }));
return;
@@ -1106,13 +1327,12 @@ async function handleTelegramDownloadModeCallback(update: Api.UpdateBotCallbackQ
state.includeComments = data === 'tgd_comments_on';
state.commentsMaxPerPost = TELEGRAM_COMMENTS_MAX_PER_POST;
state.step = state.kind === 'tg_tag' ? 'tag' : 'start_date';
telegramWizardStates.set(userId, state);
putTelegramWizardState(userId, chatKey, state, record.originMessageId);
await client.editMessage(update.peer, { message: update.msgId, text: buildTelegramWizardPrompt(state) });
await client.invoke(new Api.messages.SetBotCallbackAnswer({
queryId: update.queryId,
message: state.includeComments ? '将包含评论区文件' : '仅下载频道正文文件',
}));
return;
}
}
@@ -1186,6 +1406,51 @@ async function handleTelegramSubscriptionCallback(update: Api.UpdateBotCallbackQ
return;
}
if (parsed.action === 'sync') {
await requestTelegramSubscriptionSync(userId, parsed.id);
await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: '已请求立即同步' }));
return;
}
if (parsed.action === 'pause' || parsed.action === 'resume') {
await setTelegramSubscriptionEnabled(userId, parsed.id, parsed.action === 'resume');
const refreshed = await listManageableTelegramSubscriptions(userId);
await client.editMessage(update.peer, { message: update.msgId, text: buildSubscriptionManagePanel(refreshed, parsed.page), buttons: buildSubscriptionActionKeyboard(refreshed, parsed.page) });
await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: parsed.action === 'resume' ? '已恢复订阅' : '已暂停订阅' }));
return;
}
if (parsed.action === 'from_now') {
await setTelegramSubscriptionFromNow(userId, parsed.id);
await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: '游标已更新为当前最新消息' }));
return;
}
if (parsed.action === 'target') {
const current = target.target_mode === 'fixed';
await updateTelegramSubscriptionTarget(userId, parsed.id, current
? { mode: 'follow_global' }
: { mode: 'fixed', provider: storageManager.getActiveTarget().provider.name, accountId: storageManager.getActiveTarget().accountId });
const refreshed = await listManageableTelegramSubscriptions(userId);
await client.editMessage(update.peer, { message: update.msgId, text: buildSubscriptionManagePanel(refreshed, parsed.page), buttons: buildSubscriptionActionKeyboard(refreshed, parsed.page) });
await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: current ? '已改为跟随全局' : '已固定为当前目标' }));
return;
}
if (parsed.action === 'result') {
await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: target.last_result ? JSON.stringify(target.last_result).slice(0, 180) : '暂无运行结果', alert: true }));
return;
}
if (parsed.action === 'retry') {
const failed = await query(`SELECT id FROM telegram_background_jobs WHERE kind = 'subscription_sync' AND params->>'subscriptionId' = $1 AND status IN ('failed','completed_with_errors') ORDER BY updated_at DESC LIMIT 1`, [String(target.id)]);
const retried = failed.rows[0] ? await retryTelegramBackgroundJob(userId, String(failed.rows[0].id), target.chat_id?.toString()) : null;
await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: retried ? '已重试最近失败项' : '没有可重试失败项' }));
return;
}
if (parsed.action === 'backfill') {
const state: TelegramWizardState = { kind: 'tg_date', step: 'start_date', source: target.source };
const sent = await client.sendMessage(update.peer, { message: buildTelegramWizardPrompt(state) });
putTelegramWizardState(userId, callbackChatKey(update, userId), state, sent.id);
await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: '请输入补抓开始日期' }));
return;
}
if (parsed.action === 'view') {
await client.invoke(new Api.messages.SetBotCallbackAnswer({
queryId: update.queryId,
@@ -1204,8 +1469,8 @@ async function handleTelegramSubscriptionCallback(update: Api.UpdateBotCallbackQ
subscriptionTitle: target.title,
subscriptionSource: target.source,
};
telegramWizardStates.set(userId, state);
await client.sendMessage(update.peer, { message: buildTelegramWizardPrompt(state) });
const sent = await client.sendMessage(update.peer, { message: buildTelegramWizardPrompt(state) });
putTelegramWizardState(userId, callbackChatKey(update, userId), state, sent.id);
await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: '请发送新的专属目录' }));
return;
}
@@ -1235,10 +1500,12 @@ export async function initTelegramBot(): Promise<void> {
const botToken = process.env.TELEGRAM_BOT_TOKEN || '';
if (!apiId || !apiHash || !botToken) {
resetTelegramBotStatus(false);
console.log('⚠️ 未配置 Telegram API 凭证,Bot 未启动');
console.log(' 需要设置: TELEGRAM_API_ID, TELEGRAM_API_HASH, TELEGRAM_BOT_TOKEN');
return;
}
markTelegramBotStarting();
try {
console.log('🤖 Telegram Bot 正在同步存储配置...');
@@ -1282,10 +1549,23 @@ export async function initTelegramBot(): Promise<void> {
try { fs.chmodSync(SESSION_FILE, 0o600); } catch (e) { console.warn('🤖 修正 Telegram Bot session 文件权限失败:', e); }
console.log('🤖 Telegram Bot 已连接!');
setYtDlpNotifier(async (chatId, text) => {
if (!client) return;
await client.sendMessage(chatId, { message: text });
setYtDlpNotifier(async (task, text) => {
if (!client || !task.chatId) return;
const kind = /^✅/.test(text) ? 'success' : /^❌|^⚠️/.test(text) ? 'failure' : 'success';
const userId = resolveNotificationOwnerUserId(task.ownerUserId, task.chatId);
await enqueueTelegramNotification({ userId, chatId: task.chatId, kind, message: text }, {
send: async (targetChat, message) => { await client!.sendMessage(targetChat, { message }); },
});
});
const digestTimer = setInterval(() => {
if (!client) return;
void getConfiguredTelegramAllowedUsers().then(users => Promise.all(users.map(userId =>
flushTelegramNotificationDigest(userId, String(userId), {
send: async (chatId, message) => { await client!.sendMessage(chatId, { message }); },
}).catch(() => 0),
))).catch(() => undefined);
}, 60_000);
digestTimer.unref?.();
// Ensure database table exists
try {
@@ -1335,8 +1615,12 @@ export async function initTelegramBot(): Promise<void> {
if (stats.deletedCount > 0) {
console.log(`🧹 启动清理: 删除了 ${stats.deletedCount} 个孤儿文件,释放 ${stats.freedSpace}`);
// 向所有已认证用户发送清理通知
for (const userId of authenticatedUsers.keys()) {
// 广播收件人始终与当前 allowlist 收敛。
const allowedUsers = await getConfiguredTelegramAllowedUsers();
const recipients = allowedUsers.length > 0
? (await reconcileTelegramAllowedUsers(allowedUsers)).recipients
: [];
for (const userId of recipients) {
try {
await client.sendMessage(userId, {
message: buildCleanupNotice(stats.deletedCount, stats.freedSpace)
@@ -1441,6 +1725,19 @@ export async function initTelegramBot(): Promise<void> {
return;
}
if (text === '/logout') {
if (!(await isAuthenticatedAsync(senderId))) {
await message.reply({ message: MSG.AUTH_REQUIRED });
return;
}
await revokeAuthenticatedUser(senderId);
passwordInputState.delete(senderId);
userStates.delete(senderId);
telegramWizardStates.delete(senderId, messageChatKey(message, senderId));
await message.reply({ message: '✅ 当前 Telegram 用户的 Bot 认证已撤销。发送 /start 可重新认证。' });
return;
}
// /ytdlp <url>
{
const match = text.match(/^\s*\/ytdlp(?:@\w+)?(?:\s+([\s\S]*))?\s*$/i);
@@ -1471,7 +1768,7 @@ export async function initTelegramBot(): Promise<void> {
return;
}
await handleYtDlpCommand(message, url);
await createYtDlpPreview(message, senderId, url);
return;
}
}
@@ -1514,6 +1811,21 @@ export async function initTelegramBot(): Promise<void> {
}
if (!text.startsWith('/')) {
const pastedUrl = text.trim();
if (/^https?:\/\/\S+$/i.test(pastedUrl) && await isAuthenticatedAsync(senderId)) {
try {
await createYtDlpPreview(message, senderId, pastedUrl);
} catch (error) {
await message.reply({ message: `❌ 无法预取链接:${error instanceof Error ? error.message : '未知错误'}` });
}
return;
}
try {
if (await applyPendingTelegramFileMutation(message, senderId, text)) return;
} catch (error) {
await message.reply({ message: `❌ 文件操作失败:${(error as Error).message}\n请重新输入或发送“取消”。` });
return;
}
if (isCancelInput(text)) {
const pendingMode = getPendingTelegramPathInput(chatId.toString(), senderId);
if (pendingMode) {
@@ -1687,6 +1999,16 @@ export async function initTelegramBot(): Promise<void> {
return;
}
if (text === '/target' || text.startsWith('/target ')) {
await handleTarget(message, text.split(/\s+/).slice(1));
return;
}
if (text === '/find' || text.startsWith('/find ')) {
await handleFind(message, text.split(/\s+/).slice(1));
return;
}
if (text === '/list' || text.startsWith('/list ')) {
if (!(await isAuthenticatedAsync(senderId))) {
await message.reply({ message: MSG.AUTH_REQUIRED });
@@ -1706,6 +2028,20 @@ export async function initTelegramBot(): Promise<void> {
return;
}
if (text === '/notifications' || text.startsWith('/notifications ')) {
try {
await handleNotifications(message, text.split(/\s+/).slice(1));
} catch (error) {
await message.reply({ message: `❌ 通知设置失败:${(error as Error).message}` });
}
return;
}
if (text === '/status') {
await handleStatus(message);
return;
}
if (text === '/tasks' || text === '/task') {
if (!(await isAuthenticatedAsync(senderId))) {
await message.reply({ message: MSG.AUTH_REQUIRED });
@@ -1913,6 +2249,11 @@ export async function initTelegramBot(): Promise<void> {
return;
}
if (data.startsWith('yp_')) {
await handleYtDlpPreviewCallback(callbackUpdate, data);
return;
}
// 处理垃圾缓存清理回调
if (data.startsWith('cleanup_')) {
await handleCleanupButtonCallback(callbackUpdate, data);
@@ -1941,6 +2282,12 @@ export async function initTelegramBot(): Promise<void> {
await handleStorageCleanupCallback(activeClient, callbackUpdate, data);
return;
}
// 处理文件详情和操作卡回调
if (data.startsWith('fb_')) {
await handleTelegramFileBrowserCallback(activeClient, callbackUpdate, data);
return;
}
// 处理文件删除二次确认回调
if (data.startsWith('del_')) {
await handleDeleteConfirmCallback(activeClient, callbackUpdate, data);
@@ -1989,6 +2336,11 @@ export async function initTelegramBot(): Promise<void> {
return;
}
if (data.startsWith('receipt_')) {
await handleUploadReceiptCallback(callbackUpdate, data);
return;
}
// 处理任务队列控制回调
if (data.startsWith('tq_')) {
await handleTaskQueueCallback(callbackUpdate, data);
@@ -2003,10 +2355,15 @@ export async function initTelegramBot(): Promise<void> {
}
}, new Raw({}));
markTelegramBotReady();
console.log('🤖 Telegram Bot 启动成功! (最大 2GB,账号级下载器不受此限制)');
} catch (error) {
const status = classifyTelegramBotStartupError(error);
const message = error instanceof Error ? error.message : String(error);
markTelegramBotError(status, message, status === 'auth_failed' ? '检查 Telegram Bot token/API 凭证后重启' : '检查网络与后端日志后重启');
console.error('🤖 Telegram Bot 启动失败:', error);
throw error;
}
}
@@ -2017,8 +2374,10 @@ export async function sendSecurityNotification(message: string): Promise<void> {
return;
}
const authUsers = Array.from(authenticatedUsers.keys());
for (const userId of authUsers) {
const allowedUsers = await getConfiguredTelegramAllowedUsers();
if (allowedUsers.length === 0) return;
const { recipients } = await reconcileTelegramAllowedUsers(allowedUsers);
for (const userId of recipients) {
try {
await client.sendMessage(userId, { message });
} catch (e) {
@@ -0,0 +1,30 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';
const app = fs.readFileSync(new URL('../index.ts', import.meta.url), 'utf8');
const bot = fs.readFileSync(new URL('./telegramBot.ts', import.meta.url), 'utf8');
const storage = fs.readFileSync(new URL('../routes/storage.ts', import.meta.url), 'utf8');
const compose = fs.readFileSync(new URL('../../../docker-compose.yml', import.meta.url), 'utf8');
const envExample = fs.readFileSync(new URL('../../../.env.example', import.meta.url), 'utf8');
test('Bot startup errors are observable and required mode blocks readiness', () => {
assert.match(bot, /markTelegramBotStarting\(\)/);
assert.match(bot, /markTelegramBotReady\(\)/);
assert.match(bot, /markTelegramBotError\(status/);
assert.match(bot, /throw error/);
assert.match(app, /TELEGRAM_REQUIRED/);
assert.match(app, /telegramBotBlocksReadiness\(bot\)/);
assert.match(app, /components: \{ telegramBot: bot \}/);
});
test('optional Bot failure remains visible as degraded in Web settings and readiness', () => {
assert.match(app, /应用以 degraded 状态继续/);
assert.match(app, /status: bot\.degraded \? 'degraded' : 'ready'/);
assert.match(storage, /telegramBotStatus: getTelegramBotStatus\(\)/);
});
test('deployment contract exposes TELEGRAM_REQUIRED with safe compose default and production recommendation', () => {
assert.match(compose, /TELEGRAM_REQUIRED=\$\{TELEGRAM_REQUIRED:-false\}/);
assert.match(envExample, /TELEGRAM_REQUIRED=true/);
});
@@ -0,0 +1,43 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
getTelegramBotStatus,
markTelegramBotError,
markTelegramBotReady,
markTelegramBotStarting,
resetTelegramBotStatus,
telegramBotBlocksReadiness,
} from './telegramBotStatus.js';
test('Bot status reports not configured, starting and ready without secrets', () => {
resetTelegramBotStatus(false, '2026-08-24T00:00:00.000Z');
assert.equal(getTelegramBotStatus().status, 'not_configured');
markTelegramBotStarting('2026-08-24T00:01:00.000Z');
assert.equal(getTelegramBotStatus().status, 'starting');
markTelegramBotReady('2026-08-24T00:02:00.000Z');
const status = getTelegramBotStatus();
assert.equal(status.status, 'ready');
assert.equal(status.lastConnectedAt, '2026-08-24T00:02:00.000Z');
assert.equal(status.lastError, null);
assert.equal(status.action, null);
assert.doesNotMatch(JSON.stringify(status), /token|session/i);
});
test('required Bot errors block readiness while optional Bot errors are degraded', () => {
resetTelegramBotStatus(true, '2026-08-24T00:00:00.000Z');
markTelegramBotError('auth_failed', '认证失败', '检查 Telegram 凭证', '2026-08-24T00:03:00.000Z');
assert.equal(telegramBotBlocksReadiness(getTelegramBotStatus(), true), true);
assert.equal(telegramBotBlocksReadiness(getTelegramBotStatus(), false), false);
assert.equal(getTelegramBotStatus().degraded, true);
});
test('a reconnect recovery returns status to ready and keeps counters', () => {
resetTelegramBotStatus(true, '2026-08-24T00:00:00.000Z');
markTelegramBotStarting('2026-08-24T00:01:00.000Z');
markTelegramBotError('reconnecting', 'TIMEOUT', '等待自动重连', '2026-08-24T00:02:00.000Z');
markTelegramBotReady('2026-08-24T00:03:00.000Z');
const status = getTelegramBotStatus();
assert.equal(status.status, 'ready');
assert.equal(status.reconnectCount, 1);
assert.equal(status.lastRecoveredAt, '2026-08-24T00:03:00.000Z');
});
+115
View File
@@ -0,0 +1,115 @@
export type TelegramBotState =
| 'not_configured'
| 'starting'
| 'ready'
| 'reconnecting'
| 'auth_failed'
| 'error'
| 'stopped';
export interface TelegramBotStatus {
status: TelegramBotState;
configured: boolean;
required: boolean;
degraded: boolean;
checkedAt: string;
lastConnectedAt: string | null;
lastRecoveredAt: string | null;
lastError: string | null;
action: string | null;
reconnectCount: number;
}
function requiredFromEnv(): boolean {
return /^(1|true|yes|on)$/i.test(process.env.TELEGRAM_REQUIRED || 'false');
}
let current: TelegramBotStatus = {
status: 'not_configured',
configured: false,
required: requiredFromEnv(),
degraded: false,
checkedAt: new Date().toISOString(),
lastConnectedAt: null,
lastRecoveredAt: null,
lastError: null,
action: '配置 TELEGRAM_BOT_TOKEN、TELEGRAM_API_ID 和 TELEGRAM_API_HASH',
reconnectCount: 0,
};
export function resetTelegramBotStatus(configured: boolean, checkedAt = new Date().toISOString()): void {
current = {
status: configured ? 'stopped' : 'not_configured',
configured,
required: requiredFromEnv(),
degraded: false,
checkedAt,
lastConnectedAt: null,
lastRecoveredAt: null,
lastError: null,
action: configured ? '启动 Telegram Bot' : '配置 TELEGRAM_BOT_TOKEN、TELEGRAM_API_ID 和 TELEGRAM_API_HASH',
reconnectCount: 0,
};
}
export function getTelegramBotStatus(): TelegramBotStatus {
return { ...current, required: requiredFromEnv() };
}
export function markTelegramBotStarting(checkedAt = new Date().toISOString()): void {
current = {
...current,
configured: true,
required: requiredFromEnv(),
status: 'starting',
degraded: false,
checkedAt,
lastError: null,
action: '等待 Telegram 连接建立',
};
}
export function markTelegramBotReady(checkedAt = new Date().toISOString()): void {
const recovered = current.status === 'reconnecting' || current.status === 'auth_failed' || current.status === 'error';
current = {
...current,
configured: true,
required: requiredFromEnv(),
status: 'ready',
degraded: false,
checkedAt,
lastConnectedAt: checkedAt,
lastRecoveredAt: recovered ? checkedAt : current.lastRecoveredAt,
lastError: null,
action: null,
};
}
export function markTelegramBotError(
status: Extract<TelegramBotState, 'reconnecting' | 'auth_failed' | 'error' | 'stopped'>,
message: string,
action: string,
checkedAt = new Date().toISOString(),
): void {
current = {
...current,
configured: true,
required: requiredFromEnv(),
status,
degraded: status !== 'stopped',
checkedAt,
lastError: message,
action,
reconnectCount: status === 'reconnecting' ? current.reconnectCount + 1 : current.reconnectCount,
};
}
export function classifyTelegramBotStartupError(error: unknown): Extract<TelegramBotState, 'auth_failed' | 'error'> {
const message = error instanceof Error ? error.message : String(error);
return /token|auth|unauthorized|forbidden|401|403/i.test(message) ? 'auth_failed' : 'error';
}
export function telegramBotBlocksReadiness(status: TelegramBotStatus, required = requiredFromEnv()): boolean {
if (!required) return false;
return status.status !== 'ready';
}
@@ -0,0 +1,48 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { StorageTargetSnapshot } from './storage.js';
import { resolveChannelJobTargetSnapshot } from './telegramChannelJobAdmission.js';
function target(providerName: string, accountId: string | null): StorageTargetSnapshot {
return {
provider: {
name: providerName,
probe: async () => undefined,
saveFile: async () => '',
getFileStream: async () => { throw new Error('not used'); },
getPreviewUrl: async () => '',
deleteFile: async () => undefined,
},
accountId,
providerKey: `${providerName}:${accountId || 'local'}`,
};
}
test('an explicit confirmed target is preserved when the global target changes', () => {
const confirmed = target('s3', 'account-a');
const current = target('google_drive', 'account-b');
let activeTargetReads = 0;
const resolved = resolveChannelJobTargetSnapshot(confirmed, () => {
activeTargetReads += 1;
return current;
});
assert.equal(resolved, confirmed);
assert.equal(resolved.provider.name, 's3');
assert.equal(resolved.accountId, 'account-a');
assert.equal(activeTargetReads, 0);
});
test('legacy admission without an explicit target snapshots the active target exactly once', () => {
const current = target('webdav', 'account-c');
let activeTargetReads = 0;
const resolved = resolveChannelJobTargetSnapshot(undefined, () => {
activeTargetReads += 1;
return current;
});
assert.equal(resolved, current);
assert.equal(activeTargetReads, 1);
});
@@ -0,0 +1,8 @@
import type { StorageTargetSnapshot } from './storage.js';
export function resolveChannelJobTargetSnapshot(
explicitTarget: StorageTargetSnapshot | undefined,
getActiveTarget: () => StorageTargetSnapshot,
): StorageTargetSnapshot {
return explicitTarget ?? getActiveTarget();
}
@@ -0,0 +1,59 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { StorageTargetSnapshot } from './storage.js';
import { persistChannelJobAdmission } from './telegramChannelJobs.js';
function target(providerName: string, accountId: string | null): StorageTargetSnapshot {
return {
provider: {
name: providerName,
probe: async () => undefined,
saveFile: async () => '',
getFileStream: async () => { throw new Error('not used'); },
getPreviewUrl: async () => '',
deleteFile: async () => undefined,
},
accountId,
providerKey: `${providerName}:${accountId || 'local'}`,
};
}
test('channel job persistence keeps confirmed target in params and locks that account', async () => {
const confirmed = target('s3', 'account-a');
const current = target('google_drive', 'account-b');
const lockedAccounts: string[] = [];
const calls: Array<{ sql: string; params?: unknown[] }> = [];
let activeTargetReads = 0;
const id = await persistChannelJobAdmission({
userId: 42,
chatId: '-100123',
kind: 'date_range',
source: '@channel',
params: { mode: 'date' },
target: confirmed,
}, {
getActiveTarget: () => {
activeTargetReads += 1;
return current;
},
lockStorageAccount: async (_client, accountId) => {
lockedAccounts.push(accountId);
},
withTransaction: async operation => operation({
query: (async (sql: string, params?: unknown[]) => {
calls.push({ sql, params });
return { rows: [{ id: 'job-1' }], rowCount: 1 } as any;
}) as any,
}),
});
assert.equal(id, 'job-1');
assert.equal(activeTargetReads, 0);
assert.deepEqual(lockedAccounts, ['account-a']);
assert.equal(calls.length, 1);
const persisted = JSON.parse(String(calls[0].params?.[4]));
assert.equal(persisted.storageProvider, 's3');
assert.equal(persisted.storageAccountId, 'account-a');
assert.equal(persisted.mode, 'date');
});
+186 -77
View File
@@ -3,7 +3,8 @@ import crypto from 'node:crypto';
import type { PoolClient } from 'pg';
import { getPeerId } from 'telegram/Utils.js';
import { pool, query } from '../db/index.js';
import { storageManager, isStorageQuotaCooldownError } from './storage.js';
import { storageManager, isStorageQuotaCooldownError, type StorageTargetSnapshot } from './storage.js';
import { resolveChannelJobTargetSnapshot } from './telegramChannelJobAdmission.js';
import { formatStorageCooldownNotice } from './storageCooldownGuard.js';
import {
clearExpiredStorageCooldowns,
@@ -20,6 +21,9 @@ import { extractFileInfo, getEstimatedFileSize, type TelegramFileInfo } from '..
import { annotateTelegramMediaGroup } from '../utils/telegramMediaGroup.js';
import { lockStorageAccountForUse } from './storageAccountLifecycle.js';
import { resolveTelegramWriteCommittedWithQuery, claimTelegramWriteReconciliations, resolveClaimedTelegramWrite } from './telegramWriteReconciliation.js';
import { parseDateOnlyStrict, parseTelegramDateRange } from './telegramDateRange.js';
import { enqueueTelegramNotification } from './telegramNotificationDelivery.js';
import { resolveSubscriptionTarget } from './telegramSubscriptionManagement.js';
const SUBSCRIPTION_INTERVAL_MS = Math.max(60_000, parseInt(process.env.TELEGRAM_SUBSCRIPTION_INTERVAL_MS || '300000', 10) || 300_000);
const SUBSCRIPTION_SCAN_LIMIT = Math.max(1, parseInt(process.env.TELEGRAM_SUBSCRIPTION_SCAN_LIMIT || '100', 10) || 100);
@@ -308,8 +312,44 @@ interface TelegramCommentScanOptions {
onScanComplete?: (summary: TelegramDownloadScanSummary) => Promise<void> | void;
onProgress?: (summary: TelegramJobProgressSummary) => Promise<void> | void;
onRefDiscovered?: (ref: TelegramDownloadMessageRef) => Promise<void> | void;
targetProvider?: string;
targetAccountId?: string | null;
target?: StorageTargetSnapshot;
}
export interface ChannelJobAdmissionInput {
userId: number;
chatId?: string;
kind: string;
source: string;
params: Record<string, unknown>;
target?: StorageTargetSnapshot;
}
export interface ChannelJobAdmissionDependencies {
getActiveTarget: () => StorageTargetSnapshot;
withTransaction: <T>(operation: (client: Pick<PoolClient, 'query'>) => Promise<T>) => Promise<T>;
lockStorageAccount: (client: Pick<PoolClient, 'query'>, accountId: string) => Promise<unknown>;
}
export async function persistChannelJobAdmission(
input: ChannelJobAdmissionInput,
dependencies: ChannelJobAdmissionDependencies,
): Promise<string> {
const target = resolveChannelJobTargetSnapshot(input.target, dependencies.getActiveTarget);
const persistedParams = {
...input.params,
storageProvider: target.provider.name,
storageAccountId: target.accountId,
};
return dependencies.withTransaction(async client => {
if (target.accountId) await dependencies.lockStorageAccount(client, target.accountId);
const result = await client.query(
`INSERT INTO telegram_background_jobs (user_id, chat_id, kind, source, params, status, scan_status, download_status, scan_cursor)
VALUES ($1, $2, $3, $4, $5, 'queued', 'pending', 'pending', '{}'::jsonb)
RETURNING id`,
[input.userId, input.chatId || null, input.kind, input.source, JSON.stringify(persistedParams)],
);
return String(result.rows[0].id);
});
}
export interface TelegramJobProgressSummary {
@@ -609,50 +649,27 @@ async function updateDownloadItemsStatus(jobId: string, messageIds: number[] | u
);
}
export function parseDateOnly(value: string, endOfDay = false): Date {
const match = value.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!match) throw new Error('日期格式必须是 YYYY-MM-DD');
const [, yearText, monthText, dayText] = match;
const year = Number(yearText);
const month = Number(monthText);
const day = Number(dayText);
export const parseDateOnly = parseDateOnlyStrict;
return new Date(Date.UTC(
year,
month - 1,
day,
endOfDay ? 23 : 0,
endOfDay ? 59 : 0,
endOfDay ? 59 : 0,
endOfDay ? 999 : 0,
));
}
async function createJob(userId: number, chatId: string | undefined, kind: string, source: string, params: Record<string, unknown>) {
const target = storageManager.getActiveTarget();
const persistedParams = {
...params,
storageProvider: target.provider.name,
storageAccountId: target.accountId,
};
const client = await pool.connect();
try {
await client.query('BEGIN');
if (target.accountId) await lockStorageAccountForUse(client, target.accountId);
const result = await client.query(
`INSERT INTO telegram_background_jobs (user_id, chat_id, kind, source, params, status, scan_status, download_status, scan_cursor)
VALUES ($1, $2, $3, $4, $5, 'queued', 'pending', 'pending', '{}'::jsonb)
RETURNING id`,
[userId, chatId || null, kind, source, JSON.stringify(persistedParams)]
);
await client.query('COMMIT');
return result.rows[0].id as string;
} catch (error) {
await client.query('ROLLBACK').catch(() => undefined);
throw error;
} finally {
client.release();
}
async function createJob(input: ChannelJobAdmissionInput) {
return persistChannelJobAdmission(input, {
getActiveTarget: () => storageManager.getActiveTarget(),
lockStorageAccount: (client, accountId) => lockStorageAccountForUse(client as PoolClient, accountId),
withTransaction: async operation => {
const client = await pool.connect();
try {
await client.query('BEGIN');
const result = await operation(client);
await client.query('COMMIT');
return result;
} catch (error) {
await client.query('ROLLBACK').catch(() => undefined);
throw error;
} finally {
client.release();
}
},
});
}
async function getJob(jobId: string) {
@@ -739,8 +756,8 @@ export async function subscribeTelegramChannel(userId: number, chatId: string |
const title = resolved.title || getEntityTitle(resolved.entity, resolved.source);
const result = await query(
`INSERT INTO telegram_channel_subscriptions (user_id, chat_id, source, source_original, source_type, title, last_message_id, folder_override, enabled, disabled_reason, disabled_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, true, NULL, NULL)
`INSERT INTO telegram_channel_subscriptions (user_id, chat_id, source, source_original, source_type, title, last_message_id, folder_override, enabled, disabled_reason, disabled_at, target_mode, target_provider, target_account_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, true, NULL, NULL, 'follow_global', NULL, NULL)
ON CONFLICT (user_id, source)
DO UPDATE SET chat_id = EXCLUDED.chat_id, source_original = EXCLUDED.source_original, source_type = EXCLUDED.source_type, title = EXCLUDED.title, folder_override = EXCLUDED.folder_override, enabled = true, disabled_reason = NULL, disabled_at = NULL, updated_at = NOW()
RETURNING id, source, source_original, source_type, title, last_message_id, folder_override, enabled, disabled_reason, disabled_at`,
@@ -752,6 +769,7 @@ export async function subscribeTelegramChannel(userId: number, chatId: string |
export async function listTelegramSubscriptions(userId: number, includeDisabled = false) {
const result = await query(
`SELECT id, source, source_original, source_type, title, last_message_id, folder_override, enabled, disabled_reason, disabled_at,
target_mode, target_provider, target_account_id,
last_scan_at, last_success_at, last_error, last_result,
CASE WHEN enabled THEN NOW() + ($3::int * INTERVAL '1 millisecond') ELSE NULL END AS next_scan_at,
updated_at
@@ -777,6 +795,66 @@ async function resolveUniqueTelegramSubscriptionId(userId: number, selector: str
return result.rows.length === 1 ? String(result.rows[0].id) : null;
}
export async function setTelegramSubscriptionEnabled(userId: number, selector: string, enabled: boolean) {
const subscriptionId = await resolveUniqueTelegramSubscriptionId(userId, selector);
if (!subscriptionId) return null;
const result = await query(
`UPDATE telegram_channel_subscriptions
SET enabled = $3, disabled_reason = CASE WHEN $3 THEN NULL ELSE '用户手动暂停订阅' END,
disabled_at = CASE WHEN $3 THEN NULL ELSE NOW() END, updated_at = NOW()
WHERE user_id = $1 AND id = $2::uuid RETURNING *`,
[userId, subscriptionId, enabled],
);
return result.rows[0] || null;
}
export async function updateTelegramSubscriptionTarget(
userId: number,
selector: string,
input: { mode: 'follow_global' | 'fixed'; provider?: string | null; accountId?: string | null },
) {
const subscriptionId = await resolveUniqueTelegramSubscriptionId(userId, selector);
if (!subscriptionId) return null;
if (input.mode === 'fixed') {
if (!input.provider) throw new Error('固定订阅目标缺少 provider');
storageManager.getTarget(input.provider, input.accountId || null);
}
const result = await query(
`UPDATE telegram_channel_subscriptions
SET target_mode = $3, target_provider = CASE WHEN $3 = 'fixed' THEN $4 ELSE NULL END,
target_account_id = CASE WHEN $3 = 'fixed' THEN $5::uuid ELSE NULL END, updated_at = NOW()
WHERE user_id = $1 AND id = $2::uuid RETURNING *`,
[userId, subscriptionId, input.mode, input.provider || null, input.accountId || null],
);
return result.rows[0] || null;
}
export async function setTelegramSubscriptionFromNow(userId: number, selector: string) {
const subscriptionId = await resolveUniqueTelegramSubscriptionId(userId, selector);
if (!subscriptionId) return null;
const target = await findTelegramSubscription(userId, subscriptionId);
if (!target) return null;
const latestMessageId = await getLatestMessageId(requireUserClient(), target.source);
const result = await query(
`UPDATE telegram_channel_subscriptions SET last_message_id = $3, updated_at = NOW()
WHERE user_id = $1 AND id = $2::uuid RETURNING *`,
[userId, subscriptionId, latestMessageId],
);
return result.rows[0] || null;
}
export async function requestTelegramSubscriptionSync(userId: number, selector: string) {
const subscriptionId = await resolveUniqueTelegramSubscriptionId(userId, selector);
if (!subscriptionId) return null;
const result = await query(
`UPDATE telegram_channel_subscriptions
SET next_scan_at = NOW(), enabled = true, disabled_reason = NULL, disabled_at = NULL, updated_at = NOW()
WHERE user_id = $1 AND id = $2::uuid RETURNING *`,
[userId, subscriptionId],
);
return result.rows[0] || null;
}
export async function updateTelegramSubscriptionFolder(userId: number, selector: string, folderOverride: string | null) {
const subscriptionId = await resolveUniqueTelegramSubscriptionId(userId, selector);
if (!subscriptionId) return null;
@@ -1451,21 +1529,28 @@ export async function enqueueTelegramDateDownload(botClient: TelegramClient, req
const resolved = await resolveTelegramSource(userClient, sourceInput);
await assertTelegramSourceAllowed(resolved.source, [resolved.originalSource]);
const source = resolved.source;
const startDate = parseDateOnly(startDateText);
const endDate = parseDateOnly(endDateText, true);
if (startDate > endDate) throw new Error('开始日期不能晚于结束日期');
const range = parseTelegramDateRange(startDateText, endDateText);
const { startDate, endDate } = range;
const jobId = await createJob(userId, requestMessage.chatId?.toString(), 'date_range', source, {
mode: 'date',
startDate: startDateText,
endDate: endDateText,
startDateIso: startDate.toISOString(),
endDateIso: endDate.toISOString(),
folderOverride: folderOverride || null,
includeComments: Boolean(options.includeComments),
commentsMaxPerPost: options.commentsMaxPerPost || TELEGRAM_COMMENTS_MAX_PER_POST,
storageProvider: options.targetProvider || null,
storageAccountId: options.targetAccountId || null,
const target = resolveChannelJobTargetSnapshot(options.target, () => storageManager.getActiveTarget());
const jobId = await createJob({
userId,
chatId: requestMessage.chatId?.toString(),
kind: 'date_range',
source,
target,
params: {
mode: 'date',
startDate: startDateText,
endDate: endDateText,
startDateIso: startDate.toISOString(),
endDateIso: endDate.toISOString(),
dayCount: range.dayCount,
largeRange: range.requiresLargeRangeConfirmation,
folderOverride: folderOverride || null,
includeComments: Boolean(options.includeComments),
commentsMaxPerPost: options.commentsMaxPerPost || TELEGRAM_COMMENTS_MAX_PER_POST,
},
});
return runSegmentedTelegramJob(botClient, requestMessage, jobId, source, folderOverride, options);
}
@@ -1501,14 +1586,20 @@ export async function enqueueTelegramTagDownload(botClient: TelegramClient, requ
const source = resolved.source;
const tag = normalizeHashtag(tagInput);
const jobId = await createJob(userId, requestMessage.chatId?.toString(), 'tag_download', source, {
mode: 'tag',
tag,
folderOverride: folderOverride || null,
includeComments: Boolean(options.includeComments),
commentsMaxPerPost: options.commentsMaxPerPost || TELEGRAM_COMMENTS_MAX_PER_POST,
storageProvider: options.targetProvider || null,
storageAccountId: options.targetAccountId || null,
const target = resolveChannelJobTargetSnapshot(options.target, () => storageManager.getActiveTarget());
const jobId = await createJob({
userId,
chatId: requestMessage.chatId?.toString(),
kind: 'tag_download',
source,
target,
params: {
mode: 'tag',
tag,
folderOverride: folderOverride || null,
includeComments: Boolean(options.includeComments),
commentsMaxPerPost: options.commentsMaxPerPost || TELEGRAM_COMMENTS_MAX_PER_POST,
},
});
const result = await runSegmentedTelegramJob(botClient, requestMessage, jobId, source, folderOverride, options);
return { ...result, tag };
@@ -1823,9 +1914,11 @@ async function runSubscriptionScan(botClient: TelegramClient) {
if (!userClient || !isTelegramUserClientReady()) return;
const result = await query(
`SELECT id, user_id, chat_id, source, source_original, source_type, last_message_id, folder_override
`SELECT id, user_id, chat_id, source, source_original, source_type, last_message_id, folder_override,
target_mode, target_provider, target_account_id, next_scan_at
FROM telegram_channel_subscriptions
WHERE enabled = true
AND (next_scan_at IS NULL OR next_scan_at <= NOW())
ORDER BY updated_at ASC`
);
@@ -1835,15 +1928,27 @@ async function runSubscriptionScan(botClient: TelegramClient) {
const latestMessageId = await getLatestMessageId(userClient, row.source);
const lastMessageId = Number(row.last_message_id || 0);
await query(`UPDATE telegram_channel_subscriptions
SET last_scan_at = NOW(), last_error = NULL,
SET last_scan_at = NOW(), next_scan_at = NOW() + ($4::int * INTERVAL '1 millisecond'), last_error = NULL,
last_result = jsonb_build_object('status', CASE WHEN $2::int > $3::int THEN 'updates_found' ELSE 'no_updates' END,
'latestMessageId', $2::int, 'previousMessageId', $3::int)
WHERE id = $1`, [row.id, latestMessageId || 0, lastMessageId]);
WHERE id = $1`, [row.id, latestMessageId || 0, lastMessageId, SUBSCRIPTION_INTERVAL_MS]);
if (!latestMessageId || latestMessageId <= lastMessageId) continue;
const count = Math.min(SUBSCRIPTION_SCAN_LIMIT, latestMessageId - lastMessageId);
const ids = Array.from({ length: count }, (_, index) => lastMessageId + index + 1);
const jobId = await createJob(Number(row.user_id), row.chat_id?.toString(), 'subscription_sync', row.source, { subscriptionId: String(row.id), fromId: lastMessageId + 1, toId: latestMessageId });
const subscriptionTarget = resolveSubscriptionTarget(
row,
() => storageManager.getActiveTarget(),
(provider, accountId) => storageManager.getTarget(provider, accountId),
);
const jobId = await createJob({
userId: Number(row.user_id),
chatId: row.chat_id?.toString(),
kind: 'subscription_sync',
source: row.source,
target: subscriptionTarget,
params: { subscriptionId: String(row.id), fromId: lastMessageId + 1, toId: latestMessageId, targetMode: row.target_mode || 'follow_global' },
});
const candidateMessages = await expandMessagesWithMediaGroups(userClient, row.source, (await userClient.getMessages(row.source as any, { ids })).filter(Boolean) as Api.Message[]);
await persistDownloadMessages(jobId, row.source, candidateMessages, row.folder_override || null);
await updateJob(jobId, { status: 'running', started_at: new Date(), total_count: ids.length });
@@ -1894,7 +1999,10 @@ async function runSubscriptionScan(botClient: TelegramClient) {
last_result = jsonb_build_object('status', $3::text, 'found', $4::int, 'skipped', $5::int, 'failed', $6::int)
WHERE id = $1`, [row.id, downloadResult.failed > 0 ? `${downloadResult.failed} 个文件下载失败` : null, downloadResult.failed > 0 ? 'partial_failure' : 'success', downloadResult.found, downloadResult.skipped, downloadResult.failed]);
if (downloadResult.found > 0) {
await botClient.sendMessage(targetChat, { message: `✅ 订阅 ${row.source} 已同步 ${downloadResult.found} 个新文件,跳过 ${downloadResult.skipped} 条${downloadResult.failed ? `,失败 ${downloadResult.failed} 条` : ''}${safeAdvanceId < latestMessageId ? '。本轮达到扫描上限或存在失败项,剩余将在后续继续处理。' : '。'}` }).catch(() => undefined);
const notificationMessage = `✅ 订阅 ${row.source} 已同步 ${downloadResult.found} 个新文件,跳过 ${downloadResult.skipped} 条${downloadResult.failed ? `,失败 ${downloadResult.failed} 条` : ''}${safeAdvanceId < latestMessageId ? '。本轮达到扫描上限或存在失败项,剩余将在后续继续处理。' : '。'}`;
await enqueueTelegramNotification({ userId: Number(row.user_id), chatId: String(targetChat), kind: 'subscription', message: notificationMessage }, {
send: async (chatId, message) => { await botClient.sendMessage(chatId, { message }); },
}).catch(() => undefined);
}
} catch (error) {
console.error('🤖 Telegram 订阅同步失败:', error);
@@ -1908,8 +2016,9 @@ async function runSubscriptionScan(botClient: TelegramClient) {
const reason = subscriptionDisabledReason(error);
await pauseTelegramSubscriptionForError(row.id, reason).catch(updateError => console.error('🤖 暂停不可访问的 Telegram 订阅失败:', updateError));
const targetChat = row.chat_id || row.user_id;
await botClient.sendMessage(targetChat, {
message: `⚠️ 已暂停订阅 ${row.source_original || row.source}\n${reason}\n\n你可以在 /tg_subs 或 /tg_sub 订阅列表中查看提醒;确认账号可访问后重新添加订阅即可。`,
const pausedMessage = `⚠️ 已暂停订阅 ${row.source_original || row.source}\n${reason}\n\n你可以在 /tg_subs 或 /tg_sub 订阅列表中查看提醒;确认账号可访问后重新添加订阅即可。`;
await enqueueTelegramNotification({ userId: Number(row.user_id), chatId: String(targetChat), kind: 'failure', message: pausedMessage }, {
send: async (chatId, message) => { await botClient.sendMessage(chatId, { message }); },
}).catch(() => undefined);
}
}
@@ -0,0 +1,44 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildCommandHomePage, dispatchTelegramCommand, type TelegramCommandContext } from './telegramCommandDispatcher.js';
import { BOT_COMMANDS } from '../utils/telegramCommandRegistry.js';
test('dispatcher resolves legacy aliases and runs authentication before the handler', async () => {
const events: string[] = [];
const context: TelegramCommandContext = { userId: 7, chatKey: '100', text: '/task' };
const result = await dispatchTelegramCommand(context, {
authenticate: async () => { events.push('auth'); return true; },
rateLimit: () => { events.push('rate'); return { limited: false, retryAfterSeconds: 0 }; },
handlers: { tasks: async () => { events.push('handler'); } },
});
assert.equal(result.command?.command, 'tasks');
assert.equal(result.handled, true);
assert.deepEqual(events, ['auth', 'rate', 'handler']);
});
test('dispatcher rejects unauthenticated and rate-limited commands consistently', async () => {
const context: TelegramCommandContext = { userId: 7, chatKey: '100', text: '/tasks' };
const denied = await dispatchTelegramCommand(context, {
authenticate: async () => false,
handlers: { tasks: async () => assert.fail('must not run') },
});
assert.equal(denied.reason, 'auth-required');
const limited = await dispatchTelegramCommand(context, {
authenticate: async () => true,
rateLimit: () => ({ limited: true, retryAfterSeconds: 9 }),
handlers: { tasks: async () => assert.fail('must not run') },
});
assert.equal(limited.reason, 'rate-limited');
assert.equal(limited.retryAfterSeconds, 9);
});
test('categorized home pages make every help command reachable and callback data stays within Telegram limits', () => {
const visible = BOT_COMMANDS.filter(command => command.help);
const pages = Array.from({ length: 20 }, (_, page) => buildCommandHomePage(page)).filter(page => page.commands.length > 0);
const reached = pages.flatMap(page => page.commands.map(command => command.command));
assert.deepEqual(new Set(reached), new Set(visible.map(command => command.command)));
for (const page of pages) {
for (const button of page.buttons.flat()) assert.ok(Buffer.byteLength(button.data) < 64, button.data);
}
});
@@ -0,0 +1,84 @@
import { BOT_COMMANDS, findBotCommand, type BotCommandDefinition } from '../utils/telegramCommandRegistry.js';
export interface TelegramCommandContext {
userId: number;
chatKey: string;
text: string;
}
export type TelegramCommandHandler = (context: TelegramCommandContext, command: BotCommandDefinition) => Promise<void> | void;
export interface TelegramCommandDispatcherDependencies {
authenticate?: (userId: number) => Promise<boolean>;
rateLimit?: (userId: number, command: BotCommandDefinition) => { limited: boolean; retryAfterSeconds: number };
featureEnabled?: (feature: string) => boolean;
handlers: Record<string, TelegramCommandHandler | undefined>;
}
export interface TelegramCommandDispatchResult {
handled: boolean;
command?: BotCommandDefinition;
reason?: 'not-command' | 'unknown-command' | 'auth-required' | 'rate-limited' | 'feature-disabled' | 'missing-handler';
retryAfterSeconds?: number;
}
export async function dispatchTelegramCommand(
context: TelegramCommandContext,
dependencies: TelegramCommandDispatcherDependencies,
): Promise<TelegramCommandDispatchResult> {
if (!context.text.trim().startsWith('/')) return { handled: false, reason: 'not-command' };
const command = findBotCommand(context.text);
if (!command) return { handled: false, reason: 'unknown-command' };
if (command.requiresAuth !== false && dependencies.authenticate && !(await dependencies.authenticate(context.userId))) {
return { handled: true, command, reason: 'auth-required' };
}
const rate = dependencies.rateLimit?.(context.userId, command);
if (rate?.limited) return { handled: true, command, reason: 'rate-limited', retryAfterSeconds: rate.retryAfterSeconds };
if (command.feature && dependencies.featureEnabled && !dependencies.featureEnabled(command.feature)) {
return { handled: true, command, reason: 'feature-disabled' };
}
const handler = dependencies.handlers[command.handlerKey || command.command];
if (!handler) return { handled: false, command, reason: 'missing-handler' };
await handler(context, command);
return { handled: true, command };
}
export interface CommandHomeButton {
text: string;
data: string;
}
export interface CommandHomePage {
page: number;
totalPages: number;
category: string;
commands: BotCommandDefinition[];
buttons: CommandHomeButton[][];
}
const CATEGORY_LABELS: Record<string, string> = {
main: '首页',
files: '文件',
channels: '频道',
settings: '设置',
security: '安全',
};
export function buildCommandHomePage(requestedPage: number): CommandHomePage {
const visible = BOT_COMMANDS.filter(command => command.help);
const categories = [...new Set(visible.map(command => command.category))];
const totalPages = Math.max(1, categories.length);
const page = Math.min(totalPages - 1, Math.max(0, requestedPage));
const category = categories[page] || 'main';
const commands = visible.filter(command => command.category === category);
const buttons: CommandHomeButton[][] = commands.map(command => [{
text: `/${command.command} · ${command.description}`,
data: `home_open_${command.command}`,
}]);
const navigation: CommandHomeButton[] = [];
if (page > 0) navigation.push({ text: '◀️', data: `home_page_${page - 1}` });
navigation.push({ text: `${CATEGORY_LABELS[category] || category} ${page + 1}/${totalPages}`, data: `home_page_${page}` });
if (page + 1 < totalPages) navigation.push({ text: '▶️', data: `home_page_${page + 1}` });
buttons.push(navigation);
return { page, totalPages, category, commands, buttons };
}
+318 -2
View File
@@ -16,15 +16,16 @@ import {
getProviderDisplayName,
} from '../utils/telegramMessages.js';
import { authenticatedUsers, passwordInputState, isAuthenticatedAsync } from './telegramState.js';
import { forceStopDownloadTasksForScope, getDownloadQueueStats, getDownloadTaskScopeStatus, pauseDownloadTasks, resumeDownloadTasks, retryFailedDownloadTasks, getFileDownloadConcurrency, setFileDownloadConcurrency, listDownloadTaskGroups, getDownloadTaskGroup, prioritizeDownloadTaskGroup, pauseDownloadTaskGroup, resumeDownloadTaskGroup, cancelDownloadTaskGroup, getChannelExecutionGroup, prioritizeChannelExecutionGroup, pauseChannelExecutionGroup, resumeChannelExecutionGroup, cancelChannelExecutionGroup, refreshSilentProgress } from './telegramUpload.js';
import { forceStopDownloadTasksForScope, getDownloadQueueStats, getTaskStatus, getDownloadTaskScopeStatus, pauseDownloadTasks, resumeDownloadTasks, retryFailedDownloadTasks, getFileDownloadConcurrency, setFileDownloadConcurrency, listDownloadTaskGroups, getDownloadTaskGroup, prioritizeDownloadTaskGroup, pauseDownloadTaskGroup, resumeDownloadTaskGroup, cancelDownloadTaskGroup, getChannelExecutionGroup, prioritizeChannelExecutionGroup, pauseChannelExecutionGroup, resumeChannelExecutionGroup, cancelChannelExecutionGroup, refreshSilentProgress } from './telegramUpload.js';
import { storageManager } from './storage.js';
import { cancelTelegramBackgroundJob, listTelegramActiveTaskQueues, pauseTelegramBackgroundJob, resumeTelegramBackgroundJob, retryTelegramBackgroundJob } from './telegramChannelJobs.js';
import { getSetting, setSetting } from '../utils/settings.js';
import { DuplicateMode, getDuplicateMode } from '../utils/duplicatePolicy.js';
import { startPeriodicCleanup, stopPeriodicCleanup } from './orphanCleanup.js';
import { safeUnlink } from '../utils/localPath.js';
import { getCurrentStorageScope, nextParam, removePhysicalFile } from '../utils/fileScope.js';
import { getCurrentStorageScope, getScopedFileById, nextParam, removePhysicalFile, updateScopedFileById } from '../utils/fileScope.js';
import { canonicalTelegramChatKey, telegramChatKeyFromPeerParts } from '../utils/telegramChatKey.js';
import { clearTelegramTargetState, getTelegramTargetState, setTelegramTargetState, type TelegramTargetMode } from '../utils/telegramTargetStateStore.js';
import { buildTaskCancelConfirm, buildTaskCenterDetail, buildTaskCenterPage, channelTaskCenterItem, ordinaryTaskCenterItem, parseTaskCenterCallback, ytdlpTaskCenterItem, type TaskCenterButton, type TaskCenterItem, type TaskCenterSourceType, type TaskCenterView } from './telegramTaskCenter.js';
import { listTransferTasks } from './transferTasks.js';
import { cancelYtDlpTask, retryYtDlpTask } from './ytDlpDownload.js';
@@ -40,6 +41,22 @@ import {
setNextTelegramPathPersistent,
setSessionTelegramPathPersistent,
} from '../utils/telegramPathSettings.js';
import {
buildTelegramFileActionRows,
buildTelegramFileBrowserText,
buildTelegramFileDetail,
encodeTelegramFileCallback,
parseTelegramFileCallback,
queryTelegramFiles,
} from './telegramFileBrowser.js';
import { buildTelegramStatusPanel } from './telegramStatusPanel.js';
import { getTelegramBotStatus } from './telegramBotStatus.js';
import { getTelegramNotificationPreferences, setTelegramNotificationPreferences } from './telegramNotificationPreferences.js';
import { getTelegramUserClientStatus } from './telegramUserClientStatus.js';
import crypto from 'crypto';
import { buildStorageCapabilities } from '../utils/storageProductContract.js';
import { getSignedUrl } from '../middleware/signedUrl.js';
import { normalizeFolderPath } from '../utils/folderPath.js';
// ESM compatibility
const checkDiskSpace = (checkDiskSpaceModule as any).default || checkDiskSpaceModule;
@@ -60,11 +77,23 @@ interface PendingDeleteInfo {
messageId: number;
}
interface PendingTelegramFileMutation {
actorId: number;
chatId: string;
messageId: number;
fileId: string;
action: 'move' | 'rename';
expiresAt: number;
}
interface StorageAccountSummary {
id: string;
name?: string | null;
type: string;
is_active: boolean;
last_probe_status?: string | null;
last_probe_error?: string | null;
last_probed_at?: string | null;
}
interface PendingStorageClearSnapshot {
@@ -87,10 +116,31 @@ interface BulkTaskImpact {
}
const pendingDeleteConfirmations = new Map<string, PendingDeleteInfo>();
const pendingTelegramFileMutations = new Map<string, PendingTelegramFileMutation>();
const pendingStorageClearSnapshots = new Map<string, PendingStorageClearSnapshot>();
const pendingBulkTaskCancellations = new Map<string, PendingBulkTaskCancellation>();
const destructiveConfirmations = new DestructiveConfirmationStore();
function buildFileActionKeyboard(file: any): Api.ReplyInlineMarkup {
return new Api.ReplyInlineMarkup({
rows: buildTelegramFileActionRows(file).map(row => new Api.KeyboardButtonRow({
buttons: row.map(button => new Api.KeyboardButtonCallback({ text: button.text, data: Buffer.from(button.data) })),
})),
});
}
function buildFileSearchKeyboard(files: any[]): Api.ReplyInlineMarkup | undefined {
if (files.length === 0) return undefined;
return new Api.ReplyInlineMarkup({
rows: files.slice(0, 8).map(file => new Api.KeyboardButtonRow({
buttons: [new Api.KeyboardButtonCallback({
text: `${file.is_favorite ? '⭐ ' : ''}${String(file.name).slice(0, 38)}`,
data: Buffer.from(encodeTelegramFileCallback('detail', String(file.id))),
})],
})),
});
}
function buildDeleteConfirmKeyboard(confirmId: string): Api.ReplyInlineMarkup {
return new Api.ReplyInlineMarkup({
rows: [new Api.KeyboardButtonRow({
@@ -475,6 +525,92 @@ export async function handleHelp(message: Api.Message): Promise<void> {
await message.reply({ message: buildHelp() });
}
export async function handleNotifications(message: Api.Message, args: string[] = []): Promise<void> {
const userId = message.senderId?.toJSNumber();
if (!userId || !(await isAuthenticatedAsync(userId))) {
await message.reply({ message: MSG.AUTH_REQUIRED });
return;
}
const chatId = canonicalTelegramChatKey(message.chatId?.toString() || userId);
const current = await getTelegramNotificationPreferences(userId, chatId);
if (args.length === 0) {
await message.reply({ message: [
'🔔 **通知偏好**',
`失败:${current.failureImmediate ? '即时' : '摘要'}`,
`成功:${current.successMode}`,
`安全:始终即时(不可关闭)`,
`订阅:${current.subscriptionDigest ? '摘要' : '即时'}`,
`时区:${current.timezone}`,
`安静时段:${current.quietStart && current.quietEnd ? `${current.quietStart}-${current.quietEnd}` : '关闭'}`,
'',
'用法:/notifications timezone Asia/Shanghai;/notifications quiet 22:00-07:00;/notifications success digest|immediate|off;/notifications failure immediate|digest;/notifications subscription digest|immediate',
].join('\n') });
return;
}
const [key, value = ''] = args;
const update: Record<string, unknown> = { ...current };
if (key === 'timezone') update.timezone = value;
else if (key === 'quiet') {
const match = value.match(/^([0-2]\d:[0-5]\d)-([0-2]\d:[0-5]\d)$/);
if (!match) throw new Error('安静时段格式必须是 HH:MM-HH:MM');
update.quietStart = match[1]; update.quietEnd = match[2];
} else if (key === 'success') update.successMode = value;
else if (key === 'failure') update.failureImmediate = value === 'immediate';
else if (key === 'subscription') update.subscriptionDigest = value !== 'immediate';
else throw new Error('未知通知设置');
const saved = await setTelegramNotificationPreferences(userId, chatId, update);
await message.reply({ message: `✅ 通知偏好已更新。安全告警仍始终即时。\n时区:${saved.timezone};安静时段:${saved.quietStart || '-'}-${saved.quietEnd || '-'}` });
}
export async function handleStatus(message: Api.Message): Promise<void> {
const senderId = message.senderId?.toJSNumber();
if (!senderId || !(await isAuthenticatedAsync(senderId))) {
await message.reply({ message: MSG.AUTH_REQUIRED });
return;
}
const requestId = `tg-${crypto.randomBytes(6).toString('hex')}`;
try {
const target = storageManager.getActiveTarget();
const accounts = await storageManager.getAccounts() as StorageAccountSummary[];
const account = target.accountId ? accounts.find(row => row.id === target.accountId) : null;
const diskSpace = await checkDiskSpace(path.resolve(UPLOAD_DIR));
const queue = getDownloadQueueStats();
const [subscriptionRows, reconciliation] = await Promise.all([
query(`SELECT COUNT(*)::int AS enabled, MAX(last_scan_at) AS last_scan_at,
MAX(last_error) FILTER (WHERE last_error IS NOT NULL) AS last_error
FROM telegram_channel_subscriptions WHERE enabled = true`),
query(`SELECT COUNT(*) FILTER (WHERE status = 'pending')::int AS pending,
COUNT(*) FILTER (WHERE resolution = 'operator_required')::int AS operator_required
FROM (
SELECT status, resolution FROM telegram_write_reconciliations
UNION ALL SELECT status, resolution FROM chunk_upload_reconciliations
UNION ALL SELECT status, resolution FROM ytdlp_write_reconciliations
) reconciliations`),
]);
const subscription = subscriptionRows.rows[0] || {};
const reconcile = reconciliation.rows[0] || {};
await message.reply({ message: buildTelegramStatusPanel({
requestId,
bot: getTelegramBotStatus(),
userClient: getTelegramUserClientStatus(),
target: {
provider: target.provider.name,
accountName: account?.name || (target.provider.name === 'local' ? '服务器本地目录' : target.accountId || '默认账户'),
probeStatus: account?.last_probe_status || (target.provider.name === 'local' ? 'available' : null),
cooldownUntil: null,
probeError: account?.last_probe_error || null,
},
disk: { freeBytes: Number(diskSpace.free || 0), totalBytes: Number(diskSpace.size || 0) },
queue: { active: queue.active, pending: queue.pending, failed: getTaskStatus().history.filter(task => task.status === 'failed').length, paused: queue.paused },
subscriptions: { enabled: Number(subscription.enabled || 0), lastScanAt: subscription.last_scan_at || null, lastError: subscription.last_error || null },
reconciliation: { pending: Number(reconcile.pending || 0), operatorRequired: Number(reconcile.operator_required || 0) },
}) });
} catch (error) {
console.error(`🤖 status panel failed request=${requestId}:`, error);
await message.reply({ message: `❌ 诊断状态读取失败。操作 ID:${requestId}` });
}
}
export async function handleStorage(message: Api.Message): Promise<void> {
try {
const scope = await getCurrentStorageScope();
@@ -517,6 +653,61 @@ export async function handleStorage(message: Api.Message): Promise<void> {
}
}
export async function handleTarget(message: Api.Message, args: string[] = []): Promise<void> {
const senderId = message.senderId?.toJSNumber();
if (!senderId || !(await isAuthenticatedAsync(senderId))) {
await message.reply({ message: MSG.AUTH_REQUIRED });
return;
}
const chatId = canonicalTelegramChatKey(message.chatId?.toString() || senderId);
const modeInput = (args[0] || '').toLowerCase();
if (modeInput === 'clear') {
await clearTelegramTargetState(chatId);
await message.reply({ message: '✅ 已清除当前聊天的存储目标覆盖;后续任务使用系统默认。' });
return;
}
if (!modeInput) {
const once = await getTelegramTargetState(undefined, chatId, 'once');
const session = await getTelegramTargetState(undefined, chatId, 'session');
const active = storageManager.getActiveTarget();
await message.reply({
message: [
'🎯 **当前聊天存储目标**',
`下一次:${once ? `${once.provider} / ${once.accountId || 'local'}` : '未设置'}`,
`会话:${session ? `${session.provider} / ${session.accountId || 'local'}` : '未设置'}`,
`系统默认:${active.provider.name} / ${active.accountId || 'local'}`,
'',
'用法:`/target once <local|账户ID>`、`/target session <local|账户ID>`、`/target clear`',
].join('\n'),
});
return;
}
if (!['once', 'session'].includes(modeInput) || !args[1]) {
await message.reply({ message: '❌ 用法:/target once <local|账户ID>、/target session <local|账户ID>、/target clear' });
return;
}
const mode = modeInput as TelegramTargetMode;
const selector = String(args[1]);
let provider = 'local';
let accountId: string | null = null;
let accountName = '服务器本地目录';
if (selector !== 'local') {
const accounts = await storageManager.getAccounts() as StorageAccountSummary[];
const account = accounts.find(row => String(row.id) === selector || String(row.id).startsWith(selector));
if (!account) {
await message.reply({ message: '❌ 未找到该存储账户;请从 /storage_switch 查看完整账户列表。' });
return;
}
provider = account.type;
accountId = account.id;
accountName = account.name || account.type;
storageManager.getTarget(provider, accountId);
}
const expiresAt = new Date(Date.now() + (mode === 'once' ? 24 * 60 * 60 * 1000 : 7 * 24 * 60 * 60 * 1000));
await setTelegramTargetState(undefined, chatId, mode, provider, accountId, expiresAt);
await message.reply({ message: `✅ 已设置${mode === 'once' ? '下一次' : '当前聊天会话'}目标:${provider} / ${accountName}\n不会修改系统全局默认。` });
}
export async function handleStorageSwitch(message: Api.Message): Promise<void> {
try {
const view = await buildStorageSwitchView();
@@ -701,6 +892,34 @@ export async function handleStorageCleanupCallback(client: TelegramClient, updat
}
}
export async function handleFind(message: Api.Message, args: string[] = []): Promise<void> {
const senderId = message.senderId?.toJSNumber();
if (!senderId || !(await isAuthenticatedAsync(senderId))) {
await message.reply({ message: MSG.AUTH_REQUIRED });
return;
}
const rawOptions: Record<string, string> = { limit: '8', sort: 'date', direction: 'desc' };
const queryParts: string[] = [];
for (const arg of args) {
if (arg.startsWith('type:')) rawOptions.type = arg.slice(5);
else if (arg.startsWith('folder:')) rawOptions.folder = arg.slice(7);
else if (arg.startsWith('after:')) rawOptions.after = arg.slice(6);
else if (arg.startsWith('before:')) rawOptions.before = arg.slice(7);
else if (arg === 'fav' || arg === 'favorite') rawOptions.favorite = 'true';
else queryParts.push(arg);
}
if (queryParts.length > 0) rawOptions.q = queryParts.join(' ');
try {
const page = await queryTelegramFiles(rawOptions);
await message.reply({
message: buildTelegramFileBrowserText(page, rawOptions.q || ''),
buttons: buildFileSearchKeyboard(page.files),
});
} catch (error) {
await message.reply({ message: `❌ 搜索失败:${(error as Error).message}` });
}
}
export async function handleList(message: Api.Message, args: string[]): Promise<void> {
try {
let limit = 10;
@@ -741,6 +960,103 @@ export async function handleList(message: Api.Message, args: string[]): Promise<
}
}
export async function handleTelegramFileBrowserCallback(client: TelegramClient, update: Api.UpdateBotCallbackQuery, data: string): Promise<void> {
const actorId = update.userId.toJSNumber();
if (!(await isAuthenticatedAsync(actorId))) {
await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: MSG.AUTH_REQUIRED, alert: true }));
return;
}
const parsed = parseTelegramFileCallback(data);
if (!parsed) return;
const file = await getScopedFileById(parsed.fileId);
if (!file) {
await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: '文件已不存在或不在当前存储范围内', alert: true }));
return;
}
const chatId = getCallbackChatKey(update);
const messageId = Number(update.msgId);
if (parsed.action === 'detail') {
await client.editMessage(update.peer, { message: messageId, text: buildTelegramFileDetail(file), buttons: buildFileActionKeyboard(file) });
await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: '文件详情' }));
return;
}
if (parsed.action === 'copy') {
await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: String(file.id), alert: true }));
return;
}
if (parsed.action === 'favorite') {
await updateScopedFileById(String(file.id), 'is_favorite = $1, updated_at = NOW()', [!file.is_favorite]);
file.is_favorite = !file.is_favorite;
await client.editMessage(update.peer, { message: messageId, text: buildTelegramFileDetail(file), buttons: buildFileActionKeyboard(file) });
await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: file.is_favorite ? '已收藏' : '已取消收藏' }));
return;
}
if (parsed.action === 'link') {
const capabilities = buildStorageCapabilities(String(file.source));
if (!capabilities.share && String(file.source) !== 'local') {
await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: '当前 provider 不支持分享;可在 Web 中下载', alert: true }));
return;
}
const relative = getSignedUrl(String(file.id), 'download', 3600);
const base = (process.env.VITE_API_URL || process.env.OAUTH_CALLBACK_BASE_URL || '').replace(/\/$/, '');
const link = base ? `${base}${relative}` : relative;
await client.sendMessage(update.peer, { message: `🔗 1 小时签名链接:\n${link}` });
await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: '已生成签名链接' }));
return;
}
if (parsed.action === 'move' || parsed.action === 'rename') {
const key = `${actorId}:${chatId}`;
pendingTelegramFileMutations.set(key, { actorId, chatId, messageId, fileId: String(file.id), action: parsed.action, expiresAt: Date.now() + 5 * 60 * 1000 });
await client.sendMessage(update.peer, { message: parsed.action === 'move' ? '请发送目标目录;发送“取消”退出。' : '请发送新文件名(必须保留原扩展名);发送“取消”退出。' });
await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: parsed.action === 'move' ? '等待目标目录' : '等待新文件名' }));
return;
}
const sent = await client.sendMessage(update.peer, {
message: ['⚠️ **确认删除这个文件?**', '', `📄 ${file.name}`, `🆔 ${String(file.id).slice(0, 13)}`, '', '该操作会删除实体文件和索引。'].join('\n'),
}) as Api.Message;
const confirmId = destructiveConfirmations.issue({ actorId, chatId, messageId: sent.id, action: 'delete_file', objectId: String(file.id) });
pendingDeleteConfirmations.set(confirmId, { fileId: String(file.id), name: String(file.name), size: Number(file.size || 0), selector: String(file.id), actorId, chatId, messageId: sent.id });
await sent.edit({ text: sent.message, buttons: buildDeleteConfirmKeyboard(confirmId) });
await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: '需要二次确认' }));
}
export async function applyPendingTelegramFileMutation(message: Api.Message, actorId: number, input: string): Promise<boolean> {
const chatId = canonicalTelegramChatKey(message.chatId?.toString());
const key = `${actorId}:${chatId}`;
const pending = pendingTelegramFileMutations.get(key);
if (!pending) return false;
if (pending.expiresAt < Date.now()) {
pendingTelegramFileMutations.delete(key);
await message.reply({ message: '操作已过期,请重新打开文件详情。' });
return true;
}
if (input.trim() === '取消') {
pendingTelegramFileMutations.delete(key);
await message.reply({ message: '已取消文件操作。' });
return true;
}
const file = await getScopedFileById(pending.fileId);
if (!file) {
pendingTelegramFileMutations.delete(key);
await message.reply({ message: '文件已不存在或不在当前存储范围内。' });
return true;
}
if (pending.action === 'move') {
const folder = normalizeFolderPath(input);
await updateScopedFileById(pending.fileId, 'folder = $1, updated_at = NOW()', [folder]);
await message.reply({ message: `✅ 已移动到:${folder}` });
} else {
const name = input.trim();
if (!name || /[\/\\:*?"<>|]/.test(name)) throw new Error('文件名包含非法字符');
const extension = (value: string) => path.extname(value).toLowerCase();
if (extension(name) !== extension(String(file.name))) throw new Error('不允许修改文件后缀');
await updateScopedFileById(pending.fileId, 'name = $1, updated_at = NOW()', [name]);
await message.reply({ message: `✅ 已重命名为:${name}` });
}
pendingTelegramFileMutations.delete(key);
return true;
}
export async function handleDelete(message: Api.Message, args: string[]): Promise<void> {
if (args.length === 0) {
await message.reply({
@@ -0,0 +1,24 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { parseTelegramDateRange } from './telegramDateRange.js';
test('rejects nonexistent and out-of-range calendar dates', () => {
for (const value of ['2026-02-29', '2026-99-99', '2026-04-31']) {
assert.throws(() => parseTelegramDateRange(value, value), /有效日期/);
}
});
test('accepts leap day and returns inclusive UTC boundaries', () => {
const range = parseTelegramDateRange('2024-02-29', '2024-03-01');
assert.equal(range.startDate.toISOString(), '2024-02-29T00:00:00.000Z');
assert.equal(range.endDate.toISOString(), '2024-03-01T23:59:59.999Z');
assert.equal(range.dayCount, 2);
assert.equal(range.requiresLargeRangeConfirmation, false);
});
test('rejects reversed ranges and flags a large inclusive range', () => {
assert.throws(() => parseTelegramDateRange('2026-06-02', '2026-06-01'), /不能晚于/);
const range = parseTelegramDateRange('2026-01-01', '2026-02-15', { warningThresholdDays: 31 });
assert.equal(range.dayCount, 46);
assert.equal(range.requiresLargeRangeConfirmation, true);
});
+53
View File
@@ -0,0 +1,53 @@
const DAY_MS = 24 * 60 * 60 * 1000;
export const TELEGRAM_DATE_RANGE_WARNING_DAYS = Math.max(
1,
Number.parseInt(process.env.TELEGRAM_DATE_RANGE_WARNING_DAYS || '31', 10) || 31,
);
export interface TelegramDateRange {
startDate: Date;
endDate: Date;
dayCount: number;
requiresLargeRangeConfirmation: boolean;
}
export function parseDateOnlyStrict(value: string, endOfDay = false): Date {
const match = value.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!match) throw new Error('日期格式必须是 YYYY-MM-DD');
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
const date = new Date(Date.UTC(
year,
month - 1,
day,
endOfDay ? 23 : 0,
endOfDay ? 59 : 0,
endOfDay ? 59 : 0,
endOfDay ? 999 : 0,
));
if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) {
throw new Error('请输入有效日期(YYYY-MM-DD)');
}
return date;
}
export function parseTelegramDateRange(
startDateText: string,
endDateText: string,
options: { warningThresholdDays?: number } = {},
): TelegramDateRange {
const startDate = parseDateOnlyStrict(startDateText);
const endDate = parseDateOnlyStrict(endDateText, true);
if (startDate > endDate) throw new Error('开始日期不能晚于结束日期');
const endStartOfDay = Date.UTC(endDate.getUTCFullYear(), endDate.getUTCMonth(), endDate.getUTCDate());
const dayCount = Math.floor((endStartOfDay - startDate.getTime()) / DAY_MS) + 1;
if (dayCount < 1) throw new Error('日期范围不能为空');
const threshold = options.warningThresholdDays ?? TELEGRAM_DATE_RANGE_WARNING_DAYS;
return {
startDate,
endDate,
dayCount,
requiresLargeRangeConfirmation: dayCount > threshold,
};
}
@@ -0,0 +1,53 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildTelegramFileActionRows, buildTelegramFileBrowserText, buildTelegramFileDetail, encodeTelegramFileCallback, parseTelegramFileCallback, queryTelegramFiles } from './telegramFileBrowser.js';
function rows(count: number, start = 0) {
return Array.from({ length: count }, (_, i) => ({
id: `00000000-0000-4000-8000-${String(start + i).padStart(12, '0')}`,
name: `file-${start + i}.jpg`,
type: 'image', size: 100 + i, folder: 'album', is_favorite: i % 2 === 0,
created_at: new Date(Date.UTC(2026, 0, 1, 0, 0, start + i)).toISOString(),
})).reverse();
}
test('Telegram file browser uses keyset pages without duplicates beyond 201 files', async () => {
const all = rows(205);
const first = await queryTelegramFiles({ q: 'file', limit: '200' }, {
scope: { kind: 'local' },
runQuery: (async () => ({ rows: all.slice(0, 201) })) as any,
});
assert.equal(first.files.length, 200);
assert.equal(first.hasMore, true);
assert.ok(first.nextCursor);
const second = await queryTelegramFiles({ q: 'file', limit: '200', cursor: first.nextCursor! }, {
scope: { kind: 'local' },
runQuery: (async () => ({ rows: all.slice(200) })) as any,
});
assert.equal(new Set([...first.files, ...second.files].map(file => file.id)).size, 205);
});
test('file operation callbacks carry the full object id under Telegram limits', () => {
const file = rows(1)[0];
const callback = encodeTelegramFileCallback('delete', file.id);
assert.ok(Buffer.byteLength(callback, 'utf8') <= 64);
assert.deepEqual(parseTelegramFileCallback(callback), { action: 'delete', fileId: file.id });
assert.deepEqual(buildTelegramFileActionRows(file).flat().map(action => action.text), ['详情', '复制 ID', '取消收藏', '签名链接', '移动', '重命名', '删除…']);
const detail = buildTelegramFileDetail({ ...file, source: 'local' });
assert.match(detail, new RegExp(file.id));
assert.match(detail, /local/);
});
test('file browser card includes detail and actionable-operation guidance', async () => {
const page = await queryTelegramFiles({ limit: '8' }, {
scope: { kind: 'local' },
runQuery: (async () => ({ rows: rows(1) })) as any,
});
const text = buildTelegramFileBrowserText(page, 'photo');
assert.match(text, /file-0\.jpg/);
assert.match(text, /00000000-000/);
assert.match(text, /收藏/);
assert.match(text, /移动\/重命名/);
assert.match(text, /删除确认/);
});
+103
View File
@@ -0,0 +1,103 @@
import { query } from '../db/index.js';
import {
buildFilePageQuery,
cursorForFile,
normalizeFileQuery,
type FileQueryScope,
} from './fileQuery.js';
export interface TelegramFileBrowserPage {
files: any[];
nextCursor: string | null;
hasMore: boolean;
}
export type TelegramFileBrowserAction = 'detail' | 'copy' | 'favorite' | 'link' | 'move' | 'rename' | 'delete';
export interface TelegramFileBrowserCallback {
action: TelegramFileBrowserAction;
fileId: string;
}
export function encodeTelegramFileCallback(action: TelegramFileBrowserAction, fileId: string): string {
const value = `fb_${action}_${fileId}`;
if (Buffer.byteLength(value, 'utf8') > 64) throw new Error('Telegram callback data exceeds 64 bytes');
return value;
}
export function parseTelegramFileCallback(data: string): TelegramFileBrowserCallback | null {
const match = data.match(/^fb_(detail|copy|favorite|link|move|rename|delete)_([0-9a-f-]{36})$/i);
return match ? { action: match[1].toLowerCase() as TelegramFileBrowserAction, fileId: match[2].toLowerCase() } : null;
}
export function buildTelegramFileActionRows(file: any): Array<Array<{ text: string; data: string }>> {
const id = String(file.id);
const rows: Array<Array<{ text: string; data: string }>> = [
[{ text: '详情', data: encodeTelegramFileCallback('detail', id) }, { text: '复制 ID', data: encodeTelegramFileCallback('copy', id) }],
[{ text: file.is_favorite ? '取消收藏' : '收藏', data: encodeTelegramFileCallback('favorite', id) }],
[{ text: '签名链接', data: encodeTelegramFileCallback('link', id) }],
[{ text: '移动', data: encodeTelegramFileCallback('move', id) }, { text: '重命名', data: encodeTelegramFileCallback('rename', id) }],
[{ text: '删除…', data: encodeTelegramFileCallback('delete', id) }],
];
return rows;
}
export function buildTelegramFileDetail(file: any): string {
return [
`📄 **${file.name}**`,
`🆔 ${file.id}`,
`📦 ${Number(file.size || 0)} bytes · ${file.type || 'other'}`,
`📍 ${file.source || 'local'}`,
`📁 ${file.folder || '根目录'}`,
`🕒 ${file.created_at ? new Date(file.created_at).toLocaleString('zh-CN', { hour12: false }) : '未知'}`,
].join('\n');
}
export async function resolveTelegramFileScope(): Promise<FileQueryScope> {
const { storageManager } = await import('./storage.js');
const target = storageManager.getActiveTarget();
return target.provider.name === 'local'
? { kind: 'local' }
: { kind: 'account', accountId: target.accountId || '' };
}
export async function queryTelegramFiles(
rawOptions: Record<string, unknown>,
dependencies: {
runQuery?: typeof query;
scope?: FileQueryScope;
} = {},
): Promise<TelegramFileBrowserPage> {
const options = normalizeFileQuery({ ...rawOptions, limit: String(rawOptions.limit || 8) });
const scope = dependencies.scope || await resolveTelegramFileScope();
const built = buildFilePageQuery(scope, options);
const result = await (dependencies.runQuery || query)(built.text, built.params);
const files = result.rows.slice(0, options.limit);
return {
files,
hasMore: result.rows.length > options.limit,
nextCursor: result.rows.length > options.limit && files.length > 0
? cursorForFile(files[files.length - 1], options.sort, options.direction)
: null,
};
}
export function buildTelegramFileCard(file: any, index: number): string {
const shortId = String(file.id || '').slice(0, 12);
const createdAt = file.created_at ? new Date(file.created_at).toLocaleString('zh-CN', { hour12: false }) : '未知';
return [
`${index + 1}. ${file.is_favorite ? '⭐ ' : ''}${file.name}`,
` 🆔 ${shortId} · ${file.type || 'other'} · ${Number(file.size || 0)} bytes`,
` 📁 ${file.folder || '根目录'} · ${createdAt}`,
].join('\n');
}
export function buildTelegramFileBrowserText(page: TelegramFileBrowserPage, queryText: string): string {
return [
`🔎 **文件搜索**:${queryText || '最近文件'}`,
'',
...(page.files.length > 0 ? page.files.map(buildTelegramFileCard) : ['没有匹配文件。']),
'',
'点击文件可查看详情、复制 ID、收藏、生成链接、移动/重命名或进入删除确认。',
].join('\n');
}
@@ -0,0 +1,20 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';
const bot = fs.readFileSync(new URL('./telegramBot.ts', import.meta.url), 'utf8');
test('Bot home uses categorized pages without fixed command truncation', () => {
assert.match(bot, /buildCommandHomePage\(requestedPage\)/);
assert.match(bot, /home_page_\(\\d\+\)/);
assert.doesNotMatch(bot, /visible\.slice\(0, 12\)/);
});
test('home buttons route directly to operational panels for core commands', () => {
assert.match(bot, /command === 'tasks'\) return handleTasks/);
assert.match(bot, /command === 'storage_switch'\) return handleStorageSwitch/);
assert.match(bot, /command === 'path_rules'\) return handlePathRules/);
assert.match(bot, /command === 'tg_download'\) return startTelegramWizard/);
assert.match(bot, /command === 'tg_sub'\) return startTelegramWizard/);
assert.doesNotMatch(bot, /请发送 \/\$\{data\.replace/);
});
@@ -0,0 +1,21 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';
const state = fs.readFileSync(new URL('./telegramState.ts', import.meta.url), 'utf8');
const bot = fs.readFileSync(new URL('./telegramBot.ts', import.meta.url), 'utf8');
const paths = fs.readFileSync(new URL('../utils/telegramPathSettings.ts', import.meta.url), 'utf8');
test('PIN TOTP wizard and path prompts use TTL bounded interaction stores', () => {
assert.match(state, /passwordInputState = new ScopedInteractionMap/);
assert.match(state, /userStates = new ScopedInteractionMap/);
assert.match(bot, /new TelegramInteractionStore<TelegramWizardState>/);
assert.match(paths, /pendingPathInputState = new ScopedInteractionMap/);
assert.match(state, /TELEGRAM_INTERACTION_TTL_MS/);
assert.match(state, /TELEGRAM_INTERACTION_MAX_ENTRIES/);
});
test('restart semantics are fail closed because temporary stores are in memory only', () => {
assert.doesNotMatch(state, /INSERT INTO telegram_interaction_states/);
assert.doesNotMatch(paths, /pendingPathInputState[\s\S]*system_settings/);
});
@@ -0,0 +1,78 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { TelegramInteractionStore } from './telegramInteractionState.js';
interface WizardPayload {
kind: 'date' | 'tag';
step: string;
}
test('interaction state is isolated by user and canonical chat key', () => {
const store = new TelegramInteractionStore<WizardPayload>({ ttlMs: 15 * 60_000, maxEntries: 10, now: () => 1_000 });
store.set({ userId: 7, chatKey: '100', kind: 'wizard', step: 'source', originMessageId: 11, value: { kind: 'date', step: 'source' } });
store.set({ userId: 7, chatKey: '-100200', kind: 'wizard', step: 'tag', originMessageId: 22, value: { kind: 'tag', step: 'tag' } });
assert.equal(store.get(7, '100')?.value.kind, 'date');
assert.equal(store.get(7, '-100200')?.value.kind, 'tag');
assert.equal(store.get(8, '100'), undefined);
});
test('starting a new interaction replaces only the same user and chat scope', () => {
let now = 1_000;
const store = new TelegramInteractionStore<WizardPayload>({ ttlMs: 1_000, maxEntries: 10, now: () => now });
store.set({ userId: 7, chatKey: '100', kind: 'wizard', step: 'source', originMessageId: 11, value: { kind: 'date', step: 'source' } });
store.set({ userId: 7, chatKey: '200', kind: 'wizard', step: 'tag', originMessageId: 22, value: { kind: 'tag', step: 'tag' } });
now = 1_100;
store.set({ userId: 7, chatKey: '100', kind: 'wizard', step: 'confirm', originMessageId: 33, value: { kind: 'tag', step: 'confirm' } });
assert.equal(store.get(7, '100')?.originMessageId, 33);
assert.equal(store.get(7, '200')?.originMessageId, 22);
assert.equal(store.size, 2);
});
test('expired interactions are removed and callbacks fail closed on actor chat message or action mismatch', () => {
let now = 5_000;
const store = new TelegramInteractionStore<WizardPayload>({ ttlMs: 500, maxEntries: 10, now: () => now });
store.set({ userId: 7, chatKey: '-100200', kind: 'wizard', step: 'mode', originMessageId: 44, value: { kind: 'date', step: 'mode' } });
assert.equal(store.validateCallback({ userId: 7, chatKey: '-100200', messageId: 44, action: 'date', allowedActions: ['date', 'tag'] }).ok, true);
assert.equal(store.validateCallback({ userId: 8, chatKey: '-100200', messageId: 44, action: 'date', allowedActions: ['date'] }).ok, false);
assert.equal(store.validateCallback({ userId: 7, chatKey: '999', messageId: 44, action: 'date', allowedActions: ['date'] }).ok, false);
assert.equal(store.validateCallback({ userId: 7, chatKey: '-100200', messageId: 45, action: 'date', allowedActions: ['date'] }).ok, false);
assert.equal(store.validateCallback({ userId: 7, chatKey: '-100200', messageId: 44, action: 'delete', allowedActions: ['date'] }).ok, false);
now = 5_501;
assert.equal(store.lookup(7, '-100200').status, 'expired');
assert.equal(store.get(7, '-100200'), undefined);
const expired = store.validateCallback({ userId: 7, chatKey: '-100200', messageId: 44, action: 'date', allowedActions: ['date'] });
assert.equal(expired.ok, false);
if (!expired.ok) assert.equal(expired.reason, 'expired-or-missing');
});
test('updating an active interaction refreshes TTL without changing its origin', () => {
let now = 100;
const store = new TelegramInteractionStore<WizardPayload>({ ttlMs: 1_000, maxEntries: 10, now: () => now });
store.set({ userId: 7, chatKey: '100', kind: 'wizard', step: 'source', originMessageId: 11, value: { kind: 'date', step: 'source' } });
now = 900;
const updated = store.update(7, '100', { kind: 'wizard', step: 'confirm', value: { kind: 'date', step: 'confirm' } });
assert.equal(updated?.createdAt, 100);
assert.equal(updated?.originMessageId, 11);
assert.equal(updated?.expiresAt, 1_900);
now = 1_101;
assert.equal(store.get(7, '100')?.step, 'confirm');
});
test('capacity is bounded by evicting the oldest interaction', () => {
let now = 0;
const store = new TelegramInteractionStore<WizardPayload>({ ttlMs: 10_000, maxEntries: 2, now: () => now });
store.set({ userId: 1, chatKey: '1', kind: 'wizard', step: 'a', value: { kind: 'date', step: 'a' } });
now = 1;
store.set({ userId: 2, chatKey: '2', kind: 'wizard', step: 'b', value: { kind: 'date', step: 'b' } });
now = 2;
store.set({ userId: 3, chatKey: '3', kind: 'wizard', step: 'c', value: { kind: 'tag', step: 'c' } });
assert.equal(store.get(1, '1'), undefined);
assert.equal(store.get(2, '2')?.step, 'b');
assert.equal(store.get(3, '3')?.step, 'c');
assert.equal(store.size, 2);
});
@@ -0,0 +1,155 @@
import { canonicalTelegramChatKey } from '../utils/telegramChatKey.js';
export interface TelegramInteractionRecord<T> {
userId: number;
chatKey: string;
kind: string;
step: string;
originMessageId?: number;
createdAt: number;
expiresAt: number;
value: T;
}
export interface TelegramInteractionInput<T> {
userId: number;
chatKey: string;
kind: string;
step: string;
originMessageId?: number;
value: T;
}
interface TelegramInteractionStoreOptions {
ttlMs?: number;
maxEntries?: number;
now?: () => number;
}
export type TelegramCallbackValidation =
| { ok: true; record: TelegramInteractionRecord<unknown> }
| { ok: false; reason: 'expired-or-missing' | 'message-mismatch' | 'action-not-allowed' };
export class TelegramInteractionStore<T> {
private readonly entries = new Map<string, TelegramInteractionRecord<T>>();
private readonly ttlMs: number;
private readonly maxEntries: number;
private readonly now: () => number;
constructor(options: TelegramInteractionStoreOptions = {}) {
this.ttlMs = Math.max(1, options.ttlMs ?? 15 * 60_000);
this.maxEntries = Math.max(1, options.maxEntries ?? 1_000);
this.now = options.now ?? Date.now;
}
private key(userId: number, chatKey: string): string {
return `${userId}:${canonicalTelegramChatKey(chatKey)}`;
}
private removeExpired(now = this.now()): number {
let removed = 0;
for (const [key, value] of this.entries) {
if (value.expiresAt <= now) {
this.entries.delete(key);
removed += 1;
}
}
return removed;
}
private enforceCapacity(): void {
while (this.entries.size >= this.maxEntries) {
let oldestKey: string | undefined;
let oldestCreatedAt = Number.POSITIVE_INFINITY;
for (const [key, value] of this.entries) {
if (value.createdAt < oldestCreatedAt) {
oldestCreatedAt = value.createdAt;
oldestKey = key;
}
}
if (!oldestKey) break;
this.entries.delete(oldestKey);
}
}
set(input: TelegramInteractionInput<T>): TelegramInteractionRecord<T> {
const now = this.now();
this.removeExpired(now);
const key = this.key(input.userId, input.chatKey);
if (!this.entries.has(key)) this.enforceCapacity();
const record: TelegramInteractionRecord<T> = {
...input,
chatKey: canonicalTelegramChatKey(input.chatKey),
createdAt: now,
expiresAt: now + this.ttlMs,
};
this.entries.set(key, record);
return record;
}
lookup(userId: number, chatKey: string):
| { status: 'active'; record: TelegramInteractionRecord<T> }
| { status: 'expired' }
| { status: 'missing' } {
const key = this.key(userId, chatKey);
const record = this.entries.get(key);
if (!record) return { status: 'missing' };
if (record.expiresAt <= this.now()) {
this.entries.delete(key);
return { status: 'expired' };
}
return { status: 'active', record };
}
get(userId: number, chatKey: string): TelegramInteractionRecord<T> | undefined {
const result = this.lookup(userId, chatKey);
return result.status === 'active' ? result.record : undefined;
}
update(
userId: number,
chatKey: string,
input: { kind: string; step: string; value: T; originMessageId?: number },
): TelegramInteractionRecord<T> | undefined {
const current = this.lookup(userId, chatKey);
if (current.status !== 'active') return undefined;
const now = this.now();
const record: TelegramInteractionRecord<T> = {
...current.record,
kind: input.kind,
step: input.step,
value: input.value,
originMessageId: input.originMessageId ?? current.record.originMessageId,
expiresAt: now + this.ttlMs,
};
this.entries.set(this.key(userId, chatKey), record);
return record;
}
delete(userId: number, chatKey: string): boolean {
return this.entries.delete(this.key(userId, chatKey));
}
cleanup(): number {
return this.removeExpired();
}
validateCallback(input: {
userId: number;
chatKey: string;
messageId: number;
action: string;
allowedActions: readonly string[];
}): TelegramCallbackValidation {
const record = this.get(input.userId, input.chatKey);
if (!record) return { ok: false, reason: 'expired-or-missing' };
if (record.originMessageId !== input.messageId) return { ok: false, reason: 'message-mismatch' };
if (!input.allowedActions.includes(input.action)) return { ok: false, reason: 'action-not-allowed' };
return { ok: true, record: record as TelegramInteractionRecord<unknown> };
}
get size(): number {
this.removeExpired();
return this.entries.size;
}
}
@@ -0,0 +1,28 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';
const bot = fs.readFileSync(new URL('./telegramBot.ts', import.meta.url), 'utf8');
test('Telegram channel wizards use chat-bound expiring interaction state', () => {
assert.match(bot, /new TelegramInteractionStore<TelegramWizardState>/);
assert.match(bot, /messageChatKey\(message, senderId\)/);
assert.match(bot, /telegramWizardStates\.lookup\(senderId, chatKey\)/);
assert.match(bot, /telegramWizardStates\.delete\(senderId, chatKey\)/);
assert.doesNotMatch(bot, /new Map<number, TelegramWizardState>/);
});
test('wizard callbacks bind actor, chat, origin message and action and fail closed after restart', () => {
assert.match(bot, /callbackChatKey\(update, userId\)/);
assert.match(bot, /messageId: Number\(update\.msgId\)/);
assert.match(bot, /allowedActions: \['cancel', 'mode_date', 'mode_tag', 'comments_on', 'comments_off'\]/);
assert.match(bot, /向导已失效,请重新打开/);
});
test('wizard interactions expose cancellation, restart replacement, expiry and bounded capacity', () => {
assert.match(bot, /TELEGRAM_INTERACTION_TTL_MS/);
assert.match(bot, /TELEGRAM_INTERACTION_MAX_ENTRIES/);
assert.match(bot, /putTelegramWizardState\(senderId, chatKey, state/);
assert.match(bot, /当前向导已过期,请重新打开/);
assert.match(bot, /已取消 Telegram 频道操作向导/);
});
@@ -0,0 +1,43 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
enqueueTelegramNotification,
flushTelegramNotificationDigest,
} from './telegramNotificationDelivery.js';
const preferences = {
failureImmediate: true,
successMode: 'digest' as const,
security: true as const,
subscriptionDigest: true,
timezone: 'UTC', quietStart: null, quietEnd: null,
};
test('delivery sends security immediately and queues digest events durably', async () => {
const calls: Array<{ sql: string; params?: unknown[] }> = [];
const sent: string[] = [];
const deps = {
getPreferences: async () => preferences,
runQuery: async (sql: string, params?: unknown[]) => { calls.push({ sql, params }); return { rows: [] } as any; },
send: async (_chatId: string, text: string) => { sent.push(text); },
now: () => new Date('2026-08-24T12:00:00Z'),
};
await enqueueTelegramNotification({ userId: 1, chatId: '1', kind: 'security', message: '安全告警' }, deps);
await enqueueTelegramNotification({ userId: 1, chatId: '1', kind: 'success', message: '成功' }, deps);
assert.deepEqual(sent, ['安全告警']);
assert.ok(calls.some(call => /INSERT INTO telegram_notification_digest/.test(call.sql)));
});
test('digest flush claims pending events and marks them delivered after one summary', async () => {
const sent: string[] = [];
const calls: string[] = [];
const rows = [{ id: 'a', kind: 'success', payload: { message: '完成 A' } }, { id: 'b', kind: 'subscription', payload: { message: '同步 B' } }];
const result = await flushTelegramNotificationDigest(1, '1', {
runQuery: async (sql: string) => { calls.push(sql); return { rows: /RETURNING d\./.test(sql) ? rows : [] } as any; },
send: async (_chat, text) => { sent.push(text); },
});
assert.equal(result, 2);
assert.equal(sent.length, 1);
assert.match(sent[0], /完成 A/);
assert.ok(calls.some(sql => /delivered_at = NOW/.test(sql)));
});
@@ -0,0 +1,84 @@
import type { QueryResult } from 'pg';
import { query } from '../db/index.js';
import {
evaluateTelegramNotification,
getTelegramNotificationPreferences,
type TelegramNotificationPreferences,
} from './telegramNotificationPreferences.js';
export type TelegramNotificationKind = 'security' | 'failure' | 'success' | 'subscription';
export function resolveNotificationOwnerUserId(ownerUserId: number | null | undefined, chatId: string): number {
if (Number.isSafeInteger(ownerUserId) && Number(ownerUserId) > 0) return Number(ownerUserId);
const parsed = Number(chatId);
if (Number.isSafeInteger(parsed) && parsed > 0) return parsed;
throw new Error('notification owner user id is required for group/channel delivery');
}
export interface TelegramNotificationEvent {
userId: number;
chatId: string;
kind: TelegramNotificationKind;
message: string;
payload?: Record<string, unknown>;
}
type RunQuery = (sql: string, params?: unknown[]) => Promise<QueryResult<any>>;
export interface TelegramNotificationDeliveryDeps {
getPreferences?: (userId: number, chatId: string) => Promise<TelegramNotificationPreferences>;
runQuery?: RunQuery;
send: (chatId: string, text: string) => Promise<void>;
now?: () => Date;
}
export async function enqueueTelegramNotification(event: TelegramNotificationEvent, deps: TelegramNotificationDeliveryDeps): Promise<'immediate' | 'digest' | 'skip'> {
const preferences = await (deps.getPreferences || getTelegramNotificationPreferences)(event.userId, event.chatId);
const decision = evaluateTelegramNotification(event.kind, preferences, (deps.now || (() => new Date()))());
if (decision.deliver === 'immediate') {
await deps.send(event.chatId, event.message);
return 'immediate';
}
if (decision.deliver === 'skip') return 'skip';
await (deps.runQuery || query)(
`INSERT INTO telegram_notification_digest (user_id, chat_id, kind, payload)
VALUES ($1, $2, $3, $4::jsonb)`,
[event.userId, event.chatId, event.kind, JSON.stringify({ message: event.message, ...(event.payload || {}) })],
);
return 'digest';
}
const digestFlushInProgress = new Set<string>();
export async function claimTelegramNotificationDigest(userId: number, chatId: string, runQuery: RunQuery): Promise<any[]> {
const claimed = await runQuery(
`WITH candidates AS (
SELECT id FROM telegram_notification_digest
WHERE user_id = $1 AND chat_id = $2 AND delivered_at IS NULL
ORDER BY created_at ASC LIMIT 100
FOR UPDATE SKIP LOCKED
)
UPDATE telegram_notification_digest d SET delivered_at = NOW()
FROM candidates c WHERE d.id = c.id
RETURNING d.id, d.kind, d.payload`,
[userId, chatId],
);
return claimed.rows;
}
export async function flushTelegramNotificationDigest(
userId: number,
chatId: string,
deps: Pick<TelegramNotificationDeliveryDeps, 'runQuery' | 'send'>,
): Promise<number> {
const scope = `${userId}:${chatId}`;
if (digestFlushInProgress.has(scope)) return 0;
digestFlushInProgress.add(scope);
try {
const runQuery = deps.runQuery || query;
const selected = { rows: await claimTelegramNotificationDigest(userId, chatId, runQuery) } as QueryResult<any>;
if (selected.rows.length === 0) return 0;
const lines = selected.rows.map(row => `• ${String(row.payload?.message || row.kind)}`);
await deps.send(chatId, ['📬 **通知摘要**', '', ...lines].join('\n'));
return selected.rows.length;
} finally {
digestFlushInProgress.delete(scope);
}
}
@@ -0,0 +1,11 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';
const bot = fs.readFileSync(new URL('./telegramBot.ts', import.meta.url), 'utf8');
test('production yt-dlp notifications pass through preference delivery and digest flush', () => {
const notifier = bot.slice(bot.indexOf('setYtDlpNotifier'), bot.indexOf('// Ensure database table exists'));
assert.match(notifier, /enqueueTelegramNotification/);
assert.match(notifier, /flushTelegramNotificationDigest/);
assert.match(notifier, /getConfiguredTelegramAllowedUsers/);
});
@@ -0,0 +1,15 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { claimTelegramNotificationDigest } from './telegramNotificationDelivery.js';
test('digest claim uses row locking and a durable claim timestamp', async () => {
const calls: string[] = [];
const rows = [{ id: 'a', kind: 'success', payload: { message: 'A' } }];
const result = await claimTelegramNotificationDigest(1, '1', async sql => {
calls.push(sql);
return { rows: /RETURNING d\./.test(sql) ? rows : [] } as any;
});
assert.deepEqual(result, rows);
assert.match(calls[0], /FOR UPDATE SKIP LOCKED/);
assert.match(calls[0], /delivered_at = NOW/);
});
@@ -0,0 +1,9 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { resolveNotificationOwnerUserId } from './telegramNotificationDelivery.js';
test('notification owner uses explicit task owner rather than chat id', () => {
assert.equal(resolveNotificationOwnerUserId(42, '-100123'), 42);
assert.equal(resolveNotificationOwnerUserId(null, '123'), 123);
assert.throws(() => resolveNotificationOwnerUserId(null, '-100123'), /owner user/i);
});
@@ -0,0 +1,27 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
DEFAULT_TELEGRAM_NOTIFICATION_PREFERENCES,
evaluateTelegramNotification,
isQuietHour,
normalizeTelegramNotificationPreferences,
} from './telegramNotificationPreferences.js';
const fixed = new Date('2026-08-24T14:30:00.000Z');
test('quiet hours honor user timezone including overnight ranges', () => {
assert.equal(isQuietHour({ timezone: 'Asia/Shanghai', quietStart: '22:00', quietEnd: '07:00' }, fixed), true);
assert.equal(isQuietHour({ timezone: 'UTC', quietStart: '22:00', quietEnd: '07:00' }, fixed), false);
});
test('security notifications bypass quiet hours and cannot be disabled', () => {
const preferences = normalizeTelegramNotificationPreferences({ security: false, timezone: 'Asia/Shanghai', quietStart: '22:00', quietEnd: '07:00' });
assert.equal(preferences.security, true);
assert.deepEqual(evaluateTelegramNotification('security', preferences, fixed), { deliver: 'immediate', reason: 'security-bypass' });
});
test('ordinary success can be summarized while failures remain immediate by default', () => {
const preferences = { ...DEFAULT_TELEGRAM_NOTIFICATION_PREFERENCES, successMode: 'digest' as const };
assert.deepEqual(evaluateTelegramNotification('success', preferences, fixed), { deliver: 'digest', reason: 'preference' });
assert.deepEqual(evaluateTelegramNotification('failure', preferences, fixed), { deliver: 'immediate', reason: 'preference' });
});
@@ -0,0 +1,94 @@
import { query } from '../db/index.js';
export type TelegramSuccessNotificationMode = 'immediate' | 'digest' | 'off';
export interface TelegramNotificationPreferences {
failureImmediate: boolean;
successMode: TelegramSuccessNotificationMode;
security: true;
subscriptionDigest: boolean;
timezone: string;
quietStart: string | null;
quietEnd: string | null;
}
export const DEFAULT_TELEGRAM_NOTIFICATION_PREFERENCES: TelegramNotificationPreferences = {
failureImmediate: true,
successMode: 'immediate',
security: true,
subscriptionDigest: true,
timezone: 'UTC',
quietStart: null,
quietEnd: null,
};
function validTime(value: unknown): string | null {
const text = typeof value === 'string' ? value.trim() : '';
return /^([01]\d|2[0-3]):[0-5]\d$/.test(text) ? text : null;
}
export function normalizeTelegramNotificationPreferences(input: Record<string, unknown>): TelegramNotificationPreferences {
const successMode = ['immediate', 'digest', 'off'].includes(String(input.successMode))
? String(input.successMode) as TelegramSuccessNotificationMode
: DEFAULT_TELEGRAM_NOTIFICATION_PREFERENCES.successMode;
const timezone = typeof input.timezone === 'string' && input.timezone.trim() ? input.timezone.trim() : 'UTC';
try { new Intl.DateTimeFormat('en-US', { timeZone: timezone }).format(new Date()); }
catch { throw new Error('无效时区'); }
return {
failureImmediate: input.failureImmediate !== false,
successMode,
security: true,
subscriptionDigest: input.subscriptionDigest !== false,
timezone,
quietStart: validTime(input.quietStart),
quietEnd: validTime(input.quietEnd),
};
}
export function isQuietHour(
preferences: Pick<TelegramNotificationPreferences, 'timezone' | 'quietStart' | 'quietEnd'>,
now = new Date(),
): boolean {
if (!preferences.quietStart || !preferences.quietEnd || preferences.quietStart === preferences.quietEnd) return false;
const parts = new Intl.DateTimeFormat('en-GB', { timeZone: preferences.timezone, hour: '2-digit', minute: '2-digit', hour12: false }).formatToParts(now);
const hour = Number(parts.find(part => part.type === 'hour')?.value || 0);
const minute = Number(parts.find(part => part.type === 'minute')?.value || 0);
const current = hour * 60 + minute;
const toMinutes = (value: string) => Number(value.slice(0, 2)) * 60 + Number(value.slice(3));
const start = toMinutes(preferences.quietStart);
const end = toMinutes(preferences.quietEnd);
return start < end ? current >= start && current < end : current >= start || current < end;
}
export function evaluateTelegramNotification(
kind: 'security' | 'failure' | 'success' | 'subscription',
preferences: TelegramNotificationPreferences,
now = new Date(),
): { deliver: 'immediate' | 'digest' | 'skip'; reason: string } {
if (kind === 'security') return { deliver: 'immediate', reason: 'security-bypass' };
const quiet = isQuietHour(preferences, now);
if (kind === 'failure') return preferences.failureImmediate && !quiet
? { deliver: 'immediate', reason: 'preference' }
: { deliver: 'digest', reason: quiet ? 'quiet-hours' : 'preference' };
if (kind === 'subscription') return preferences.subscriptionDigest || quiet
? { deliver: 'digest', reason: quiet ? 'quiet-hours' : 'preference' }
: { deliver: 'immediate', reason: 'preference' };
if (preferences.successMode === 'off') return { deliver: 'skip', reason: 'preference' };
if (quiet || preferences.successMode === 'digest') return { deliver: 'digest', reason: quiet ? 'quiet-hours' : 'preference' };
return { deliver: 'immediate', reason: 'preference' };
}
export async function getTelegramNotificationPreferences(userId: number, chatId: string): Promise<TelegramNotificationPreferences> {
const result = await query('SELECT preferences FROM telegram_notification_preferences WHERE user_id = $1 AND chat_id = $2', [userId, chatId]);
return normalizeTelegramNotificationPreferences(result.rows[0]?.preferences || {});
}
export async function setTelegramNotificationPreferences(userId: number, chatId: string, input: Record<string, unknown>): Promise<TelegramNotificationPreferences> {
const preferences = normalizeTelegramNotificationPreferences(input);
await query(
`INSERT INTO telegram_notification_preferences (user_id, chat_id, preferences)
VALUES ($1, $2, $3::jsonb)
ON CONFLICT (user_id, chat_id) DO UPDATE SET preferences = EXCLUDED.preferences, updated_at = NOW()`,
[userId, chatId, JSON.stringify(preferences)],
);
return preferences;
}
@@ -0,0 +1,16 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';
const schema = fs.readFileSync(new URL('../db/schema.sql', import.meta.url), 'utf8');
const commands = fs.readFileSync(new URL('./telegramCommands.ts', import.meta.url), 'utf8');
test('notification preference and digest storage are durable', () => {
assert.match(schema, /CREATE TABLE IF NOT EXISTS telegram_notification_preferences/);
assert.match(schema, /CREATE TABLE IF NOT EXISTS telegram_notification_digest/);
});
test('Bot exposes notification settings while security delivery remains mandatory', () => {
assert.match(commands, /handleNotifications/);
assert.match(commands, /安全告警仍始终即时/);
});
+47 -5
View File
@@ -1,5 +1,7 @@
import { query } from '../db/index.js';
import type { QueryResult } from 'pg';
import { getConfiguredTelegramAllowedUsers } from '../utils/authSettings.js';
import { ScopedInteractionMap } from './scopedInteractionMap.js';
// Telegram User States
export enum TelegramUserState {
@@ -8,18 +10,21 @@ export enum TelegramUserState {
WAITING_2FA_SETUP = 'WAITING_2FA_SETUP',
}
// User state storage
export const userStates = new Map<number, {
const INTERACTION_TTL_MS = Math.max(60_000, Number.parseInt(process.env.TELEGRAM_INTERACTION_TTL_MS || '900000', 10) || 900_000);
const INTERACTION_MAX_ENTRIES = Math.max(10, Number.parseInt(process.env.TELEGRAM_INTERACTION_MAX_ENTRIES || '1000', 10) || 1_000);
// PIN / TOTP states are temporary and intentionally vanish after restart.
export const userStates = new ScopedInteractionMap<number, {
state: TelegramUserState;
qrMessageId?: number;
promptMessageId?: number;
}>();
}>({ ttlMs: INTERACTION_TTL_MS, maxEntries: INTERACTION_MAX_ENTRIES });
// Authenticated user storage (Cache)
export const authenticatedUsers = new Map<number, { authenticatedAt: Date }>();
// Password input state
export const passwordInputState = new Map<number, { password: string }>();
export const passwordInputState = new ScopedInteractionMap<number, { password: string }>({ ttlMs: INTERACTION_TTL_MS, maxEntries: INTERACTION_MAX_ENTRIES });
export async function revokeAuthenticatedUser(userId: number): Promise<void> {
authenticatedUsers.delete(userId);
@@ -30,7 +35,44 @@ export async function revokeAuthenticatedUser(userId: number): Promise<void> {
}
}
// Initialize authenticated users from database
export interface TelegramAllowlistReconciliationResult {
allowed: number[];
added: number[];
removed: number[];
revoked: number[];
recipients: number[];
}
type TelegramStateQuery = (sql: string, params?: unknown[]) => Promise<Pick<QueryResult, 'rows' | 'rowCount'>>;
export async function reconcileTelegramAllowedUsers(
userIds: number[],
dependencies: {
query?: TelegramStateQuery;
cache?: Map<number, { authenticatedAt: Date }>;
} = {},
): Promise<TelegramAllowlistReconciliationResult> {
const allowed = [...new Set(userIds.filter(id => Number.isSafeInteger(id) && id > 0))].sort((a, b) => a - b);
const runQuery: TelegramStateQuery = dependencies.query || (query as TelegramStateQuery);
const cache = dependencies.cache || authenticatedUsers;
const existing = await runQuery('SELECT user_id FROM telegram_auth ORDER BY user_id');
const authenticated = existing.rows.map(row => Number(row.user_id)).filter(Number.isSafeInteger);
const allowedSet = new Set(allowed);
const authenticatedSet = new Set(authenticated);
const added = allowed.filter(id => !authenticatedSet.has(id));
const removed = authenticated.filter(id => !allowedSet.has(id)).sort((a, b) => a - b);
const revokedResult = await runQuery(
`DELETE FROM telegram_auth
WHERE NOT (user_id = ANY($1::bigint[]))
RETURNING user_id`,
[allowed],
);
const revoked = revokedResult.rows.map(row => Number(row.user_id)).filter(Number.isSafeInteger).sort((a, b) => a - b);
for (const userId of new Set([...removed, ...revoked])) cache.delete(userId);
const recipients = [...cache.keys()].filter(id => allowedSet.has(id)).sort((a, b) => a - b);
return { allowed, added, removed, revoked, recipients };
}
export async function loadAuthenticatedUsers(): Promise<void> {
try {
const allowedUsers = await getConfiguredTelegramAllowedUsers();
@@ -0,0 +1,30 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildTelegramStatusPanel, sanitizeTelegramStatusText } from './telegramStatusPanel.js';
const input = {
requestId: 'req-1234',
bot: { status: 'ready', degraded: false, reconnectCount: 1, lastError: null, action: null },
userClient: { status: 'expired', username: 'admin', action: '重新生成 session 并重启后端', lastError: '/root/data/session secret-token' },
target: { provider: 's3', accountName: 'Archive', probeStatus: 'failed', cooldownUntil: '2026-08-24T12:00:00Z', probeError: 'AccessKey AKIA-SECRET denied at /root/data' },
disk: { freeBytes: 1024, totalBytes: 4096 },
queue: { active: 2, pending: 3, failed: 1, paused: false },
subscriptions: { enabled: 4, lastScanAt: '2026-08-24T10:00:00Z', lastError: 'channel denied token=abc' },
reconciliation: { pending: 2, operatorRequired: 1 },
};
test('status panel presents every operational component and an operation id', () => {
const text = buildTelegramStatusPanel(input as any);
for (const expected of ['req-1234', 'Bot:ready', '账号下载器:expired', 's3 / Archive', 'Probe:failed', '临时磁盘', '队列', '订阅', '待对账:2', '需人工:1']) {
assert.match(text, new RegExp(expected));
}
});
test('status panel redacts absolute paths credentials tokens and raw provider errors', () => {
const text = buildTelegramStatusPanel(input as any);
assert.doesNotMatch(text, /\/root\/data/);
assert.doesNotMatch(text, /AKIA-SECRET/);
assert.doesNotMatch(text, /secret-token|token=abc/);
assert.match(text, /已脱敏/);
assert.equal(sanitizeTelegramStatusText('Bearer abc /home/user/x password=hunter2'), '[已脱敏]');
});
@@ -0,0 +1,48 @@
export interface TelegramStatusPanelInput {
requestId: string;
bot: { status: string; degraded?: boolean; reconnectCount?: number; lastError?: string | null; action?: string | null };
userClient: { status: string; username?: string | null; action?: string | null; lastError?: string | null };
target: { provider: string; accountName: string; probeStatus?: string | null; cooldownUntil?: string | null; probeError?: string | null };
disk: { freeBytes: number; totalBytes: number };
queue: { active: number; pending: number; failed: number; paused: boolean };
subscriptions: { enabled: number; lastScanAt?: string | null; lastError?: string | null };
reconciliation: { pending: number; operatorRequired: number };
}
export function sanitizeTelegramStatusText(value: unknown): string {
if (!value) return '无';
const text = String(value);
if (/(?:^|\s)(?:\/[^\s]+|[A-Za-z]:\\[^\s]+)/.test(text)
|| /(?:bearer|token|secret|password|api[_-]?key|access[_-]?key|refresh[_-]?token)[=: ]/i.test(text)
|| /AKIA[0-9A-Z-]+/i.test(text)) return '[已脱敏]';
return text.length > 120 ? `${text.slice(0, 117)}…` : text;
}
function percent(used: number, total: number): number {
return total > 0 ? Math.max(0, Math.min(100, Math.round((used / total) * 100))) : 0;
}
export function buildTelegramStatusPanel(input: TelegramStatusPanelInput): string {
const used = Math.max(0, input.disk.totalBytes - input.disk.freeBytes);
return [
'🩺 **TG Vault 诊断状态**',
`操作 ID:${sanitizeTelegramStatusText(input.requestId)}`,
'',
`Bot:${input.bot.status}${input.bot.degraded ? '(降级)' : ''} · 重连 ${input.bot.reconnectCount || 0}`,
`账号下载器:${input.userClient.status}${input.userClient.username ? ` / @${sanitizeTelegramStatusText(input.userClient.username)}` : ''}`,
input.userClient.action ? `账号恢复:${sanitizeTelegramStatusText(input.userClient.action)}` : null,
'',
`Target:${input.target.provider} / ${sanitizeTelegramStatusText(input.target.accountName)}`,
`Probe:${input.target.probeStatus || 'unknown'}`,
input.target.cooldownUntil ? `Cooldown:${sanitizeTelegramStatusText(input.target.cooldownUntil)}` : 'Cooldown:无',
input.target.probeError ? `Probe 错误:${sanitizeTelegramStatusText(input.target.probeError)}` : null,
'',
`临时磁盘:${input.disk.freeBytes}/${input.disk.totalBytes} bytes 可用 · 已用 ${percent(used, input.disk.totalBytes)}%`,
`队列:活跃 ${input.queue.active} · 等待 ${input.queue.pending} · 失败 ${input.queue.failed}${input.queue.paused ? ' · 已暂停' : ''}`,
`订阅:启用 ${input.subscriptions.enabled} · 最近扫描 ${sanitizeTelegramStatusText(input.subscriptions.lastScanAt || '无')}`,
input.subscriptions.lastError ? `订阅错误:${sanitizeTelegramStatusText(input.subscriptions.lastError)}` : null,
`对账:待对账:${input.reconciliation.pending} · 需人工:${input.reconciliation.operatorRequired}`,
'',
input.bot.action ? `建议:${sanitizeTelegramStatusText(input.bot.action)}` : '建议:如组件异常,请携带操作 ID 查看结构化日志。',
].filter(Boolean).join('\n');
}
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import fs from 'node:fs';
const bot = fs.readFileSync(new URL('./telegramBot.ts', import.meta.url), 'utf8');
const presentation = fs.readFileSync(new URL('../bot/presentation/subscription.ts', import.meta.url), 'utf8');
const jobs = fs.readFileSync(new URL('./telegramChannelJobs.ts', import.meta.url), 'utf8');
const schema = fs.readFileSync(new URL('../db/schema.sql', import.meta.url), 'utf8');
@@ -11,7 +11,7 @@ test('subscription health records scan, success, failure and next-scan visibilit
assert.match(jobs, /last_scan_at = NOW\(\)/);
assert.match(jobs, /last_success_at = NOW\(\)/);
assert.match(jobs, /next_scan_at/);
assert.match(bot, /上次扫描/);
assert.match(bot, /下次扫描约/);
assert.match(bot, /最近结果/);
assert.match(presentation, /上次扫描/);
assert.match(presentation, /下次扫描约/);
assert.match(presentation, /最近结果/);
});
@@ -1,3 +1,5 @@
import type { StorageTargetSnapshot } from './storage.js';
export const TELEGRAM_SUBSCRIPTION_PAGE_SIZE = 5;
export type TelegramSubscriptionRow = {
@@ -30,18 +32,43 @@ export function buildTelegramSubscriptionPage<T extends TelegramSubscriptionRow>
export type TelegramSubscriptionCallback =
| { kind: 'page'; page: number }
| { kind: 'action'; action: 'view' | 'folder' | 'clear' | 'cancel'; id: string; page: number }
| { kind: 'action'; action: 'view' | 'folder' | 'clear' | 'cancel' | 'sync' | 'pause' | 'resume' | 'target' | 'from_now' | 'backfill' | 'result' | 'retry'; id: string; page: number }
| { kind: 'confirm' | 'back'; token: string };
export type TelegramSubscriptionAction = Extract<TelegramSubscriptionCallback, { kind: 'action' }>['action'];
export function buildSubscriptionOperations(row: TelegramSubscriptionRow & { enabled?: boolean }): Array<{ action: TelegramSubscriptionAction; label: string }> {
const operations: Array<{ action: TelegramSubscriptionAction; label: string }> = [
{ action: 'sync', label: '立即同步' },
{ action: row.enabled ? 'pause' : 'resume', label: row.enabled ? '暂停' : '恢复' },
{ action: 'target', label: '修改目标' },
{ action: 'from_now', label: '从现在开始' },
{ action: 'backfill', label: '按日期补抓' },
{ action: 'result', label: '最近结果' },
{ action: 'retry', label: '重试失败项' },
];
return operations;
}
export function resolveSubscriptionTarget(
row: { target_mode?: string | null; target_provider?: string | null; target_account_id?: string | null },
getActiveTarget: () => StorageTargetSnapshot,
getTarget: (provider: string, accountId: string | null) => StorageTargetSnapshot,
): StorageTargetSnapshot {
if (row.target_mode !== 'fixed') return getActiveTarget();
if (!row.target_provider) throw new Error('固定订阅目标缺少 provider');
return getTarget(row.target_provider, row.target_account_id || null);
}
export function parseTelegramSubscriptionCallback(data: string): TelegramSubscriptionCallback | null {
let match = data.match(/^tsub_page_(\d{1,6})$/);
if (match) return { kind: 'page', page: Number(match[1]) };
match = data.match(/^tsub_(view|folder|clear|cancel)_([0-9a-f-]{36})(?:_(\d{1,6}))?$/i);
match = data.match(/^tsub_(view|folder|clear|cancel|sync|pause|resume|target|from_now|backfill|result|retry)_([0-9a-f-]{36})(?:_(\d{1,6}))?$/i);
if (match) {
return {
kind: 'action',
action: match[1] as 'view' | 'folder' | 'clear' | 'cancel',
action: match[1] as TelegramSubscriptionAction,
id: match[2],
page: Number(match[3] || 0),
};
@@ -0,0 +1,25 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildSubscriptionOperations,
parseTelegramSubscriptionCallback,
resolveSubscriptionTarget,
} from './telegramSubscriptionManagement.js';
const fixed = { target_mode: 'fixed', target_provider: 's3', target_account_id: 'account-a' };
test('subscription operations expose sync pause resume target cursor backfill results and retry', () => {
const actions = buildSubscriptionOperations({ id: '00000000-0000-4000-8000-000000000001', enabled: true } as any);
assert.deepEqual(actions.map(action => action.action), ['sync', 'pause', 'target', 'from_now', 'backfill', 'result', 'retry']);
assert.deepEqual(parseTelegramSubscriptionCallback('tsub_sync_00000000-0000-4000-8000-000000000001_2'), { kind: 'action', action: 'sync', id: '00000000-0000-4000-8000-000000000001', page: 2 });
assert.deepEqual(parseTelegramSubscriptionCallback('tsub_resume_00000000-0000-4000-8000-000000000001'), { kind: 'action', action: 'resume', id: '00000000-0000-4000-8000-000000000001', page: 0 });
});
test('subscription target mode snapshots global or fixed targets explicitly', () => {
const global = { provider: { name: 'local' }, accountId: null, providerKey: 'local' } as any;
const getTarget = (provider: string, accountId: string | null) => ({ provider: { name: provider }, accountId, providerKey: `${provider}:${accountId}` }) as any;
assert.equal(resolveSubscriptionTarget({ target_mode: 'follow_global' } as any, () => global, getTarget), global);
const target = resolveSubscriptionTarget(fixed as any, () => global, getTarget);
assert.equal(target.provider.name, 's3');
assert.equal(target.accountId, 'account-a');
});
@@ -0,0 +1,36 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';
const bot = fs.readFileSync(new URL('./telegramBot.ts', import.meta.url), 'utf8');
const upload = fs.readFileSync(new URL('./telegramUpload.ts', import.meta.url), 'utf8');
const ytdlp = fs.readFileSync(new URL('./ytDlpDownload.ts', import.meta.url), 'utf8');
const jobs = fs.readFileSync(new URL('./telegramChannelJobs.ts', import.meta.url), 'utf8');
const schema = fs.readFileSync(new URL('../db/schema.sql', import.meta.url), 'utf8');
const lifecycle = fs.readFileSync(new URL('./storageAccountLifecycle.ts', import.meta.url), 'utf8');
test('ordinary files, albums, channel jobs and yt-dlp resolve chat target at admission', () => {
assert.match(upload, /consumeOrGetTelegramTargetState\(chatKey\)/);
assert.match(upload, /consumeOrGetTelegramTargetState\(chatIdStr\)/);
assert.match(bot, /consumeOrGetTelegramTargetState\(chatKey\)/);
assert.match(bot, /createYtDlpPreview\(message, senderId, url\)/);
assert.match(bot, /consumeOrGetTelegramTargetState\(messageChatKey\(message, userId\)\)/);
assert.match(bot, /handleYtDlpCommand\(currentMessage, consumed\.value\.url, consumed\.value\.target/);
assert.match(ytdlp, /explicitTarget \|\| storageManager\.getActiveTarget\(\)/);
assert.match(jobs, /target\?: StorageTargetSnapshot/);
});
test('chat target state is durable, account-bound and deletion-safe', () => {
assert.match(schema, /CREATE TABLE IF NOT EXISTS telegram_target_states/);
assert.match(schema, /account_id UUID REFERENCES storage_accounts\(id\) ON DELETE RESTRICT/);
assert.match(lifecycle, /SELECT chat_id FROM telegram_target_states/);
});
test('/target supports next, session, clear and never switches global active account', () => {
assert.match(bot, /text === '\/target'/);
assert.match(bot, /handleTarget\(message/);
const commands = fs.readFileSync(new URL('./telegramCommands.ts', import.meta.url), 'utf8');
assert.match(commands, /setTelegramTargetState/);
assert.match(commands, /clearTelegramTargetState/);
assert.doesNotMatch(commands.match(/export async function handleTarget[\s\S]*?\n}\n/)?.[0] || '', /switchAccount/);
});
+13 -5
View File
@@ -1,5 +1,6 @@
import type { DownloadTaskGroupSnapshot } from './downloadTaskQueue.js';
import type { TransferTaskRecord } from './transferTasks.js';
import { telegramChannelJobTaskState } from './unifiedTaskMapper.js';
export type TaskCenterSourceType = 'memory' | 'channel' | 'ytdlp';
export type TaskCenterKind = 'single' | 'album' | 'channel' | 'ytdlp';
@@ -416,15 +417,22 @@ export function channelTaskCenterItem(row: any): TaskCenterItem | null {
: undefined,
}
: undefined;
const channelState = telegramChannelJobTaskState(row);
const state: TaskCenterState = inCooldown
? 'cooling'
: row.status === 'paused'
? (active > 0 ? 'pausing' : 'paused')
: row.status === 'queued' || row.status === 'pending'
? 'waiting'
: active > 0 || row.scan_status === 'scanning' || row.is_actively_running
? 'running'
: 'waiting';
: channelState.status === 'cancelled'
? 'failed'
: channelState.status === 'completed'
? 'waiting'
: channelState.status === 'failed'
? 'failed'
: row.status === 'queued' || row.status === 'pending'
? 'waiting'
: active > 0 || channelState.stage === 'scanning' || channelState.stage === 'downloading' || row.is_actively_running
? 'running'
: 'waiting';
const optionsSource = row.options ?? row.params ?? {};
let options: Record<string, any>;
if (typeof optionsSource === 'string') {
+25 -5
View File
@@ -39,6 +39,7 @@ import { buildStorageFolderWithRules, getStoragePathRules, getTelegramBatchFolde
import { resolveTelegramGeneratedFileName } from '../utils/telegramNaming.js';
import { annotateTelegramMediaGroup, createTelegramMediaGroupDebouncer, getForwardedSourceLookup, prefetchForwardedSourceMessages, takePendingMediaGroupSnapshot, telegramMediaGroupQueueKey, type ForwardedSourceMessageCache } from '../utils/telegramMediaGroup.js';
import { resolveTelegramStorageFolderPersistent, resolveTelegramTaskStorageFolderPersistent, previewTelegramStorageFolderPersistent } from '../utils/telegramPathSettings.js';
import { consumeOrGetTelegramTargetState } from '../utils/telegramTargetStateStore.js';
import { findDuplicateFile, getDuplicateMode } from '../utils/duplicatePolicy.js';
import { DownloadTaskQueue, type DownloadTaskGroupInput, type DownloadTaskGroupSnapshot } from './downloadTaskQueue.js';
import { persistOrdinaryTransferTask } from './transferTasks.js';
@@ -676,7 +677,7 @@ async function finalizeSilentSessionIfDone(client: TelegramClient, chatId: Api.T
const controls = s?.failed
? (() => {
const queueStats = getDownloadQueueStats();
return buildTaskControlButtons(s?.taskId, queueStats.paused, queueStats.systemPause);
return buildTaskControlButtons(s?.taskId, queueStats.paused, queueStats.systemPause, false, queueStats.userPaused, s?.failed || 0);
})()
: undefined;
const edited = await safeEditMessage(client, chatId, { message: silentMsgId, text, buttons: controls });
@@ -1044,7 +1045,7 @@ export async function refreshSilentProgress(client: TelegramClient, chatId: Api.
);
const controls = isComplete && session.failed === 0
? undefined
: buildTaskControlButtons(session.taskId, cardPaused, cardSystemPause, cardPausing, cardUserPaused);
: buildTaskControlButtons(session.taskId, cardPaused, cardSystemPause, cardPausing, cardUserPaused, session.failed);
const buttons = controls;
await safeEditMessage(client, chatId, { message: silentMsgId, text, buttons });
}
@@ -1329,6 +1330,17 @@ export async function cancelSilentTask(client: TelegramClient, chatId: Api.TypeE
return result;
}
export function listFailedDownloadTaskDetails(taskId: string, chatId: string, userId: number): string[] {
if (!canControlTask(taskId, chatId, userId)) return [];
const files = getConsolidatedFiles(chatId)
.filter(file => file.phase === 'failed')
.map(file => `${file.fileName}: ${file.error || '未知错误'}`);
const batches = getConsolidatedBatches(chatId)
.filter(batch => batch.failed > 0)
.map(batch => `${batch.folderPath || batch.folderName}: ${batch.failed} 项失败`);
return [...files, ...batches];
}
export function retryFailedDownloadTasks(limit = 10, taskId?: string, chatId?: string, userId?: number) {
return downloadQueue.retryFailed(limit, { chatId, userId }, taskId);
}
@@ -2576,6 +2588,11 @@ export async function handleFileUpload(client: TelegramClient, event: NewMessage
const queueKey = telegramMediaGroupQueueKey(message.chatId, mediaGroupId);
let queue = mediaGroupQueues.get(queueKey);
if (!queue) {
const chatKey = message.chatId?.toString() || String(senderId);
const selectedTarget = await consumeOrGetTelegramTargetState(chatKey);
const storageTarget = selectedTarget
? storageManager.getTarget(selectedTarget.provider, selectedTarget.accountId)
: storageManager.getActiveTarget();
queue = {
mediaGroupId,
queueKey,
@@ -2584,7 +2601,7 @@ export async function handleFileUpload(client: TelegramClient, event: NewMessage
client,
files: [],
processingStarted: false,
storageTarget: storageManager.getActiveTarget(),
storageTarget,
createdAt: Date.now(),
lastAddedAt: Date.now(),
};
@@ -2645,7 +2662,10 @@ export async function handleFileUpload(client: TelegramClient, event: NewMessage
const chatIdStr = chatId.toString();
const chatName = await getTelegramChatName(message);
const previewRules = await getStoragePathRules();
const singleStorageTarget = storageManager.getActiveTarget();
const selectedTarget = await consumeOrGetTelegramTargetState(chatIdStr);
const singleStorageTarget = selectedTarget
? storageManager.getTarget(selectedTarget.provider, selectedTarget.accountId)
: storageManager.getActiveTarget();
const previewFolder = await resolveTelegramStorageFolderPersistent(chatIdStr, buildStorageFolderWithRules({
source: 'telegram',
chatName,
@@ -2886,7 +2906,7 @@ export async function handleFileUpload(client: TelegramClient, event: NewMessage
await runStatusAction(chatId, async () => {
await client.editMessage(chatId, {
message: statusMsg!.id,
text: buildUploadSuccess(finalFileName, actualSize, fileType, provider.name, storageFolder),
text: buildUploadSuccess(finalFileName, actualSize, fileType, provider.name, storageFolder, indexedFileId, duplicateMode === 'copy' ? 'copied' : null),
});
});
}
@@ -0,0 +1,47 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildUploadSuccess, buildTaskControlButtons } from '../utils/telegramMessages.js';
import { buildUploadReceipt } from './telegramUploadReceipt.js';
test('successful receipt exposes actual target folder file id and duplicate outcome', () => {
const receipt = buildUploadReceipt({
taskId: 'task-1', fileName: 'photo.jpg', provider: 's3', accountName: 'Archive', folder: 'photos/2026',
fileId: '12345678-1234-4000-8000-123456789abc', duplicateOutcome: 'copied', status: 'success',
});
assert.match(receipt.text, /s3 \/ Archive/);
assert.match(receipt.text, /photos\/2026/);
assert.match(receipt.text, /12345678-1234/);
assert.match(receipt.text, /已生成副本/);
assert.deepEqual(receipt.actions.map(action => action.action), ['find_folder', 'copy_id', 'delete_file']);
});
test('production single-upload receipt includes the actual indexed file id and follow-up actions', () => {
const text = buildUploadSuccess('photo.jpg', 123, 'image', 's3', 'photos', '12345678-1234-4000-8000-123456789abc', 'copied');
assert.match(text, /12345678-1234/);
assert.match(text, /已生成副本/);
assert.match(text, /\/find/);
assert.match(text, /\/delete <ID>/);
});
test('batch receipt remains one card and exposes retry-failed and failure details', () => {
const receipt = buildUploadReceipt({
taskId: 'batch-1', fileName: '相册', provider: 'local', accountName: '本地', folder: null,
status: 'partial', total: 20, successful: 17, failed: 3,
});
assert.match(receipt.text, /成功 17/);
assert.match(receipt.text, /失败 3/);
assert.deepEqual(receipt.actions.map(action => action.action), ['retry_failed', 'failure_details']);
});
test('failed task card exposes actionable retry and failure-detail callbacks', () => {
const rows = buildTaskControlButtons('task-1', false, undefined, false, false, 3)?.rows || [];
const data = rows.flatMap(row => row.buttons.map(button => Buffer.from((button as any).data || []).toString('utf8')));
assert.ok(data.includes('receipt_retry_task-1'));
assert.ok(data.includes('receipt_failures_task-1'));
});
test('9+ item receipts stay in silent single-card mode', () => {
const receipt = buildUploadReceipt({ taskId: 'batch-2', fileName: '批量文件', provider: 'local', accountName: '本地', status: 'running', total: 9 });
assert.equal(receipt.silentSingleCard, true);
});
@@ -0,0 +1,52 @@
export type TelegramUploadReceiptAction =
| 'find_folder'
| 'copy_id'
| 'delete_file'
| 'retry_failed'
| 'failure_details';
export interface TelegramUploadReceiptInput {
taskId: string;
fileName: string;
provider: string;
accountName: string;
folder?: string | null;
fileId?: string | null;
duplicateOutcome?: 'copied' | 'skipped' | null;
status: 'running' | 'success' | 'partial' | 'failed';
total?: number;
successful?: number;
failed?: number;
}
export interface TelegramUploadReceipt {
text: string;
silentSingleCard: boolean;
actions: Array<{ action: TelegramUploadReceiptAction; label: string; data: string }>;
}
export function buildUploadReceipt(input: TelegramUploadReceiptInput): TelegramUploadReceipt {
const total = Math.max(1, input.total || 1);
const failed = Math.max(0, input.failed || 0);
const successful = Math.max(0, input.successful ?? (input.status === 'success' ? total : 0));
const lines = [
input.status === 'success' ? '✅ **文件已保存**' : input.status === 'partial' ? '⚠️ **批量任务部分完成**' : input.status === 'failed' ? '❌ **保存失败**' : '⏳ **正在处理**',
`📄 ${input.fileName}`,
`🎯 ${input.provider} / ${input.accountName}`,
`📁 ${input.folder || '根目录'}`,
input.fileId ? `🆔 ${input.fileId.slice(0, 13)}` : null,
total > 1 ? `📊 共 ${total} · 成功 ${successful} · 失败 ${failed}` : null,
input.duplicateOutcome === 'copied' ? '♻️ 重复处理:已生成副本' : input.duplicateOutcome === 'skipped' ? '⏭️ 重复处理:已跳过' : null,
`任务:${input.taskId}`,
].filter(Boolean) as string[];
const actions: TelegramUploadReceipt['actions'] = [];
if (failed > 0 || input.status === 'failed') {
actions.push({ action: 'retry_failed', label: '重试失败项', data: `receipt_retry_${input.taskId}` });
actions.push({ action: 'failure_details', label: '查看失败明细', data: `receipt_failures_${input.taskId}` });
} else if (input.status === 'success' && input.fileId) {
actions.push({ action: 'find_folder', label: '搜索同目录', data: `receipt_find_${input.taskId}` });
actions.push({ action: 'copy_id', label: '复制 ID', data: `receipt_copy_${input.taskId}` });
actions.push({ action: 'delete_file', label: '删除该文件', data: `receipt_delete_${input.taskId}` });
}
return { text: lines.join('\n'), actions, silentSingleCard: total >= 9 };
}
@@ -0,0 +1,94 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
mapTelegramChannelJob,
mapTransferTask,
telegramChannelJobTaskState,
} from './unifiedTaskMapper.js';
import type { TransferTaskRecord } from './transferTasks.js';
const now = Date.parse('2026-08-24T00:00:00.000Z');
function channelRow(overrides: Record<string, unknown> = {}) {
return {
id: 'cafebabedeadbeef',
user_id: 7,
chat_id: '-100123',
kind: 'tag_download',
source: '@wallpaper',
status: 'running',
scan_status: 'scanning',
download_status: 'active',
total_count: 20,
item_count: 20,
pending_count: 10,
downloading_count: 1,
success_count: 7,
failed_count: 1,
skipped_count_items: 1,
total_bytes: '1024',
params: { tag: '#壁纸', storageProvider: 'local', folderOverride: '频道/壁纸' },
created_at: new Date(now - 60_000).toISOString(),
updated_at: new Date(now).toISOString(),
finished_at: null,
...overrides,
};
}
test('scan_status done maps an active channel job to downloading, never scanning', () => {
assert.deepEqual(
telegramChannelJobTaskState(channelRow({ scan_status: 'done' })),
{ status: 'running', stage: 'downloading' },
);
const task = mapTelegramChannelJob(channelRow({ scan_status: 'done' }), new Map());
assert.equal(task.stage, 'downloading');
assert.notEqual(task.stage, 'scanning');
});
test('channel status and scan phase map consistently across lifecycle states', () => {
assert.deepEqual(telegramChannelJobTaskState(channelRow({ status: 'queued', scan_status: 'pending' })), {
status: 'pending', stage: 'waiting',
});
assert.deepEqual(telegramChannelJobTaskState(channelRow({ status: 'running', scan_status: 'scanning' })), {
status: 'running', stage: 'scanning',
});
assert.deepEqual(telegramChannelJobTaskState(channelRow({ status: 'completed', scan_status: 'done' })), {
status: 'completed', stage: 'completed',
});
assert.deepEqual(telegramChannelJobTaskState(channelRow({ status: 'completed_with_errors', scan_status: 'done' })), {
status: 'failed', stage: 'failed',
});
assert.deepEqual(telegramChannelJobTaskState(channelRow({ status: 'cancelled', scan_status: 'cancelled' })), {
status: 'cancelled', stage: 'cancelled',
});
});
test('unified mappers preserve targets, counts, and task safety capabilities', () => {
const accounts = new Map([['account-a', '主存储']]);
const channel = mapTelegramChannelJob(channelRow({
status: 'completed_with_errors',
scan_status: 'done',
params: JSON.stringify({ storageProvider: 'google_drive', storageAccountId: 'account-a', folderOverride: '归档' }),
}), accounts);
assert.deepEqual(channel.target, {
provider: 'google_drive', accountId: 'account-a', accountName: '主存储', folder: '归档',
});
assert.deepEqual(channel.counts, { total: 20, completed: 7, failed: 1 });
assert.equal(channel.retryable, true);
assert.equal(channel.cancellable, false);
const transfer: TransferTaskRecord = {
sourceType: 'telegram_bot', id: 'bot-1', kind: 'single', title: 'report.pdf', status: 'running',
stage: 'uploading', progress: 75, ownerUserId: 7, chatId: '-100123', source: 'Telegram',
targetProvider: 'local', targetAccountId: null, targetFolder: '文档', totalItems: 1,
completedItems: 0, failedItems: 0, totalBytes: 4096, transferredBytes: 3072,
payload: { speed: '1 MiB/s' }, error: null, retryable: false, cancelRequested: false,
startedAt: new Date(now - 30_000), finishedAt: null, createdAt: new Date(now - 60_000), updatedAt: new Date(now),
};
const task = mapTransferTask(transfer, accounts);
assert.equal(task.sourceType, 'telegram_bot');
assert.equal(task.target.accountName, '服务器本地目录');
assert.equal(task.cancellable, true);
assert.deepEqual(task.bytes, { total: 4096, transferred: 3072 });
});
+144
View File
@@ -0,0 +1,144 @@
import type { TransferTaskRecord } from './transferTasks.js';
export type UnifiedTaskSourceType = 'telegram_bot' | 'telegram_channel' | 'ytdlp' | 'web_upload' | 'subscription';
export interface UnifiedTask {
id: string;
sourceType: UnifiedTaskSourceType;
kind: string;
title: string;
status: string;
stage: string;
progress: number;
ownerUserId: number | null;
chatId: string | null;
source: string | null;
target: {
provider: string | null;
accountId: string | null;
accountName: string | null;
folder: string | null;
};
counts: { total: number; completed: number; failed: number };
bytes: { total: number; transferred: number };
detail: Record<string, unknown>;
error: string | null;
retryable: boolean;
cancellable: boolean;
createdAt: Date | string;
updatedAt: Date | string;
finishedAt: Date | string | null;
}
export interface TelegramChannelTaskState {
status: string;
stage: string;
}
function safeNumber(value: unknown): number {
const parsed = Number(value ?? 0);
return Number.isFinite(parsed) ? Math.max(0, parsed) : 0;
}
export function parseTaskOptions(value: unknown): Record<string, any> {
if (value && typeof value === 'object' && !Array.isArray(value)) return value as Record<string, any>;
if (typeof value !== 'string') return {};
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
} catch {
return {};
}
}
function channelStatus(status: string): string {
if (status === 'queued') return 'pending';
if (status === 'cooling') return 'waiting';
if (status === 'completed_with_errors') return 'failed';
return status;
}
export function telegramChannelJobTaskState(row: any): TelegramChannelTaskState {
const status = channelStatus(String(row?.status || 'pending'));
const scanStatus = String(row?.scan_status || 'pending');
const stage = status === 'completed'
? 'completed'
: status === 'failed'
? 'failed'
: status === 'cancelled' || scanStatus === 'cancelled'
? 'cancelled'
: scanStatus === 'scanning'
? 'scanning'
: scanStatus === 'done'
? 'downloading'
: 'waiting';
return { status, stage };
}
export function mapTelegramChannelJob(row: any, accountNames: ReadonlyMap<string, string>): UnifiedTask {
const params = parseTaskOptions(row?.params);
const state = telegramChannelJobTaskState(row);
const total = Math.max(safeNumber(row?.total_count), safeNumber(row?.item_count));
const completed = safeNumber(row?.completed_items ?? row?.success_count);
const failed = safeNumber(row?.failed_items ?? row?.failed_count);
const skipped = safeNumber(row?.skipped_count_items ?? row?.skipped_count);
const accountId = params.storageAccountId == null ? null : String(params.storageAccountId);
const provider = params.storageProvider == null ? null : String(params.storageProvider);
return {
id: String(row?.id),
sourceType: 'telegram_channel',
kind: String(row?.kind),
title: row?.source || 'Telegram 频道任务',
status: state.status,
stage: state.stage,
progress: total > 0 ? Math.min(100, ((completed + failed + skipped) / total) * 100) : 0,
ownerUserId: row?.user_id == null ? null : safeNumber(row.user_id),
chatId: row?.chat_id == null ? null : String(row.chat_id),
source: row?.source == null ? null : String(row.source),
target: {
provider,
accountId,
accountName: accountId ? accountNames.get(accountId) || null : (provider === 'local' ? '服务器本地目录' : null),
folder: params.folderOverride == null ? null : String(params.folderOverride),
},
counts: { total, completed, failed },
bytes: { total: safeNumber(row?.total_bytes), transferred: 0 },
detail: { scanStatus: row?.scan_status, downloadStatus: row?.download_status, skipped },
error: row?.error == null ? null : String(row.error),
retryable: ['failed', 'completed_with_errors'].includes(String(row?.status)),
cancellable: ['queued', 'running', 'paused', 'cooling'].includes(String(row?.status)),
createdAt: row?.created_at,
updatedAt: row?.updated_at,
finishedAt: row?.finished_at || null,
};
}
export function mapTransferTask(task: TransferTaskRecord, accountNames: ReadonlyMap<string, string>): UnifiedTask {
return {
id: task.id,
sourceType: task.sourceType,
kind: task.kind,
title: task.title,
status: task.status,
stage: task.stage,
progress: task.progress,
ownerUserId: task.ownerUserId,
chatId: task.chatId,
source: task.source,
target: {
provider: task.targetProvider,
accountId: task.targetAccountId,
accountName: task.targetAccountId ? accountNames.get(task.targetAccountId) || null : (task.targetProvider === 'local' ? '服务器本地目录' : null),
folder: task.targetFolder,
},
counts: { total: task.totalItems, completed: task.completedItems, failed: task.failedItems },
bytes: { total: task.totalBytes, transferred: task.transferredBytes },
detail: task.payload,
error: task.error,
retryable: task.retryable,
cancellable: ['pending', 'running', 'paused'].includes(task.status),
createdAt: task.createdAt,
updatedAt: task.updatedAt,
finishedAt: task.finishedAt,
};
}
@@ -0,0 +1,25 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { YtDlpConfirmationStore } from './ytDlpConfirmation.js';
const target = { provider: { name: 'local' }, accountId: null, providerKey: 'local' } as any;
test('confirmation is bound to actor chat message and immutable target snapshot', () => {
let now = 1_000;
const store = new YtDlpConfirmationStore({ ttlMs: 500, maxEntries: 10, now: () => now, tokenFactory: () => 'token-a' });
const value = store.issue({ actorId: 7, chatKey: '-1001', messageId: 42, url: 'https://example.test/v', metadata: { title: 'Video' } as any, target, format: 'best', folder: 'ytdlp' });
assert.equal(value.token, 'token-a');
assert.equal(store.consume('token-a', { actorId: 8, chatKey: '-1001', messageId: 42 }).status, 'mismatch');
const consumed = store.consume('token-a', { actorId: 7, chatKey: '-1001', messageId: 42 });
assert.equal(consumed.status, 'ok');
if (consumed.status === 'ok') assert.equal(consumed.value.target, target);
assert.equal(store.consume('token-a', { actorId: 7, chatKey: '-1001', messageId: 42 }).status, 'missing');
});
test('confirmation expires and can be cancelled without creating a task', () => {
let now = 0;
const store = new YtDlpConfirmationStore({ ttlMs: 100, now: () => now, tokenFactory: () => 'token-b' });
store.issue({ actorId: 7, chatKey: '1', messageId: 1, url: 'https://example.test/v', metadata: { title: 'Video' } as any, target, format: 'audio', folder: 'music' });
now = 101;
assert.equal(store.consume('token-b', { actorId: 7, chatKey: '1', messageId: 1 }).status, 'expired');
});
+63
View File
@@ -0,0 +1,63 @@
import crypto from 'node:crypto';
import type { StorageTargetSnapshot } from './storage.js';
import type { YtDlpProbeResult } from './ytDlpProbe.js';
export interface YtDlpConfirmationValue {
actorId: number;
chatKey: string;
messageId: number;
url: string;
metadata: YtDlpProbeResult;
target: StorageTargetSnapshot;
format: 'best' | 'audio';
folder: string;
expiresAt: number;
}
export class YtDlpConfirmationStore {
private readonly values = new Map<string, YtDlpConfirmationValue>();
private readonly ttlMs: number;
private readonly maxEntries: number;
private readonly now: () => number;
private readonly tokenFactory: () => string;
constructor(options: { ttlMs?: number; maxEntries?: number; now?: () => number; tokenFactory?: () => string } = {}) {
this.ttlMs = Math.max(1, options.ttlMs ?? 5 * 60_000);
this.maxEntries = Math.max(1, options.maxEntries ?? 500);
this.now = options.now ?? Date.now;
this.tokenFactory = options.tokenFactory ?? (() => crypto.randomBytes(12).toString('base64url'));
}
private cleanup(): void {
const now = this.now();
for (const [token, value] of this.values) if (value.expiresAt <= now) this.values.delete(token);
while (this.values.size >= this.maxEntries) this.values.delete(this.values.keys().next().value!);
}
issue(value: Omit<YtDlpConfirmationValue, 'expiresAt'>): { token: string; expiresAt: number } {
this.cleanup();
const token = this.tokenFactory();
const expiresAt = this.now() + this.ttlMs;
this.values.set(token, { ...value, expiresAt });
return { token, expiresAt };
}
consume(token: string, binding: { actorId: number; chatKey: string; messageId: number }):
| { status: 'ok'; value: YtDlpConfirmationValue }
| { status: 'missing' | 'expired' | 'mismatch' } {
const value = this.values.get(token);
if (!value) return { status: 'missing' };
if (value.expiresAt <= this.now()) {
this.values.delete(token);
return { status: 'expired' };
}
if (value.actorId !== binding.actorId || value.chatKey !== binding.chatKey || value.messageId !== binding.messageId) return { status: 'mismatch' };
this.values.delete(token);
return { status: 'ok', value };
}
cancel(token: string, binding: { actorId: number; chatKey: string; messageId: number }): boolean {
const result = this.consume(token, binding);
return result.status === 'ok';
}
}
+27 -8
View File
@@ -6,6 +6,7 @@ import path from 'node:path';
import { spawn } from 'node:child_process';
import { query, pool } from '../db/index.js';
import { storageManager, isStorageQuotaCooldownError, type StorageTargetSnapshot } from './storage.js';
import { buildYtDlpPlaylistArgs, type YtDlpPlaylistSelectionInput } from './ytDlpPlaylistSelection.js';
import { assertStorageTargetWritable, formatStorageCooldownNotice } from './storageCooldownGuard.js';
import { markStorageAccountCooldown } from './storageCooldown.js';
import { formatBytes, getFileType, getMimeTypeFromFilename, sanitizeFilename } from '../utils/telegramUtils.js';
@@ -40,7 +41,7 @@ const YTDLP_MAX_CONCURRENT = Math.max(1, parseInt(process.env.YTDLP_MAX_CONCURRE
const activeControllers = new Map<string, AbortController>();
let initialized = false;
type YtDlpNotifier = (chatId: string, message: string) => Promise<void>;
type YtDlpNotifier = (task: TransferTaskRecord, message: string) => Promise<void>;
let taskNotifier: YtDlpNotifier | null = null;
export function setYtDlpNotifier(notifier: YtDlpNotifier | null): void {
@@ -102,10 +103,17 @@ async function runYtDlpDownload(
taskDir: string,
signal: AbortSignal,
onProgress: (progress: ParsedYtDlpProgress) => void,
format: 'best' | 'audio' = 'best',
playlist?: YtDlpPlaylistSelectionInput,
): Promise<void> {
ensureDir(taskDir);
const outputTemplate = path.join(taskDir, '%(title).200s-%(id)s.%(ext)s');
const args = ['--no-playlist', '--newline', '--merge-output-format', 'mp4', '-o', outputTemplate, '--', url];
const formatArgs = format === 'audio'
? ['-x', '--audio-format', 'mp3']
: ['--merge-output-format', 'mp4'];
const args = playlist
? [...buildYtDlpPlaylistArgs(url, playlist).slice(0, -2), '--newline', ...formatArgs, '-o', outputTemplate, '--', url]
: ['--no-playlist', '--newline', ...formatArgs, '-o', outputTemplate, '--', url];
await new Promise<void>((resolve, reject) => {
const binLower = YTDLP_BIN.toLowerCase();
@@ -289,7 +297,7 @@ function classifyYtDlpError(error: unknown): string {
async function notifyTask(task: TransferTaskRecord, message: string): Promise<void> {
if (!task.chatId || !taskNotifier) return;
await taskNotifier(task.chatId, message).catch(() => undefined);
await taskNotifier(task, message).catch(() => undefined);
}
async function executeYtDlpTask(id: string): Promise<void> {
@@ -325,7 +333,7 @@ async function executeYtDlpTask(id: string): Promise<void> {
console.error(`[yt-dlp] progress persistence failed: ${id}`, error);
controller.abort('progress_persistence_failed');
});
});
}, String(task.payload.format || 'best') === 'audio' ? 'audio' : 'best', task.payload.playlist as YtDlpPlaylistSelectionInput | undefined);
const primary = selectPrimaryOutputFile(taskDir);
if (!primary) throw new Error('下载完成但未找到可上传的输出文件');
const advancing = await updateYtDlpExecutionProgress(pool, id, execution.generation, execution.leaseToken, {
@@ -493,9 +501,14 @@ export async function retryYtDlpTask(id: string): Promise<TransferTaskRecord | n
return getTransferTask('ytdlp', id);
}
export async function handleYtDlpCommand(message: Api.Message, url: string): Promise<void> {
export async function handleYtDlpCommand(
message: Api.Message,
url: string,
explicitTarget?: StorageTargetSnapshot,
options: { format?: 'best' | 'audio'; folder?: string; metadata?: Record<string, unknown>; playlist?: YtDlpPlaylistSelectionInput } = {},
): Promise<void> {
const id = `yd-${crypto.randomBytes(8).toString('hex')}`;
const target = storageManager.getActiveTarget();
const target = explicitTarget || storageManager.getActiveTarget();
await assertStorageTargetWritable(target);
const client = target.accountId ? await pool.connect() : null;
let admissionLease: Awaited<ReturnType<typeof acquireStorageAccountOperationLease>> | null = null;
@@ -521,9 +534,15 @@ export async function handleYtDlpCommand(message: Api.Message, url: string): Pro
source: url,
targetProvider: target.provider.name,
targetAccountId: target.accountId,
targetFolder: 'ytdlp',
targetFolder: options.folder || 'ytdlp',
totalItems: 1,
payload: { url, targetAccountName: account?.name || (target.provider.name === 'local' ? '服务器本地目录' : null) },
payload: {
url,
format: options.format || 'best',
metadata: options.metadata || {},
playlist: options.playlist,
targetAccountName: account?.name || (target.provider.name === 'local' ? '服务器本地目录' : null),
},
retryable: false,
});
ytDlpQueue.enqueue(task.id);
@@ -0,0 +1,18 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildYtDlpPlaylistArgs, validateYtDlpPlaylistSelection } from './ytDlpPlaylistSelection.js';
test('playlist selection must be explicit, bounded, and inside budget', () => {
assert.throws(() => validateYtDlpPlaylistSelection({ enabled: false, start: 1, end: 2, maxItems: 2 }), /显式启用/);
assert.throws(() => validateYtDlpPlaylistSelection({ enabled: true, start: 10, end: 2, maxItems: 5 }), /范围/);
assert.throws(() => validateYtDlpPlaylistSelection({ enabled: true, start: 1, end: 1000, maxItems: 1000 }), /上限/);
const selected = validateYtDlpPlaylistSelection({ enabled: true, start: 2, end: 10, maxItems: 9 }, { hardMaxItems: 25, maxBudgetBytes: 900, estimatedItemBytes: 100 });
assert.equal(selected.count, 9);
});
test('playlist args retain URL argument boundary and server-enforced range', () => {
const args = buildYtDlpPlaylistArgs('https://example.test/list', { enabled: true, start: 2, end: 4, maxItems: 3 });
assert.deepEqual(args.slice(0, 6), ['--yes-playlist', '--playlist-start', '2', '--playlist-end', '4', '--max-downloads']);
assert.equal(args.at(-2), '--');
assert.equal(args.at(-1), 'https://example.test/list');
});
@@ -0,0 +1,32 @@
export interface YtDlpPlaylistSelectionInput {
enabled: boolean;
start: number;
end: number;
maxItems: number;
}
export interface YtDlpPlaylistBudget {
hardMaxItems?: number;
maxBudgetBytes?: number;
estimatedItemBytes?: number;
}
export interface YtDlpPlaylistSelection extends YtDlpPlaylistSelectionInput { count: number }
export function validateYtDlpPlaylistSelection(input: YtDlpPlaylistSelectionInput, budget: YtDlpPlaylistBudget = {}): YtDlpPlaylistSelection {
if (!input.enabled) throw new Error('播放列表必须显式启用');
for (const [name, value] of Object.entries({ start: input.start, end: input.end, maxItems: input.maxItems })) {
if (!Number.isSafeInteger(value) || value < 1) throw new Error(`${name} 必须是正整数`);
}
if (input.end < input.start) throw new Error('播放列表范围无效');
const count = input.end - input.start + 1;
const configuredMax = Number.parseInt(process.env.YTDLP_PLAYLIST_MAX_ITEMS || '25', 10) || 25;
const hardMaxItems = Math.max(1, budget.hardMaxItems ?? configuredMax);
if (count > input.maxItems || input.maxItems > hardMaxItems) throw new Error(`播放列表超过最大条目上限 ${hardMaxItems}`);
const estimatedBytes = count * Math.max(0, budget.estimatedItemBytes ?? 0);
if (budget.maxBudgetBytes !== undefined && estimatedBytes > budget.maxBudgetBytes) throw new Error('播放列表预计大小超过下载预算');
return { ...input, count };
}
export function buildYtDlpPlaylistArgs(url: string, input: YtDlpPlaylistSelectionInput): string[] {
const selection = validateYtDlpPlaylistSelection(input);
return ['--yes-playlist', '--playlist-start', String(selection.start), '--playlist-end', String(selection.end), '--max-downloads', String(selection.maxItems), '--', url];
}
@@ -0,0 +1,11 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';
const download = fs.readFileSync(new URL('./ytDlpDownload.ts', import.meta.url), 'utf8');
test('production yt-dlp keeps playlists disabled unless bounded selection is persisted', () => {
assert.match(download, /const args = playlist[\s\S]*buildYtDlpPlaylistArgs/);
assert.match(download, /: \['--no-playlist'/);
assert.match(download, /playlist: options\.playlist/);
assert.match(download, /YtDlpPlaylistSelectionInput/);
});
+66
View File
@@ -0,0 +1,66 @@
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import { PassThrough } from 'node:stream';
import test from 'node:test';
import {
buildYtDlpProbeArgs,
parseYtDlpProbeOutput,
runYtDlpProbe,
} from './ytDlpProbe.js';
class FakeChild extends EventEmitter {
stdout = new PassThrough();
stderr = new PassThrough();
kills: string[] = [];
kill(signal = 'SIGTERM') {
this.kills.push(signal);
this.emit('close', null);
return true;
}
}
test('probe arguments are metadata-only, single-item, and keep the URL behind --', () => {
const args = buildYtDlpProbeArgs('https://video.example/watch?v=abc');
assert.deepEqual(args, [
'--dump-single-json', '--skip-download', '--no-playlist', '--no-warnings', '--',
'https://video.example/watch?v=abc',
]);
assert.equal(args.some(arg => /cookie/i.test(arg)), false);
});
test('metadata parser exposes bounded safe fields and rejects playlists', () => {
assert.deepEqual(parseYtDlpProbeOutput(JSON.stringify({
id: 'abc', title: 'A video', duration: 123, extractor_key: 'YouTube', webpage_url: 'https://example.test/v',
})), {
id: 'abc', title: 'A video', durationSeconds: 123, site: 'YouTube', webpageUrl: 'https://example.test/v', isPlaylist: false, playlistCount: null,
});
assert.throws(() => parseYtDlpProbeOutput(JSON.stringify({ _type: 'playlist', entries: [{ id: '1' }, { id: '2' }] })), /播放列表默认禁用/);
});
test('probe abort terminates the child and rejects without starting a download', async () => {
const child = new FakeChild();
const controller = new AbortController();
const pending = runYtDlpProbe('https://example.test/video', {
signal: controller.signal,
assertUrl: async () => undefined,
spawnProcess: (() => child) as any,
timeoutMs: 5_000,
maxOutputBytes: 1_024,
});
controller.abort('cancelled');
await assert.rejects(pending, error => error instanceof Error && error.name === 'AbortError');
assert.deepEqual(child.kills, ['SIGTERM']);
});
test('probe rejects output beyond the configured byte cap and kills the child', async () => {
const child = new FakeChild();
const pending = runYtDlpProbe('https://example.test/video', {
assertUrl: async () => undefined,
spawnProcess: (() => child) as any,
timeoutMs: 5_000,
maxOutputBytes: 32,
});
child.stdout.write('x'.repeat(33));
await assert.rejects(pending, /输出超过限制/);
assert.deepEqual(child.kills, ['SIGTERM']);
});
+100
View File
@@ -0,0 +1,100 @@
import os from 'node:os';
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
import { assertPublicHttpUrl } from '../utils/networkSecurity.js';
const YTDLP_BIN = process.env.YTDLP_BIN || 'yt-dlp';
export interface YtDlpProbeResult {
id: string;
title: string;
durationSeconds: number | null;
site: string;
webpageUrl: string;
isPlaylist: boolean;
playlistCount: number | null;
}
export function buildYtDlpProbeArgs(url: string): string[] {
return ['--dump-single-json', '--skip-download', '--no-playlist', '--no-warnings', '--', url];
}
export function parseYtDlpProbeOutput(output: string): YtDlpProbeResult {
let parsed: any;
try {
parsed = JSON.parse(output);
} catch {
throw new Error('yt-dlp 返回了无效元数据');
}
const entries = Array.isArray(parsed?.entries) ? parsed.entries : null;
const isPlaylist = parsed?._type === 'playlist' || Boolean(entries);
if (isPlaylist) throw new Error('播放列表默认禁用;请显式启用并指定范围与最大条目');
return {
id: String(parsed?.id || ''),
title: String(parsed?.title || '未命名媒体').slice(0, 300),
durationSeconds: Number.isFinite(Number(parsed?.duration)) ? Math.max(0, Number(parsed.duration)) : null,
site: String(parsed?.extractor_key || parsed?.extractor || 'unknown').slice(0, 100),
webpageUrl: String(parsed?.webpage_url || parsed?.original_url || '').slice(0, 2_000),
isPlaylist: false,
playlistCount: null,
};
}
export async function runYtDlpProbe(
url: string,
options: {
signal?: AbortSignal;
timeoutMs?: number;
maxOutputBytes?: number;
spawnProcess?: typeof spawn;
assertUrl?: (url: string) => Promise<void>;
} = {},
): Promise<YtDlpProbeResult> {
await (options.assertUrl || assertPublicHttpUrl)(url);
const timeoutMs = Math.max(1_000, options.timeoutMs ?? 20_000);
const maxOutputBytes = Math.max(32, options.maxOutputBytes ?? 512 * 1_024);
const spawnProcess = options.spawnProcess || spawn;
const binLower = YTDLP_BIN.toLowerCase();
const shell = os.platform() === 'win32' && (binLower.endsWith('.cmd') || binLower.endsWith('.bat'));
const child = spawnProcess(YTDLP_BIN, buildYtDlpProbeArgs(url), { windowsHide: true, shell }) as ChildProcessWithoutNullStreams;
return new Promise<YtDlpProbeResult>((resolve, reject) => {
let stdout = Buffer.alloc(0);
let stderr = '';
let settled = false;
let aborted = false;
const abortError = () => Object.assign(new Error('yt-dlp probe cancelled'), { name: 'AbortError' });
const finish = (error?: Error, value?: YtDlpProbeResult) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
options.signal?.removeEventListener('abort', abortChild);
error ? reject(error) : resolve(value!);
};
const kill = (error: Error) => {
if (settled) return;
aborted = error.name === 'AbortError';
finish(error);
child.kill('SIGTERM');
};
const abortChild = () => kill(abortError());
const timeout = setTimeout(() => kill(new Error(`yt-dlp 探测超时(${timeoutMs}ms)`)), timeoutMs);
child.stdout.on('data', chunk => {
const bytes = Buffer.from(chunk);
if (stdout.length + bytes.length > maxOutputBytes) return kill(new Error('yt-dlp 探测输出超过限制'));
stdout = Buffer.concat([stdout, bytes]);
});
child.stderr.on('data', chunk => {
stderr += String(chunk);
if (stderr.length > 8_000) stderr = stderr.slice(-8_000);
});
child.once('error', error => finish(aborted ? abortError() : error));
child.once('close', code => {
if (settled) return;
if (options.signal?.aborted || aborted) return finish(abortError());
if (code !== 0) return finish(new Error(stderr.trim() || `yt-dlp probe exited with code ${code}`));
try { finish(undefined, parseYtDlpProbeOutput(stdout.toString('utf8'))); }
catch (error) { finish(error as Error); }
});
if (options.signal?.aborted) abortChild();
else options.signal?.addEventListener('abort', abortChild, { once: true });
});
}
@@ -0,0 +1,23 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';
const bot = fs.readFileSync(new URL('./telegramBot.ts', import.meta.url), 'utf8');
const download = fs.readFileSync(new URL('./ytDlpDownload.ts', import.meta.url), 'utf8');
test('pasted and explicit URLs only create a probe preview before confirmation', () => {
assert.match(bot, /createYtDlpPreview\(message, senderId, pastedUrl\)/);
assert.match(bot, /createYtDlpPreview\(message, senderId, url\)/);
assert.match(bot, /没有确认不会创建下载任务/);
assert.match(bot, /handleYtDlpPreviewCallback/);
assert.match(bot, /ytDlpConfirmations\.consume/);
});
test('confirmed yt-dlp keeps immutable target format folder and metadata in task admission', () => {
assert.match(bot, /handleYtDlpCommand\(currentMessage, consumed\.value\.url, consumed\.value\.target/);
assert.match(download, /targetFolder: options\.folder \|\| 'ytdlp'/);
assert.match(download, /format: options\.format \|\| 'best'/);
assert.match(download, /\['--no-playlist', '--newline'/);
assert.match(download, /'-o', outputTemplate, '--', url/);
assert.doesNotMatch(bot, /cookie.*text/i);
});
+31 -4
View File
@@ -1,7 +1,7 @@
import crypto from 'crypto';
import type { PoolClient } from 'pg';
import { pool } from '../db/index.js';
import { getSetting, setSetting } from './settings.js';
import { getSetting } from './settings.js';
const WEB_PASSWORD_KEY = 'admin_password_hash';
const TELEGRAM_PIN_KEY = 'telegram_pin_hash';
@@ -181,10 +181,37 @@ export async function countAuthenticatedTelegramUsers(): Promise<number> {
return Number(result.rows[0]?.count || 0);
}
export async function setTelegramAllowedUsers(userIds: number[]): Promise<number[]> {
export async function setTelegramAllowedUsersAndReconcile(userIds: number[]) {
const previous = await getConfiguredTelegramAllowedUsers();
const users = parseTelegramAllowedUserIds(userIds.join(','));
await setSetting(TELEGRAM_ALLOWED_USERS_KEY, users.join(','));
return users;
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query(`SELECT pg_advisory_xact_lock(hashtext('tg-vault:telegram-allowlist'))`);
await client.query(
`INSERT INTO system_settings (key, value, updated_at)
VALUES ($1, $2, NOW())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()`,
[TELEGRAM_ALLOWED_USERS_KEY, users.join(',')],
);
const { reconcileTelegramAllowedUsers } = await import('../services/telegramState.js');
const reconciliation = await reconcileTelegramAllowedUsers(users, { query: client.query.bind(client) as any });
await client.query('COMMIT');
const previousSet = new Set(previous);
return {
...reconciliation,
added: users.filter(id => !previousSet.has(id)),
};
} catch (error) {
await client.query('ROLLBACK').catch(() => undefined);
throw error;
} finally {
client.release();
}
}
export async function setTelegramAllowedUsers(userIds: number[]): Promise<number[]> {
return (await setTelegramAllowedUsersAndReconcile(userIds)).allowed;
}
export async function addTelegramAllowedUser(userId: number): Promise<number[]> {
+31
View File
@@ -0,0 +1,31 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { isPrivateAddress } from './networkSecurity.js';
test('rejects ambiguous IPv4 forms before DNS or URL admission', () => {
for (const address of [
'127.0.0.1/8',
'127.000.000.001',
'0177.0.0.1',
'2130706433',
'0x7f000001',
]) {
assert.equal(isPrivateAddress(address), true, address);
}
});
test('rejects IPv4-mapped and NAT64 IPv6 encodings of private IPv4 addresses', () => {
for (const address of [
'::ffff:127.0.0.1',
'::ffff:7f00:1',
'64:ff9b::127.0.0.1',
'64:ff9b::7f00:1',
]) {
assert.equal(isPrivateAddress(address), true, address);
}
});
test('keeps ordinary public IPv4 and IPv6 addresses admissible', () => {
assert.equal(isPrivateAddress('8.8.8.8'), false);
assert.equal(isPrivateAddress('2606:4700:4700::1111'), false);
});
+19 -1
View File
@@ -20,9 +20,27 @@ function isPrivateIPv4(ip: string): boolean {
return ranges.some(([start, end]) => n >= ipv4ToInt(start) && n <= ipv4ToInt(end));
}
function embeddedIPv4FromIPv6(ip: string): string | null {
const normalized = ip.toLowerCase().split('%', 1)[0];
const dotted = normalized.match(/(?:^|:)(\d{1,3}(?:\.\d{1,3}){3})$/)?.[1];
if (dotted && net.isIP(dotted) === 4) return dotted;
const segments = normalized.split(':').filter(Boolean);
if (segments.length < 2) return null;
const high = Number.parseInt(segments.at(-2) || '', 16);
const low = Number.parseInt(segments.at(-1) || '', 16);
if (!Number.isInteger(high) || !Number.isInteger(low) || high < 0 || high > 0xffff || low < 0 || low > 0xffff) return null;
return `${high >>> 8}.${high & 0xff}.${low >>> 8}.${low & 0xff}`;
}
function isPrivateIPv6(ip: string): boolean {
const normalized = ip.toLowerCase();
return normalized === '::1' || normalized === '::' || normalized.startsWith('fc') || normalized.startsWith('fd') || normalized.startsWith('fe80:');
if (normalized === '::1' || normalized === '::' || normalized.startsWith('fc') || normalized.startsWith('fd') || normalized.startsWith('fe80:')) return true;
const isMapped = normalized.startsWith('::ffff:');
const isNat64 = normalized.startsWith('64:ff9b::');
if (!isMapped && !isNat64) return false;
const embedded = embeddedIPv4FromIPv6(normalized);
return !embedded || isPrivateIPv4(embedded);
}
export function isPrivateAddress(ip: string): boolean {
+13 -3
View File
@@ -9,20 +9,26 @@ export interface BotCommandDefinition {
usage?: string;
menu?: boolean;
help?: boolean;
handlerKey?: string;
requiresAuth?: boolean;
feature?: string;
}
export const BOT_COMMANDS: BotCommandDefinition[] = [
{ command: 'start', description: '开始使用 / 验证身份', helpDescription: '身份认证 / 开始使用', category: 'main', menu: true, help: true },
{ command: 'start', description: '开始使用 / 验证身份', helpDescription: '身份认证 / 开始使用', category: 'main', menu: true, help: true, requiresAuth: false },
{ command: 'tasks', description: '查看实时任务', helpDescription: '查看实时传输任务队列', category: 'main', aliases: ['task'], menu: true, help: true },
{ command: 'storage', description: '存储状态 / 删除本地实体文件', helpDescription: '查看存储状态;可确认删除本地实体文件', category: 'main', menu: true, help: true },
{ command: 'path_rules', description: '保存位置 / 自定义目录', helpDescription: '打开保存位置与自定义目录面板', category: 'main', aliases: ['path', 'save_rules'], menu: true, help: true },
{ command: 'tg_download', description: '按日期 / 标签下载频道文件', helpDescription: '打开按日期或标签下载向导', category: 'main', aliases: ['tg_dl'], menu: true, help: true },
{ command: 'list', description: '查看最近文件', helpDescription: '查看最近文件和可复制的文件 ID', category: 'files', usage: '[数量] [页码]', menu: true, help: true },
{ command: 'find', description: '搜索和操作文件', helpDescription: '按名称、类型、目录、日期或收藏搜索文件并打开操作卡', category: 'files', usage: '[关键词] [type:image|video|audio|document] [folder:目录] [after:YYYY-MM-DD] [before:YYYY-MM-DD] [fav]', menu: true, help: true },
{ command: 'tg_sub', description: '管理频道自动同步', helpDescription: '打开频道订阅管理向导', category: 'channels', aliases: ['tg_subscribe'], menu: true, help: true },
{ command: 'storage_switch', description: '切换系统默认存储', helpDescription: '切换所有新任务使用的系统默认存储', category: 'settings', aliases: ['switch_storage', 'storage_source'], menu: true, help: true },
{ command: 'target', description: '设置当前聊天存储目标', helpDescription: '设置下一次或当前聊天持续使用的存储账户;不修改系统默认', category: 'files', usage: '[once|session|clear] [local|账户ID]', menu: true, help: true },
{ command: 'ytdlp', description: '下载视频链接', helpDescription: '解析并下载一个视频链接', category: 'files', usage: '<url>', menu: true, help: true },
{ command: 'help', description: '查看完整帮助', helpDescription: '显示此帮助', category: 'main', menu: true, help: true },
{ command: 'help', description: '查看完整帮助', helpDescription: '显示此帮助', category: 'main', menu: true, help: true, requiresAuth: false },
{ command: 'setup_2fa', description: '配置双重验证', helpDescription: '配置双重验证 (TOTP)', category: 'security', aliases: ['setup-2fa'], menu: false, help: true },
{ command: 'logout', description: '撤销本设备 Bot 认证', helpDescription: '立即退出并撤销当前 Telegram 用户的 Bot 认证', category: 'security', menu: false, help: true },
{ command: 'p', description: '设置下一次保存目录', helpDescription: '下一次下载保存到指定目录', category: 'files', usage: '<目录>', help: true },
{ command: 'ps', description: '设置本会话保存目录', helpDescription: '本会话持续保存到指定目录', category: 'files', usage: '<目录>', help: true },
{ command: 'pc', description: '清除自定义保存目录', helpDescription: '清除下一次和本会话自定义目录', category: 'files', help: true },
@@ -33,7 +39,9 @@ export const BOT_COMMANDS: BotCommandDefinition[] = [
{ command: 'tg_retry', description: '重试失败任务', helpDescription: '重试最近失败的 Telegram 下载任务', category: 'channels', usage: '[数量] [任务 ID]', help: true },
{ command: 'stop_tasks', description: '停止当前聊天任务', helpDescription: '兼容入口:预览并停止当前聊天下载任务', category: 'settings', aliases: ['stop', 'cancel_tasks'], help: true },
{ command: 'download_workers', description: '单文件分片并发', helpDescription: '设置单文件分片下载并发', category: 'settings', aliases: ['workers'], help: true },
{ command: 'file_concurrency', description: '同时下载文件数', helpDescription: '设置同时下载文件数', category: 'settings', aliases: ['file_workers', 'download_files'], help: true },
{ command: 'file_concurrency', description: '文件级下载并发', helpDescription: '设置 1 / 2 / 3 / 4 个文件并行,避免把分片并发和文件级并发混淆', category: 'settings', aliases: ['file_workers', 'download_files'], menu: false, help: true },
{ command: 'status', description: '系统诊断状态', helpDescription: '查看 Bot、账号下载器、存储、磁盘、队列、订阅和对账状态', category: 'settings', menu: true, help: true },
{ command: 'notifications', description: '通知偏好', helpDescription: '设置成功/失败/订阅摘要、时区和安静时段;安全告警始终即时', category: 'settings', usage: '[timezone|quiet|success|failure|subscription] [值]', menu: true, help: true },
{ command: 'duplicate_mode', description: '重复文件处理', helpDescription: '设置重复文件处理策略', category: 'settings', aliases: ['duplicate', 'dup'], help: true },
{ command: 'cleanup_settings', description: '自动清理未索引临时文件', helpDescription: '设置自动清理未登记临时文件的开关', category: 'settings', aliases: ['cleanup'], help: true },
{ command: 'tg_subs', description: '查看频道订阅', helpDescription: '查看频道订阅列表', category: 'channels', aliases: ['tg_subscriptions'], help: true },
@@ -42,6 +50,8 @@ export const BOT_COMMANDS: BotCommandDefinition[] = [
const lookup = new Map<string, BotCommandDefinition>();
for (const definition of BOT_COMMANDS) {
definition.handlerKey ||= definition.command;
definition.requiresAuth ??= definition.command !== 'start' && definition.command !== 'help';
lookup.set(definition.command, definition);
definition.aliases?.forEach(alias => lookup.set(alias, definition));
}
+16 -5
View File
@@ -62,7 +62,7 @@ function buildTaskControlLines(taskId?: string, queuePaused = false, pauseReason
];
}
export function buildTaskControlButtons(taskId?: string, queuePaused = false, systemPause?: TaskSystemPauseView, queuePausing = false, userPaused = queuePaused && !systemPause): Api.ReplyInlineMarkup | undefined {
export function buildTaskControlButtons(taskId?: string, queuePaused = false, systemPause?: TaskSystemPauseView, queuePausing = false, userPaused = queuePaused && !systemPause, failedCount = 0): Api.ReplyInlineMarkup | undefined {
if (!taskId) return undefined;
const actionButtons: Api.TypeKeyboardButton[] = queuePaused || queuePausing
? systemPause && !userPaused
@@ -70,9 +70,14 @@ export function buildTaskControlButtons(taskId?: string, queuePaused = false, sy
: [new Api.KeyboardButtonCallback({ text: '▶️ 继续', data: Buffer.from(`tq_resume_${taskId}`) })]
: [new Api.KeyboardButtonCallback({ text: '⏸ 暂停', data: Buffer.from(`tq_pause_${taskId}`) })];
actionButtons.push(new Api.KeyboardButtonCallback({ text: '🛑 取消', data: Buffer.from(`tq_cancel_${taskId}`) }));
return new Api.ReplyInlineMarkup({
rows: [new Api.KeyboardButtonRow({ buttons: actionButtons })],
});
const rows = [new Api.KeyboardButtonRow({ buttons: actionButtons })];
if (failedCount > 0) {
rows.push(new Api.KeyboardButtonRow({ buttons: [
new Api.KeyboardButtonCallback({ text: `🔄 重试失败 (${failedCount})`, data: Buffer.from(`receipt_retry_${taskId}`) }),
new Api.KeyboardButtonCallback({ text: '查看失败明细', data: Buffer.from(`receipt_failures_${taskId}`) }),
] }));
}
return new Api.ReplyInlineMarkup({ rows });
}
function collectCompletedFolders(
@@ -388,7 +393,9 @@ export function buildUploadSuccess(
size: number,
fileType: string,
providerName: string,
folder?: string | null
folder?: string | null,
fileId?: string | null,
duplicateOutcome?: 'copied' | 'skipped' | null,
): string {
const typeEmoji = getTypeEmoji(
fileType === 'image' ? 'image/' :
@@ -404,6 +411,10 @@ export function buildUploadSuccess(
`📦 ${formatBytes(size)}`,
`📍 ${getProviderDisplayName(providerName)}`,
...(folder ? [`📁 ${folder}`] : []),
...(fileId ? [`🆔 ${fileId.slice(0, 13)}`] : []),
...(duplicateOutcome === 'copied' ? ['♻️ 重复处理:已生成副本'] : duplicateOutcome === 'skipped' ? ['⏭️ 重复处理:已跳过'] : []),
``,
...(fileId ? ['操作:/find 可搜索同目录;复制上方 ID;/delete <ID> 可进入删除确认。'] : []),
].join('\n');
}
+5 -1
View File
@@ -2,6 +2,7 @@ import { Api } from 'telegram';
import { sanitizeFilename } from './telegramUtils.js';
import { getSetting, setSetting } from './settings.js';
import { clearTelegramPathStateRows, consumeTelegramOncePath, getTelegramSessionPath, previewTelegramPersistentPath, setTelegramPathStateRow } from './telegramPathStateStore.js';
import { ScopedInteractionMap } from '../services/scopedInteractionMap.js';
interface ChatPathState {
nextFolder?: string;
@@ -11,7 +12,10 @@ interface ChatPathState {
export type PendingPathInputMode = 'once' | 'session';
const chatPathState = new Map<string, ChatPathState>();
const pendingPathInputState = new Map<string, PendingPathInputMode>();
const pendingPathInputState = new ScopedInteractionMap<string, PendingPathInputMode>({
ttlMs: Math.max(60_000, Number.parseInt(process.env.TELEGRAM_INTERACTION_TTL_MS || '900000', 10) || 900_000),
maxEntries: Math.max(10, Number.parseInt(process.env.TELEGRAM_INTERACTION_MAX_ENTRIES || '1000', 10) || 1_000),
});
const recentPathState = new Map<string, string[]>();
const MAX_RECENT_PATHS = 6;
const RECENT_PATH_SETTING_PREFIX = 'telegram_recent_paths:';
@@ -0,0 +1,51 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { StorageTargetSnapshot } from '../services/storage.js';
import {
consumeTelegramTargetState,
getTelegramTargetState,
setTelegramTargetState,
type TelegramTargetStateQuery,
} from './telegramTargetStateStore.js';
function memoryQuery(): TelegramTargetStateQuery {
const rows = new Map<string, any>();
return async (sql, params) => {
const [chatId, mode] = params;
const key = `${chatId}:${mode || ''}`;
if (/INSERT INTO telegram_target_states/.test(sql)) {
rows.set(key, { chat_id: chatId, mode, provider: params[2], account_id: params[3], expires_at: params[4] });
return { rows: [], rowCount: 1 };
}
if (/DELETE FROM telegram_target_states[\s\S]*RETURNING/.test(sql)) {
const row = rows.get(`${chatId}:once`);
rows.delete(`${chatId}:once`);
return { rows: row ? [row] : [], rowCount: row ? 1 : 0 };
}
if (/SELECT .*telegram_target_states/.test(sql)) {
const row = rows.get(`${chatId}:${mode}`);
return { rows: row ? [row] : [], rowCount: row ? 1 : 0 };
}
throw new Error(`unexpected SQL ${sql}`);
};
}
test('next target is consumed atomically once while session target remains', async () => {
const query = memoryQuery();
await setTelegramTargetState(query, 'chat-a', 'once', 's3', 'account-a', new Date(Date.now() + 100_000));
const first = await consumeTelegramTargetState(query, 'chat-a');
const second = await consumeTelegramTargetState(query, 'chat-a');
assert.deepEqual(first, { provider: 's3', accountId: 'account-a', mode: 'once' });
assert.equal(second, null);
await setTelegramTargetState(query, 'chat-b', 'session', 'webdav', 'account-b', new Date(Date.now() + 100_000));
assert.deepEqual(await getTelegramTargetState(query, 'chat-b', 'session'), { provider: 'webdav', accountId: 'account-b', mode: 'session' });
assert.deepEqual(await getTelegramTargetState(query, 'chat-b', 'session'), { provider: 'webdav', accountId: 'account-b', mode: 'session' });
});
test('two chats resolve independent target snapshots and global fallback is captured once', async () => {
const query = memoryQuery();
await setTelegramTargetState(query, 'chat-a', 'session', 's3', 'account-a', new Date(Date.now() + 100_000));
await setTelegramTargetState(query, 'chat-b', 'session', 'google_drive', 'account-b', new Date(Date.now() + 100_000));
assert.notDeepEqual(await getTelegramTargetState(query, 'chat-a', 'session'), await getTelegramTargetState(query, 'chat-b', 'session'));
});
@@ -0,0 +1,77 @@
import { query } from '../db/index.js';
export type TelegramTargetMode = 'once' | 'session';
export interface TelegramTargetSelection {
provider: string;
accountId: string | null;
mode: TelegramTargetMode;
}
export type TelegramTargetStateQuery = (
sql: string,
params: unknown[],
) => Promise<{ rows: any[]; rowCount: number | null }>;
const defaultQuery: TelegramTargetStateQuery = (sql, params) => query(sql, params) as any;
export async function setTelegramTargetState(
runQuery: TelegramTargetStateQuery = defaultQuery,
chatId: string,
mode: TelegramTargetMode,
provider: string,
accountId: string | null,
expiresAt: Date,
): Promise<void> {
await runQuery(
`INSERT INTO telegram_target_states (chat_id, mode, provider, account_id, expires_at)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (chat_id, mode)
DO UPDATE SET provider = EXCLUDED.provider, account_id = EXCLUDED.account_id,
expires_at = EXCLUDED.expires_at, updated_at = NOW()`,
[chatId, mode, provider, accountId, expiresAt],
);
}
function mapRow(row: any): TelegramTargetSelection | null {
if (!row) return null;
return {
provider: String(row.provider),
accountId: row.account_id == null ? null : String(row.account_id),
mode: String(row.mode) as TelegramTargetMode,
};
}
export async function consumeTelegramTargetState(
runQuery: TelegramTargetStateQuery = defaultQuery,
chatId: string,
): Promise<TelegramTargetSelection | null> {
const result = await runQuery(
`DELETE FROM telegram_target_states
WHERE chat_id = $1 AND mode = 'once' AND expires_at > NOW()
RETURNING provider, account_id, mode`,
[chatId],
);
return mapRow(result.rows[0]);
}
export async function getTelegramTargetState(
runQuery: TelegramTargetStateQuery = defaultQuery,
chatId: string,
mode: TelegramTargetMode = 'session',
): Promise<TelegramTargetSelection | null> {
const result = await runQuery(
`SELECT provider, account_id, mode FROM telegram_target_states
WHERE chat_id = $1 AND mode = $2 AND expires_at > NOW()`,
[chatId, mode],
);
return mapRow(result.rows[0]);
}
export async function consumeOrGetTelegramTargetState(chatId: string): Promise<TelegramTargetSelection | null> {
return await consumeTelegramTargetState(undefined, chatId)
|| await getTelegramTargetState(undefined, chatId, 'session');
}
export async function clearTelegramTargetState(chatId: string): Promise<void> {
await defaultQuery('DELETE FROM telegram_target_states WHERE chat_id = $1', [chatId]);
}
+20
View File
@@ -21,6 +21,8 @@ nano .env
```dotenv
DB_PASSWORD=使用 openssl rand -hex 32 生成的随机值
IMAGE_VERSION=v2.0.1
SOURCE_REVISION=当前部署提交的完整 SHA(运行 git rev-parse HEAD 获取)
SOURCE_VERSION=v2.0.1
VITE_API_URL=https://api.example.com
OAUTH_CALLBACK_BASE_URL=https://api.example.com
OAUTH_FRONTEND_ORIGIN=https://cloud.example.com
@@ -40,6 +42,13 @@ STORAGE_CREDENTIALS_SECRET=至少32字符;可用 openssl rand -hex 32
## 3. 构建并启动
Compose 会拒绝缺失的 `IMAGE_VERSION`、`SOURCE_REVISION` 或 `SOURCE_VERSION`,避免生成 `unknown/worktree` 标签的不可审计镜像。每次发布前更新:
```bash
revision=$(git rev-parse HEAD)
# 将 .env 中 SOURCE_REVISION 更新为 $revision;IMAGE_VERSION 与 SOURCE_VERSION 必须等于发布版本。
```
```bash
docker compose config --quiet
docker compose up -d --build
@@ -77,8 +86,19 @@ docker compose ps
curl -fsS http://127.0.0.1:51947/livez
curl -fsS http://127.0.0.1:51947/readyz
docker compose logs --tail=100 backend frontend postgres
expected_revision=$(git rev-parse HEAD)
expected_version=$(grep '^SOURCE_VERSION=' .env | cut -d= -f2-)
docker inspect --format='{{index .Config.Labels "org.opencontainers.image.revision"}} {{index .Config.Labels "org.opencontainers.image.version"}}' tg-vault-backend tg-vault-frontend
# 两个容器都必须输出 "$expected_revision $expected_version"。
# 验证运行中的前端入口 asset;必须与刚构建镜像中的 index.html 一致。
docker exec tg-vault-frontend sh -c 'grep -oE "assets/[^\"]+\.(js|css)" /usr/share/nginx/html/index.html | sort'
curl -fsS http://127.0.0.1:47832/ | grep -oE 'assets/[^\"]+\.(js|css)' | sort
```
只有 OCI revision/version、镜像内 `assets/` 入口与 HTTP 返回的入口全部一致,才可宣称部署成功。
## 6. 常用运维命令
```bash
+13
View File
@@ -17,7 +17,12 @@ if [[ ! -f .env ]]; then
DB_PASSWORD=$(openssl rand -hex 32)
SESSION_SECRET=$(openssl rand -hex 32)
STORAGE_CREDENTIALS_SECRET=$(openssl rand -hex 32)
IMAGE_VERSION=v2.0.1
SOURCE_REVISION=$(git rev-parse HEAD)
SOURCE_VERSION=v2.0.1
VITE_API_URL=https://api.example.com
OAUTH_CALLBACK_BASE_URL=https://api.example.com
OAUTH_FRONTEND_ORIGIN=https://cloud.example.com
CORS_ORIGIN=https://cloud.example.com
DOMAIN=cloud.example.com
COOKIE_SECURE=true
@@ -26,6 +31,14 @@ EOF
exit 2
fi
required_keys=(IMAGE_VERSION SOURCE_REVISION SOURCE_VERSION VITE_API_URL OAUTH_CALLBACK_BASE_URL OAUTH_FRONTEND_ORIGIN CORS_ORIGIN DOMAIN)
for key in "${required_keys[@]}"; do
if ! grep -qE "^${key}=.+" .env; then
echo ".env 缺少必填项 ${key};请补齐后重试。" >&2
exit 2
fi
done
docker compose config --quiet
docker compose up -d --build
docker compose ps
+18
View File
@@ -0,0 +1,18 @@
services:
frontend:
container_name: tg-vault-staging-frontend
ports: !override
- "127.0.0.1:${STAGING_FRONTEND_PORT:-47842}:80"
backend:
container_name: tg-vault-staging-backend
ports: !override
- "127.0.0.1:${STAGING_BACKEND_PORT:-51957}:${PORT:-51947}"
environment:
- TELEGRAM_BOT_TOKEN=${STAGING_TELEGRAM_BOT_TOKEN:-}
- TELEGRAM_API_ID=${STAGING_TELEGRAM_API_ID:-}
- TELEGRAM_API_HASH=${STAGING_TELEGRAM_API_HASH:-}
- TELEGRAM_SESSION_FILE=/data/staging_bot_session.txt
- TELEGRAM_USER_SESSION_FILE=/data/staging_user_session.txt
- TELEGRAM_REQUIRED=${STAGING_TELEGRAM_REQUIRED:-false}
postgres:
container_name: tg-vault-staging-postgres
+5 -4
View File
@@ -10,8 +10,8 @@ services:
provenance: mode=max
args:
- VITE_API_URL=${VITE_API_URL:-https://api.example.com}
- SOURCE_REVISION=${SOURCE_REVISION:-unknown}
- SOURCE_VERSION=${SOURCE_VERSION:-worktree}
- SOURCE_REVISION=${SOURCE_REVISION:?SOURCE_REVISION is required}
- SOURCE_VERSION=${SOURCE_VERSION:?SOURCE_VERSION is required}
container_name: tg-vault-frontend
ports:
- "127.0.0.1:47832:80"
@@ -41,8 +41,8 @@ services:
sbom: true
provenance: mode=max
args:
- SOURCE_REVISION=${SOURCE_REVISION:-unknown}
- SOURCE_VERSION=${SOURCE_VERSION:-worktree}
- SOURCE_REVISION=${SOURCE_REVISION:?SOURCE_REVISION is required}
- SOURCE_VERSION=${SOURCE_VERSION:?SOURCE_VERSION is required}
container_name: tg-vault-backend
ports:
- "127.0.0.1:${PORT:-51947}:${PORT:-51947}"
@@ -82,6 +82,7 @@ services:
- SQL_LOG_ALL=${SQL_LOG_ALL:-false}
- LOG_LEVEL=${LOG_LEVEL:-info}
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN:-}
- TELEGRAM_REQUIRED=${TELEGRAM_REQUIRED:-false}
- TELEGRAM_API_ID=${TELEGRAM_API_ID:-}
- TELEGRAM_API_HASH=${TELEGRAM_API_HASH:-}
- TELEGRAM_ALLOWED_USER_IDS=${TELEGRAM_ALLOWED_USER_IDS:-}
@@ -1,313 +0,0 @@
# Telegram Task Center Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build a human-friendly Telegram `/tasks` task center with selectable single-send, album, and channel jobs plus real per-task start, pause, resume, and cancel controls.
**Architecture:** Extract the in-memory file scheduler into a focused `DownloadTaskQueue` module that owns task-group metadata, scheduling, and group-level control. Adapt its group snapshots and persistent channel jobs into a shared pure task-center view model, while keeping Telegram rendering/callback I/O in `telegramCommands.ts`.
**Tech Stack:** TypeScript 6, Node.js 22-compatible ESM, Node built-in `assert`, `tsx`, GramJS, PostgreSQL, esbuild, Docker Compose.
## Global Constraints
- A normal send is one task group; a Telegram album is one task group; a persistent channel job is one task group.
- New tasks start automatically.
- Pause stops new files in the selected group but lets already-running files finish.
- Start on a waiting task performs a one-time stable priority promotion without preempting active files.
- Cancel only removes/aborts files from the selected task group.
- `/tasks` shows non-terminal work only; no history ledger.
- Persistent channel jobs remain database-backed and restart-recoverable; ordinary sends/albums remain in memory.
- Preserve current hidden slash-command compatibility and do not expose manual control commands in the new task center.
- Keep Telegram callback payloads under 64 bytes and validate authenticated user plus task ownership.
- Preserve the existing uncommitted album naming/media-group work; do not push GitHub without explicit permission.
---
## File Map
- Create `backend/src/services/downloadTaskQueue.ts`: pure grouped queue, scheduler, snapshots, ownership, and group controls.
- Create `backend/src/services/downloadTaskQueue.test.ts`: behavioral RED/GREEN tests for scheduling and isolation.
- Create `backend/src/services/telegramTaskCenter.ts`: pure task-center view model, pagination, text rendering, and button descriptors.
- Create `backend/src/services/telegramTaskCenter.test.ts`: list/detail/confirm/pagination/control-layout tests.
- Modify `backend/src/services/telegramUpload.ts`: replace embedded `BetterDownloadQueue`, register single/album groups, pass group IDs for every file, and expose grouped controls/snapshots.
- Modify `backend/src/services/telegramChannelJobs.ts`: include title/folder/current-file fields needed by the common task detail adapter.
- Modify `backend/src/services/telegramCommands.ts`: replace current `/tasks` output and `ctq_*` callbacks with task-center navigation/control callbacks.
- Modify `backend/src/services/telegramBot.ts`: route `tc_*` callbacks and preserve old callback paths.
- Modify `backend/scripts/verify_telegram_task_queue_status.py`: assert the new task-center route and removal of old noisy rendering.
- Modify `backend/package.json`: add explicit task-center test command if useful for repeatable verification.
### Task 1: Group-aware in-memory scheduler
**Files:**
- Create: `backend/src/services/downloadTaskQueue.ts`
- Create: `backend/src/services/downloadTaskQueue.test.ts`
**Interfaces:**
- Produces `DownloadTaskQueue`, `DownloadTaskGroupInput`, `DownloadTaskGroupSnapshot`, `DownloadTaskQueueSnapshot`, and `DownloadTaskGroupControlResult`.
- Core methods: `ensureGroup(input)`, `add(groupId, fileName, execute, totalSize)`, `getSnapshot()`, `getGroup(id)`, `prioritizeGroup(id, scope)`, `pauseGroup(id, scope)`, `resumeGroup(id, scope)`, `cancelGroup(id, scope)`, `pauseAll()`, `resumeAll()`, `forceStopAll()`, `retryFailed()`, `setMaxConcurrent()`.
- [ ] **Step 1: Write the failing grouping and automatic-start test**
Create tests that instantiate a queue with `maxConcurrent: 1`, register one single group and one album group, enqueue controlled promises, and assert snapshots contain two groups with correct total/active/pending counts and that the first file starts automatically.
- [ ] **Step 2: Run RED**
Run: `npx tsx src/services/downloadTaskQueue.test.ts`
Expected: FAIL because `downloadTaskQueue.ts` or exported interfaces do not exist.
- [ ] **Step 3: Implement minimal queue/group registration and snapshots**
Implement a scheduler that stores `queue`, `active`, `history`, and a `Map<string, DownloadTaskGroupRecord>`. Aggregate snapshots from live file records; keep terminal group metadata only long enough to settle promises and omit terminal groups from the active snapshot.
- [ ] **Step 4: Run GREEN**
Run the same test and expect the grouping/automatic-start assertions to pass.
- [ ] **Step 5: Add a failing priority test**
Enqueue groups A, B, C behind one blocker, call `prioritizeGroup('C')`, release the blocker, and assert C starts next while C's internal file order remains stable.
- [ ] **Step 6: Implement one-time stable priority promotion**
Stable-partition pending tasks so the selected group's tasks move before other pending tasks without reversing either partition.
- [ ] **Step 7: Add failing pause/resume tests**
Assert pause enters `pausing` while a file is active, starts no second file from that group, transitions to `paused` when its active file settles, and resume only re-enables that group.
- [ ] **Step 8: Implement group pause/resume and system-pause precedence**
Skip paused/pausing groups during scheduling. Keep global/system pause independent so `resumeGroup` cannot clear disk-pressure/global suspension.
- [ ] **Step 9: Add failing cancellation/ownership/idempotency tests**
Assert cancellation removes only the selected group's pending items, aborts only its active controllers, repeated cancel is harmless, and mismatched chat/user scope returns `forbidden` without mutation.
- [ ] **Step 10: Implement cancellation and ownership checks**
Set `cancelling` before mutation, settle removed pending promises, abort matching active tasks, finalize as cancelled after active tasks leave, and return explicit `ok | not_found | forbidden | terminal` results.
- [ ] **Step 11: Run full scheduler test**
Run: `npx tsx src/services/downloadTaskQueue.test.ts`
Expected: output `download task queue ok`, exit 0.
### Task 2: Pure task-center presentation model
**Files:**
- Create: `backend/src/services/telegramTaskCenter.ts`
- Create: `backend/src/services/telegramTaskCenter.test.ts`
**Interfaces:**
- Consumes `DownloadTaskGroupSnapshot` and persistent channel job rows.
- Produces `TaskCenterItem`, `TaskCenterPage`, `TaskCenterButton`, `buildTaskCenterPage(items, page)`, `buildTaskCenterDetail(item)`, `buildTaskCancelConfirm(item, page)`, and callback parse/build helpers.
- [ ] **Step 1: Write failing list-page tests**
Use fixtures for running, waiting, pausing, paused, cooling, and channel items. Assert a six-item page, concise two-line rows, status totals, clamped page number, numbered selection buttons, pagination, and refresh callback payloads.
- [ ] **Step 2: Run RED**
Run: `npx tsx src/services/telegramTaskCenter.test.ts`
Expected: FAIL because the task-center module does not exist.
- [ ] **Step 3: Implement list model and callback codec**
Use callbacks shaped like `tc_l_<page>`, `tc_d_<kind>_<id>_<page>`, `tc_a_<action>_<kind>_<id>_<page>`, and keep identifiers/payloads below 64 bytes.
- [ ] **Step 4: Run list tests GREEN**
Run the test and expect list assertions to pass.
- [ ] **Step 5: Add failing detail/action tests**
Assert waiting shows prioritize, running shows finish-current-file pause, pausing hides duplicate pause, paused shows resume, all live states show cancel, and all details show back/refresh. Assert system reasons/current filename/target folder/time are rendered.
- [ ] **Step 6: Implement detail and button descriptors**
Keep Telegram classes out of this module; return plain `{ text, data }` rows for the adapter.
- [ ] **Step 7: Add failing cancellation-confirm and terminal/expired tests**
Assert confirm/back controls, no direct destructive action from detail, and terminal items are absent from list builders.
- [ ] **Step 8: Implement confirm page and item adapters**
Map ordinary queue snapshots and channel rows to the shared states and labels.
- [ ] **Step 9: Run task-center tests**
Run: `npx tsx src/services/telegramTaskCenter.test.ts`
Expected: output `telegram task center ok`, exit 0.
### Task 3: Integrate task groups into ordinary sends and albums
**Files:**
- Modify: `backend/src/services/telegramUpload.ts`
- Test: `backend/src/services/downloadTaskQueue.test.ts`
**Interfaces:**
- Consumes `DownloadTaskQueue` from Task 1.
- Exposes `listDownloadTaskGroups(chatId?, userId?)`, `prioritizeDownloadTaskGroup`, `pauseDownloadTaskGroup`, `resumeDownloadTaskGroup`, and `cancelDownloadTaskGroup` for the Bot command adapter.
- [ ] **Step 1: Add a failing integration/static assertion**
Extend the scheduler test or a focused verifier to assert `telegramUpload.ts` passes a group ID to every `downloadQueue.add` call and no longer defines `class BetterDownloadQueue` inline.
- [ ] **Step 2: Run RED**
Run the focused test/verifier and expect failure against the current embedded queue.
- [ ] **Step 3: Replace the embedded queue with the extracted module**
Preserve existing global stats, retry, disk-pressure, progress update, and force-stop wrappers so unrelated code keeps compiling.
- [ ] **Step 4: Register one group per single message**
Build a short group ID from chat/message identity, set kind `single`, title to canonical file name, and scope to message chat/user. Pass that group ID into the queued single-file task.
- [ ] **Step 5: Register one group per album snapshot**
Use the existing chat-scoped media-group key as group identity input, title from shared caption/folder fallback, total from snapshot, and pass the same group ID to each `processFileUpload` call.
- [ ] **Step 6: Pass group context through `processFileUpload`**
Add group ID to the function signature/queue metadata and use it in `downloadQueue.add(groupId, taskDisplayName, queueTask)`.
- [ ] **Step 7: Keep channel files out of duplicate ordinary `/tasks` rows**
Allow `downloadTelegramChannelRange` to enqueue with an internal/non-visible group keyed by job ID or mark those queue tasks hidden, because the persistent channel job is the task-center source of truth.
- [ ] **Step 8: Run scheduler tests and backend build**
Run:
```bash
npx tsx src/services/downloadTaskQueue.test.ts
npm run build
```
Expected: test exits 0 and esbuild emits both bundles with exit 0.
### Task 4: Task-center Telegram UI and callbacks
**Files:**
- Modify: `backend/src/services/telegramChannelJobs.ts`
- Modify: `backend/src/services/telegramCommands.ts`
- Modify: `backend/src/services/telegramBot.ts`
- Modify: `backend/scripts/verify_telegram_task_queue_status.py`
- Test: `backend/src/services/telegramTaskCenter.test.ts`
**Interfaces:**
- Consumes pure pages/button rows from Task 2 and ordinary queue controls from Task 3.
- Produces `handleTaskCenterCallback(client, update, data)` and updated `handleTasks(message)`.
- [ ] **Step 1: Write failing verifier assertions**
Update the Python verifier to require `handleTaskCenterCallback`, `tc_` routing, list/detail/confirm text, queue-group controls, channel controls, ownership checks, and absence of old `buildTasksReport`/direct per-row destructive UI from `/tasks`.
- [ ] **Step 2: Run RED**
Run: `python3 scripts/verify_telegram_task_queue_status.py`
Expected: FAIL because new handlers/routes are absent.
- [ ] **Step 3: Add fields to channel active-task query**
Return `folder_override`, user-visible mode details from `options`, and a current file name subquery/aggregate where available. Keep terminal filtering unchanged.
- [ ] **Step 4: Replace `/tasks` rendering**
Fetch ordinary group snapshots scoped to chat/user plus active channel jobs for user, adapt them, sort active before waiting/paused and newest activity within state, then reply with page 0 and converted GramJS markup.
- [ ] **Step 5: Implement navigation callbacks**
Parse `tc_*`, authenticate, refetch current item state on every click, enforce ordinary ownership/channel user ownership, edit the same message, and treat `MESSAGE_NOT_MODIFIED` as success.
- [ ] **Step 6: Implement controls**
Route ordinary start/pause/resume/cancel to Task 3 functions; route channel controls to existing DB functions. Make cancel a two-step confirmation and refresh/clamp the originating page after terminal mutation.
- [ ] **Step 7: Keep legacy commands and old task-card callbacks compatible**
When an ID identifies a new ordinary group, old `/task_* <id>` handlers act on that group. Existing global no-ID semantics and existing task-card callbacks remain operational.
- [ ] **Step 8: Route callbacks in `telegramBot.ts`**
Route `tc_` before legacy `ctq_`/`tq_` paths and export/import the new handler.
- [ ] **Step 9: Run UI tests, verifier, and build**
Run:
```bash
npx tsx src/services/telegramTaskCenter.test.ts
python3 scripts/verify_telegram_task_queue_status.py
npm run build
```
Expected: both tests/verifier exit 0; esbuild succeeds.
### Task 5: Regression verification and live deployment
**Files:**
- Verify all changed backend sources and current uncommitted album tests.
- Deploy with `/www/wwwroot/tg-vault/docker-compose.yml`.
**Interfaces:**
- No new interfaces; this task proves the complete artifact.
- [ ] **Step 1: Run focused and existing TypeScript tests**
Run every `backend/src/**/*.test.ts` through `npx tsx`, including the pre-existing uncommitted media-group/naming tests, and inspect every exit code.
- [ ] **Step 2: Run static verifiers**
Run `python3 scripts/verify_telegram_task_queue_status.py` plus existing relevant Telegram media/private-invite/large-batch verifiers.
- [ ] **Step 3: Run clean backend build and diff checks**
Run:
```bash
npm run build
git diff --check
git status --short --branch
```
Expected: build and diff check exit 0; status shows intended files plus the user's pre-existing work.
- [ ] **Step 4: Build and recreate backend container**
Run from the project directory:
```bash
docker compose build backend
docker compose up -d --force-recreate backend
```
Expected: `tg-vault-backend` recreated and running.
- [ ] **Step 5: Verify live service**
Run:
```bash
curl -fsS http://127.0.0.1:51947/health
docker compose ps backend
docker compose logs --tail=200 backend
```
Expected: health endpoint succeeds, container state is Up, logs include Telegram Bot startup and contain no startup exception, callback registration error, SQL error, or unhandled rejection.
- [ ] **Step 6: Verify deployed artifact contains the task center**
Search inside the backend container's built `dist/index.js` for the task-center callback prefix and key Chinese labels, and verify callback payload examples are below 64 bytes through the pure task-center test.
- [ ] **Step 7: Review final diff and report without pushing**
Separate the task-center files from the pre-existing album/media naming work in the summary. Do not push or open a PR.
@@ -1,143 +0,0 @@
# TG Vault First-Round Audit Fixes Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 修复第一轮审查确认的 Telegram 持久任务结算、订阅恢复、存储切换、并发同名上传、API Key、首次初始化及测试执行可靠性问题。
**Architecture:** 将外部存储写入、数据库索引和 Telegram 子项结算视为一个可验证协议;为每次上传固化存储目标;用数据库原子操作处理初始化和名称分配;通过独立 API Key 挂载及权限中间件恢复外部 API。每项修复都先加入会失败的回归测试,再做最小实现。
**Tech Stack:** Node.js 20、TypeScript、Express、PostgreSQL 16、GramJS、node:test/tsx、Docker Compose。
## Global Constraints
- 不覆盖当前工作树已有未提交改动。
- 不推送 GitHub。
- 每个行为修复先验证 RED,再实现 GREEN。
- 完成后运行全部后端测试、TypeScript、构建、Docker 重建和线上健康/数据库不变量验证。
- 生产部署保留现有数据卷、密钥和 Telegram session。
---
### Task 1: Persistent child settlement protocol
**Files:**
- Modify: `backend/src/services/telegramChannelJobs.ts`
- Test: `backend/src/services/telegramPersistentSettlementRef.test.ts`
**Interfaces:**
- Produce settlement result with affected row count.
- Successful settlement must remain valid when a user pause races after the child was claimed.
- Unsettled rows must not be restored to pending after an external side effect was successfully materialized.
- [ ] Add a failing regression test for pause-after-save settlement.
- [ ] Run the focused test and confirm expected failure.
- [ ] Implement affected-row validation and settlement semantics.
- [ ] Run focused test to green.
### Task 2: Subscription protection parity
**Files:**
- Modify: `backend/src/services/telegramChannelJobs.ts`
- Test: `backend/src/services/telegramStorageCooldownRetry.test.ts`
**Interfaces:**
- Subscription jobs consume the same claim/download/cooling/restoration path as date/tag jobs.
- FloodWait and storage quota restore unfinished claims to pending without increasing attempts.
- [ ] Add a failing test proving the subscription path uses the common protected downloader.
- [ ] Run RED.
- [ ] Replace direct bulk execution with common protected claim execution.
- [ ] Run GREEN.
### Task 3: Immutable storage target snapshot
**Files:**
- Modify: `backend/src/services/storage.ts`
- Modify: `backend/src/routes/upload.ts`
- Modify: `backend/src/routes/chunkedUpload.ts`
- Modify: `backend/src/services/telegramUpload.ts`
- Modify: `backend/src/services/downloadTaskQueue.ts`
- Test: new or existing focused storage race test.
**Interfaces:**
- Produce immutable `{ provider, accountId, generation }` per upload/task.
- Provider and DB `storage_account_id` must come from the same snapshot.
- [ ] Write a failing switch-vs-upload snapshot test.
- [ ] Run RED.
- [ ] Add snapshot API and pass it through Web, chunked and Telegram upload paths.
- [ ] Run GREEN.
### Task 4: Concurrent unique object identity
**Files:**
- Modify: `backend/src/utils/fileUtils.ts`
- Modify schema only if required.
- Test: focused concurrent name allocation test.
**Interfaces:**
- Concurrent uploads with the same display name must receive distinct object keys.
- [ ] Add failing concurrent allocation test.
- [ ] Run RED.
- [ ] Implement atomic/UUID-backed stored object names while preserving display names and duplicate policy.
- [ ] Run GREEN.
### Task 5: API Key-only upload
**Files:**
- Modify: `backend/src/index.ts`
- Modify: `backend/src/routes/upload.ts`
- Modify: `backend/src/middleware/apiKey.ts`
- Test: new HTTP/router-level test.
**Interfaces:**
- `POST /api/v1/upload` accepts API Key without Web cookie.
- API Key must include `upload`; successful use updates `last_used_at`.
- [ ] Add failing router/permission tests.
- [ ] Run RED.
- [ ] Split Web and API mounts and enforce permission.
- [ ] Run GREEN.
### Task 6: Atomic initial setup
**Files:**
- Modify: `backend/src/utils/authSettings.ts`
- Modify: `backend/src/routes/auth.ts`
- Test: new PostgreSQL-backed or repository-level concurrency test.
**Interfaces:**
- Web password and Telegram PIN are created atomically.
- Exactly one concurrent setup succeeds.
- [ ] Add failing concurrent setup test.
- [ ] Run RED.
- [ ] Implement transaction/advisory lock and atomic credential writes.
- [ ] Run GREEN.
### Task 7: Async test completion
**Files:**
- Modify: `backend/src/services/downloadTaskQueue.test.ts`
- Modify: `backend/package.json`
**Interfaces:**
- Standalone queue test must print its completion marker and fail on unresolved flow/timeout.
- Canonical `npm test` and `npm run typecheck` commands exist.
- [ ] Convert entry to top-level await and add timeout protection where necessary.
- [ ] Verify completion marker.
### Task 8: Full verification and deployment
**Files:**
- Generated: `backend/dist/index.js`
- [ ] Run focused tests.
- [ ] Run all tests.
- [ ] Run typecheck and build.
- [ ] Run `git diff --check` and dependency audit.
- [ ] Build backend Docker image.
- [ ] Recreate backend container.
- [ ] Verify health, auth/API behavior, PostgreSQL invariants, container logs and Bot startup.
@@ -1,195 +0,0 @@
# TG Vault Second-Round Audit Fixes Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 修复第二轮审计确认的删除一致性、Telegram 持久任务租约/恢复、认证与存储 fail-closed、分块上传协议、OAuth 绑定、前端状态真相及部署可用性问题。
**Architecture:** 把上传、删除、后台任务 claim/settle、OAuth 和部署健康都建模为可持久化且可比较交换的协议,而不是进程内标志或 UI 约定。优先消除数据丢失、重复外部副作用和安全降级;随后统一 Web UI/API 的部分成功与认证语义;最后补齐 readiness、优雅停机、日志滚动和可执行运维文档。
**Tech Stack:** Node.js 20、TypeScript、Express、React/Vite、PostgreSQL 16、GramJS、Docker Compose、node:test/tsx。
## Global Constraints
- 不提交、不推送 GitHub。
- 每个修复先加入能失败的行为测试,再写最小实现。
- 外部对象与数据库索引不能出现“UI 成功但只删除/只创建一边”的结果。
- destructive confirmation 必须绑定 actor/session、存储 scope、不可变对象 ID 快照、TTL,并且一次消费。
- Telegram claim、heartbeat、settlement、restore 必须携带 lease token/generation;旧 worker 不得结算新 lease。
- 用户 pause 只阻止新 claim;已 active 的文件允许完成。cancel 才执行硬中断/补偿。
- 认证关键密钥或 active cloud provider 初始化失败必须 fail closed,不得自动降级为未启用 2FA/本地存储。
- `/livez` 只表示进程存活;`/readyz` 必须反映 DB/schema/storage/security initialization。
- 现有脏工作树属于用户工作;不重置、不覆盖无关改动。
- 修复完成后必须 lint、typecheck、全量测试、构建、Docker 重建部署,并用 HTTP/数据库不变量验证线上结果。
---
### Task 1: 删除与分享的一致性和确认协议
**Files:**
- Create: `backend/src/services/fileDeletion.ts`
- Create: `backend/src/services/batchDeleteConfirmation.ts`
- Modify: `backend/src/routes/files.ts`
- Modify: `backend/src/routes/folderOperations.ts`
- Modify: `backend/src/services/storage.ts`
- Modify: `frontend/src/services/api.ts`
- Modify: `frontend/src/App.tsx`
- Modify: `frontend/src/components/ui/DeleteAlert.tsx`
- Test: `backend/src/services/fileDeletion.test.ts`
- Test: `backend/src/services/batchDeleteConfirmation.test.ts`
**Interfaces:**
- `deleteIndexedFile(file): Promise<{status:'deleted'|'not_found'|'failed'; error?:string}>`
- preview returns `{confirmationToken,fileCount,dataFileCount,placeholderCount,folderCount,totalSizeBytes,expiresAt}`.
- execute accepts `{confirmationToken}` and returns a typed complete/partial result including deleted IDs and failed files.
- [ ] Write failing tests for physical-delete failure retaining the index, not-found idempotency, DB-delete failure tombstone/result, token scope/TTL/replay, and 207 typed UI payload.
- [ ] Run focused tests and confirm RED.
- [ ] Implement shared single/batch deletion outcome service and route integration.
- [ ] Implement server-side one-time batch confirmation bound to auth token hash + storage target + immutable IDs.
- [ ] Correct GiB formatting and exclude/label `.folder` placeholders.
- [ ] Make frontend refresh after any partial deletion and preserve failed selections.
- [ ] Reject Google Drive password/expiration before creating public permission.
- [ ] Run focused backend tests, frontend lint/typecheck.
### Task 2: Telegram claim lease、恢复单 owner 与取消补偿
**Files:**
- Modify: `backend/src/db/schema.sql`
- Modify: `backend/src/db/index.ts`
- Modify: `backend/src/services/taskAbortRegistry.ts`
- Modify: `backend/src/services/telegramChannelJobs.ts`
- Modify: `backend/src/services/telegramUpload.ts`
- Modify: `backend/src/services/telegramCommands.ts`
- Test: `backend/src/services/taskAbortRegistry.test.ts`
- Test: `backend/src/services/telegramLeaseProtocol.test.ts`
- Test: `backend/src/services/telegramFinalizationCas.test.ts`
**Interfaces:**
- child claim returns immutable `{id, leaseToken}` and writes `lease_owner/lease_token/lease_expires_at`.
- heartbeat refreshes only the matching token; settle/restore require the token.
- global recovery uses a dedicated PostgreSQL advisory-lock connection.
- cancellation after indexed save removes both object and exact index row or persists reconciliation failure.
- [ ] Write RED tests for ref-counted abort registry, old-token settlement rejection, heartbeat preventing stale reclaim, two recovery instances, pause-after-active, and final CAS preventing cursor/notification.
- [ ] Add lease columns/indexes with backward-compatible migration.
- [ ] Implement ref-counted abort registry.
- [ ] Implement tokenized claim/heartbeat/settlement/restore and cross-instance recovery lock.
- [ ] Change pause so active workers complete while new claim stops.
- [ ] Couple terminal CAS and subscription cursor update; check every affected-row result before user-visible completion.
- [ ] Make subscription short-ID mutation resolve exactly one full UUID.
- [ ] Fail closed legacy `ctq_cancel_*` and remove misleading cooling resume/cause copy.
- [ ] Implement indexed-save cancellation compensation with durable failure evidence.
- [ ] Run focused tests and real PostgreSQL barrier tests.
### Task 3: Authentication/storage initialization fail-closed and readiness
**Files:**
- Modify: `backend/src/utils/security.ts`
- Modify: `backend/src/utils/secretStore.ts`
- Modify: `backend/src/services/storage.ts`
- Modify: `backend/src/index.ts`
- Modify: `backend/src/routes/auth.ts`
- Modify: `docker-compose.yml`
- Test: `backend/src/utils/securityReadiness.test.ts`
- Test: `backend/src/services/storageReadiness.test.ts`
**Interfaces:**
- security readiness distinguishes disabled from enabled-but-secret-unreadable.
- storage manager exposes initialized/ready/error state and never silently switches a configured cloud target to local.
- `/livez` returns process liveness; `/readyz` returns 503 until DB/schema/security/storage are ready.
- [ ] Write RED tests for wrong SESSION_SECRET with enabled 2FA and corrupt active cloud credentials.
- [ ] Make TOTP enabled+missing/decrypt-failed block login/readiness; remove `verifyTOTP` fail-open.
- [ ] Make storage initialization fail closed for configured cloud provider.
- [ ] Start listening only after initialization state is known; add readiness and graceful SIGTERM shutdown.
- [ ] Add backend/frontend Compose healthchecks, dependency ordering, stop grace periods, and Docker log rotation.
- [ ] Run typecheck/tests and health transition smoke tests.
### Task 4: Storage account deletion and OAuth flow binding
**Files:**
- Create: `backend/src/services/oauthFlowStore.ts`
- Modify: `backend/src/routes/storage.ts`
- Modify: `backend/src/services/storage.ts`
- Modify: `frontend/src/components/pages/SettingsPage.tsx`
- Modify: `frontend/src/services/api.ts`
- Test: `backend/src/services/oauthFlowStore.test.ts`
- Test: `backend/src/services/storageAccountDeletion.test.ts`
**Interfaces:**
- OAuth pending flow is a one-time DB row keyed by random state and bound to provider, auth session hash, immutable redirect URI/config, expiry.
- account delete transaction locks account, rechecks active/task/upload references, deletes indexes/account atomically, and evicts the exact provider key.
- [ ] Write RED tests for concurrent OAuth tabs, wrong session, replay/expiry, redirect override, delete-vs-switch/job-create, rollback, provider eviction.
- [ ] Replace global OAuth settings slots with per-flow records and exact callback URI.
- [ ] Restrict popup postMessage to configured frontend origin; verify origin, source and flow nonce in the frontend.
- [ ] Fix account SELECT to include type; lock/recheck/delete transactionally and evict exact provider.
- [ ] Run focused tests and typechecks.
### Task 5: Durable bounded chunk uploads and frontend upload queue
**Files:**
- Create: `backend/src/services/chunkUploadSessions.ts`
- Modify: `backend/src/db/schema.sql`
- Modify: `backend/src/db/index.ts`
- Modify: `backend/src/routes/chunkedUpload.ts`
- Modify: `frontend/src/services/api.ts`
- Modify: `frontend/src/App.tsx`
- Modify: `frontend/src/components/ui/UploadQueueModal.tsx`
- Test: `backend/src/services/chunkUploadSessions.test.ts`
**Interfaces:**
- persisted session statuses: `open|completing|completed|cancelled|failed` with owner, target, expected bytes/chunks, received bytes, expiry, completion lease.
- each chunk uses idempotent index+size/hash metadata; complete performs one CAS.
- server enforces per-file and global temporary byte budgets/disk watermark.
- frontend queue is bounded and exposes cancel/retry with typed status.
- [ ] Write RED tests for quota, duplicate chunk, truncated write, two complete requests, cancel-vs-complete, restart/status resume.
- [ ] Persist session metadata and completion lease; wait for write stream finish before recording chunk.
- [ ] Enforce max total size, global temporary bytes, disk reserve, chunk expected size/hash.
- [ ] Keep chunks until permanent write+index succeeds; recover/expire sessions deterministically.
- [ ] Lower simple-upload threshold below the production proxy limit or route all practical files through chunks.
- [ ] Add bounded frontend workers, AbortController/XHR handles, retry, partial summary, queue generation-safe clearing.
- [ ] Run focused tests, proxy-size boundary smoke, lint/typecheck.
### Task 6: Global file query semantics and frontend request races
**Files:**
- Modify: `backend/src/routes/files.ts`
- Modify: `frontend/src/services/api.ts`
- Modify: `frontend/src/App.tsx`
- Test: `backend/src/services/fileQuery.test.ts`
**Interfaces:**
- query accepts `q,type,folder,favorite,sort,direction,cursor` and returns deterministic keyset pages.
- folder aggregation endpoint returns global count/size/latest/cover/favorite.
- frontend requests carry AbortSignal/generation and only latest generation may commit.
- [ ] Write RED tests with 201+ records and out-of-order deferred responses.
- [ ] Add validated server-side search/filter/sort and matching indexes.
- [ ] Add folder aggregation endpoint.
- [ ] Add AbortController/request generation and explicit initial-error/stale-error/empty states.
- [ ] Ensure preview navigation and counts use global query semantics or clearly bounded loaded-state copy.
- [ ] Run backend tests, frontend lint/typecheck.
### Task 7: Operational truth, documentation, observability and verification
**Files:**
- Modify: `deploy/DEPLOY.md`
- Modify: `deploy/install.sh`
- Modify: `README.md`
- Modify: `docker-compose.yml`
- Create: `deploy/backup.sh`
- Create: `deploy/restore-verify.sh`
- [ ] Remove nonexistent Compose nginx/certbot instructions or explicitly implement the selected host-Nginx model.
- [ ] Document exact project-directory update commands and separate optional verification commands.
- [ ] Add coordinated encrypted/off-host-capable DB + `/data` backup manifest and isolated restore verification instructions.
- [ ] Add request/operation IDs, redaction, structured destructive/storage events and bounded container logs.
- [ ] Run shell syntax checks, Compose config, lint/typecheck/tests.
- [ ] Build frontend/backend locally.
- [ ] Independent reviewer checks the complete diff and security scan.
- [ ] Rebuild/recreate Docker services.
- [ ] Verify `/livez`, `/readyz`, protected 401/428, CORS, browser console, served frontend asset parity.
- [ ] Run read-only PostgreSQL invariants: terminal unfinished, stale leases, missing snapshots, duplicate task identity, failed reconciliation, orphan rows.
- [ ] Confirm no commit and no push occurred.
@@ -1,49 +0,0 @@
# Global Indeterminate Spinner Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace every unknown-duration frontend loading animation with one accessible SVG indeterminate spinner while retaining determinate progress indicators.
**Architecture:** A single `IndeterminateSpinner` component owns visual variants and ARIA semantics. Existing call sites provide contextual labels; reduced-motion behavior lives in global CSS.
**Tech Stack:** React 19, TypeScript, Tailwind CSS, SVG, Node test runner, Vite, Docker Compose.
## Global Constraints
- Preserve current routing, task-management, and responsive dirty-tree changes.
- Unknown progress uses `role="progressbar"` without `aria-valuenow`.
- Known percentages continue using determinate bars.
- No new runtime dependencies.
- Do not commit or push.
---
### Task 1: Accessible spinner primitive
**Files:**
- Create: `frontend/src/components/ui/IndeterminateSpinner.tsx`
- Modify: `frontend/src/index.css`
- Create: `frontend/src/services/indeterminateSpinner.test.ts`
- [ ] Write failing contracts for SVG track/arc, required label, progressbar role, no aria-valuenow, variants, and reduced-motion CSS.
- [ ] Run focused test and confirm RED.
- [ ] Implement the component and CSS keyframes/media query.
- [ ] Run focused test and confirm GREEN.
### Task 2: Replace all unknown-duration indicators
**Files:**
- Modify all current frontend files containing loading-related `animate-spin`, `Loader2`, or rotating `RefreshCw` usage.
- Modify: `frontend/src/services/indeterminateSpinner.test.ts`
- [ ] Add RED inventory tests requiring contextual spinner labels and prohibiting legacy unknown-duration spinner patterns.
- [ ] Replace page, button, modal, preview, notification, upload, and queue indicators.
- [ ] Preserve determinate bars and percentages.
- [ ] Run focused test and production build GREEN.
### Task 3: Verify and deploy
- [ ] Run full frontend tests, ESLint, build, and `git diff --check`.
- [ ] Rebuild/recreate only the frontend container.
- [ ] Verify the public asset contains the global spinner and no legacy loading-spinner patterns.
- [ ] Inspect representative light/dark/mobile loading states, frontend health, restart count, and recent logs.
@@ -1,93 +0,0 @@
# Task Center Management Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add safe terminal-task removal, multi-select removal, filter-scoped cleanup, and improved mobile task management without deleting files or underlying business records.
**Architecture:** Persist exact-version task-center dismissals separately from task state. Reuse one unified task snapshot builder for listing and frozen prepare/confirm removal. The frontend consumes explicit `dismissible` capability and typed preview/result contracts.
**Tech Stack:** Express, PostgreSQL 16, TypeScript, React 19, Node test runner, Docker Compose.
## Global Constraints
- Preserve all current dirty routing and responsive work.
- Never delete files, cloud objects, subscriptions, source task rows, or reconciliation journals.
- Only terminal task versions may be dismissed.
- Every removal requires server-side prepare/confirm with an immutable snapshot.
- Do not push to GitHub.
---
### Task 1: Persistent exact-version dismissal repository
**Files:**
- Modify: `backend/src/db/schema.sql`
- Modify: `backend/src/db/index.ts`
- Create: `backend/src/services/taskCenterDismissals.ts`
- Create: `backend/src/services/taskCenterDismissals.test.ts`
**Interfaces:**
- `isTaskDismissible(task): boolean`
- `filterDismissedTasks(tasks, dismissals): task[]`
- `saveDismissals(snapshot[]): Promise<void>`
- [ ] Write tests proving only terminal states are eligible and an `updatedAt` change makes a dismissed task visible again.
- [ ] Run focused test and confirm RED.
- [ ] Add the table/index and pure/repository helpers.
- [ ] Run focused test and confirm GREEN.
### Task 2: Frozen prepare/confirm backend protocol
**Files:**
- Modify: `backend/src/services/webDestructiveConfirmation.ts`
- Modify: `backend/src/routes/tasks.ts`
- Create: `backend/src/routes/taskDismissalContract.test.ts`
**Interfaces:**
- `POST /api/tasks/dismissals/prepare`
- `POST /api/tasks/dismissals/confirm`
- [ ] Add RED tests for direct-confirm rejection, terminal filtering, immutable snapshot, replay, changed-version rejection, and no-file-deletion contract.
- [ ] Extract unified task collection so list and prepare inspect identical truth.
- [ ] Extend confirmation action binding for `dismiss_tasks` with frozen snapshot context.
- [ ] Implement preview counts and complete/207 confirm results.
- [ ] Run focused and backend full tests.
### Task 3: Typed frontend API
**Files:**
- Modify: `frontend/src/services/api.ts`
- Create: `frontend/src/services/taskDismissalExperience.test.ts`
**Interfaces:**
- `UnifiedTask.dismissible`
- `prepareTaskDismissal(input)`
- `confirmTaskDismissal(token)`
- [ ] Write RED source/contract tests.
- [ ] Add types and API methods with typed partial outcomes.
- [ ] Run focused test GREEN.
### Task 4: Mobile management UX
**Files:**
- Modify: `frontend/src/components/pages/TasksPage.tsx`
- Add/modify focused tests under `frontend/src/components/pages/` and `frontend/src/services/`.
- [ ] Write RED tests for terminal-only delete, selection mode, filter-scoped cleanup, impact confirmation, and compact mobile controls.
- [ ] Add single-record removal and confirmation.
- [ ] Add multi-select, eligible select-all, and bulk removal.
- [ ] Add filter-scoped cleanup preview.
- [ ] Compress filters, remove duplicate state text, and improve mobile metadata/title layout.
- [ ] Run focused and frontend full tests, lint, and build.
### Task 5: Deploy and production verification
**Files:**
- Generated backend bundle: `backend/dist/index.js`
- [ ] Run `git diff --check`, backend tests/typecheck/build, frontend tests/lint/build.
- [ ] Rebuild backend and frontend images and recreate both services once.
- [ ] Verify `task_center_dismissals` schema and production row counts.
- [ ] Use a short-lived authenticated test session or local safe harness to prove preview/confirm hides only a terminal test-version and changed versions reappear; delete test dismissal/session afterward.
- [ ] Verify mobile Chromium at 320/360/390/412px, health 200, zero restarts, and zero recent severe errors.
@@ -1,76 +0,0 @@
# Web URL Routing Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add stable Browser History URLs for every file category, task center, settings page, and settings subsection while preserving deep-link refresh and Back/Forward behavior.
**Architecture:** A dependency-free `appRoute.ts` module parses and generates canonical routes. `App.tsx` owns route state; `AppLayout` and `SettingsPage` become controlled navigation views. Nginx already falls back to `index.html` for deep links.
**Tech Stack:** React 19, TypeScript 5.9, Vite 7, Node test runner via `tsx`, Browser History API, Docker Compose, Nginx.
## Global Constraints
- Preserve all current uncommitted responsive fixes.
- Do not add React Router or another dependency.
- Do not change backend/API/database behavior.
- Use `pushState` for page/category/folder navigation and `replaceState` for canonicalization and search query changes.
- Do not push to GitHub.
---
### Task 1: Typed route model and canonical parser
**Files:**
- Create: `frontend/src/services/appRoute.ts`
- Create: `frontend/src/services/appRoute.test.ts`
**Interfaces:**
- Produces: `AppRoute`, `FileCategory`, `parseAppRoute(location)`, `appRouteHref(route)`, `routeForCategory(category, query?)`, `routeForSettings(section)`.
- [ ] **Step 1: Write failing tests** covering `/`, all file paths, `/tasks`, every settings subsection, invalid fallback, and `folder`/`q` query round-trips.
- [ ] **Step 2: Run** `npx tsx --test src/services/appRoute.test.ts`; expect failure because `appRoute.ts` is absent.
- [ ] **Step 3: Implement** a pure path/query lookup table and canonical serializer. `/` and unknown paths parse to the `/files` route with `needsReplace: true`.
- [ ] **Step 4: Rerun focused tests** and expect all route tests to pass.
### Task 2: App-owned Browser History state
**Files:**
- Modify: `frontend/src/App.tsx`
- Create: `frontend/src/services/appRouteWiring.test.ts`
**Interfaces:**
- Consumes: route module from Task 1.
- Produces: controlled `currentCategory`, `settingsSection`, category navigation, settings navigation, folder/search URL synchronization, and `popstate` restoration.
- [ ] **Step 1: Write a source-contract test** requiring initial `parseAppRoute`, `popstate` listener, `pushState`, `replaceState`, and controlled props passed to layout/settings.
- [ ] **Step 2: Run focused test** and confirm RED because wiring is absent.
- [ ] **Step 3: Implement minimal wiring**: initialize from `window.location`, canonicalize `/`/unknown paths, listen to `popstate`, update category/folder/search from route, push page/folder transitions, replace search updates.
- [ ] **Step 4: Run focused tests** and confirm GREEN.
### Task 3: Controlled sidebar and settings tabs
**Files:**
- Modify: `frontend/src/components/layout/AppLayout.tsx`
- Modify: `frontend/src/components/pages/SettingsPage.tsx`
- Create: `frontend/src/services/controlledNavigation.test.ts`
**Interfaces:**
- `AppLayout` consumes `activeCategory: string` and `onCategoryChange(id: string)`.
- `SettingsPage` consumes `activeSection: SettingsSectionId` and `onSectionChange(section)`.
- [ ] **Step 1: Write failing contracts** asserting local active-tab/active-section state is removed and controlled props are used.
- [ ] **Step 2: Run focused tests** and confirm RED.
- [ ] **Step 3: Change components** so highlighting and clicks derive from parent route state; preserve mobile drawer closing and horizontal settings-tab scrolling.
- [ ] **Step 4: Run focused tests** and confirm GREEN.
### Task 4: Full verification and live deployment
**Files:**
- No new production files.
- Temporary browser script under `/tmp` only.
- [ ] **Step 1: Run** `npm test`, `npm run lint`, `npm run build`, and `git diff --check` from `frontend/`; expect zero failures.
- [ ] **Step 2: Build and deploy** with `docker compose build frontend && docker compose up -d --no-deps frontend`.
- [ ] **Step 3: Browser verify** `/files`, each category, `/tasks`, all settings subroutes, direct refresh, unknown fallback, and Back/Forward against `https://cloud.moyin.cc`.
- [ ] **Step 4: Verify live state** using Compose health, HTTP 200 checks, restart count 0, and recent error logs 0.
- [ ] **Step 5: Report** exact changed files and evidence; do not commit or push unless separately requested.
@@ -1,220 +0,0 @@
# Telegram `/tasks` 任务中心设计
日期:2026-07-10
## 目标
把 tg-vault Telegram Bot 的 `/tasks` 从混杂、偏命令行式的任务清单,改造成可选择任务并按任务执行开始、暂停、继续和取消的两级任务中心。
本次范围覆盖:
- 普通单文件下载:一次发送视为一个任务组;
- Telegram 相册:一个相册视为一个任务组;
- 频道日期/标签批量任务:一个后台 job 视为一个任务组;
- `/tasks` 列表页、详情页、取消确认页;
- 任务组级调度与控制;
- 旧命令兼容。
本次不做:
- 普通任务的数据库持久化与重启恢复;
- 单文件断点续传;
- 新的历史任务账本;
- 失败文件的细粒度选择重试 UI。
## 已确认的产品决策
1. 普通下载按“一次发送/一个相册”归组,不按每个文件单独展示。
2. 新任务保持自动开始,不增加手动审批步骤。
3. 运行中点击暂停时,当前文件继续完成;该组不再启动新文件,随后进入已暂停。
4. 等待任务的“开始”表示提升优先级,在下一个空闲下载位尽快开始,不抢占活动文件。
5. 采用现有下载队列增加任务组层的方案,不把所有普通任务改造成数据库任务。
6. 完成后构建、部署并验证 backend;未经明确要求不推送 GitHub。
## 用户交互
### 列表页
`/tasks` 回复一个可编辑的任务中心卡片:
```text
📥 下载任务
🟢 运行中 2 ⏳ 等待 1 ⏸ 已暂停 1
共 4 个进行中的任务
1. 🟢 夏日相册
相册 · 4/12 · 当前:IMG_004.jpg
2. ⏳ report.pdf
单文件 · 等待可用下载位
3. ⏸ @channel · #壁纸
频道任务 · 38/120 · 已暂停
```
规则:
- 每页最多显示 6 个非终态任务;
- 每个任务对应一个编号选择按钮;
- 支持上一页、下一页和刷新;
- 不显示成功、失败、取消等历史任务;
- 普通任务和频道任务使用一致的状态术语和导航。
### 详情页
选择任务后编辑原消息,显示:
- 状态与暂停/系统阻塞原因;
- 类型、任务名称/来源;
- 文件统计及当前文件;
- 保存目标;
- 创建时间与最近更新时间;
- 状态对应的操作按钮。
操作按钮:
- 等待中:`▶️ 优先开始`;
- 运行中:`⏸ 完成本文件后暂停`;
- 暂停请求中:不再显示可重复暂停按钮;
- 已暂停:`▶️ 继续`;
- 所有非终态:`🛑 取消`;
- 固定导航:`↩️ 返回任务列表`、`🔄 刷新`。
### 取消确认页
取消必须经过确认页:
- 明确列出任务名称和影响范围;
- `⚠️ 确认取消` 执行任务级取消;
- `返回详情` 不改变任务状态;
- 成功取消后刷新列表;若任务中心已空,显示空状态并移除无效按钮。
## 任务组模型
新增轻量 `DownloadTaskGroup`:
```ts
type DownloadTaskGroupKind = 'single' | 'album';
type DownloadTaskGroupState =
| 'waiting'
| 'running'
| 'pausing'
| 'paused'
| 'cancelling'
| 'completed'
| 'cancelled';
interface DownloadTaskGroup {
id: string;
kind: DownloadTaskGroupKind;
title: string;
chatId: string;
userId?: number;
source?: string;
targetFolder?: string | null;
state: DownloadTaskGroupState;
createdAt: number;
updatedAt: number;
}
```
每个底层文件任务增加 `groupId`。组聚合统计由队列内的 pending、active 与短期 group metadata 计算,避免重复维护容易漂移的计数。
频道任务仍以 `telegram_background_jobs` 和 `telegram_download_items` 为事实来源,通过适配器转换为相同的任务中心视图,不重复写入普通任务组存储。
## 调度与控制
### 自动开始
任务文件进入队列后正常自动调度,维持现有使用习惯。
### 优先开始
把选中组的等待文件稳定地移动到其它等待文件之前,保留组内原始顺序。不会中断已活动文件。
### 暂停
- 将组状态设为 `pausing`;
- 立即阻止该组新的等待文件被调度;
- 已活动文件继续执行;
- 该组最后一个活动文件结束后转为 `paused`;
- 如果点击时没有活动文件,则立即转为 `paused`。
### 继续
- 仅将选中组从 paused/pausing 恢复为 waiting;
- 重新触发调度;
- 不改变其它任务组,也不能绕过磁盘保护等系统级暂停。
### 取消
- 先把组状态锁为 cancelling,保证并发点击幂等;
- 从等待队列移除该组全部文件并结算 Promise;
- 对该组活动文件调用 AbortController;
- 活动文件退出后清理临时文件;
- 最终标记 cancelled 并从 `/tasks` 活跃列表移除;
- 不触碰其它组。
### 公平性
正常状态沿用入队顺序。用户对某组执行“优先开始”后,仅做一次稳定优先级提升;该组开始占用空闲位后,不永久垄断调度优先权。
## 频道任务适配
- 列表数据继续由 `listTelegramActiveTaskQueues` 提供;
- 详情页使用现有 item 聚合字段;
- 暂停、继续、取消分别调用现有 `pauseTelegramBackgroundJob`、`resumeTelegramBackgroundJob`、`cancelTelegramBackgroundJob`;
- 频道任务仍可跨 Bot 重启恢复;
- 终态、取消态和无实际活动的历史任务不进入 `/tasks`;
- 频道 job ID 的短前缀作为安全、紧凑的 callback selector。
## 安全与异常处理
- 所有回调重新校验用户认证;
- 普通任务按 chatId/userId 校验所有权;
- 频道任务按 user_id 查询并操作;
- 旧按钮、已完成任务返回“任务已结束或已失效”;
- callback data 使用短动作码和短 ID,保持在 Telegram 64 字节限制内;
- 页码在任务数变化后自动夹紧;
- 取消、暂停、继续均保持幂等;
- 磁盘压力、云盘限额和 FloodWait 等系统状态优先于用户继续操作;
- Telegram `MESSAGE_NOT_MODIFIED` 视为成功刷新,不弹错误警告。
## 兼容性
保留:
- `/task_pause <id>`;
- `/task_resume <id>`;
- `/task_cancel <id>`;
- 无 ID 的旧全局操作语义。
新的 `/tasks` 卡片不再显示需要用户手敲的控制命令。
## 测试策略
先把任务组调度器做成纯模块并按 TDD 覆盖:
1. 单文件和相册归组;
2. 自动开始;
3. 等待组稳定优先;
4. 暂停后不再启动新文件;
5. 活动文件完成后从 pausing 转 paused;
6. 继续只恢复选中组;
7. 取消只清理选中组并中断其活动文件;
8. 重复操作幂等;
9. 系统级暂停不可被组级继续绕过;
10. 列表分页、详情渲染、动态按钮和取消确认;
11. 所有权与过期按钮;
12. 频道任务适配及旧命令兼容。
## 交付验证
1. 运行新增单元测试与现有测试;
2. 运行 backend 构建;
3. 重建并强制重建 backend 容器;
4. 检查容器状态和 `/health`;
5. 检查 Telegram Bot 启动日志和错误日志;
6. 静态确认 callback 路由、按钮数据和任务级控制进入 `dist/index.js`;
7. 不推送远端,除非用户另行明确要求。
@@ -1,25 +0,0 @@
# Global Indeterminate Spinner Design
## Goal
Replace every unknown-duration loading animation in the TG Vault frontend with one consistent SVG indeterminate spinner while preserving all real determinate progress bars.
## Component
Create `IndeterminateSpinner` with `sm`, `md`, and `lg` sizes plus `primary`, `current`, and `inverse` tones. It renders a muted circular track and a highlighted arc. The wrapper owns `role="progressbar"` and a required contextual `aria-label`; it intentionally omits `aria-valuenow` because remaining work is unknown. The SVG itself is decorative and hidden from assistive technology.
The arc rotates continuously under ordinary motion preferences. Under `prefers-reduced-motion: reduce`, rotation stops and the highlighted arc remains visible as a static busy affordance.
## Scope
Replace unknown-duration spinners in application bootstrap, lazy page fallbacks, file and task list loading, refresh/action buttons, login/setup, settings probes, notifications, upload zone, upload queue processing/resume states, bulk operations, and media preview loading/buffering.
Keep existing determinate indicators for upload percentage, byte progress, task percentage, and any other known completion values. A determinate progress bar may coexist with a small busy spinner when server-side post-processing has unknown duration.
## Visual Direction
Use the existing TG Vault monochrome/primary token system. Light surfaces use the current primary color with a low-opacity track; dark preview surfaces use white with a translucent white track. Do not add gradients, extra decoration, or new dependencies.
## Verification
Add source/component contract tests for ARIA semantics, no fake value, reduced-motion CSS, size/tone variants, and removal of legacy `animate-spin` loading indicators. Run the full frontend tests, ESLint, production build, Docker rebuild/recreate, public asset verification, responsive browser inspection, health checks, and recent error-log review.
Loaded 100 of 122 files, more files were not shown because too many files have changed in this diff. Show more