feat: complete bot and web reliability enhancement plan
This commit is contained in:
1 parent
6b2ee50a9e
commit
28d229f8c9
122 files changed
+9173
-2688
No files matched your search
+3
-1
@@ -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。
|
||||
- [ ] 回滚命令、旧镜像和备份路径在变更单中已记录。
|
||||
Vendored
+2671
-958
File diff suppressed because it is too large.
Load diff
Generated
+7
-6
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}')
|
||||
@@ -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),
|
||||
]
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 允许用户列表失败' });
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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, /删除确认/);
|
||||
});
|
||||
@@ -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, /安全告警仍始终即时/);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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') {
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
Reference in new issue
Block a user