From 28d229f8c97e711481a17924fa6441cf3bbdd0bb Mon Sep 17 00:00:00 2001 From: hicocos Date: Mon, 24 Aug 2026 07:17:08 +0800 Subject: [PATCH] feat: complete bot and web reliability enhancement plan --- .env.example | 4 +- ...24_000159-tg-vault-bot-enhancement-plan.md | 772 ++++ .../2026-08-24_000159-tg-vault-handoff.md | 647 +++ backend/dist/index.js | 3629 ++++++++++++----- backend/package-lock.json | 13 +- backend/package.json | 4 +- backend/scripts/generate_init_sql.py | 15 + backend/scripts/verify_db_optimizations.py | 2 +- .../scripts/verify_private_invite_support.py | 4 +- backend/src/bot/context.ts | 15 + backend/src/bot/presentation/subscription.ts | 37 + backend/src/db/index.ts | 75 +- backend/src/db/schema.sql | 58 + backend/src/index.ts | 20 +- backend/src/routes/storage.ts | 17 +- backend/src/routes/tasks.ts | 88 +- .../src/routes/tasksMapperContract.test.ts | 30 + .../src/services/botModuleBoundary.test.ts | 22 + backend/src/services/fileQuery.test.ts | 12 + backend/src/services/fileQuery.ts | 28 + backend/src/services/migrationLedger.test.ts | 26 + .../reproducibleBuildContract.test.ts | 20 + .../src/services/scopedInteractionMap.test.ts | 30 + backend/src/services/scopedInteractionMap.ts | 109 + .../services/storageAccountLifecycle.test.ts | 63 +- .../src/services/storageAccountLifecycle.ts | 16 + .../storageLifecycleProductionWiring.test.ts | 5 +- .../telegramAllowlistReconciliation.test.ts | 46 + .../services/telegramAuthAllowlist.test.ts | 10 + .../telegramBatchConfirmation.test.ts | 14 +- backend/src/services/telegramBot.ts | 539 ++- .../services/telegramBotHealthWiring.test.ts | 30 + .../src/services/telegramBotReadiness.test.ts | 43 + backend/src/services/telegramBotStatus.ts | 115 + .../telegramChannelJobAdmission.test.ts | 48 + .../services/telegramChannelJobAdmission.ts | 8 + .../telegramChannelJobPersistence.test.ts | 59 + backend/src/services/telegramChannelJobs.ts | 263 +- .../telegramCommandDispatcher.test.ts | 44 + .../src/services/telegramCommandDispatcher.ts | 84 + backend/src/services/telegramCommands.ts | 320 +- .../src/services/telegramDateRange.test.ts | 24 + backend/src/services/telegramDateRange.ts | 53 + .../src/services/telegramFileBrowser.test.ts | 53 + backend/src/services/telegramFileBrowser.ts | 103 + .../services/telegramHomeNavigation.test.ts | 20 + .../telegramInteractionMigration.test.ts | 21 + .../services/telegramInteractionState.test.ts | 78 + .../src/services/telegramInteractionState.ts | 155 + .../telegramInteractionWiring.test.ts | 28 + .../telegramNotificationDelivery.test.ts | 43 + .../services/telegramNotificationDelivery.ts | 84 + ...telegramNotificationDeliveryWiring.test.ts | 11 + .../telegramNotificationDigestClaim.test.ts | 15 + .../telegramNotificationOwner.test.ts | 9 + .../telegramNotificationPreferences.test.ts | 27 + .../telegramNotificationPreferences.ts | 94 + .../telegramNotificationWiring.test.ts | 16 + backend/src/services/telegramState.ts | 52 +- .../src/services/telegramStatusPanel.test.ts | 30 + backend/src/services/telegramStatusPanel.ts | 48 + .../telegramSubscriptionHealth.test.ts | 8 +- .../telegramSubscriptionManagement.ts | 33 +- .../telegramSubscriptionRunCenter.test.ts | 25 + .../src/services/telegramTargetWiring.test.ts | 36 + backend/src/services/telegramTaskCenter.ts | 18 +- backend/src/services/telegramUpload.ts | 30 +- .../services/telegramUploadReceipt.test.ts | 47 + backend/src/services/telegramUploadReceipt.ts | 52 + .../src/services/unifiedTaskMapper.test.ts | 94 + backend/src/services/unifiedTaskMapper.ts | 144 + .../src/services/ytDlpConfirmation.test.ts | 25 + backend/src/services/ytDlpConfirmation.ts | 63 + backend/src/services/ytDlpDownload.ts | 35 +- .../services/ytDlpPlaylistSelection.test.ts | 18 + .../src/services/ytDlpPlaylistSelection.ts | 32 + .../src/services/ytDlpPlaylistWiring.test.ts | 11 + backend/src/services/ytDlpProbe.test.ts | 66 + backend/src/services/ytDlpProbe.ts | 100 + .../src/services/ytDlpWizardWiring.test.ts | 23 + backend/src/utils/authSettings.ts | 35 +- backend/src/utils/networkSecurity.test.ts | 31 + backend/src/utils/networkSecurity.ts | 20 +- backend/src/utils/telegramCommandRegistry.ts | 16 +- backend/src/utils/telegramMessages.ts | 21 +- backend/src/utils/telegramPathSettings.ts | 6 +- .../utils/telegramTargetStateStore.test.ts | 51 + backend/src/utils/telegramTargetStateStore.ts | 77 + deploy/DEPLOY.md | 20 + deploy/install.sh | 13 + docker-compose.staging.yml | 18 + docker-compose.yml | 9 +- .../plans/2026-07-10-telegram-task-center.md | 313 -- .../2026-07-12-first-round-audit-fixes.md | 143 - .../2026-07-13-second-round-audit-fixes.md | 195 - ...2026-07-23-global-indeterminate-spinner.md | 49 - .../2026-07-23-task-center-management.md | 93 - .../plans/2026-07-23-web-url-routing.md | 76 - .../2026-07-10-telegram-task-center-design.md | 220 - ...-23-global-indeterminate-spinner-design.md | 25 - ...026-07-23-task-center-management-design.md | 59 - .../2026-07-23-web-url-routing-design.md | 54 - frontend/src/App.tsx | 80 +- frontend/src/components/ui/ConfirmDialog.tsx | 38 +- .../src/components/ui/CreateFolderModal.tsx | 36 +- frontend/src/components/ui/Dialog.tsx | 61 + frontend/src/components/ui/FileCard.tsx | 7 +- frontend/src/components/ui/PreviewModal.tsx | 35 +- .../src/components/ui/RenameModal.test.ts | 32 + frontend/src/components/ui/RenameModal.tsx | 31 +- frontend/src/i18n.ts | 6 + frontend/src/index.css | 11 + .../src/services/actionErrorWiring.test.ts | 26 + frontend/src/services/api.ts | 17 +- frontend/src/services/apiActionError.test.ts | 22 + frontend/src/services/apiActionError.ts | 51 + .../src/services/dialogAccessibility.test.ts | 28 + .../src/services/fileQueryController.test.ts | 43 + frontend/src/services/fileQueryController.ts | 58 + frontend/src/services/fileQueryEffect.test.ts | 24 + .../src/services/nativeDialogContract.test.ts | 14 +- init.sql | 489 ++- 122 files changed, 9173 insertions(+), 2688 deletions(-) create mode 100644 .hermes/plans/2026-08-24_000159-tg-vault-bot-enhancement-plan.md create mode 100644 .hermes/plans/2026-08-24_000159-tg-vault-handoff.md create mode 100644 backend/scripts/generate_init_sql.py create mode 100644 backend/src/bot/context.ts create mode 100644 backend/src/bot/presentation/subscription.ts create mode 100644 backend/src/routes/tasksMapperContract.test.ts create mode 100644 backend/src/services/botModuleBoundary.test.ts create mode 100644 backend/src/services/migrationLedger.test.ts create mode 100644 backend/src/services/scopedInteractionMap.test.ts create mode 100644 backend/src/services/scopedInteractionMap.ts create mode 100644 backend/src/services/telegramAllowlistReconciliation.test.ts create mode 100644 backend/src/services/telegramBotHealthWiring.test.ts create mode 100644 backend/src/services/telegramBotReadiness.test.ts create mode 100644 backend/src/services/telegramBotStatus.ts create mode 100644 backend/src/services/telegramChannelJobAdmission.test.ts create mode 100644 backend/src/services/telegramChannelJobAdmission.ts create mode 100644 backend/src/services/telegramChannelJobPersistence.test.ts create mode 100644 backend/src/services/telegramCommandDispatcher.test.ts create mode 100644 backend/src/services/telegramCommandDispatcher.ts create mode 100644 backend/src/services/telegramDateRange.test.ts create mode 100644 backend/src/services/telegramDateRange.ts create mode 100644 backend/src/services/telegramFileBrowser.test.ts create mode 100644 backend/src/services/telegramFileBrowser.ts create mode 100644 backend/src/services/telegramHomeNavigation.test.ts create mode 100644 backend/src/services/telegramInteractionMigration.test.ts create mode 100644 backend/src/services/telegramInteractionState.test.ts create mode 100644 backend/src/services/telegramInteractionState.ts create mode 100644 backend/src/services/telegramInteractionWiring.test.ts create mode 100644 backend/src/services/telegramNotificationDelivery.test.ts create mode 100644 backend/src/services/telegramNotificationDelivery.ts create mode 100644 backend/src/services/telegramNotificationDeliveryWiring.test.ts create mode 100644 backend/src/services/telegramNotificationDigestClaim.test.ts create mode 100644 backend/src/services/telegramNotificationOwner.test.ts create mode 100644 backend/src/services/telegramNotificationPreferences.test.ts create mode 100644 backend/src/services/telegramNotificationPreferences.ts create mode 100644 backend/src/services/telegramNotificationWiring.test.ts create mode 100644 backend/src/services/telegramStatusPanel.test.ts create mode 100644 backend/src/services/telegramStatusPanel.ts create mode 100644 backend/src/services/telegramSubscriptionRunCenter.test.ts create mode 100644 backend/src/services/telegramTargetWiring.test.ts create mode 100644 backend/src/services/telegramUploadReceipt.test.ts create mode 100644 backend/src/services/telegramUploadReceipt.ts create mode 100644 backend/src/services/unifiedTaskMapper.test.ts create mode 100644 backend/src/services/unifiedTaskMapper.ts create mode 100644 backend/src/services/ytDlpConfirmation.test.ts create mode 100644 backend/src/services/ytDlpConfirmation.ts create mode 100644 backend/src/services/ytDlpPlaylistSelection.test.ts create mode 100644 backend/src/services/ytDlpPlaylistSelection.ts create mode 100644 backend/src/services/ytDlpPlaylistWiring.test.ts create mode 100644 backend/src/services/ytDlpProbe.test.ts create mode 100644 backend/src/services/ytDlpProbe.ts create mode 100644 backend/src/services/ytDlpWizardWiring.test.ts create mode 100644 backend/src/utils/networkSecurity.test.ts create mode 100644 backend/src/utils/telegramTargetStateStore.test.ts create mode 100644 backend/src/utils/telegramTargetStateStore.ts create mode 100644 docker-compose.staging.yml delete mode 100644 docs/superpowers/plans/2026-07-10-telegram-task-center.md delete mode 100644 docs/superpowers/plans/2026-07-12-first-round-audit-fixes.md delete mode 100644 docs/superpowers/plans/2026-07-13-second-round-audit-fixes.md delete mode 100644 docs/superpowers/plans/2026-07-23-global-indeterminate-spinner.md delete mode 100644 docs/superpowers/plans/2026-07-23-task-center-management.md delete mode 100644 docs/superpowers/plans/2026-07-23-web-url-routing.md delete mode 100644 docs/superpowers/specs/2026-07-10-telegram-task-center-design.md delete mode 100644 docs/superpowers/specs/2026-07-23-global-indeterminate-spinner-design.md delete mode 100644 docs/superpowers/specs/2026-07-23-task-center-management-design.md delete mode 100644 docs/superpowers/specs/2026-07-23-web-url-routing-design.md create mode 100644 frontend/src/components/ui/Dialog.tsx create mode 100644 frontend/src/components/ui/RenameModal.test.ts create mode 100644 frontend/src/services/actionErrorWiring.test.ts create mode 100644 frontend/src/services/apiActionError.test.ts create mode 100644 frontend/src/services/apiActionError.ts create mode 100644 frontend/src/services/dialogAccessibility.test.ts create mode 100644 frontend/src/services/fileQueryController.test.ts create mode 100644 frontend/src/services/fileQueryController.ts create mode 100644 frontend/src/services/fileQueryEffect.test.ts diff --git a/.env.example b/.env.example index 10c1add..606c652 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/.hermes/plans/2026-08-24_000159-tg-vault-bot-enhancement-plan.md b/.hermes/plans/2026-08-24_000159-tg-vault-bot-enhancement-plan.md new file mode 100644 index 0000000..436d841 --- /dev/null +++ b/.hermes/plans/2026-08-24_000159-tg-vault-bot-enhancement-plan.md @@ -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`。 +- `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; + target: StorageTargetSnapshot; +}): Promise +``` + +**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` 与关键命令。 diff --git a/.hermes/plans/2026-08-24_000159-tg-vault-handoff.md b/.hermes/plans/2026-08-24_000159-tg-vault-handoff.md new file mode 100644 index 0000000..7a2e54e --- /dev/null +++ b/.hermes/plans/2026-08-24_000159-tg-vault-handoff.md @@ -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= +``` + +5. 运行协调备份(会停 backend): + +```bash +cd /www/wwwroot/tg-vault +BACKUP_DIR=/安全且空间充足的路径 ./deploy/backup.sh +``` + +6. 校验备份: + +```bash +./deploy/restore-verify.sh /安全路径/ +``` + +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。 +- [ ] 回滚命令、旧镜像和备份路径在变更单中已记录。 diff --git a/backend/dist/index.js b/backend/dist/index.js index abacbb4..13ae7de 100644 --- a/backend/dist/index.js +++ b/backend/dist/index.js @@ -60,60 +60,42 @@ import fs from "fs/promises"; import path from "path"; import dotenv from "dotenv"; import { fileURLToPath } from "url"; -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) { - if (err?.code === "42P01") { - return; - } - console.error("\u274C \u6570\u636E\u5E93\u8FC1\u79FB\u5931\u8D25 (\u6536\u85CF\u5B57\u6BB5):", 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) { - if (err?.code === "42P01") { - return; - } - console.error("\u274C \u6570\u636E\u5E93\u8FC1\u79FB\u5931\u8D25 (\u6587\u4EF6\u5217\u8868\u6027\u80FD\u7D22\u5F15):", err); - throw err; - } +import crypto from "node:crypto"; +async function applyCanonicalSchemaMigration(schemaSql) { + 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] + ); } async function initializeDatabase() { try { const schemaPath = path.join(__dirname, "schema.sql"); const schemaSql = await fs.readFile(schemaPath, "utf-8"); const statements = []; - let current2 = ""; + let current3 = ""; let inDollarQuote = false; for (let i = 0; i < schemaSql.length; i++) { const char = schemaSql[i]; - current2 += char; + current3 += char; if (char === "$" && schemaSql[i + 1] === "$") { inDollarQuote = !inDollarQuote; - current2 += "$"; + current3 += "$"; i++; } else if (char === ";" && !inDollarQuote) { - const stmt = current2.trim(); + const stmt = current3.trim(); if (stmt.length > 1) { const withoutLeadingLineComments = stmt.replace(/^\s*(--[^\n]*\n\s*)+/g, "").trim(); if (withoutLeadingLineComments.length > 0) { statements.push(withoutLeadingLineComments.slice(0, -1)); } } - current2 = ""; + current3 = ""; } } - const lastStmt = current2.trim(); + const lastStmt = current3.trim(); if (lastStmt.length > 0) { const withoutLeadingLineComments = lastStmt.replace(/^\s*(--[^\n]*\n\s*)+/g, "").trim(); if (withoutLeadingLineComments.length > 0) { @@ -130,42 +112,7 @@ async function initializeDatabase() { throw err; } } - 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("\u2705 \u6570\u636E\u5E93\u8868\u7ED3\u6784\u521D\u59CB\u5316\u5B8C\u6210"); } catch (err) { console.error("\u274C \u6570\u636E\u5E93\u521D\u59CB\u5316\u5931\u8D25:", err); @@ -176,7 +123,7 @@ function ensureDatabaseInitialized() { if (!initializationPromise) initializationPromise = initializeDatabase(); return initializationPromise; } -var __filename, __dirname, Pool, pool, initializationPromise, sqlLoggingConfig, query; +var __filename, __dirname, Pool, pool, initializationPromise, CANONICAL_SCHEMA_VERSION, sqlLoggingConfig, query; var init_db = __esm({ "src/db/index.ts"() { "use strict"; @@ -189,6 +136,7 @@ var init_db = __esm({ connectionString: process.env.DATABASE_URL || "postgresql://tgvault:password@localhost:5432/tgvault" }); initializationPromise = null; + CANONICAL_SCHEMA_VERSION = 2026082401; sqlLoggingConfig = parseSqlLoggingConfig(); pool.on("connect", () => { if (process.env.LOG_LEVEL === "debug") console.log(JSON.stringify({ event: "db.connected" })); @@ -213,7 +161,7 @@ var init_db = __esm({ }); // src/utils/secretStore.ts -import crypto from "crypto"; +import crypto2 from "crypto"; import fs2 from "fs"; import path2 from "path"; function getCandidateSecretDirs() { @@ -281,7 +229,7 @@ function getOrCreatePersistentSecret(envName, fileName) { if (fromFile) { return fromFile; } - const generated = crypto.randomBytes(32).toString("hex"); + const generated = crypto2.randomBytes(32).toString("hex"); persistSecretWithFallback(envName, fileName, generated); return generated; } @@ -292,7 +240,7 @@ var init_secretStore = __esm({ }); // src/utils/credentialCrypto.ts -import crypto3 from "crypto"; +import crypto4 from "crypto"; function getCredentialSecret() { const secret = getOrCreatePersistentSecret("STORAGE_CREDENTIALS_SECRET", "storage_credentials_secret"); if (secret.length < 32) { @@ -306,15 +254,15 @@ function getCredentialSecret() { return secret; } function getKey() { - return crypto3.createHash("sha256").update(getCredentialSecret()).digest(); + return crypto4.createHash("sha256").update(getCredentialSecret()).digest(); } function isEncryptedCredential(value) { return typeof value === "string" && value.startsWith(ENCRYPTION_PREFIX); } function encryptCredential(value) { if (!value || isEncryptedCredential(value)) return value; - const iv = crypto3.randomBytes(12); - const cipher = crypto3.createCipheriv("aes-256-gcm", getKey(), iv); + const iv = crypto4.randomBytes(12); + const cipher = crypto4.createCipheriv("aes-256-gcm", getKey(), iv); const ciphertext = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]); const tag = cipher.getAuthTag(); return `${ENCRYPTION_PREFIX}${iv.toString("base64url")}:${tag.toString("base64url")}:${ciphertext.toString("base64url")}`; @@ -325,7 +273,7 @@ function decryptCredential(value) { if (!ivText || !tagText || !cipherText) { throw new Error("Invalid encrypted credential format"); } - const decipher = crypto3.createDecipheriv("aes-256-gcm", getKey(), Buffer.from(ivText, "base64url")); + const decipher = crypto4.createDecipheriv("aes-256-gcm", getKey(), Buffer.from(ivText, "base64url")); decipher.setAuthTag(Buffer.from(tagText, "base64url")); return Buffer.concat([ decipher.update(Buffer.from(cipherText, "base64url")), @@ -385,6 +333,38 @@ var init_credentialCrypto = __esm({ } }); +// src/utils/settings.ts +async function getSetting(key, defaultValue) { + try { + const res = await query("SELECT value FROM system_settings WHERE key = $1", [key]); + if (res.rowCount === 0) { + return defaultValue ?? null; + } + return decryptSettingValue(key, res.rows[0].value); + } catch (e) { + console.error(`\u83B7\u53D6\u8BBE\u7F6E ${key} \u5931\u8D25:`, e); + return defaultValue ?? null; + } +} +async function setSetting(key, value) { + try { + await query( + "INSERT INTO system_settings (key, value) VALUES ($1, $2) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()", + [key, encryptSettingValue(key, value)] + ); + } catch (e) { + console.error(`\u4FDD\u5B58\u8BBE\u7F6E ${key} \u5931\u8D25:`, e); + throw e; + } +} +var init_settings = __esm({ + "src/utils/settings.ts"() { + "use strict"; + init_db(); + init_credentialCrypto(); + } +}); + // src/utils/localPath.ts import fs3 from "fs"; import path3 from "path"; @@ -637,6 +617,20 @@ async function deleteStorageAccountWithClient(client2, accountId) { [accountId] ); if (taskReference.rows.length > 0) throw new StorageAccountConflictError("job"); + const targetStateReference = await client2.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 client2.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 client2.query( `SELECT id FROM transfer_tasks WHERE target_account_id = $1 @@ -2387,6 +2381,387 @@ var init_storage = __esm({ } }); +// src/utils/authSettings.ts +import crypto6 from "crypto"; +function hashSecret(secret) { + const salt = crypto6.randomBytes(16).toString("base64url"); + const derived = crypto6.scryptSync(secret, salt, 64).toString("base64url"); + return `${SCRYPT_PREFIX}:${salt}:${derived}`; +} +function safeEqualText(a, b) { + try { + const left = Buffer.from(a); + const right = Buffer.from(b); + return left.length === right.length && crypto6.timingSafeEqual(left, right); + } catch { + return false; + } +} +function verifySecret(secret, stored) { + if (!stored) return false; + if (stored.startsWith(`${SCRYPT_PREFIX}:`)) { + const [, , salt, expected] = stored.split(":"); + if (!salt || !expected) return false; + const actual = crypto6.scryptSync(secret, salt, 64).toString("base64url"); + return safeEqualText(actual, expected); + } + if (/^[a-f0-9]{64}$/i.test(stored)) { + const actual = crypto6.createHash("sha256").update(secret).digest("hex"); + return safeEqualText(actual, stored.toLowerCase()); + } + return false; +} +async function getStoredWebPasswordHash() { + const stored = await getSetting(WEB_PASSWORD_KEY, ""); + return stored || ""; +} +async function isInitialSetupRequired() { + return !await getStoredWebPasswordHash(); +} +async function verifyWebPassword(password) { + return verifySecret(password, await getStoredWebPasswordHash()); +} +async function verifyTelegramPin(pin) { + const stored = await getSetting(TELEGRAM_PIN_KEY, ""); + if (stored) return verifySecret(pin, stored); + return verifySecret(pin, await getStoredWebPasswordHash()); +} +function validateWebPassword(password) { + if (typeof password !== "string" || password.length < 8) { + return "\u7F51\u9875\u7BA1\u7406\u5458\u5BC6\u7801\u81F3\u5C11\u9700\u8981 8 \u4F4D"; + } + if (password.length > 256) { + return "\u7F51\u9875\u7BA1\u7406\u5458\u5BC6\u7801\u8FC7\u957F"; + } + return null; +} +function validateTelegramPin(pin) { + if (typeof pin !== "string" || !/^\d{4}$/.test(pin)) { + return "Telegram Bot \u5BC6\u7801\u5FC5\u987B\u662F 4 \u4F4D\u6570\u5B57"; + } + return null; +} +async function createInitialAdminCredentialsWithClient(client2, webPassword, telegramPin) { + const webError = validateWebPassword(webPassword); + if (webError) throw new Error(webError); + const pinError = validateTelegramPin(telegramPin); + if (pinError) throw new Error(pinError); + if (webPassword === telegramPin) { + throw new Error("\u7F51\u9875\u5BC6\u7801\u4E0D\u80FD\u4E0E Telegram Bot 4 \u4F4D\u5BC6\u7801\u76F8\u540C"); + } + await client2.query(`SELECT pg_advisory_xact_lock(hashtext('tg-vault:initial-admin-setup'))`); + const existing = await client2.query("SELECT value FROM system_settings WHERE key = $1 FOR UPDATE", [WEB_PASSWORD_KEY]); + if ((existing.rowCount || 0) > 0 && existing.rows[0]?.value) { + throw new Error("\u7BA1\u7406\u5458\u5BC6\u7801\u5DF2\u521B\u5EFA\uFF0C\u4E0D\u80FD\u91CD\u590D\u521D\u59CB\u5316"); + } + await client2.query( + "INSERT INTO system_settings (key, value) VALUES ($1, $2)", + [WEB_PASSWORD_KEY, hashSecret(webPassword)] + ); + await client2.query( + "INSERT INTO system_settings (key, value) VALUES ($1, $2)", + [TELEGRAM_PIN_KEY, hashSecret(telegramPin)] + ); +} +async function createInitialAdminCredentials(webPassword, telegramPin) { + const client2 = await pool.connect(); + try { + await client2.query("BEGIN"); + await createInitialAdminCredentialsWithClient(client2, webPassword, telegramPin); + await client2.query("COMMIT"); + } catch (error) { + await client2.query("ROLLBACK").catch(() => void 0); + throw error; + } finally { + client2.release(); + } +} +async function changeWebPasswordAndRevokeSessions(currentPassword, newPassword) { + const validationError = validateWebPassword(newPassword); + if (validationError) throw new Error(validationError); + if (currentPassword === newPassword) throw new Error("\u65B0\u5BC6\u7801\u4E0D\u80FD\u4E0E\u5F53\u524D\u5BC6\u7801\u76F8\u540C"); + const client2 = await pool.connect(); + try { + await client2.query("BEGIN"); + await client2.query(`SELECT pg_advisory_xact_lock(hashtext('tg-vault:web-password-change'))`); + const current3 = await client2.query("SELECT value FROM system_settings WHERE key = $1 FOR UPDATE", [WEB_PASSWORD_KEY]); + const storedHash = String(current3.rows[0]?.value || ""); + if (!verifySecret(currentPassword, storedHash)) throw new Error("\u5F53\u524D\u5BC6\u7801\u4E0D\u6B63\u786E"); + await client2.query( + `UPDATE system_settings SET value = $2, updated_at = NOW() WHERE key = $1`, + [WEB_PASSWORD_KEY, hashSecret(newPassword)] + ); + await client2.query("DELETE FROM web_sessions"); + await client2.query("COMMIT"); + } catch (error) { + await client2.query("ROLLBACK").catch(() => void 0); + throw error; + } finally { + client2.release(); + } +} +function parseTelegramAllowedUserIds(value) { + if (!value) return []; + return [...new Set(String(value).split(/[\s,]+/).map((item) => Number(item.trim())).filter((item) => Number.isSafeInteger(item) && item > 0))].sort((a, b) => a - b); +} +function shouldAutoAllowFirstTelegramUser(allowedUsers, authenticatedUserCount) { + return allowedUsers.length === 0 && authenticatedUserCount === 0; +} +async function getStoredTelegramAllowedUsers() { + const stored = await getSetting(TELEGRAM_ALLOWED_USERS_KEY, ""); + return parseTelegramAllowedUserIds(stored || ""); +} +async function getConfiguredTelegramAllowedUsers() { + const envUsers = parseTelegramAllowedUserIds(process.env.TELEGRAM_ALLOWED_USER_IDS || ""); + if (envUsers.length > 0) return envUsers; + return getStoredTelegramAllowedUsers(); +} +async function countAuthenticatedTelegramUsers() { + const result = await pool.query("SELECT COUNT(*)::int AS count FROM telegram_auth"); + return Number(result.rows[0]?.count || 0); +} +async function setTelegramAllowedUsersAndReconcile(userIds) { + const previous = await getConfiguredTelegramAllowedUsers(); + const users = parseTelegramAllowedUserIds(userIds.join(",")); + const client2 = await pool.connect(); + try { + await client2.query("BEGIN"); + await client2.query(`SELECT pg_advisory_xact_lock(hashtext('tg-vault:telegram-allowlist'))`); + await client2.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: reconcileTelegramAllowedUsers2 } = await Promise.resolve().then(() => (init_telegramState(), telegramState_exports)); + const reconciliation = await reconcileTelegramAllowedUsers2(users, { query: client2.query.bind(client2) }); + await client2.query("COMMIT"); + const previousSet = new Set(previous); + return { + ...reconciliation, + added: users.filter((id) => !previousSet.has(id)) + }; + } catch (error) { + await client2.query("ROLLBACK").catch(() => void 0); + throw error; + } finally { + client2.release(); + } +} +async function setTelegramAllowedUsers(userIds) { + return (await setTelegramAllowedUsersAndReconcile(userIds)).allowed; +} +async function addTelegramAllowedUser(userId) { + const users = await getStoredTelegramAllowedUsers(); + if (!users.includes(userId)) users.push(userId); + return setTelegramAllowedUsers(users); +} +var WEB_PASSWORD_KEY, TELEGRAM_PIN_KEY, TELEGRAM_ALLOWED_USERS_KEY, SCRYPT_PREFIX; +var init_authSettings = __esm({ + "src/utils/authSettings.ts"() { + "use strict"; + init_db(); + init_settings(); + WEB_PASSWORD_KEY = "admin_password_hash"; + TELEGRAM_PIN_KEY = "telegram_pin_hash"; + TELEGRAM_ALLOWED_USERS_KEY = "telegram_allowed_user_ids"; + SCRYPT_PREFIX = "scrypt:v1"; + } +}); + +// src/services/scopedInteractionMap.ts +var ScopedInteractionMap; +var init_scopedInteractionMap = __esm({ + "src/services/scopedInteractionMap.ts"() { + "use strict"; + ScopedInteractionMap = class { + entries = /* @__PURE__ */ new Map(); + ttlMs; + maxEntries; + now; + constructor(options = {}) { + this.ttlMs = Math.max(1, options.ttlMs ?? 15 * 6e4); + this.maxEntries = Math.max(1, options.maxEntries ?? 1e3); + this.now = options.now ?? Date.now; + } + evictForCapacity() { + this.cleanup(); + while (this.entries.size >= this.maxEntries) { + let oldest; + let oldestTime = Number.POSITIVE_INFINITY; + for (const [key, entry] of this.entries) { + if (entry.createdAt < oldestTime) { + oldest = key; + oldestTime = entry.createdAt; + } + } + if (oldest === void 0) break; + this.entries.delete(oldest); + } + } + set(key, value) { + 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) { + const entry = this.entries.get(key); + if (!entry) return void 0; + if (entry.expiresAt <= this.now()) { + this.entries.delete(key); + return void 0; + } + return entry.value; + } + has(key) { + return this.get(key) !== void 0; + } + touch(key) { + const value = this.get(key); + if (value === void 0) return false; + const entry = this.entries.get(key); + entry.expiresAt = this.now() + this.ttlMs; + return true; + } + delete(key) { + return this.entries.delete(key); + } + keys() { + this.cleanup(); + return this.entries.keys(); + } + values() { + this.cleanup(); + return Array.from(this.entries.values(), (entry) => entry.value).values(); + } + entriesIterator() { + this.cleanup(); + return Array.from(this.entries, ([key, entry]) => [key, entry.value]).values(); + } + cleanup() { + 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() { + this.entries.clear(); + } + get size() { + this.cleanup(); + return this.entries.size; + } + }; + } +}); + +// src/services/telegramState.ts +var telegramState_exports = {}; +__export(telegramState_exports, { + TelegramUserState: () => TelegramUserState, + authenticatedUsers: () => authenticatedUsers, + isAuthenticated: () => isAuthenticated, + isAuthenticatedAsync: () => isAuthenticatedAsync, + loadAuthenticatedUsers: () => loadAuthenticatedUsers, + passwordInputState: () => passwordInputState, + persistAuthenticatedUser: () => persistAuthenticatedUser, + reconcileTelegramAllowedUsers: () => reconcileTelegramAllowedUsers, + revokeAuthenticatedUser: () => revokeAuthenticatedUser, + userStates: () => userStates +}); +async function revokeAuthenticatedUser(userId) { + authenticatedUsers.delete(userId); + try { + await query("DELETE FROM telegram_auth WHERE user_id = $1", [userId]); + } catch (error) { + console.error("\u{1F916} \u64A4\u9500 Telegram \u6388\u6743\u7528\u6237\u5931\u8D25:", error); + } +} +async function reconcileTelegramAllowedUsers(userIds, dependencies = {}) { + const allowed = [...new Set(userIds.filter((id) => Number.isSafeInteger(id) && id > 0))].sort((a, b) => a - b); + const runQuery = dependencies.query || query; + 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 /* @__PURE__ */ 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 }; +} +async function loadAuthenticatedUsers() { + try { + const allowedUsers = await getConfiguredTelegramAllowedUsers(); + const result = await query("SELECT user_id, authenticated_at FROM telegram_auth"); + for (const row of result.rows) { + const userId = Number(row.user_id); + if (allowedUsers.length > 0 && !allowedUsers.includes(userId)) { + await revokeAuthenticatedUser(userId); + continue; + } + authenticatedUsers.set(userId, { authenticatedAt: new Date(row.authenticated_at) }); + } + console.log(`\u{1F916} \u5DF2\u4ECE\u6570\u636E\u5E93\u8F7D\u5165 ${authenticatedUsers.size} \u4E2A\u6388\u6743\u7528\u6237`); + } catch (error) { + console.error("\u{1F916} \u8F7D\u5165\u5DF2\u9A8C\u8BC1\u7528\u6237\u5931\u8D25:", error); + } +} +async function persistAuthenticatedUser(userId) { + try { + await query("INSERT INTO telegram_auth (user_id) VALUES ($1) ON CONFLICT (user_id) DO NOTHING", [userId]); + authenticatedUsers.set(userId, { authenticatedAt: /* @__PURE__ */ new Date() }); + console.log(`\u{1F916} \u7528\u6237 ${userId} \u5DF2\u6301\u4E45\u5316\u5230\u6570\u636E\u5E93`); + } catch (error) { + console.error("\u{1F916} \u6301\u4E45\u5316\u7528\u6237\u5931\u8D25:", error); + } +} +function isAuthenticated(userId) { + return authenticatedUsers.has(userId); +} +async function isAuthenticatedAsync(userId) { + const allowedUsers = await getConfiguredTelegramAllowedUsers(); + if (allowedUsers.length > 0 && !allowedUsers.includes(userId)) { + if (authenticatedUsers.has(userId)) await revokeAuthenticatedUser(userId); + return false; + } + return authenticatedUsers.has(userId); +} +var TelegramUserState, INTERACTION_TTL_MS, INTERACTION_MAX_ENTRIES, userStates, authenticatedUsers, passwordInputState; +var init_telegramState = __esm({ + "src/services/telegramState.ts"() { + "use strict"; + init_db(); + init_authSettings(); + init_scopedInteractionMap(); + TelegramUserState = /* @__PURE__ */ ((TelegramUserState2) => { + TelegramUserState2["IDLE"] = "IDLE"; + TelegramUserState2["WAITING_2FA_LOGIN"] = "WAITING_2FA_LOGIN"; + TelegramUserState2["WAITING_2FA_SETUP"] = "WAITING_2FA_SETUP"; + return TelegramUserState2; + })(TelegramUserState || {}); + INTERACTION_TTL_MS = Math.max(6e4, Number.parseInt(process.env.TELEGRAM_INTERACTION_TTL_MS || "900000", 10) || 9e5); + INTERACTION_MAX_ENTRIES = Math.max(10, Number.parseInt(process.env.TELEGRAM_INTERACTION_MAX_ENTRIES || "1000", 10) || 1e3); + userStates = new ScopedInteractionMap({ ttlMs: INTERACTION_TTL_MS, maxEntries: INTERACTION_MAX_ENTRIES }); + authenticatedUsers = /* @__PURE__ */ new Map(); + passwordInputState = new ScopedInteractionMap({ ttlMs: INTERACTION_TTL_MS, maxEntries: INTERACTION_MAX_ENTRIES }); + } +}); + // src/index.ts import express from "express"; import cors from "cors"; @@ -2433,7 +2808,7 @@ import fs12 from "fs"; import path17 from "path"; // src/middleware/signedUrl.ts -import crypto16 from "crypto"; +import crypto19 from "crypto"; // src/utils/config.ts init_secretStore(); @@ -2459,13 +2834,13 @@ import { Router } from "express"; init_db(); // src/services/webSessionStore.ts -import crypto2 from "node:crypto"; +import crypto3 from "node:crypto"; function hashToken(token) { - return crypto2.createHash("sha256").update(token).digest("hex"); + return crypto3.createHash("sha256").update(token).digest("hex"); } function createWebSessionStore(repository) { return { - async issue(expiresAt, randomBytes2 = crypto2.randomBytes) { + async issue(expiresAt, randomBytes2 = crypto3.randomBytes) { const token = randomBytes2(32).toString("hex"); await repository.insert(hashToken(token), expiresAt); return { token, expiresAt }; @@ -2489,44 +2864,16 @@ function createWebSessionStore(repository) { import { rateLimit } from "express-rate-limit"; // src/utils/security.ts +init_settings(); import { TOTP, NobleCryptoPlugin, ScureBase32Plugin } from "otplib"; import QRCode from "qrcode"; -import crypto4 from "crypto"; - -// src/utils/settings.ts -init_db(); -init_credentialCrypto(); -async function getSetting(key, defaultValue) { - try { - const res = await query("SELECT value FROM system_settings WHERE key = $1", [key]); - if (res.rowCount === 0) { - return defaultValue ?? null; - } - return decryptSettingValue(key, res.rows[0].value); - } catch (e) { - console.error(`\u83B7\u53D6\u8BBE\u7F6E ${key} \u5931\u8D25:`, e); - return defaultValue ?? null; - } -} -async function setSetting(key, value) { - try { - await query( - "INSERT INTO system_settings (key, value) VALUES ($1, $2) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()", - [key, encryptSettingValue(key, value)] - ); - } catch (e) { - console.error(`\u4FDD\u5B58\u8BBE\u7F6E ${key} \u5931\u8D25:`, e); - throw e; - } -} - -// src/utils/security.ts +import crypto5 from "crypto"; function totpEncryptionKey() { - return crypto4.createHash("sha256").update(SESSION_SECRET).digest(); + return crypto5.createHash("sha256").update(SESSION_SECRET).digest(); } function encryptSecret(plain) { - const iv = crypto4.randomBytes(12); - const cipher = crypto4.createCipheriv("aes-256-gcm", totpEncryptionKey(), iv); + const iv = crypto5.randomBytes(12); + const cipher = crypto5.createCipheriv("aes-256-gcm", totpEncryptionKey(), iv); const ciphertext = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]); const tag = cipher.getAuthTag(); return `enc:v1:${iv.toString("base64url")}:${tag.toString("base64url")}:${ciphertext.toString("base64url")}`; @@ -2534,7 +2881,7 @@ function encryptSecret(plain) { function decryptSecret(value) { if (!value.startsWith("enc:v1:")) return value; const [, , ivText, tagText, cipherText] = value.split(":"); - const decipher = crypto4.createDecipheriv("aes-256-gcm", totpEncryptionKey(), Buffer.from(ivText, "base64url")); + const decipher = crypto5.createDecipheriv("aes-256-gcm", totpEncryptionKey(), Buffer.from(ivText, "base64url")); decipher.setAuthTag(Buffer.from(tagText, "base64url")); return Buffer.concat([decipher.update(Buffer.from(cipherText, "base64url")), decipher.final()]).toString("utf8"); } @@ -2622,218 +2969,14 @@ import axios2 from "axios"; // src/services/telegramBot.ts init_storage(); +init_telegramState(); import { TelegramClient as TelegramClient5, Api as Api7 } from "telegram"; import { StringSession as StringSession2 } from "telegram/sessions/index.js"; import { NewMessage } from "telegram/events/index.js"; import { Raw } from "telegram/events/index.js"; import fs11 from "fs"; import path16 from "path"; -import crypto15 from "crypto"; - -// src/services/telegramState.ts -init_db(); - -// src/utils/authSettings.ts -init_db(); -import crypto5 from "crypto"; -var WEB_PASSWORD_KEY = "admin_password_hash"; -var TELEGRAM_PIN_KEY = "telegram_pin_hash"; -var TELEGRAM_ALLOWED_USERS_KEY = "telegram_allowed_user_ids"; -var SCRYPT_PREFIX = "scrypt:v1"; -function hashSecret(secret) { - const salt = crypto5.randomBytes(16).toString("base64url"); - const derived = crypto5.scryptSync(secret, salt, 64).toString("base64url"); - return `${SCRYPT_PREFIX}:${salt}:${derived}`; -} -function safeEqualText(a, b) { - try { - const left = Buffer.from(a); - const right = Buffer.from(b); - return left.length === right.length && crypto5.timingSafeEqual(left, right); - } catch { - return false; - } -} -function verifySecret(secret, stored) { - if (!stored) return false; - if (stored.startsWith(`${SCRYPT_PREFIX}:`)) { - const [, , salt, expected] = stored.split(":"); - if (!salt || !expected) return false; - const actual = crypto5.scryptSync(secret, salt, 64).toString("base64url"); - return safeEqualText(actual, expected); - } - if (/^[a-f0-9]{64}$/i.test(stored)) { - const actual = crypto5.createHash("sha256").update(secret).digest("hex"); - return safeEqualText(actual, stored.toLowerCase()); - } - return false; -} -async function getStoredWebPasswordHash() { - const stored = await getSetting(WEB_PASSWORD_KEY, ""); - return stored || ""; -} -async function isInitialSetupRequired() { - return !await getStoredWebPasswordHash(); -} -async function verifyWebPassword(password) { - return verifySecret(password, await getStoredWebPasswordHash()); -} -async function verifyTelegramPin(pin) { - const stored = await getSetting(TELEGRAM_PIN_KEY, ""); - if (stored) return verifySecret(pin, stored); - return verifySecret(pin, await getStoredWebPasswordHash()); -} -function validateWebPassword(password) { - if (typeof password !== "string" || password.length < 8) { - return "\u7F51\u9875\u7BA1\u7406\u5458\u5BC6\u7801\u81F3\u5C11\u9700\u8981 8 \u4F4D"; - } - if (password.length > 256) { - return "\u7F51\u9875\u7BA1\u7406\u5458\u5BC6\u7801\u8FC7\u957F"; - } - return null; -} -function validateTelegramPin(pin) { - if (typeof pin !== "string" || !/^\d{4}$/.test(pin)) { - return "Telegram Bot \u5BC6\u7801\u5FC5\u987B\u662F 4 \u4F4D\u6570\u5B57"; - } - return null; -} -async function createInitialAdminCredentialsWithClient(client2, webPassword, telegramPin) { - const webError = validateWebPassword(webPassword); - if (webError) throw new Error(webError); - const pinError = validateTelegramPin(telegramPin); - if (pinError) throw new Error(pinError); - if (webPassword === telegramPin) { - throw new Error("\u7F51\u9875\u5BC6\u7801\u4E0D\u80FD\u4E0E Telegram Bot 4 \u4F4D\u5BC6\u7801\u76F8\u540C"); - } - await client2.query(`SELECT pg_advisory_xact_lock(hashtext('tg-vault:initial-admin-setup'))`); - const existing = await client2.query("SELECT value FROM system_settings WHERE key = $1 FOR UPDATE", [WEB_PASSWORD_KEY]); - if ((existing.rowCount || 0) > 0 && existing.rows[0]?.value) { - throw new Error("\u7BA1\u7406\u5458\u5BC6\u7801\u5DF2\u521B\u5EFA\uFF0C\u4E0D\u80FD\u91CD\u590D\u521D\u59CB\u5316"); - } - await client2.query( - "INSERT INTO system_settings (key, value) VALUES ($1, $2)", - [WEB_PASSWORD_KEY, hashSecret(webPassword)] - ); - await client2.query( - "INSERT INTO system_settings (key, value) VALUES ($1, $2)", - [TELEGRAM_PIN_KEY, hashSecret(telegramPin)] - ); -} -async function createInitialAdminCredentials(webPassword, telegramPin) { - const client2 = await pool.connect(); - try { - await client2.query("BEGIN"); - await createInitialAdminCredentialsWithClient(client2, webPassword, telegramPin); - await client2.query("COMMIT"); - } catch (error) { - await client2.query("ROLLBACK").catch(() => void 0); - throw error; - } finally { - client2.release(); - } -} -async function changeWebPasswordAndRevokeSessions(currentPassword, newPassword) { - const validationError = validateWebPassword(newPassword); - if (validationError) throw new Error(validationError); - if (currentPassword === newPassword) throw new Error("\u65B0\u5BC6\u7801\u4E0D\u80FD\u4E0E\u5F53\u524D\u5BC6\u7801\u76F8\u540C"); - const client2 = await pool.connect(); - try { - await client2.query("BEGIN"); - await client2.query(`SELECT pg_advisory_xact_lock(hashtext('tg-vault:web-password-change'))`); - const current2 = await client2.query("SELECT value FROM system_settings WHERE key = $1 FOR UPDATE", [WEB_PASSWORD_KEY]); - const storedHash = String(current2.rows[0]?.value || ""); - if (!verifySecret(currentPassword, storedHash)) throw new Error("\u5F53\u524D\u5BC6\u7801\u4E0D\u6B63\u786E"); - await client2.query( - `UPDATE system_settings SET value = $2, updated_at = NOW() WHERE key = $1`, - [WEB_PASSWORD_KEY, hashSecret(newPassword)] - ); - await client2.query("DELETE FROM web_sessions"); - await client2.query("COMMIT"); - } catch (error) { - await client2.query("ROLLBACK").catch(() => void 0); - throw error; - } finally { - client2.release(); - } -} -function parseTelegramAllowedUserIds(value) { - if (!value) return []; - return [...new Set(String(value).split(/[\s,]+/).map((item) => Number(item.trim())).filter((item) => Number.isSafeInteger(item) && item > 0))].sort((a, b) => a - b); -} -function shouldAutoAllowFirstTelegramUser(allowedUsers, authenticatedUserCount) { - return allowedUsers.length === 0 && authenticatedUserCount === 0; -} -async function getStoredTelegramAllowedUsers() { - const stored = await getSetting(TELEGRAM_ALLOWED_USERS_KEY, ""); - return parseTelegramAllowedUserIds(stored || ""); -} -async function getConfiguredTelegramAllowedUsers() { - const envUsers = parseTelegramAllowedUserIds(process.env.TELEGRAM_ALLOWED_USER_IDS || ""); - if (envUsers.length > 0) return envUsers; - return getStoredTelegramAllowedUsers(); -} -async function countAuthenticatedTelegramUsers() { - const result = await pool.query("SELECT COUNT(*)::int AS count FROM telegram_auth"); - return Number(result.rows[0]?.count || 0); -} -async function setTelegramAllowedUsers(userIds) { - const users = parseTelegramAllowedUserIds(userIds.join(",")); - await setSetting(TELEGRAM_ALLOWED_USERS_KEY, users.join(",")); - return users; -} -async function addTelegramAllowedUser(userId) { - const users = await getStoredTelegramAllowedUsers(); - if (!users.includes(userId)) users.push(userId); - return setTelegramAllowedUsers(users); -} - -// src/services/telegramState.ts -var userStates = /* @__PURE__ */ new Map(); -var authenticatedUsers = /* @__PURE__ */ new Map(); -var passwordInputState = /* @__PURE__ */ new Map(); -async function revokeAuthenticatedUser(userId) { - authenticatedUsers.delete(userId); - try { - await query("DELETE FROM telegram_auth WHERE user_id = $1", [userId]); - } catch (error) { - console.error("\u{1F916} \u64A4\u9500 Telegram \u6388\u6743\u7528\u6237\u5931\u8D25:", error); - } -} -async function loadAuthenticatedUsers() { - try { - const allowedUsers = await getConfiguredTelegramAllowedUsers(); - const result = await query("SELECT user_id, authenticated_at FROM telegram_auth"); - for (const row of result.rows) { - const userId = Number(row.user_id); - if (allowedUsers.length > 0 && !allowedUsers.includes(userId)) { - await revokeAuthenticatedUser(userId); - continue; - } - authenticatedUsers.set(userId, { authenticatedAt: new Date(row.authenticated_at) }); - } - console.log(`\u{1F916} \u5DF2\u4ECE\u6570\u636E\u5E93\u8F7D\u5165 ${authenticatedUsers.size} \u4E2A\u6388\u6743\u7528\u6237`); - } catch (error) { - console.error("\u{1F916} \u8F7D\u5165\u5DF2\u9A8C\u8BC1\u7528\u6237\u5931\u8D25:", error); - } -} -async function persistAuthenticatedUser(userId) { - try { - await query("INSERT INTO telegram_auth (user_id) VALUES ($1) ON CONFLICT (user_id) DO NOTHING", [userId]); - authenticatedUsers.set(userId, { authenticatedAt: /* @__PURE__ */ new Date() }); - console.log(`\u{1F916} \u7528\u6237 ${userId} \u5DF2\u6301\u4E45\u5316\u5230\u6570\u636E\u5E93`); - } catch (error) { - console.error("\u{1F916} \u6301\u4E45\u5316\u7528\u6237\u5931\u8D25:", error); - } -} -async function isAuthenticatedAsync(userId) { - const allowedUsers = await getConfiguredTelegramAllowedUsers(); - if (allowedUsers.length > 0 && !allowedUsers.includes(userId)) { - if (authenticatedUsers.has(userId)) await revokeAuthenticatedUser(userId); - return false; - } - return authenticatedUsers.has(userId); -} +import crypto18 from "crypto"; // src/services/telegramCommands.ts init_db(); @@ -2963,17 +3106,20 @@ import { Api } from "telegram"; // src/utils/telegramCommandRegistry.ts var BOT_COMMANDS = [ - { command: "start", description: "\u5F00\u59CB\u4F7F\u7528 / \u9A8C\u8BC1\u8EAB\u4EFD", helpDescription: "\u8EAB\u4EFD\u8BA4\u8BC1 / \u5F00\u59CB\u4F7F\u7528", category: "main", menu: true, help: true }, + { command: "start", description: "\u5F00\u59CB\u4F7F\u7528 / \u9A8C\u8BC1\u8EAB\u4EFD", helpDescription: "\u8EAB\u4EFD\u8BA4\u8BC1 / \u5F00\u59CB\u4F7F\u7528", category: "main", menu: true, help: true, requiresAuth: false }, { command: "tasks", description: "\u67E5\u770B\u5B9E\u65F6\u4EFB\u52A1", helpDescription: "\u67E5\u770B\u5B9E\u65F6\u4F20\u8F93\u4EFB\u52A1\u961F\u5217", category: "main", aliases: ["task"], menu: true, help: true }, { command: "storage", description: "\u5B58\u50A8\u72B6\u6001 / \u5220\u9664\u672C\u5730\u5B9E\u4F53\u6587\u4EF6", helpDescription: "\u67E5\u770B\u5B58\u50A8\u72B6\u6001\uFF1B\u53EF\u786E\u8BA4\u5220\u9664\u672C\u5730\u5B9E\u4F53\u6587\u4EF6", category: "main", menu: true, help: true }, { command: "path_rules", description: "\u4FDD\u5B58\u4F4D\u7F6E / \u81EA\u5B9A\u4E49\u76EE\u5F55", helpDescription: "\u6253\u5F00\u4FDD\u5B58\u4F4D\u7F6E\u4E0E\u81EA\u5B9A\u4E49\u76EE\u5F55\u9762\u677F", category: "main", aliases: ["path", "save_rules"], menu: true, help: true }, { command: "tg_download", description: "\u6309\u65E5\u671F / \u6807\u7B7E\u4E0B\u8F7D\u9891\u9053\u6587\u4EF6", helpDescription: "\u6253\u5F00\u6309\u65E5\u671F\u6216\u6807\u7B7E\u4E0B\u8F7D\u5411\u5BFC", category: "main", aliases: ["tg_dl"], menu: true, help: true }, { command: "list", description: "\u67E5\u770B\u6700\u8FD1\u6587\u4EF6", helpDescription: "\u67E5\u770B\u6700\u8FD1\u6587\u4EF6\u548C\u53EF\u590D\u5236\u7684\u6587\u4EF6 ID", category: "files", usage: "[\u6570\u91CF] [\u9875\u7801]", menu: true, help: true }, + { command: "find", description: "\u641C\u7D22\u548C\u64CD\u4F5C\u6587\u4EF6", helpDescription: "\u6309\u540D\u79F0\u3001\u7C7B\u578B\u3001\u76EE\u5F55\u3001\u65E5\u671F\u6216\u6536\u85CF\u641C\u7D22\u6587\u4EF6\u5E76\u6253\u5F00\u64CD\u4F5C\u5361", category: "files", usage: "[\u5173\u952E\u8BCD] [type:image|video|audio|document] [folder:\u76EE\u5F55] [after:YYYY-MM-DD] [before:YYYY-MM-DD] [fav]", menu: true, help: true }, { command: "tg_sub", description: "\u7BA1\u7406\u9891\u9053\u81EA\u52A8\u540C\u6B65", helpDescription: "\u6253\u5F00\u9891\u9053\u8BA2\u9605\u7BA1\u7406\u5411\u5BFC", category: "channels", aliases: ["tg_subscribe"], menu: true, help: true }, { command: "storage_switch", description: "\u5207\u6362\u7CFB\u7EDF\u9ED8\u8BA4\u5B58\u50A8", helpDescription: "\u5207\u6362\u6240\u6709\u65B0\u4EFB\u52A1\u4F7F\u7528\u7684\u7CFB\u7EDF\u9ED8\u8BA4\u5B58\u50A8", category: "settings", aliases: ["switch_storage", "storage_source"], menu: true, help: true }, + { command: "target", description: "\u8BBE\u7F6E\u5F53\u524D\u804A\u5929\u5B58\u50A8\u76EE\u6807", helpDescription: "\u8BBE\u7F6E\u4E0B\u4E00\u6B21\u6216\u5F53\u524D\u804A\u5929\u6301\u7EED\u4F7F\u7528\u7684\u5B58\u50A8\u8D26\u6237\uFF1B\u4E0D\u4FEE\u6539\u7CFB\u7EDF\u9ED8\u8BA4", category: "files", usage: "[once|session|clear] [local|\u8D26\u6237ID]", menu: true, help: true }, { command: "ytdlp", description: "\u4E0B\u8F7D\u89C6\u9891\u94FE\u63A5", helpDescription: "\u89E3\u6790\u5E76\u4E0B\u8F7D\u4E00\u4E2A\u89C6\u9891\u94FE\u63A5", category: "files", usage: "", menu: true, help: true }, - { command: "help", description: "\u67E5\u770B\u5B8C\u6574\u5E2E\u52A9", helpDescription: "\u663E\u793A\u6B64\u5E2E\u52A9", category: "main", menu: true, help: true }, + { command: "help", description: "\u67E5\u770B\u5B8C\u6574\u5E2E\u52A9", helpDescription: "\u663E\u793A\u6B64\u5E2E\u52A9", category: "main", menu: true, help: true, requiresAuth: false }, { command: "setup_2fa", description: "\u914D\u7F6E\u53CC\u91CD\u9A8C\u8BC1", helpDescription: "\u914D\u7F6E\u53CC\u91CD\u9A8C\u8BC1 (TOTP)", category: "security", aliases: ["setup-2fa"], menu: false, help: true }, + { command: "logout", description: "\u64A4\u9500\u672C\u8BBE\u5907 Bot \u8BA4\u8BC1", helpDescription: "\u7ACB\u5373\u9000\u51FA\u5E76\u64A4\u9500\u5F53\u524D Telegram \u7528\u6237\u7684 Bot \u8BA4\u8BC1", category: "security", menu: false, help: true }, { command: "p", description: "\u8BBE\u7F6E\u4E0B\u4E00\u6B21\u4FDD\u5B58\u76EE\u5F55", helpDescription: "\u4E0B\u4E00\u6B21\u4E0B\u8F7D\u4FDD\u5B58\u5230\u6307\u5B9A\u76EE\u5F55", category: "files", usage: "<\u76EE\u5F55>", help: true }, { command: "ps", description: "\u8BBE\u7F6E\u672C\u4F1A\u8BDD\u4FDD\u5B58\u76EE\u5F55", helpDescription: "\u672C\u4F1A\u8BDD\u6301\u7EED\u4FDD\u5B58\u5230\u6307\u5B9A\u76EE\u5F55", category: "files", usage: "<\u76EE\u5F55>", help: true }, { command: "pc", description: "\u6E05\u9664\u81EA\u5B9A\u4E49\u4FDD\u5B58\u76EE\u5F55", helpDescription: "\u6E05\u9664\u4E0B\u4E00\u6B21\u548C\u672C\u4F1A\u8BDD\u81EA\u5B9A\u4E49\u76EE\u5F55", category: "files", help: true }, @@ -2984,7 +3130,9 @@ var BOT_COMMANDS = [ { command: "tg_retry", description: "\u91CD\u8BD5\u5931\u8D25\u4EFB\u52A1", helpDescription: "\u91CD\u8BD5\u6700\u8FD1\u5931\u8D25\u7684 Telegram \u4E0B\u8F7D\u4EFB\u52A1", category: "channels", usage: "[\u6570\u91CF] [\u4EFB\u52A1 ID]", help: true }, { command: "stop_tasks", description: "\u505C\u6B62\u5F53\u524D\u804A\u5929\u4EFB\u52A1", helpDescription: "\u517C\u5BB9\u5165\u53E3\uFF1A\u9884\u89C8\u5E76\u505C\u6B62\u5F53\u524D\u804A\u5929\u4E0B\u8F7D\u4EFB\u52A1", category: "settings", aliases: ["stop", "cancel_tasks"], help: true }, { command: "download_workers", description: "\u5355\u6587\u4EF6\u5206\u7247\u5E76\u53D1", helpDescription: "\u8BBE\u7F6E\u5355\u6587\u4EF6\u5206\u7247\u4E0B\u8F7D\u5E76\u53D1", category: "settings", aliases: ["workers"], help: true }, - { command: "file_concurrency", description: "\u540C\u65F6\u4E0B\u8F7D\u6587\u4EF6\u6570", helpDescription: "\u8BBE\u7F6E\u540C\u65F6\u4E0B\u8F7D\u6587\u4EF6\u6570", category: "settings", aliases: ["file_workers", "download_files"], help: true }, + { command: "file_concurrency", description: "\u6587\u4EF6\u7EA7\u4E0B\u8F7D\u5E76\u53D1", helpDescription: "\u8BBE\u7F6E 1 / 2 / 3 / 4 \u4E2A\u6587\u4EF6\u5E76\u884C\uFF0C\u907F\u514D\u628A\u5206\u7247\u5E76\u53D1\u548C\u6587\u4EF6\u7EA7\u5E76\u53D1\u6DF7\u6DC6", category: "settings", aliases: ["file_workers", "download_files"], menu: false, help: true }, + { command: "status", description: "\u7CFB\u7EDF\u8BCA\u65AD\u72B6\u6001", helpDescription: "\u67E5\u770B Bot\u3001\u8D26\u53F7\u4E0B\u8F7D\u5668\u3001\u5B58\u50A8\u3001\u78C1\u76D8\u3001\u961F\u5217\u3001\u8BA2\u9605\u548C\u5BF9\u8D26\u72B6\u6001", category: "settings", menu: true, help: true }, + { command: "notifications", description: "\u901A\u77E5\u504F\u597D", helpDescription: "\u8BBE\u7F6E\u6210\u529F/\u5931\u8D25/\u8BA2\u9605\u6458\u8981\u3001\u65F6\u533A\u548C\u5B89\u9759\u65F6\u6BB5\uFF1B\u5B89\u5168\u544A\u8B66\u59CB\u7EC8\u5373\u65F6", category: "settings", usage: "[timezone|quiet|success|failure|subscription] [\u503C]", menu: true, help: true }, { command: "duplicate_mode", description: "\u91CD\u590D\u6587\u4EF6\u5904\u7406", helpDescription: "\u8BBE\u7F6E\u91CD\u590D\u6587\u4EF6\u5904\u7406\u7B56\u7565", category: "settings", aliases: ["duplicate", "dup"], help: true }, { command: "cleanup_settings", description: "\u81EA\u52A8\u6E05\u7406\u672A\u7D22\u5F15\u4E34\u65F6\u6587\u4EF6", helpDescription: "\u8BBE\u7F6E\u81EA\u52A8\u6E05\u7406\u672A\u767B\u8BB0\u4E34\u65F6\u6587\u4EF6\u7684\u5F00\u5173", category: "settings", aliases: ["cleanup"], help: true }, { command: "tg_subs", description: "\u67E5\u770B\u9891\u9053\u8BA2\u9605", helpDescription: "\u67E5\u770B\u9891\u9053\u8BA2\u9605\u5217\u8868", category: "channels", aliases: ["tg_subscriptions"], help: true }, @@ -2992,6 +3140,8 @@ var BOT_COMMANDS = [ ]; var lookup = /* @__PURE__ */ new Map(); 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)); } @@ -3050,13 +3200,18 @@ function buildTaskControlLines(taskId, queuePaused = false, pauseReason, systemP `\u{1F6D1} \u53D6\u6D88\uFF1A\u7ED3\u675F\u5F53\u524D\u4EFB\u52A1\u5361\u5E76\u79FB\u9664\u6309\u94AE\uFF0C\u4E0D\u4F1A\u8BEF\u53D6\u6D88\u5176\u5B83\u804A\u5929\u4EFB\u52A1` ]; } -function buildTaskControlButtons(taskId, queuePaused = false, systemPause, queuePausing = false, userPaused = queuePaused && !systemPause) { +function buildTaskControlButtons(taskId, queuePaused = false, systemPause, queuePausing = false, userPaused = queuePaused && !systemPause, failedCount = 0) { if (!taskId) return void 0; const actionButtons = queuePaused || queuePausing ? systemPause && !userPaused ? [] : [new Api.KeyboardButtonCallback({ text: "\u25B6\uFE0F \u7EE7\u7EED", data: Buffer.from(`tq_resume_${taskId}`) })] : [new Api.KeyboardButtonCallback({ text: "\u23F8 \u6682\u505C", data: Buffer.from(`tq_pause_${taskId}`) })]; actionButtons.push(new Api.KeyboardButtonCallback({ text: "\u{1F6D1} \u53D6\u6D88", 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: `\u{1F504} \u91CD\u8BD5\u5931\u8D25 (${failedCount})`, data: Buffer.from(`receipt_retry_${taskId}`) }), + new Api.KeyboardButtonCallback({ text: "\u67E5\u770B\u5931\u8D25\u660E\u7EC6", data: Buffer.from(`receipt_failures_${taskId}`) }) + ] })); + } + return new Api.ReplyInlineMarkup({ rows }); } function collectCompletedFolders(singleFiles, batches) { const folders = /* @__PURE__ */ new Set(); @@ -3237,7 +3392,7 @@ function buildFileList(files, total) { lines.push(`\u{1F4C4} \u66F4\u591A\u8BB0\u5F55: /list 20 \u6216 /list 20 2`); return lines.join("\n"); } -function buildUploadSuccess(fileName, size, fileType, providerName, folder) { +function buildUploadSuccess(fileName, size, fileType, providerName, folder, fileId, duplicateOutcome) { const typeEmoji = getTypeEmoji( fileType === "image" ? "image/" : fileType === "video" ? "video/" : fileType === "audio" ? "audio/" : "other" ); @@ -3249,7 +3404,11 @@ function buildUploadSuccess(fileName, size, fileType, providerName, folder) { `${typeEmoji} ${fileName}`, `\u{1F4E6} ${formatBytes(size)}`, `\u{1F4CD} ${getProviderDisplayName(providerName)}`, - ...folder ? [`\u{1F4C1} ${folder}`] : [] + ...folder ? [`\u{1F4C1} ${folder}`] : [], + ...fileId ? [`\u{1F194} ${fileId.slice(0, 13)}`] : [], + ...duplicateOutcome === "copied" ? ["\u267B\uFE0F \u91CD\u590D\u5904\u7406\uFF1A\u5DF2\u751F\u6210\u526F\u672C"] : duplicateOutcome === "skipped" ? ["\u23ED\uFE0F \u91CD\u590D\u5904\u7406\uFF1A\u5DF2\u8DF3\u8FC7"] : [], + ``, + ...fileId ? ["\u64CD\u4F5C\uFF1A/find \u53EF\u641C\u7D22\u540C\u76EE\u5F55\uFF1B\u590D\u5236\u4E0A\u65B9 ID\uFF1B/delete \u53EF\u8FDB\u5165\u5220\u9664\u786E\u8BA4\u3002"] : [] ].join("\n"); } function buildUploadFail(fileName, error) { @@ -3578,12 +3737,15 @@ function buildCleanupNotice(deletedCount, freedSpace) { ].join("\n"); } +// src/services/telegramCommands.ts +init_telegramState(); + // src/services/telegramUpload.ts init_db(); import { Api as Api4 } from "telegram"; import fs7 from "fs"; import path11 from "path"; -import crypto11 from "crypto"; +import crypto12 from "crypto"; import bigInt from "big-integer"; // src/utils/thumbnail.ts @@ -3591,7 +3753,7 @@ import path6 from "path"; import sharp from "sharp"; import ffmpeg from "fluent-ffmpeg"; import fs5 from "fs"; -import crypto6 from "crypto"; +import crypto7 from "crypto"; var THUMBNAIL_DIR = path6.resolve(process.env.THUMBNAIL_DIR || "./data/thumbnails"); if (!fs5.existsSync(THUMBNAIL_DIR)) { fs5.mkdirSync(THUMBNAIL_DIR, { recursive: true }); @@ -3614,14 +3776,14 @@ async function generateMediaPreview(filePath, storedName, mimeType) { if (!fs5.existsSync(absFilePath)) return null; try { if (mimeType.startsWith("image/") && mimeType !== "image/gif") { - const previewName = `preview_${crypto6.randomUUID()}.webp`; + const previewName = `preview_${crypto7.randomUUID()}.webp`; const previewPath = path6.join(PREVIEW_DIR, previewName); await sharp(absFilePath).rotate().resize(2048, 2048, { fit: "inside", withoutEnlargement: true }).webp({ quality: 86, effort: 4 }).toFile(previewPath); console.log(`[Preview] \u2705 Image preview created: ${previewName}`); return previewPath; } if (mimeType.startsWith("video/")) { - const previewName = `preview_${crypto6.randomUUID()}.mp4`; + const previewName = `preview_${crypto7.randomUUID()}.mp4`; const previewPath = path6.join(PREVIEW_DIR, previewName); const mp4Like = isMp4Like(mimeType, storedName || absFilePath); if (mp4Like) { @@ -3666,7 +3828,7 @@ async function generateMediaPreview(filePath, storedName, mimeType) { } async function generateThumbnail(filePath, storedName, mimeType) { const absFilePath = path6.resolve(filePath); - const thumbName = `thumb_${crypto6.randomUUID()}.webp`; + const thumbName = `thumb_${crypto7.randomUUID()}.webp`; const thumbPath = path6.join(THUMBNAIL_DIR, thumbName); console.log(`[Thumbnail] \u{1F680} Starting generation for: ${storedName}`); console.log(`[Thumbnail] Source: ${absFilePath}`); @@ -3765,10 +3927,10 @@ init_storage(); var TaskAbortRegistry = class { controllers = /* @__PURE__ */ new Map(); acquire(taskId) { - const current2 = this.controllers.get(taskId); - if (current2 && !current2.controller.signal.aborted) { - current2.references += 1; - return current2.controller; + const current3 = this.controllers.get(taskId); + if (current3 && !current3.controller.signal.aborted) { + current3.references += 1; + return current3.controller; } const controller = new AbortController(); this.controllers.set(taskId, { controller, references: 1 }); @@ -3947,6 +4109,10 @@ function getTelegramUserSessionFilePath() { return userSessionFilePath || getSessionFilePath(); } +// src/services/telegramUpload.ts +init_settings(); +init_telegramState(); + // src/utils/telegramMedia.ts import { Api as Api2 } from "telegram"; function getDownloadableMedia(message) { @@ -4060,12 +4226,12 @@ function extractFileInfo(message) { // src/utils/fileUtils.ts import path8 from "path"; -import crypto7 from "crypto"; +import crypto8 from "crypto"; async function getUniqueStoredName(originalName, _folder = null, _storageAccountId = null) { const sanitizedName = sanitizeFilename(originalName); const ext = path8.extname(sanitizedName); const rawBaseName = ext ? sanitizedName.slice(0, -ext.length) : sanitizedName; - const suffix = `--${crypto7.randomUUID()}`; + const suffix = `--${crypto8.randomUUID()}`; const maxBaseLength = Math.max(1, 255 - ext.length - suffix.length); const baseName = rawBaseName.slice(0, maxBaseLength) || "file"; return `${baseName}${suffix}${ext}`; @@ -4231,7 +4397,7 @@ async function getTelegramBatchFolderName(message, fallback) { // src/utils/telegramNaming.ts import path10 from "path"; -import crypto8 from "crypto"; +import crypto9 from "crypto"; function normalizeExtension(extension) { if (!extension) return ""; const trimmed = extension.trim(); @@ -4303,7 +4469,7 @@ function buildTelegramGeneratedFileName(options) { return appendSequenceNumber(nameWithExtension, options.sequenceNumber); } } - const suffix = sanitizeFilename(options.randomSuffix || crypto8.randomBytes(4).toString("hex")).replace(/\s+/g, "_"); + const suffix = sanitizeFilename(options.randomSuffix || crypto9.randomBytes(4).toString("hex")).replace(/\s+/g, "_"); return appendSequenceNumber(`${fallbackPrefix(options.mimeType)}_${suffix}${ext}`, options.sequenceNumber); } function resolveTelegramGeneratedFileName(options) { @@ -4428,6 +4594,7 @@ async function prefetchForwardedSourceMessages(userClient2, messages) { // src/utils/telegramPathSettings.ts import { Api as Api3 } from "telegram"; +init_settings(); // src/utils/telegramPathStateStore.ts init_db(); @@ -4463,8 +4630,12 @@ async function clearTelegramPathStateRows(runQuery = defaultQuery, chatId) { } // src/utils/telegramPathSettings.ts +init_scopedInteractionMap(); var chatPathState = /* @__PURE__ */ new Map(); -var pendingPathInputState = /* @__PURE__ */ new Map(); +var pendingPathInputState = new ScopedInteractionMap({ + ttlMs: Math.max(6e4, Number.parseInt(process.env.TELEGRAM_INTERACTION_TTL_MS || "900000", 10) || 9e5), + maxEntries: Math.max(10, Number.parseInt(process.env.TELEGRAM_INTERACTION_MAX_ENTRIES || "1000", 10) || 1e3) +}); var recentPathState = /* @__PURE__ */ new Map(); var MAX_RECENT_PATHS = 6; var RECENT_PATH_SETTING_PREFIX = "telegram_recent_paths:"; @@ -4515,8 +4686,8 @@ function sanitizeCustomStoragePath(input) { } async function rememberRecentTelegramPathPersistent(chatId, folder) { const normalized = sanitizeCustomStoragePath(folder); - const current2 = await loadRecentTelegramPaths(chatId); - const next = [normalized, ...current2.filter((item) => item !== normalized)].slice(0, MAX_RECENT_PATHS); + const current3 = await loadRecentTelegramPaths(chatId); + const next = [normalized, ...current3.filter((item) => item !== normalized)].slice(0, MAX_RECENT_PATHS); await persistRecentTelegramPaths(chatId, next); return normalized; } @@ -4647,8 +4818,54 @@ function buildPathSettingsText(_state, chatId) { ].join("\n"); } +// src/utils/telegramTargetStateStore.ts +init_db(); +var defaultQuery2 = (sql, params) => query(sql, params); +async function setTelegramTargetState(runQuery = defaultQuery2, chatId, mode, provider, accountId, expiresAt) { + 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) { + if (!row) return null; + return { + provider: String(row.provider), + accountId: row.account_id == null ? null : String(row.account_id), + mode: String(row.mode) + }; +} +async function consumeTelegramTargetState(runQuery = defaultQuery2, chatId) { + 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]); +} +async function getTelegramTargetState(runQuery = defaultQuery2, chatId, mode = "session") { + 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]); +} +async function consumeOrGetTelegramTargetState(chatId) { + return await consumeTelegramTargetState(void 0, chatId) || await getTelegramTargetState(void 0, chatId, "session"); +} +async function clearTelegramTargetState(chatId) { + await defaultQuery2("DELETE FROM telegram_target_states WHERE chat_id = $1", [chatId]); +} + // src/utils/duplicatePolicy.ts init_db(); +init_settings(); function normalizeDuplicateMode(value) { return value === "skip" ? "skip" : "copy"; } @@ -4703,21 +4920,21 @@ var DownloadTaskQueue = class { if (existing && ["completed", "cancelled"].includes(this.snapshotGroup(existing).state)) { this.groups.delete(id); } - const current2 = this.groups.get(id); - if (current2) { - current2.kind = input.kind; - current2.title = input.title || current2.title; - current2.chatId = input.chatId || current2.chatId; - current2.userId = input.userId ?? current2.userId; - current2.source = input.source ?? current2.source; - if (current2.targetProvider === void 0 && input.targetProvider !== void 0) current2.targetProvider = input.targetProvider; - if (current2.targetAccountId === void 0 && input.targetAccountId !== void 0) current2.targetAccountId = input.targetAccountId; - current2.targetFolder = input.targetFolder ?? current2.targetFolder; - current2.hidden = input.hidden ?? current2.hidden; - current2.expectedTotal = Math.max(current2.expectedTotal, Math.max(0, input.expectedTotal || 0)); - current2.updatedAt = this.nextGroupVersion(current2, now); - this.notifyGroup(current2); - return this.snapshotGroup(current2); + const current3 = this.groups.get(id); + if (current3) { + current3.kind = input.kind; + current3.title = input.title || current3.title; + current3.chatId = input.chatId || current3.chatId; + current3.userId = input.userId ?? current3.userId; + current3.source = input.source ?? current3.source; + if (current3.targetProvider === void 0 && input.targetProvider !== void 0) current3.targetProvider = input.targetProvider; + if (current3.targetAccountId === void 0 && input.targetAccountId !== void 0) current3.targetAccountId = input.targetAccountId; + current3.targetFolder = input.targetFolder ?? current3.targetFolder; + current3.hidden = input.hidden ?? current3.hidden; + current3.expectedTotal = Math.max(current3.expectedTotal, Math.max(0, input.expectedTotal || 0)); + current3.updatedAt = this.nextGroupVersion(current3, now); + this.notifyGroup(current3); + return this.snapshotGroup(current3); } const record = { ...input, @@ -5837,9 +6054,9 @@ async function saveAndIndexWithCompensation(provider, tempPath, storedName, mime // src/services/storageAccountLease.ts init_storageAccountLifecycle(); -import crypto9 from "node:crypto"; +import crypto10 from "node:crypto"; async function acquireStorageAccountLease(client2, accountId, purpose, ttlMs = 30 * 60 * 1e3) { - const leaseId = crypto9.randomUUID(); + const leaseId = crypto10.randomUUID(); const expiresAt = new Date(Date.now() + ttlMs); const result = await client2.query( `INSERT INTO storage_account_leases (id, storage_account_id, purpose, expires_at) @@ -5916,7 +6133,7 @@ async function acquireStorageAccountOperationLease(pool2, accountId, purpose, op init_storageAccountLifecycle(); // src/services/telegramWriteReconciliation.ts -import crypto10 from "node:crypto"; +import crypto11 from "node:crypto"; async function ownsTelegramReconciliationLease(db, operationId, leaseToken) { const result = await db.query( `UPDATE telegram_write_reconciliations @@ -5928,7 +6145,7 @@ async function ownsTelegramReconciliationLease(db, operationId, leaseToken) { return result.rowCount === 1; } async function beginTelegramWriteReconciliation(db, input) { - const operationId = crypto10.randomUUID(); + const operationId = crypto11.randomUUID(); const result = await db.query( `INSERT INTO telegram_write_reconciliations (operation_id, job_id, item_id, child_lease_token, provider, account_id, @@ -6213,7 +6430,7 @@ async function getDiskWatermarkState(requiredBytes = 0) { } async function waitForDiskWatermark(requiredBytes = 0, signal) { const startedAt = Date.now(); - const blockerId = crypto11.randomUUID(); + const blockerId = crypto12.randomUUID(); let announcedPause = false; try { while (true) { @@ -6298,16 +6515,16 @@ var telegramStorageWriteLocks = /* @__PURE__ */ new Map(); async function withTelegramStorageWriteLock(key, fn) { const previous = telegramStorageWriteLocks.get(key) || Promise.resolve(); let release; - const current2 = new Promise((resolve) => { + const current3 = new Promise((resolve) => { release = resolve; }); - telegramStorageWriteLocks.set(key, previous.then(() => current2, () => current2)); + telegramStorageWriteLocks.set(key, previous.then(() => current3, () => current3)); await previous.catch(() => void 0); try { return await fn(); } finally { release(); - if (telegramStorageWriteLocks.get(key) === current2) { + if (telegramStorageWriteLocks.get(key) === current3) { telegramStorageWriteLocks.delete(key); } } @@ -6549,7 +6766,7 @@ async function finalizeSilentSessionIfDone(client2, chatId) { ); 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); })() : void 0; const edited = await safeEditMessage(client2, chatId, { message: silentMsgId, text, buttons: controls }); if (!edited) { @@ -6824,7 +7041,7 @@ async function refreshSilentProgress(client2, chatId, userId, pauseHint) { cardPausing, cardSystemPause ); - const controls = isComplete && session.failed === 0 ? void 0 : buildTaskControlButtons(session.taskId, cardPaused, cardSystemPause, cardPausing, cardUserPaused); + const controls = isComplete && session.failed === 0 ? void 0 : buildTaskControlButtons(session.taskId, cardPaused, cardSystemPause, cardPausing, cardUserPaused, session.failed); const buttons = controls; await safeEditMessage(client2, chatId, { message: silentMsgId, text, buttons }); } @@ -6896,6 +7113,9 @@ async function loadFileDownloadConcurrencySetting() { const value = await getSetting("telegram_file_download_concurrency", process.env.TELEGRAM_FILE_DOWNLOAD_CONCURRENCY || "2"); return setFileDownloadConcurrency(normalizeFileDownloadConcurrency(value)); } +function getTaskStatus() { + return downloadQueue.getDetailedStatus(); +} function listDownloadTaskGroups(chatId, userId) { return downloadQueue.getSnapshot({ chatId, userId }).groups; } @@ -6937,10 +7157,10 @@ function cancelDownloadTaskGroup(groupId, chatId, userId) { return downloadQueue.cancelGroup(groupId, { chatId, userId }, "\u7528\u6237\u901A\u8FC7\u4EFB\u52A1\u4E2D\u5FC3\u53D6\u6D88\u4EFB\u52A1"); } function ordinaryGroupId(prefix, chatId, identity) { - return `${prefix}${crypto11.createHash("sha256").update(`${chatId}:${identity}`).digest("base64url").slice(0, 22)}`; + return `${prefix}${crypto12.createHash("sha256").update(`${chatId}:${identity}`).digest("base64url").slice(0, 22)}`; } function channelExecutionGroupId(key) { - return `j${crypto11.createHash("sha256").update(key).digest("base64url").slice(0, 22)}`; + return `j${crypto12.createHash("sha256").update(key).digest("base64url").slice(0, 22)}`; } function getChannelExecutionGroup(jobId) { return downloadQueue.getGroup(channelExecutionGroupId(jobId), {}, true); @@ -7031,6 +7251,12 @@ async function cancelSilentTask(client2, chatId, taskId, fallbackMessageId, user resetChatTransferSession(chatIdStr); return result; } +function listFailedDownloadTaskDetails(taskId, chatId, userId) { + if (!canControlTask(taskId, chatId, userId)) return []; + const files = getConsolidatedFiles(chatId).filter((file) => file.phase === "failed").map((file) => `${file.fileName}: ${file.error || "\u672A\u77E5\u9519\u8BEF"}`); + const batches = getConsolidatedBatches(chatId).filter((batch) => batch.failed > 0).map((batch) => `${batch.folderPath || batch.folderName}: ${batch.failed} \u9879\u5931\u8D25`); + return [...files, ...batches]; +} function retryFailedDownloadTasks(limit = 10, taskId, chatId, userId) { return downloadQueue.retryFailed(limit, { chatId, userId }, taskId); } @@ -7041,7 +7267,7 @@ var mediaGroupDebouncer = createTelegramMediaGroupDebouncer({ }); async function downloadAndSaveFile(client2, message, originalFileName, targetDir, onProgress, signal) { const ext = path11.extname(originalFileName) || ""; - const tempStoredName = `${crypto11.randomUUID()}${ext}`; + const tempStoredName = `${crypto12.randomUUID()}${ext}`; let saveDir = targetDir || UPLOAD_DIR; if (!fs7.existsSync(saveDir)) { try { @@ -8006,6 +8232,9 @@ async function handleFileUpload(client2, event) { 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, @@ -8014,7 +8243,7 @@ async function handleFileUpload(client2, event) { client: client2, files: [], processingStarted: false, - storageTarget: storageManager.getActiveTarget(), + storageTarget, createdAt: Date.now(), lastAddedAt: Date.now() }; @@ -8071,7 +8300,8 @@ async function handleFileUpload(client2, event) { 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, @@ -8294,7 +8524,7 @@ async function handleFileUpload(client2, event) { await runStatusAction(chatId, async () => { await client2.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) }); }); } @@ -8454,10 +8684,234 @@ init_storage(); init_db(); init_storage(); import { Api as Api5 } from "telegram"; -import crypto12 from "node:crypto"; +import crypto13 from "node:crypto"; import { getPeerId } from "telegram/Utils.js"; + +// src/services/telegramChannelJobAdmission.ts +function resolveChannelJobTargetSnapshot(explicitTarget, getActiveTarget) { + return explicitTarget ?? getActiveTarget(); +} + +// src/services/telegramChannelJobs.ts init_storageCooldown(); +init_settings(); init_storageAccountLifecycle(); + +// src/services/telegramDateRange.ts +var DAY_MS = 24 * 60 * 60 * 1e3; +var TELEGRAM_DATE_RANGE_WARNING_DAYS = Math.max( + 1, + Number.parseInt(process.env.TELEGRAM_DATE_RANGE_WARNING_DAYS || "31", 10) || 31 +); +function parseDateOnlyStrict(value, endOfDay = false) { + const match = value.match(/^(\d{4})-(\d{2})-(\d{2})$/); + if (!match) throw new Error("\u65E5\u671F\u683C\u5F0F\u5FC5\u987B\u662F 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("\u8BF7\u8F93\u5165\u6709\u6548\u65E5\u671F\uFF08YYYY-MM-DD\uFF09"); + } + return date; +} +function parseTelegramDateRange(startDateText, endDateText, options = {}) { + const startDate = parseDateOnlyStrict(startDateText); + const endDate = parseDateOnlyStrict(endDateText, true); + if (startDate > endDate) throw new Error("\u5F00\u59CB\u65E5\u671F\u4E0D\u80FD\u665A\u4E8E\u7ED3\u675F\u65E5\u671F"); + 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("\u65E5\u671F\u8303\u56F4\u4E0D\u80FD\u4E3A\u7A7A"); + const threshold = options.warningThresholdDays ?? TELEGRAM_DATE_RANGE_WARNING_DAYS; + return { + startDate, + endDate, + dayCount, + requiresLargeRangeConfirmation: dayCount > threshold + }; +} + +// src/services/telegramNotificationDelivery.ts +init_db(); + +// src/services/telegramNotificationPreferences.ts +init_db(); +var DEFAULT_TELEGRAM_NOTIFICATION_PREFERENCES = { + failureImmediate: true, + successMode: "immediate", + security: true, + subscriptionDigest: true, + timezone: "UTC", + quietStart: null, + quietEnd: null +}; +function validTime(value) { + const text = typeof value === "string" ? value.trim() : ""; + return /^([01]\d|2[0-3]):[0-5]\d$/.test(text) ? text : null; +} +function normalizeTelegramNotificationPreferences(input) { + const successMode = ["immediate", "digest", "off"].includes(String(input.successMode)) ? String(input.successMode) : 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(/* @__PURE__ */ new Date()); + } catch { + throw new Error("\u65E0\u6548\u65F6\u533A"); + } + return { + failureImmediate: input.failureImmediate !== false, + successMode, + security: true, + subscriptionDigest: input.subscriptionDigest !== false, + timezone, + quietStart: validTime(input.quietStart), + quietEnd: validTime(input.quietEnd) + }; +} +function isQuietHour(preferences, now = /* @__PURE__ */ new Date()) { + 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 current3 = hour * 60 + minute; + const toMinutes = (value) => Number(value.slice(0, 2)) * 60 + Number(value.slice(3)); + const start = toMinutes(preferences.quietStart); + const end = toMinutes(preferences.quietEnd); + return start < end ? current3 >= start && current3 < end : current3 >= start || current3 < end; +} +function evaluateTelegramNotification(kind, preferences, now = /* @__PURE__ */ new Date()) { + 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" }; +} +async function getTelegramNotificationPreferences(userId, chatId) { + 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 || {}); +} +async function setTelegramNotificationPreferences(userId, chatId, input) { + 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; +} + +// src/services/telegramNotificationDelivery.ts +function resolveNotificationOwnerUserId(ownerUserId, chatId) { + 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"); +} +async function enqueueTelegramNotification(event, deps) { + const preferences = await (deps.getPreferences || getTelegramNotificationPreferences)(event.userId, event.chatId); + const decision = evaluateTelegramNotification(event.kind, preferences, (deps.now || (() => /* @__PURE__ */ 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"; +} +var digestFlushInProgress = /* @__PURE__ */ new Set(); +async function claimTelegramNotificationDigest(userId, chatId, runQuery) { + 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; +} +async function flushTelegramNotificationDigest(userId, chatId, deps) { + 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) }; + if (selected.rows.length === 0) return 0; + const lines = selected.rows.map((row) => `\u2022 ${String(row.payload?.message || row.kind)}`); + await deps.send(chatId, ["\u{1F4EC} **\u901A\u77E5\u6458\u8981**", "", ...lines].join("\n")); + return selected.rows.length; + } finally { + digestFlushInProgress.delete(scope); + } +} + +// src/services/telegramSubscriptionManagement.ts +var TELEGRAM_SUBSCRIPTION_PAGE_SIZE = 5; +function buildTelegramSubscriptionPage(rows, requestedPage = 0) { + const totalPages = Math.max(1, Math.ceil(rows.length / TELEGRAM_SUBSCRIPTION_PAGE_SIZE)); + const page = Math.min(Math.max(0, Math.floor(requestedPage || 0)), totalPages - 1); + const startIndex = page * TELEGRAM_SUBSCRIPTION_PAGE_SIZE; + return { + page, + totalPages, + startIndex, + visibleRows: rows.slice(startIndex, startIndex + TELEGRAM_SUBSCRIPTION_PAGE_SIZE) + }; +} +function buildSubscriptionOperations(row) { + const operations = [ + { action: "sync", label: "\u7ACB\u5373\u540C\u6B65" }, + { action: row.enabled ? "pause" : "resume", label: row.enabled ? "\u6682\u505C" : "\u6062\u590D" }, + { action: "target", label: "\u4FEE\u6539\u76EE\u6807" }, + { action: "from_now", label: "\u4ECE\u73B0\u5728\u5F00\u59CB" }, + { action: "backfill", label: "\u6309\u65E5\u671F\u8865\u6293" }, + { action: "result", label: "\u6700\u8FD1\u7ED3\u679C" }, + { action: "retry", label: "\u91CD\u8BD5\u5931\u8D25\u9879" } + ]; + return operations; +} +function resolveSubscriptionTarget(row, getActiveTarget, getTarget) { + if (row.target_mode !== "fixed") return getActiveTarget(); + if (!row.target_provider) throw new Error("\u56FA\u5B9A\u8BA2\u9605\u76EE\u6807\u7F3A\u5C11 provider"); + return getTarget(row.target_provider, row.target_account_id || null); +} +function parseTelegramSubscriptionCallback(data) { + 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|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], + id: match[2], + page: Number(match[3] || 0) + }; + } + match = data.match(/^tsub_(confirm|back)_([A-Za-z0-9_-]{1,64})$/); + if (match) return { kind: match[1], token: match[2] }; + return null; +} + +// src/services/telegramChannelJobs.ts var SUBSCRIPTION_INTERVAL_MS = Math.max(6e4, parseInt(process.env.TELEGRAM_SUBSCRIPTION_INTERVAL_MS || "300000", 10) || 3e5); var SUBSCRIPTION_SCAN_LIMIT = Math.max(1, parseInt(process.env.TELEGRAM_SUBSCRIPTION_SCAN_LIMIT || "100", 10) || 100); var TG_JOB_RECOVERY_DELAY_MS = Math.max(1e3, parseInt(process.env.TG_JOB_RECOVERY_DELAY_MS || "10000", 10) || 1e4); @@ -8657,6 +9111,24 @@ async function persistDownloadRefs(jobId, source, refs, folderOverride) { ); } } +async function persistChannelJobAdmission(input, dependencies) { + const target = resolveChannelJobTargetSnapshot(input.target, dependencies.getActiveTarget); + const persistedParams = { + ...input.params, + storageProvider: target.provider.name, + storageAccountId: target.accountId + }; + return dependencies.withTransaction(async (client2) => { + if (target.accountId) await dependencies.lockStorageAccount(client2, target.accountId); + const result = await client2.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); + }); +} async function getDiscussionMediaRefs(userClient2, source, postMessages, options = {}) { if (!options.includeComments || postMessages.length === 0) { return { refs: [], scanned: 0, mediaFound: 0 }; @@ -8842,48 +9314,25 @@ async function persistDownloadMessages(jobId, source, messages, folderOverride) propagateTelegramDownloadGroupContext(refs); await persistDownloadRefs(jobId, source, refs, folderOverride); } -function parseDateOnly(value, endOfDay = false) { - const match = value.match(/^(\d{4})-(\d{2})-(\d{2})$/); - if (!match) throw new Error("\u65E5\u671F\u683C\u5F0F\u5FC5\u987B\u662F YYYY-MM-DD"); - const [, yearText, monthText, dayText] = match; - const year = Number(yearText); - const month = Number(monthText); - const day = Number(dayText); - 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, chatId, kind, source, params) { - const target = storageManager.getActiveTarget(); - const persistedParams = { - ...params, - storageProvider: target.provider.name, - storageAccountId: target.accountId - }; - const client2 = await pool.connect(); - try { - await client2.query("BEGIN"); - if (target.accountId) await lockStorageAccountForUse(client2, target.accountId); - const result = await client2.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 client2.query("COMMIT"); - return result.rows[0].id; - } catch (error) { - await client2.query("ROLLBACK").catch(() => void 0); - throw error; - } finally { - client2.release(); - } +async function createJob(input) { + return persistChannelJobAdmission(input, { + getActiveTarget: () => storageManager.getActiveTarget(), + lockStorageAccount: (client2, accountId) => lockStorageAccountForUse(client2, accountId), + withTransaction: async (operation) => { + const client2 = await pool.connect(); + try { + await client2.query("BEGIN"); + const result = await operation(client2); + await client2.query("COMMIT"); + return result; + } catch (error) { + await client2.query("ROLLBACK").catch(() => void 0); + throw error; + } finally { + client2.release(); + } + } + }); } async function getJob(jobId) { const result = await query(`SELECT * FROM telegram_background_jobs WHERE id = $1`, [jobId]); @@ -8961,8 +9410,8 @@ async function subscribeTelegramChannel(userId, chatId, sourceInput, folderOverr const latestMessageId = await getLatestMessageId(userClient2, resolved.source); 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`, @@ -8973,6 +9422,7 @@ async function subscribeTelegramChannel(userId, chatId, sourceInput, folderOverr async function listTelegramSubscriptions(userId, 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 @@ -8996,6 +9446,58 @@ async function resolveUniqueTelegramSubscriptionId(userId, selector) { ); return result.rows.length === 1 ? String(result.rows[0].id) : null; } +async function setTelegramSubscriptionEnabled(userId, selector, enabled) { + 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 '\u7528\u6237\u624B\u52A8\u6682\u505C\u8BA2\u9605' 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; +} +async function updateTelegramSubscriptionTarget(userId, selector, input) { + const subscriptionId = await resolveUniqueTelegramSubscriptionId(userId, selector); + if (!subscriptionId) return null; + if (input.mode === "fixed") { + if (!input.provider) throw new Error("\u56FA\u5B9A\u8BA2\u9605\u76EE\u6807\u7F3A\u5C11 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; +} +async function setTelegramSubscriptionFromNow(userId, selector) { + 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; +} +async function requestTelegramSubscriptionSync(userId, selector) { + 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; +} async function updateTelegramSubscriptionFolder(userId, selector, folderOverride) { const subscriptionId = await resolveUniqueTelegramSubscriptionId(userId, selector); if (!subscriptionId) return null; @@ -9296,13 +9798,13 @@ async function heartbeatTelegramDownloadRefsWithQuery(runQuery, jobId, refs) { for (let index = 0; index < results.length; index += 1) { if ((results[index].rowCount || 0) === 1) continue; const ref = leased[index]; - const current2 = await runQuery( + const current3 = await runQuery( `SELECT status, lease_token FROM telegram_download_items WHERE job_id = $1 AND source_peer = $2 AND message_id = $3`, [jobId, sourcePeerKey(ref.source, ref.origin === "comment" ? "comment" : "channel"), ref.id] ); - const row = current2.rows[0]; + const row = current3.rows[0]; if (row && ["success", "failed", "skipped"].includes(String(row.status))) { ref.leaseToken = void 0; continue; @@ -9580,9 +10082,9 @@ async function runSegmentedTelegramJob(botClient, requestMessage, jobId, source, return { jobId, deferred: true, ...totals, requested: 0, commentMessagesScanned: 0, commentMediaFound: 0 }; } while (await waitUntilRunnable(jobId, options)) { - const current2 = await getJob(jobId); - cursor = current2?.scan_cursor || cursor || {}; - if (current2?.scan_status === "done") break; + const current3 = await getJob(jobId); + cursor = current3?.scan_cursor || cursor || {}; + if (current3?.scan_status === "done") break; try { const segment = await scanChannelSegment(userClient2, jobId, source, params, cursor, options); const onRefDiscovered = async (ref) => { @@ -9636,20 +10138,27 @@ async function enqueueTelegramDateDownload(botClient, requestMessage, userId, so const resolved = await resolveTelegramSource(userClient2, 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("\u5F00\u59CB\u65E5\u671F\u4E0D\u80FD\u665A\u4E8E\u7ED3\u675F\u65E5\u671F"); - 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 range = parseTelegramDateRange(startDateText, endDateText); + const { startDate, endDate } = range; + 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); } @@ -9659,14 +10168,20 @@ async function enqueueTelegramTagDownload(botClient, requestMessage, userId, sou await assertTelegramSourceAllowed(resolved.source, [resolved.originalSource]); 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 }; @@ -9929,9 +10444,11 @@ async function runSubscriptionScan(botClient) { const userClient2 = getTelegramUserClient(); if (!userClient2 || !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` ); for (const row of result.rows) { @@ -9940,14 +10457,26 @@ async function runSubscriptionScan(botClient) { const latestMessageId = await getLatestMessageId(userClient2, 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(userClient2, row.source, (await userClient2.getMessages(row.source, { ids })).filter(Boolean)); await persistDownloadMessages(jobId, row.source, candidateMessages, row.folder_override || null); await updateJob(jobId, { status: "running", started_at: /* @__PURE__ */ new Date(), total_count: ids.length }); @@ -9993,7 +10522,12 @@ async function runSubscriptionScan(botClient) { 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} \u4E2A\u6587\u4EF6\u4E0B\u8F7D\u5931\u8D25` : null, downloadResult.failed > 0 ? "partial_failure" : "success", downloadResult.found, downloadResult.skipped, downloadResult.failed]); if (downloadResult.found > 0) { - await botClient.sendMessage(targetChat, { message: `\u2705 \u8BA2\u9605 ${row.source} \u5DF2\u540C\u6B65 ${downloadResult.found} \u4E2A\u65B0\u6587\u4EF6\uFF0C\u8DF3\u8FC7 ${downloadResult.skipped} \u6761${downloadResult.failed ? `\uFF0C\u5931\u8D25 ${downloadResult.failed} \u6761` : ""}${safeAdvanceId < latestMessageId ? "\u3002\u672C\u8F6E\u8FBE\u5230\u626B\u63CF\u4E0A\u9650\u6216\u5B58\u5728\u5931\u8D25\u9879\uFF0C\u5269\u4F59\u5C06\u5728\u540E\u7EED\u7EE7\u7EED\u5904\u7406\u3002" : "\u3002"}` }).catch(() => void 0); + const notificationMessage = `\u2705 \u8BA2\u9605 ${row.source} \u5DF2\u540C\u6B65 ${downloadResult.found} \u4E2A\u65B0\u6587\u4EF6\uFF0C\u8DF3\u8FC7 ${downloadResult.skipped} \u6761${downloadResult.failed ? `\uFF0C\u5931\u8D25 ${downloadResult.failed} \u6761` : ""}${safeAdvanceId < latestMessageId ? "\u3002\u672C\u8F6E\u8FBE\u5230\u626B\u63CF\u4E0A\u9650\u6216\u5B58\u5728\u5931\u8D25\u9879\uFF0C\u5269\u4F59\u5C06\u5728\u540E\u7EED\u7EE7\u7EED\u5904\u7406\u3002" : "\u3002"}`; + await enqueueTelegramNotification({ userId: Number(row.user_id), chatId: String(targetChat), kind: "subscription", message: notificationMessage }, { + send: async (chatId, message) => { + await botClient.sendMessage(chatId, { message }); + } + }).catch(() => void 0); } } catch (error) { console.error("\u{1F916} Telegram \u8BA2\u9605\u540C\u6B65\u5931\u8D25:", error); @@ -10007,11 +10541,14 @@ async function runSubscriptionScan(botClient) { const reason = subscriptionDisabledReason(error); await pauseTelegramSubscriptionForError(row.id, reason).catch((updateError) => console.error("\u{1F916} \u6682\u505C\u4E0D\u53EF\u8BBF\u95EE\u7684 Telegram \u8BA2\u9605\u5931\u8D25:", updateError)); const targetChat = row.chat_id || row.user_id; - await botClient.sendMessage(targetChat, { - message: `\u26A0\uFE0F \u5DF2\u6682\u505C\u8BA2\u9605 ${row.source_original || row.source} + const pausedMessage = `\u26A0\uFE0F \u5DF2\u6682\u505C\u8BA2\u9605 ${row.source_original || row.source} ${reason} -\u4F60\u53EF\u4EE5\u5728 /tg_subs \u6216 /tg_sub \u8BA2\u9605\u5217\u8868\u4E2D\u67E5\u770B\u63D0\u9192\uFF1B\u786E\u8BA4\u8D26\u53F7\u53EF\u8BBF\u95EE\u540E\u91CD\u65B0\u6DFB\u52A0\u8BA2\u9605\u5373\u53EF\u3002` +\u4F60\u53EF\u4EE5\u5728 /tg_subs \u6216 /tg_sub \u8BA2\u9605\u5217\u8868\u4E2D\u67E5\u770B\u63D0\u9192\uFF1B\u786E\u8BA4\u8D26\u53F7\u53EF\u8BBF\u95EE\u540E\u91CD\u65B0\u6DFB\u52A0\u8BA2\u9605\u5373\u53EF\u3002`; + await enqueueTelegramNotification({ userId: Number(row.user_id), chatId: String(targetChat), kind: "failure", message: pausedMessage }, { + send: async (chatId, message) => { + await botClient.sendMessage(chatId, { message }); + } }).catch(() => void 0); } } @@ -10169,7 +10706,7 @@ async function recoverInterruptedTelegramJobs(botClient) { const lockResult = await client2.query(`SELECT pg_try_advisory_lock(hashtext('tg-vault:telegram-job-recovery')) AS locked`); lockHeld = Boolean(lockResult.rows[0]?.locked); if (!lockHeld) return; - const reconciliationLease = crypto12.randomUUID(); + const reconciliationLease = crypto13.randomUUID(); const pendingWrites = await claimTelegramWriteReconciliations(pool, reconciliationLease, 100); for (const pendingWrite of pendingWrites) { const target = storageManager.getTarget(pendingWrite.provider, pendingWrite.accountId); @@ -10256,6 +10793,9 @@ function startTelegramSubscriptionWorker(botClient) { console.log(`\u{1F916} Telegram \u9891\u9053\u8BA2\u9605\u626B\u63CF\u5DF2\u542F\u52A8\uFF0C\u95F4\u9694 ${Math.round(SUBSCRIPTION_INTERVAL_MS / 1e3)} \u79D2`); } +// src/services/telegramCommands.ts +init_settings(); + // src/services/orphanCleanup.ts init_db(); init_localPath(); @@ -10513,6 +11053,100 @@ function telegramChatKeyFromPeerParts(peer, fallbackUserId) { return numericPeerValue(fallbackUserId); } +// src/services/unifiedTaskMapper.ts +function safeNumber(value) { + const parsed = Number(value ?? 0); + return Number.isFinite(parsed) ? Math.max(0, parsed) : 0; +} +function parseTaskOptions(value) { + if (value && typeof value === "object" && !Array.isArray(value)) return value; + 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) { + if (status === "queued") return "pending"; + if (status === "cooling") return "waiting"; + if (status === "completed_with_errors") return "failed"; + return status; +} +function telegramChannelJobTaskState(row) { + 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 }; +} +function mapTelegramChannelJob(row, accountNames) { + 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 \u9891\u9053\u4EFB\u52A1", + 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" ? "\u670D\u52A1\u5668\u672C\u5730\u76EE\u5F55" : 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 + }; +} +function mapTransferTask(task, accountNames) { + 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" ? "\u670D\u52A1\u5668\u672C\u5730\u76EE\u5F55" : 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 + }; +} + // src/services/telegramTaskCenter.ts var PAGE_SIZE = 6; var VALID_ID = /^[A-Za-z0-9-]{1,24}$/; @@ -10527,7 +11161,7 @@ var ACTION_CODES = { var CODE_ACTIONS = Object.fromEntries(Object.entries(ACTION_CODES).map(([action, code]) => [code, action])); var SOURCE_CODES = { memory: "m", channel: "c", ytdlp: "y" }; var CODE_SOURCES = { m: "memory", c: "channel", y: "ytdlp" }; -function safeNumber(value) { +function safeNumber2(value) { const parsed = Number(value || 0); return Number.isFinite(parsed) ? Math.max(0, parsed) : 0; } @@ -10803,12 +11437,12 @@ function channelTaskCenterItem(row) { const rawId = String(row?.id || "").replace(/[^A-Za-z0-9-]/g, ""); if (!rawId) return null; const id = rawId.slice(0, 12); - const total = Math.max(safeNumber(row.total_count), safeNumber(row.item_count)); - const active = safeNumber(row.downloading_count); - const pending = safeNumber(row.pending_count); - const completed = safeNumber(row.success_count); - const failed = safeNumber(row.failed_count); - const skipped = safeNumber(row.skipped_count_items ?? row.skipped_count); + const total = Math.max(safeNumber2(row.total_count), safeNumber2(row.item_count)); + const active = safeNumber2(row.downloading_count); + const pending = safeNumber2(row.pending_count); + const completed = safeNumber2(row.success_count); + const failed = safeNumber2(row.failed_count); + const skipped = safeNumber2(row.skipped_count_items ?? row.skipped_count); const cooldownUntil = row.cooldown_until ? new Date(row.cooldown_until) : void 0; const inCooldown = Boolean(row.cooldown_until && safeTime(row.cooldown_until, 0) > Date.now()) || row.status === "cooling" && (!row.cooldown_until || safeTime(row.cooldown_until, 0) > Date.now()); const protection = inCooldown ? { @@ -10817,7 +11451,8 @@ function channelTaskCenterItem(row) { autoResume: true, retryAt: cooldownUntil && !Number.isNaN(cooldownUntil.getTime()) ? cooldownUntil.toLocaleString("zh-CN", { hour12: false }) : void 0 } : void 0; - const state = 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"; + const channelState = telegramChannelJobTaskState(row); + const state = inCooldown ? "cooling" : row.status === "paused" ? active > 0 ? "pausing" : "paused" : 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; if (typeof optionsSource === "string") { @@ -10833,7 +11468,7 @@ function channelTaskCenterItem(row) { const qualifier = options.tag || (options.startDate && options.endDate ? `${options.startDate} \u2192 ${options.endDate}` : ""); const source = String(row.source || "\u9891\u9053\u4EFB\u52A1"); const chatId = row.chat_id !== void 0 && row.chat_id !== null ? String(row.chat_id) : void 0; - const userId = row.user_id !== void 0 && row.user_id !== null ? safeNumber(row.user_id) : void 0; + const userId = row.user_id !== void 0 && row.user_id !== null ? safeNumber2(row.user_id) : void 0; return { sourceType: "channel", id, @@ -10861,11 +11496,33 @@ function channelTaskCenterItem(row) { // src/services/ytDlpDownload.ts init_db(); init_storage(); -import crypto13 from "node:crypto"; +import crypto14 from "node:crypto"; import fs9 from "node:fs"; import os from "node:os"; import path14 from "node:path"; import { spawn } from "node:child_process"; + +// src/services/ytDlpPlaylistSelection.ts +function validateYtDlpPlaylistSelection(input, budget = {}) { + if (!input.enabled) throw new Error("\u64AD\u653E\u5217\u8868\u5FC5\u987B\u663E\u5F0F\u542F\u7528"); + 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} \u5FC5\u987B\u662F\u6B63\u6574\u6570`); + } + if (input.end < input.start) throw new Error("\u64AD\u653E\u5217\u8868\u8303\u56F4\u65E0\u6548"); + 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(`\u64AD\u653E\u5217\u8868\u8D85\u8FC7\u6700\u5927\u6761\u76EE\u4E0A\u9650 ${hardMaxItems}`); + const estimatedBytes = count * Math.max(0, budget.estimatedItemBytes ?? 0); + if (budget.maxBudgetBytes !== void 0 && estimatedBytes > budget.maxBudgetBytes) throw new Error("\u64AD\u653E\u5217\u8868\u9884\u8BA1\u5927\u5C0F\u8D85\u8FC7\u4E0B\u8F7D\u9884\u7B97"); + return { ...input, count }; +} +function buildYtDlpPlaylistArgs(url, input) { + const selection = validateYtDlpPlaylistSelection(input); + return ["--yes-playlist", "--playlist-start", String(selection.start), "--playlist-end", String(selection.end), "--max-downloads", String(selection.maxItems), "--", url]; +} + +// src/services/ytDlpDownload.ts init_storageCooldown(); init_storageAccountLifecycle(); var YTDLP_BIN = process.env.YTDLP_BIN || "yt-dlp"; @@ -10909,10 +11566,11 @@ function parseYtDlpProgress(line) { const eta = line.match(/\bETA\s+([^\s]+)/i)?.[1]; return { percent: Math.max(0, Math.min(100, Number(match[1]))), speed, eta }; } -async function runYtDlpDownload(url, taskDir, signal, onProgress) { +async function runYtDlpDownload(url, taskDir, signal, onProgress, format = "best", playlist) { ensureDir(taskDir); const outputTemplate = path14.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((resolve, reject) => { const binLower = YTDLP_BIN.toLowerCase(); const needsShell = os.platform() === "win32" && (binLower.endsWith(".cmd") || binLower.endsWith(".bat")); @@ -11002,7 +11660,7 @@ async function uploadDownloadedFile(task, execution, localFilePath, originalFile thumbnailPath = null; } } - const operationId = crypto13.randomUUID(); + const operationId = crypto14.randomUUID(); await beginYtDlpWrite(pool, { operationId, taskId: task.id, @@ -11085,10 +11743,10 @@ function classifyYtDlpError(error) { } async function notifyTask(task, message) { if (!task.chatId || !taskNotifier) return; - await taskNotifier(task.chatId, message).catch(() => void 0); + await taskNotifier(task, message).catch(() => void 0); } async function executeYtDlpTask(id) { - const leaseToken = crypto13.randomUUID(); + const leaseToken = crypto14.randomUUID(); const execution = await claimYtDlpExecution(pool, id, leaseToken); if (!execution) return; const task = await getTransferTask("ytdlp", id); @@ -11119,7 +11777,7 @@ async function executeYtDlpTask(id) { 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); const primary = selectPrimaryOutputFile(taskDir); if (!primary) throw new Error("\u4E0B\u8F7D\u5B8C\u6210\u4F46\u672A\u627E\u5230\u53EF\u4E0A\u4F20\u7684\u8F93\u51FA\u6587\u4EF6"); const advancing = await updateYtDlpExecutionProgress(pool, id, execution.generation, execution.leaseToken, { @@ -11187,8 +11845,8 @@ async function executeYtDlpTask(id) { ].join("\n")); } } catch (error) { - const current2 = await getTransferTask("ytdlp", id); - const cancelled = controller.signal.aborted || current2?.status === "cancelled" || current2?.status === "pending"; + const current3 = await getTransferTask("ytdlp", id); + const cancelled = controller.signal.aborted || current3?.status === "cancelled" || current3?.status === "pending"; if (!cancelled) { let message = classifyYtDlpError(error); if (isStorageQuotaCooldownError(error)) { @@ -11221,8 +11879,8 @@ async function executeYtDlpTask(id) { clearInterval(heartbeat); activeControllers.delete(id); await safeRmDir(taskDir); - const current2 = await getTransferTask("ytdlp", id); - if (current2?.status === "pending") ytDlpQueue.enqueue(id); + const current3 = await getTransferTask("ytdlp", id); + if (current3?.status === "pending") ytDlpQueue.enqueue(id); } } var PersistentYtDlpQueue = class { @@ -11291,9 +11949,9 @@ async function retryYtDlpTask(id) { ytDlpQueue.enqueue(id); return getTransferTask("ytdlp", id); } -async function handleYtDlpCommand(message, url) { - const id = `yd-${crypto13.randomBytes(8).toString("hex")}`; - const target = storageManager.getActiveTarget(); +async function handleYtDlpCommand(message, url, explicitTarget, options = {}) { + const id = `yd-${crypto14.randomBytes(8).toString("hex")}`; + const target = explicitTarget || storageManager.getActiveTarget(); await assertStorageTargetWritable(target); const client2 = target.accountId ? await pool.connect() : null; let admissionLease = null; @@ -11319,9 +11977,15 @@ async function handleYtDlpCommand(message, url) { 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" ? "\u670D\u52A1\u5668\u672C\u5730\u76EE\u5F55" : null) }, + payload: { + url, + format: options.format || "best", + metadata: options.metadata || {}, + playlist: options.playlist, + targetAccountName: account?.name || (target.provider.name === "local" ? "\u670D\u52A1\u5668\u672C\u5730\u76EE\u5F55" : null) + }, retryable: false }); ytDlpQueue.enqueue(task.id); @@ -11345,7 +12009,7 @@ async function handleYtDlpCommand(message, url) { } // src/services/destructiveConfirmation.ts -import crypto14 from "crypto"; +import crypto15 from "crypto"; var DestructiveConfirmationStore = class { confirmations = /* @__PURE__ */ new Map(); ttlMs; @@ -11354,7 +12018,7 @@ var DestructiveConfirmationStore = class { constructor(options = {}) { this.ttlMs = options.ttlMs ?? 5 * 60 * 1e3; this.now = options.now ?? (() => Date.now()); - this.tokenFactory = options.tokenFactory ?? (() => crypto14.randomBytes(18).toString("base64url")); + this.tokenFactory = options.tokenFactory ?? (() => crypto15.randomBytes(18).toString("base64url")); } issue(binding) { const token = this.tokenFactory(); @@ -11388,6 +12052,424 @@ var DestructiveConfirmationStore = class { } }; +// src/services/telegramFileBrowser.ts +init_db(); + +// src/services/fileQuery.ts +var FILE_TYPES = /* @__PURE__ */ new Set(["image", "video", "audio", "document", "other", "media"]); +var SORTS = /* @__PURE__ */ new Set(["date", "name"]); +var DIRECTIONS = /* @__PURE__ */ new Set(["asc", "desc"]); +var UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +function single(value) { + if (Array.isArray(value)) throw new Error("query parameter must be singular"); + return typeof value === "string" ? value : void 0; +} +function normalizeDateBound(value, field) { + 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 timestamp2 = 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(timestamp2); + 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(); +} +function normalizeFileQuery(input) { + const rawQ = single(input.q)?.trim() || null; + if (rawQ && rawQ.length > 200) throw new Error("q is too long"); + const rawType = single(input.type)?.trim() || null; + if (rawType && !FILE_TYPES.has(rawType)) throw new Error("invalid type"); + const rawFavorite = single(input.favorite); + let favorite = null; + if (rawFavorite !== void 0 && rawFavorite !== "") { + if (rawFavorite !== "true" && rawFavorite !== "false") throw new Error("invalid favorite"); + favorite = rawFavorite === "true"; + } + const rawSort = single(input.sort) || "date"; + if (!SORTS.has(rawSort)) throw new Error("invalid sort"); + const rawDirection = single(input.direction) || "desc"; + if (!DIRECTIONS.has(rawDirection)) throw new Error("invalid direction"); + const rawFolder = single(input.folder); + const folder = rawFolder === void 0 ? void 0 : rawFolder.trim() || null; + if (folder && folder.length > 500) throw new Error("folder is too long"); + const parsedLimit = Number.parseInt(single(input.limit) || "200", 10); + const limit = Math.min(500, Math.max(1, Number.isFinite(parsedLimit) ? parsedLimit : 200)); + return { + q: rawQ, + type: rawType, + folder, + favorite, + after: normalizeDateBound(input.after, "after"), + before: normalizeDateBound(input.before, "before"), + sort: rawSort, + direction: rawDirection, + limit, + cursor: single(input.cursor) || null + }; +} +function encodeFileQueryCursor(cursor) { + return Buffer.from(JSON.stringify(cursor), "utf8").toString("base64url"); +} +function decodeFileQueryCursor(encoded, sort, direction) { + if (!encoded) return null; + try { + const parsed = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")); + if (parsed.sort !== sort || parsed.direction !== direction || typeof parsed.value !== "string" || !UUID_PATTERN.test(parsed.id || "")) { + return null; + } + if (sort === "date" && Number.isNaN(Date.parse(parsed.value))) return null; + return parsed; + } catch { + return null; + } +} +function escapeLike(value) { + return value.replace(/[\\%_]/g, (match) => `\\${match}`); +} +function addScope(scope, where, params, alias = "") { + const prefix = alias ? `${alias}.` : ""; + if (scope.kind === "local") { + params.push("local"); + where.push(`${prefix}source = $${params.length}`); + } else { + params.push(scope.accountId); + where.push(`${prefix}storage_account_id = $${params.length}`); + } +} +function addFilters(options, where, params, alias = "", includeFolder = true) { + const prefix = alias ? `${alias}.` : ""; + if (options.q) { + params.push(`%${escapeLike(options.q)}%`); + where.push(`(${prefix}name ILIKE $${params.length} ESCAPE '\\' OR ${prefix}folder ILIKE $${params.length} ESCAPE '\\')`); + } + if (options.type === "media") { + where.push(`${prefix}type IN ('image', 'video', 'audio')`); + } else if (options.type === "document") { + where.push(`${prefix}type NOT IN ('image', 'video', 'audio')`); + } else if (options.type) { + params.push(options.type); + where.push(`${prefix}type = $${params.length}`); + } + if (includeFolder && options.folder !== void 0) { + if (options.folder === null) { + where.push(`${prefix}folder IS NULL`); + } else { + params.push(options.folder); + where.push(`${prefix}folder = $${params.length}`); + } + } + if (options.favorite !== null) { + 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`); + } +} +function buildFilePageQuery(scope, options) { + const where = []; + const params = []; + addScope(scope, where, params); + addFilters(options, where, params); + const cursor = decodeFileQueryCursor(options.cursor, options.sort, options.direction); + const comparator = options.direction === "asc" ? ">" : "<"; + const direction = options.direction.toUpperCase(); + if (cursor) { + if (options.sort === "name") { + params.push(cursor.value.toLocaleLowerCase(), cursor.id); + where.push(`(LOWER(name), id) ${comparator} ($${params.length - 1}, $${params.length}::uuid)`); + } else { + params.push(cursor.value, cursor.id); + where.push(`(created_at, id) ${comparator} ($${params.length - 1}::timestamptz, $${params.length}::uuid)`); + } + } + params.push(options.limit + 1); + const sortColumn = options.sort === "name" ? "LOWER(name)" : "created_at"; + return { + text: `SELECT + id, name, stored_name, type, mime_type, size, thumbnail_path, preview_path, + width, height, source, folder, storage_account_id, is_favorite, created_at, updated_at +FROM files +WHERE ${where.join(" AND ")} +ORDER BY ${sortColumn} ${direction}, id ${direction} +LIMIT $${params.length}`, + params + }; +} +function buildFolderAggregationQuery(scope, options) { + const where = ["folder IS NOT NULL"]; + const params = []; + addScope(scope, where, params); + addFilters({ ...options, folder: void 0 }, where, params, "", false); + const direction = options.direction.toUpperCase(); + const order = options.sort === "name" ? `folder ${direction}` : `latest_at ${direction}, folder ${direction}`; + return { + text: `WITH filtered AS ( + SELECT * FROM files WHERE ${where.join(" AND ")} +), grouped AS ( + SELECT + folder, + COUNT(*) FILTER (WHERE name <> '.folder')::int AS file_count, + COALESCE(SUM(size) FILTER (WHERE name <> '.folder'), 0)::bigint AS total_size_bytes, + MAX(created_at) AS latest_at, + BOOL_AND(is_favorite)::boolean AS is_favorite + FROM filtered + GROUP BY folder +) +SELECT grouped.*, cover.id AS cover_id, cover.name AS cover_name, cover.type AS cover_type, + cover.mime_type AS cover_mime_type, cover.thumbnail_path AS cover_thumbnail_path, + cover.preview_path AS cover_preview_path, cover.created_at AS cover_created_at +FROM grouped +LEFT JOIN LATERAL ( + SELECT id, name, type, mime_type, thumbnail_path, preview_path, created_at + FROM filtered candidate + WHERE candidate.folder = grouped.folder AND candidate.name <> '.folder' + ORDER BY candidate.created_at DESC, candidate.id DESC + LIMIT 1 +) cover ON TRUE +ORDER BY ${order}`, + params + }; +} +function cursorForFile(file, sort, direction) { + const value = sort === "name" ? String(file.name || "").toLocaleLowerCase() : new Date(String(file.created_at)).toISOString(); + return encodeFileQueryCursor({ sort, direction, value, id: String(file.id) }); +} + +// src/services/telegramFileBrowser.ts +function encodeTelegramFileCallback(action, fileId) { + const value = `fb_${action}_${fileId}`; + if (Buffer.byteLength(value, "utf8") > 64) throw new Error("Telegram callback data exceeds 64 bytes"); + return value; +} +function parseTelegramFileCallback(data) { + const match = data.match(/^fb_(detail|copy|favorite|link|move|rename|delete)_([0-9a-f-]{36})$/i); + return match ? { action: match[1].toLowerCase(), fileId: match[2].toLowerCase() } : null; +} +function buildTelegramFileActionRows(file) { + const id = String(file.id); + const rows = [ + [{ text: "\u8BE6\u60C5", data: encodeTelegramFileCallback("detail", id) }, { text: "\u590D\u5236 ID", data: encodeTelegramFileCallback("copy", id) }], + [{ text: file.is_favorite ? "\u53D6\u6D88\u6536\u85CF" : "\u6536\u85CF", data: encodeTelegramFileCallback("favorite", id) }], + [{ text: "\u7B7E\u540D\u94FE\u63A5", data: encodeTelegramFileCallback("link", id) }], + [{ text: "\u79FB\u52A8", data: encodeTelegramFileCallback("move", id) }, { text: "\u91CD\u547D\u540D", data: encodeTelegramFileCallback("rename", id) }], + [{ text: "\u5220\u9664\u2026", data: encodeTelegramFileCallback("delete", id) }] + ]; + return rows; +} +function buildTelegramFileDetail(file) { + return [ + `\u{1F4C4} **${file.name}**`, + `\u{1F194} ${file.id}`, + `\u{1F4E6} ${Number(file.size || 0)} bytes \xB7 ${file.type || "other"}`, + `\u{1F4CD} ${file.source || "local"}`, + `\u{1F4C1} ${file.folder || "\u6839\u76EE\u5F55"}`, + `\u{1F552} ${file.created_at ? new Date(file.created_at).toLocaleString("zh-CN", { hour12: false }) : "\u672A\u77E5"}` + ].join("\n"); +} +async function resolveTelegramFileScope() { + const { storageManager: storageManager2 } = await Promise.resolve().then(() => (init_storage(), storage_exports)); + const target = storageManager2.getActiveTarget(); + return target.provider.name === "local" ? { kind: "local" } : { kind: "account", accountId: target.accountId || "" }; +} +async function queryTelegramFiles(rawOptions, dependencies = {}) { + 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 + }; +} +function buildTelegramFileCard(file, index) { + const shortId = String(file.id || "").slice(0, 12); + const createdAt = file.created_at ? new Date(file.created_at).toLocaleString("zh-CN", { hour12: false }) : "\u672A\u77E5"; + return [ + `${index + 1}. ${file.is_favorite ? "\u2B50 " : ""}${file.name}`, + ` \u{1F194} ${shortId} \xB7 ${file.type || "other"} \xB7 ${Number(file.size || 0)} bytes`, + ` \u{1F4C1} ${file.folder || "\u6839\u76EE\u5F55"} \xB7 ${createdAt}` + ].join("\n"); +} +function buildTelegramFileBrowserText(page, queryText) { + return [ + `\u{1F50E} **\u6587\u4EF6\u641C\u7D22**\uFF1A${queryText || "\u6700\u8FD1\u6587\u4EF6"}`, + "", + ...page.files.length > 0 ? page.files.map(buildTelegramFileCard) : ["\u6CA1\u6709\u5339\u914D\u6587\u4EF6\u3002"], + "", + "\u70B9\u51FB\u6587\u4EF6\u53EF\u67E5\u770B\u8BE6\u60C5\u3001\u590D\u5236 ID\u3001\u6536\u85CF\u3001\u751F\u6210\u94FE\u63A5\u3001\u79FB\u52A8/\u91CD\u547D\u540D\u6216\u8FDB\u5165\u5220\u9664\u786E\u8BA4\u3002" + ].join("\n"); +} + +// src/services/telegramStatusPanel.ts +function sanitizeTelegramStatusText(value) { + if (!value) return "\u65E0"; + 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 "[\u5DF2\u8131\u654F]"; + return text.length > 120 ? `${text.slice(0, 117)}\u2026` : text; +} +function percent(used, total) { + return total > 0 ? Math.max(0, Math.min(100, Math.round(used / total * 100))) : 0; +} +function buildTelegramStatusPanel(input) { + const used = Math.max(0, input.disk.totalBytes - input.disk.freeBytes); + return [ + "\u{1FA7A} **TG Vault \u8BCA\u65AD\u72B6\u6001**", + `\u64CD\u4F5C ID\uFF1A${sanitizeTelegramStatusText(input.requestId)}`, + "", + `Bot\uFF1A${input.bot.status}${input.bot.degraded ? "\uFF08\u964D\u7EA7\uFF09" : ""} \xB7 \u91CD\u8FDE ${input.bot.reconnectCount || 0}`, + `\u8D26\u53F7\u4E0B\u8F7D\u5668\uFF1A${input.userClient.status}${input.userClient.username ? ` / @${sanitizeTelegramStatusText(input.userClient.username)}` : ""}`, + input.userClient.action ? `\u8D26\u53F7\u6062\u590D\uFF1A${sanitizeTelegramStatusText(input.userClient.action)}` : null, + "", + `Target\uFF1A${input.target.provider} / ${sanitizeTelegramStatusText(input.target.accountName)}`, + `Probe\uFF1A${input.target.probeStatus || "unknown"}`, + input.target.cooldownUntil ? `Cooldown\uFF1A${sanitizeTelegramStatusText(input.target.cooldownUntil)}` : "Cooldown\uFF1A\u65E0", + input.target.probeError ? `Probe \u9519\u8BEF\uFF1A${sanitizeTelegramStatusText(input.target.probeError)}` : null, + "", + `\u4E34\u65F6\u78C1\u76D8\uFF1A${input.disk.freeBytes}/${input.disk.totalBytes} bytes \u53EF\u7528 \xB7 \u5DF2\u7528 ${percent(used, input.disk.totalBytes)}%`, + `\u961F\u5217\uFF1A\u6D3B\u8DC3 ${input.queue.active} \xB7 \u7B49\u5F85 ${input.queue.pending} \xB7 \u5931\u8D25 ${input.queue.failed}${input.queue.paused ? " \xB7 \u5DF2\u6682\u505C" : ""}`, + `\u8BA2\u9605\uFF1A\u542F\u7528 ${input.subscriptions.enabled} \xB7 \u6700\u8FD1\u626B\u63CF ${sanitizeTelegramStatusText(input.subscriptions.lastScanAt || "\u65E0")}`, + input.subscriptions.lastError ? `\u8BA2\u9605\u9519\u8BEF\uFF1A${sanitizeTelegramStatusText(input.subscriptions.lastError)}` : null, + `\u5BF9\u8D26\uFF1A\u5F85\u5BF9\u8D26\uFF1A${input.reconciliation.pending} \xB7 \u9700\u4EBA\u5DE5\uFF1A${input.reconciliation.operatorRequired}`, + "", + input.bot.action ? `\u5EFA\u8BAE\uFF1A${sanitizeTelegramStatusText(input.bot.action)}` : "\u5EFA\u8BAE\uFF1A\u5982\u7EC4\u4EF6\u5F02\u5E38\uFF0C\u8BF7\u643A\u5E26\u64CD\u4F5C ID \u67E5\u770B\u7ED3\u6784\u5316\u65E5\u5FD7\u3002" + ].filter(Boolean).join("\n"); +} + +// src/services/telegramBotStatus.ts +function requiredFromEnv() { + return /^(1|true|yes|on)$/i.test(process.env.TELEGRAM_REQUIRED || "false"); +} +var current2 = { + status: "not_configured", + configured: false, + required: requiredFromEnv(), + degraded: false, + checkedAt: (/* @__PURE__ */ new Date()).toISOString(), + lastConnectedAt: null, + lastRecoveredAt: null, + lastError: null, + action: "\u914D\u7F6E TELEGRAM_BOT_TOKEN\u3001TELEGRAM_API_ID \u548C TELEGRAM_API_HASH", + reconnectCount: 0 +}; +function resetTelegramBotStatus(configured2, checkedAt = (/* @__PURE__ */ new Date()).toISOString()) { + current2 = { + status: configured2 ? "stopped" : "not_configured", + configured: configured2, + required: requiredFromEnv(), + degraded: false, + checkedAt, + lastConnectedAt: null, + lastRecoveredAt: null, + lastError: null, + action: configured2 ? "\u542F\u52A8 Telegram Bot" : "\u914D\u7F6E TELEGRAM_BOT_TOKEN\u3001TELEGRAM_API_ID \u548C TELEGRAM_API_HASH", + reconnectCount: 0 + }; +} +function getTelegramBotStatus() { + return { ...current2, required: requiredFromEnv() }; +} +function markTelegramBotStarting(checkedAt = (/* @__PURE__ */ new Date()).toISOString()) { + current2 = { + ...current2, + configured: true, + required: requiredFromEnv(), + status: "starting", + degraded: false, + checkedAt, + lastError: null, + action: "\u7B49\u5F85 Telegram \u8FDE\u63A5\u5EFA\u7ACB" + }; +} +function markTelegramBotReady(checkedAt = (/* @__PURE__ */ new Date()).toISOString()) { + const recovered = current2.status === "reconnecting" || current2.status === "auth_failed" || current2.status === "error"; + current2 = { + ...current2, + configured: true, + required: requiredFromEnv(), + status: "ready", + degraded: false, + checkedAt, + lastConnectedAt: checkedAt, + lastRecoveredAt: recovered ? checkedAt : current2.lastRecoveredAt, + lastError: null, + action: null + }; +} +function markTelegramBotError(status, message, action, checkedAt = (/* @__PURE__ */ new Date()).toISOString()) { + current2 = { + ...current2, + configured: true, + required: requiredFromEnv(), + status, + degraded: status !== "stopped", + checkedAt, + lastError: message, + action, + reconnectCount: status === "reconnecting" ? current2.reconnectCount + 1 : current2.reconnectCount + }; +} +function classifyTelegramBotStartupError(error) { + const message = error instanceof Error ? error.message : String(error); + return /token|auth|unauthorized|forbidden|401|403/i.test(message) ? "auth_failed" : "error"; +} +function telegramBotBlocksReadiness(status, required = requiredFromEnv()) { + if (!required) return false; + return status.status !== "ready"; +} + +// src/services/telegramCommands.ts +import crypto16 from "crypto"; + +// src/utils/folderPath.ts +var INVALID_SEGMENT_CHARACTERS = /[\\:*?"<>|\x00-\x1f\x7f]/; +var MAX_FOLDER_PATH_LENGTH = 255; +function normalizeFolderPath(value) { + if (typeof value !== "string") throw new Error("\u6587\u4EF6\u5939\u8DEF\u5F84\u683C\u5F0F\u9519\u8BEF"); + const normalized = value.trim().replace(/^\/+|\/+$/g, ""); + if (!normalized) throw new Error("\u6587\u4EF6\u5939\u8DEF\u5F84\u4E0D\u80FD\u4E3A\u7A7A"); + if (normalized.length > MAX_FOLDER_PATH_LENGTH) throw new Error(`\u6587\u4EF6\u5939\u8DEF\u5F84\u4E0D\u80FD\u8D85\u8FC7 ${MAX_FOLDER_PATH_LENGTH} \u4E2A\u5B57\u7B26`); + const segments = normalized.split("/"); + if (segments.some((segment) => !segment || segment === "." || segment === "..")) { + throw new Error("\u6587\u4EF6\u5939\u8DEF\u5F84\u5305\u542B\u7A7A\u76EE\u5F55\u6216\u76F8\u5BF9\u8DEF\u5F84"); + } + if (segments.some((segment) => segment !== segment.trim() || INVALID_SEGMENT_CHARACTERS.test(segment))) { + throw new Error("\u6587\u4EF6\u5939\u8DEF\u5F84\u5305\u542B\u975E\u6CD5\u5B57\u7B26"); + } + return segments.join("/"); +} +function normalizeFolderName(value) { + const name = normalizeFolderPath(value); + if (name.includes("/")) throw new Error("\u6587\u4EF6\u5939\u540D\u79F0\u4E0D\u80FD\u5305\u542B\u8DEF\u5F84\u5206\u9694\u7B26"); + return name; +} +function folderBaseName(folder) { + const segments = folder.split("/"); + return segments[segments.length - 1]; +} +function folderParent(folder) { + const segments = folder.split("/"); + return segments.length > 1 ? segments.slice(0, -1).join("/") : null; +} +function joinFolderPath(parent, name) { + return normalizeFolderPath(parent ? `${parent}/${name}` : name); +} +function isFolderWithin(folder, ancestor) { + return folder === ancestor || folder.startsWith(`${ancestor}/`); +} + // src/services/telegramCommands.ts var checkDiskSpace = checkDiskSpaceModule.default || checkDiskSpaceModule; var DOWNLOAD_WORKER_OPTIONS = [4, 8, 12, 16]; @@ -11397,9 +12479,28 @@ var ON_VALUES = /* @__PURE__ */ new Set(["1", "true", "yes", "on"]); var UPLOAD_DIR4 = process.env.UPLOAD_DIR || "./data/uploads"; var THUMBNAIL_DIR4 = process.env.THUMBNAIL_DIR || "./data/thumbnails"; var pendingDeleteConfirmations = /* @__PURE__ */ new Map(); +var pendingTelegramFileMutations = /* @__PURE__ */ new Map(); var pendingStorageClearSnapshots = /* @__PURE__ */ new Map(); var pendingBulkTaskCancellations = /* @__PURE__ */ new Map(); var destructiveConfirmations = new DestructiveConfirmationStore(); +function buildFileActionKeyboard(file) { + return new Api6.ReplyInlineMarkup({ + rows: buildTelegramFileActionRows(file).map((row) => new Api6.KeyboardButtonRow({ + buttons: row.map((button) => new Api6.KeyboardButtonCallback({ text: button.text, data: Buffer.from(button.data) })) + })) + }); +} +function buildFileSearchKeyboard(files) { + if (files.length === 0) return void 0; + return new Api6.ReplyInlineMarkup({ + rows: files.slice(0, 8).map((file) => new Api6.KeyboardButtonRow({ + buttons: [new Api6.KeyboardButtonCallback({ + text: `${file.is_favorite ? "\u2B50 " : ""}${String(file.name).slice(0, 38)}`, + data: Buffer.from(encodeTelegramFileCallback("detail", String(file.id))) + })] + })) + }); +} function buildDeleteConfirmKeyboard(confirmId) { return new Api6.ReplyInlineMarkup({ rows: [new Api6.KeyboardButtonRow({ @@ -11428,7 +12529,7 @@ async function getCurrentDownloadWorkers() { const value = await getSetting("telegram_download_workers", process.env.TELEGRAM_DOWNLOAD_WORKERS || "4"); return normalizeDownloadWorkers(value); } -function buildDownloadWorkersKeyboard(current2, confirmValue) { +function buildDownloadWorkersKeyboard(current3, confirmValue) { if (confirmValue) { return new Api6.ReplyInlineMarkup({ rows: [ @@ -11445,14 +12546,14 @@ function buildDownloadWorkersKeyboard(current2, confirmValue) { rows: [ new Api6.KeyboardButtonRow({ buttons: [ - new Api6.KeyboardButtonCallback({ text: `${current2 === 4 ? "\u2705 " : ""}4`, data: Buffer.from("dw_set_4") }), - new Api6.KeyboardButtonCallback({ text: `${current2 === 8 ? "\u2705 " : ""}8`, data: Buffer.from("dw_set_8") }) + new Api6.KeyboardButtonCallback({ text: `${current3 === 4 ? "\u2705 " : ""}4`, data: Buffer.from("dw_set_4") }), + new Api6.KeyboardButtonCallback({ text: `${current3 === 8 ? "\u2705 " : ""}8`, data: Buffer.from("dw_set_8") }) ] }), new Api6.KeyboardButtonRow({ buttons: [ - new Api6.KeyboardButtonCallback({ text: `${current2 === 12 ? "\u2705 " : ""}12 \u26A0\uFE0F`, data: Buffer.from("dw_set_12") }), - new Api6.KeyboardButtonCallback({ text: `${current2 === 16 ? "\u2705 " : ""}16 \u26A0\uFE0F`, data: Buffer.from("dw_set_16") }) + new Api6.KeyboardButtonCallback({ text: `${current3 === 12 ? "\u2705 " : ""}12 \u26A0\uFE0F`, data: Buffer.from("dw_set_12") }), + new Api6.KeyboardButtonCallback({ text: `${current3 === 16 ? "\u2705 " : ""}16 \u26A0\uFE0F`, data: Buffer.from("dw_set_16") }) ] }) ] @@ -11588,11 +12689,11 @@ async function pruneEmptyDirs(dir, baseDir = path15.resolve(UPLOAD_DIR4)) { await pruneEmptyDirs(path15.dirname(dir), baseDir); } } -function buildDownloadWorkersText(current2) { +function buildDownloadWorkersText(current3) { return [ "\u2699\uFE0F **Telegram \u5206\u7247\u5E76\u53D1\u8BBE\u7F6E**", "", - `\u5F53\u524D worker \u6570\uFF1A**${current2}**`, + `\u5F53\u524D worker \u6570\uFF1A**${current3}**`, "", "\u8BF4\u660E\uFF1ATelegram \u5355\u6B21\u8BF7\u6C42\u4E0A\u9650\u4ECD\u662F 512KB\uFF0C\u8FD9\u91CC\u8C03\u6574\u7684\u662F\u5355\u4E2A\u6587\u4EF6\u5185\u90E8\u7684\u5E76\u53D1\u5206\u7247\u8BF7\u6C42\u6570\u3002", "\u5982\u679C\u8981\u8C03\u6574\u201C\u4E00\u6B21\u540C\u65F6\u4E0B\u8F7D\u51E0\u4E2A\u6587\u4EF6\u201D\uFF0C\u8BF7\u4F7F\u7528 /file_concurrency\u3002", @@ -11611,7 +12712,7 @@ async function getCurrentFileConcurrency() { const value = await getSetting("telegram_file_download_concurrency", process.env.TELEGRAM_FILE_DOWNLOAD_CONCURRENCY || String(getFileDownloadConcurrency())); return normalizeFileConcurrency(value); } -function buildFileConcurrencyKeyboard(current2, confirmValue) { +function buildFileConcurrencyKeyboard(current3, confirmValue) { if (confirmValue) { return new Api6.ReplyInlineMarkup({ rows: [ @@ -11628,25 +12729,25 @@ function buildFileConcurrencyKeyboard(current2, confirmValue) { rows: [ new Api6.KeyboardButtonRow({ buttons: [ - new Api6.KeyboardButtonCallback({ text: `${current2 === 1 ? "\u2705 " : ""}1`, data: Buffer.from("fc_set_1") }), - new Api6.KeyboardButtonCallback({ text: `${current2 === 2 ? "\u2705 " : ""}2`, data: Buffer.from("fc_set_2") }) + new Api6.KeyboardButtonCallback({ text: `${current3 === 1 ? "\u2705 " : ""}1`, data: Buffer.from("fc_set_1") }), + new Api6.KeyboardButtonCallback({ text: `${current3 === 2 ? "\u2705 " : ""}2`, data: Buffer.from("fc_set_2") }) ] }), new Api6.KeyboardButtonRow({ buttons: [ - new Api6.KeyboardButtonCallback({ text: `${current2 === 3 ? "\u2705 " : ""}3`, data: Buffer.from("fc_set_3") }), - new Api6.KeyboardButtonCallback({ text: `${current2 === 4 ? "\u2705 " : ""}4 \u26A0\uFE0F`, data: Buffer.from("fc_set_4") }) + new Api6.KeyboardButtonCallback({ text: `${current3 === 3 ? "\u2705 " : ""}3`, data: Buffer.from("fc_set_3") }), + new Api6.KeyboardButtonCallback({ text: `${current3 === 4 ? "\u2705 " : ""}4 \u26A0\uFE0F`, data: Buffer.from("fc_set_4") }) ] }) ] }); } -function buildFileConcurrencyText(current2) { +function buildFileConcurrencyText(current3) { const stats = getDownloadQueueStats(); return [ "\u{1F4E6} **Telegram \u6587\u4EF6\u7EA7\u5E76\u53D1\u8BBE\u7F6E**", "", - `\u5F53\u524D\u540C\u65F6\u4E0B\u8F7D\u6587\u4EF6\u6570\uFF1A**${current2}**`, + `\u5F53\u524D\u540C\u65F6\u4E0B\u8F7D\u6587\u4EF6\u6570\uFF1A**${current3}**`, `\u5F53\u524D\u961F\u5217\uFF1A\u8FDB\u884C\u4E2D ${stats.active}\uFF0C\u7B49\u5F85\u4E2D ${stats.pending}`, "", "\u8BF4\u660E\uFF1A\u8FD9\u91CC\u63A7\u5236\u201C\u4E00\u6B21\u540C\u65F6\u4E0B\u8F7D\u51E0\u4E2A\u6587\u4EF6\u201D\u3002", @@ -11737,6 +12838,92 @@ async function handleStart(message, senderId, buttons) { async function handleHelp(message) { await message.reply({ message: buildHelp() }); } +async function handleNotifications(message, args = []) { + 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 current3 = await getTelegramNotificationPreferences(userId, chatId); + if (args.length === 0) { + await message.reply({ message: [ + "\u{1F514} **\u901A\u77E5\u504F\u597D**", + `\u5931\u8D25\uFF1A${current3.failureImmediate ? "\u5373\u65F6" : "\u6458\u8981"}`, + `\u6210\u529F\uFF1A${current3.successMode}`, + `\u5B89\u5168\uFF1A\u59CB\u7EC8\u5373\u65F6\uFF08\u4E0D\u53EF\u5173\u95ED\uFF09`, + `\u8BA2\u9605\uFF1A${current3.subscriptionDigest ? "\u6458\u8981" : "\u5373\u65F6"}`, + `\u65F6\u533A\uFF1A${current3.timezone}`, + `\u5B89\u9759\u65F6\u6BB5\uFF1A${current3.quietStart && current3.quietEnd ? `${current3.quietStart}-${current3.quietEnd}` : "\u5173\u95ED"}`, + "", + "\u7528\u6CD5\uFF1A/notifications timezone Asia/Shanghai\uFF1B/notifications quiet 22:00-07:00\uFF1B/notifications success digest|immediate|off\uFF1B/notifications failure immediate|digest\uFF1B/notifications subscription digest|immediate" + ].join("\n") }); + return; + } + const [key, value = ""] = args; + const update = { ...current3 }; + 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("\u5B89\u9759\u65F6\u6BB5\u683C\u5F0F\u5FC5\u987B\u662F 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("\u672A\u77E5\u901A\u77E5\u8BBE\u7F6E"); + const saved = await setTelegramNotificationPreferences(userId, chatId, update); + await message.reply({ message: `\u2705 \u901A\u77E5\u504F\u597D\u5DF2\u66F4\u65B0\u3002\u5B89\u5168\u544A\u8B66\u4ECD\u59CB\u7EC8\u5373\u65F6\u3002 +\u65F6\u533A\uFF1A${saved.timezone}\uFF1B\u5B89\u9759\u65F6\u6BB5\uFF1A${saved.quietStart || "-"}-${saved.quietEnd || "-"}` }); +} +async function handleStatus(message) { + const senderId = message.senderId?.toJSNumber(); + if (!senderId || !await isAuthenticatedAsync(senderId)) { + await message.reply({ message: MSG.AUTH_REQUIRED }); + return; + } + const requestId = `tg-${crypto16.randomBytes(6).toString("hex")}`; + try { + const target = storageManager.getActiveTarget(); + const accounts = await storageManager.getAccounts(); + const account = target.accountId ? accounts.find((row) => row.id === target.accountId) : null; + const diskSpace = await checkDiskSpace(path15.resolve(UPLOAD_DIR4)); + 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" ? "\u670D\u52A1\u5668\u672C\u5730\u76EE\u5F55" : target.accountId || "\u9ED8\u8BA4\u8D26\u6237"), + 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(`\u{1F916} status panel failed request=${requestId}:`, error); + await message.reply({ message: `\u274C \u8BCA\u65AD\u72B6\u6001\u8BFB\u53D6\u5931\u8D25\u3002\u64CD\u4F5C ID\uFF1A${requestId}` }); + } +} async function handleStorage(message) { try { const scope = await getCurrentStorageScope(); @@ -11773,6 +12960,61 @@ async function handleStorage(message) { await message.reply({ message: MSG.ERR_STORAGE }); } } +async function handleTarget(message, args = []) { + 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: "\u2705 \u5DF2\u6E05\u9664\u5F53\u524D\u804A\u5929\u7684\u5B58\u50A8\u76EE\u6807\u8986\u76D6\uFF1B\u540E\u7EED\u4EFB\u52A1\u4F7F\u7528\u7CFB\u7EDF\u9ED8\u8BA4\u3002" }); + return; + } + if (!modeInput) { + const once = await getTelegramTargetState(void 0, chatId, "once"); + const session = await getTelegramTargetState(void 0, chatId, "session"); + const active = storageManager.getActiveTarget(); + await message.reply({ + message: [ + "\u{1F3AF} **\u5F53\u524D\u804A\u5929\u5B58\u50A8\u76EE\u6807**", + `\u4E0B\u4E00\u6B21\uFF1A${once ? `${once.provider} / ${once.accountId || "local"}` : "\u672A\u8BBE\u7F6E"}`, + `\u4F1A\u8BDD\uFF1A${session ? `${session.provider} / ${session.accountId || "local"}` : "\u672A\u8BBE\u7F6E"}`, + `\u7CFB\u7EDF\u9ED8\u8BA4\uFF1A${active.provider.name} / ${active.accountId || "local"}`, + "", + "\u7528\u6CD5\uFF1A`/target once `\u3001`/target session `\u3001`/target clear`" + ].join("\n") + }); + return; + } + if (!["once", "session"].includes(modeInput) || !args[1]) { + await message.reply({ message: "\u274C \u7528\u6CD5\uFF1A/target once \u3001/target session \u3001/target clear" }); + return; + } + const mode = modeInput; + const selector = String(args[1]); + let provider = "local"; + let accountId = null; + let accountName = "\u670D\u52A1\u5668\u672C\u5730\u76EE\u5F55"; + if (selector !== "local") { + const accounts = await storageManager.getAccounts(); + const account = accounts.find((row) => String(row.id) === selector || String(row.id).startsWith(selector)); + if (!account) { + await message.reply({ message: "\u274C \u672A\u627E\u5230\u8BE5\u5B58\u50A8\u8D26\u6237\uFF1B\u8BF7\u4ECE /storage_switch \u67E5\u770B\u5B8C\u6574\u8D26\u6237\u5217\u8868\u3002" }); + 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 * 1e3 : 7 * 24 * 60 * 60 * 1e3)); + await setTelegramTargetState(void 0, chatId, mode, provider, accountId, expiresAt); + await message.reply({ message: `\u2705 \u5DF2\u8BBE\u7F6E${mode === "once" ? "\u4E0B\u4E00\u6B21" : "\u5F53\u524D\u804A\u5929\u4F1A\u8BDD"}\u76EE\u6807\uFF1A${provider} / ${accountName} +\u4E0D\u4F1A\u4FEE\u6539\u7CFB\u7EDF\u5168\u5C40\u9ED8\u8BA4\u3002` }); +} async function handleStorageSwitch(message) { try { const view = await buildStorageSwitchView(); @@ -11943,6 +13185,33 @@ async function handleStorageCleanupCallback(client2, update, data) { await client2.invoke(new Api6.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: `\u6E05\u7406\u5931\u8D25: ${error.message}`, alert: true })); } } +async function handleFind(message, args = []) { + const senderId = message.senderId?.toJSNumber(); + if (!senderId || !await isAuthenticatedAsync(senderId)) { + await message.reply({ message: MSG.AUTH_REQUIRED }); + return; + } + const rawOptions = { limit: "8", sort: "date", direction: "desc" }; + const queryParts = []; + 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: `\u274C \u641C\u7D22\u5931\u8D25\uFF1A${error.message}` }); + } +} async function handleList(message, args) { try { let limit = 10; @@ -11979,6 +13248,102 @@ async function handleList(message, args) { await message.reply({ message: MSG.ERR_FILE_LIST }); } } +async function handleTelegramFileBrowserCallback(client2, update, data) { + const actorId = update.userId.toJSNumber(); + if (!await isAuthenticatedAsync(actorId)) { + await client2.invoke(new Api6.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 client2.invoke(new Api6.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u6587\u4EF6\u5DF2\u4E0D\u5B58\u5728\u6216\u4E0D\u5728\u5F53\u524D\u5B58\u50A8\u8303\u56F4\u5185", alert: true })); + return; + } + const chatId = getCallbackChatKey(update); + const messageId = Number(update.msgId); + if (parsed.action === "detail") { + await client2.editMessage(update.peer, { message: messageId, text: buildTelegramFileDetail(file), buttons: buildFileActionKeyboard(file) }); + await client2.invoke(new Api6.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u6587\u4EF6\u8BE6\u60C5" })); + return; + } + if (parsed.action === "copy") { + await client2.invoke(new Api6.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 client2.editMessage(update.peer, { message: messageId, text: buildTelegramFileDetail(file), buttons: buildFileActionKeyboard(file) }); + await client2.invoke(new Api6.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: file.is_favorite ? "\u5DF2\u6536\u85CF" : "\u5DF2\u53D6\u6D88\u6536\u85CF" })); + return; + } + if (parsed.action === "link") { + const capabilities = buildStorageCapabilities(String(file.source)); + if (!capabilities.share && String(file.source) !== "local") { + await client2.invoke(new Api6.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5F53\u524D provider \u4E0D\u652F\u6301\u5206\u4EAB\uFF1B\u53EF\u5728 Web \u4E2D\u4E0B\u8F7D", 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 client2.sendMessage(update.peer, { message: `\u{1F517} 1 \u5C0F\u65F6\u7B7E\u540D\u94FE\u63A5\uFF1A +${link}` }); + await client2.invoke(new Api6.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5DF2\u751F\u6210\u7B7E\u540D\u94FE\u63A5" })); + 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 * 1e3 }); + await client2.sendMessage(update.peer, { message: parsed.action === "move" ? "\u8BF7\u53D1\u9001\u76EE\u6807\u76EE\u5F55\uFF1B\u53D1\u9001\u201C\u53D6\u6D88\u201D\u9000\u51FA\u3002" : "\u8BF7\u53D1\u9001\u65B0\u6587\u4EF6\u540D\uFF08\u5FC5\u987B\u4FDD\u7559\u539F\u6269\u5C55\u540D\uFF09\uFF1B\u53D1\u9001\u201C\u53D6\u6D88\u201D\u9000\u51FA\u3002" }); + await client2.invoke(new Api6.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: parsed.action === "move" ? "\u7B49\u5F85\u76EE\u6807\u76EE\u5F55" : "\u7B49\u5F85\u65B0\u6587\u4EF6\u540D" })); + return; + } + const sent = await client2.sendMessage(update.peer, { + message: ["\u26A0\uFE0F **\u786E\u8BA4\u5220\u9664\u8FD9\u4E2A\u6587\u4EF6\uFF1F**", "", `\u{1F4C4} ${file.name}`, `\u{1F194} ${String(file.id).slice(0, 13)}`, "", "\u8BE5\u64CD\u4F5C\u4F1A\u5220\u9664\u5B9E\u4F53\u6587\u4EF6\u548C\u7D22\u5F15\u3002"].join("\n") + }); + 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 client2.invoke(new Api6.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u9700\u8981\u4E8C\u6B21\u786E\u8BA4" })); +} +async function applyPendingTelegramFileMutation(message, actorId, input) { + 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: "\u64CD\u4F5C\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u6253\u5F00\u6587\u4EF6\u8BE6\u60C5\u3002" }); + return true; + } + if (input.trim() === "\u53D6\u6D88") { + pendingTelegramFileMutations.delete(key); + await message.reply({ message: "\u5DF2\u53D6\u6D88\u6587\u4EF6\u64CD\u4F5C\u3002" }); + return true; + } + const file = await getScopedFileById(pending.fileId); + if (!file) { + pendingTelegramFileMutations.delete(key); + await message.reply({ message: "\u6587\u4EF6\u5DF2\u4E0D\u5B58\u5728\u6216\u4E0D\u5728\u5F53\u524D\u5B58\u50A8\u8303\u56F4\u5185\u3002" }); + return true; + } + if (pending.action === "move") { + const folder = normalizeFolderPath(input); + await updateScopedFileById(pending.fileId, "folder = $1, updated_at = NOW()", [folder]); + await message.reply({ message: `\u2705 \u5DF2\u79FB\u52A8\u5230\uFF1A${folder}` }); + } else { + const name = input.trim(); + if (!name || /[\/\\:*?"<>|]/.test(name)) throw new Error("\u6587\u4EF6\u540D\u5305\u542B\u975E\u6CD5\u5B57\u7B26"); + const extension = (value) => path15.extname(value).toLowerCase(); + if (extension(name) !== extension(String(file.name))) throw new Error("\u4E0D\u5141\u8BB8\u4FEE\u6539\u6587\u4EF6\u540E\u7F00"); + await updateScopedFileById(pending.fileId, "name = $1, updated_at = NOW()", [name]); + await message.reply({ message: `\u2705 \u5DF2\u91CD\u547D\u540D\u4E3A\uFF1A${name}` }); + } + pendingTelegramFileMutations.delete(key); + return true; +} async function handleDelete(message, args) { if (args.length === 0) { await message.reply({ @@ -12713,10 +14078,10 @@ async function handleRetryFailedTasks(message, args) { } async function handleDownloadWorkers(message) { try { - const current2 = await getCurrentDownloadWorkers(); + const current3 = await getCurrentDownloadWorkers(); await message.reply({ - message: buildDownloadWorkersText(current2), - buttons: buildDownloadWorkersKeyboard(current2) + message: buildDownloadWorkersText(current3), + buttons: buildDownloadWorkersKeyboard(current3) }); } catch (error) { console.error("\u{1F916} \u83B7\u53D6\u5206\u7247\u5E76\u53D1\u8BBE\u7F6E\u5931\u8D25:", error); @@ -12725,11 +14090,11 @@ async function handleDownloadWorkers(message) { } async function handleFileConcurrency(message) { try { - const current2 = await getCurrentFileConcurrency(); - setFileDownloadConcurrency(current2); + const current3 = await getCurrentFileConcurrency(); + setFileDownloadConcurrency(current3); await message.reply({ - message: buildFileConcurrencyText(current2), - buttons: buildFileConcurrencyKeyboard(current2) + message: buildFileConcurrencyText(current3), + buttons: buildFileConcurrencyKeyboard(current3) }); } catch (error) { console.error("\u{1F916} \u83B7\u53D6\u6587\u4EF6\u7EA7\u5E76\u53D1\u8BBE\u7F6E\u5931\u8D25:", error); @@ -12889,11 +14254,11 @@ async function handleDownloadWorkersCallback(client2, update, data) { } try { if (data === "dw_cancel") { - const current2 = await getCurrentDownloadWorkers(); + const current3 = await getCurrentDownloadWorkers(); await client2.editMessage(update.peer, { message: Number(update.msgId), - text: buildDownloadWorkersText(current2), - buttons: buildDownloadWorkersKeyboard(current2) + text: buildDownloadWorkersText(current3), + buttons: buildDownloadWorkersKeyboard(current3) }); await client2.invoke(new Api6.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5DF2\u53D6\u6D88" })); return; @@ -12964,12 +14329,12 @@ async function handleFileConcurrencyCallback(client2, update, data) { } try { if (data === "fc_cancel") { - const current2 = await getCurrentFileConcurrency(); - setFileDownloadConcurrency(current2); + const current3 = await getCurrentFileConcurrency(); + setFileDownloadConcurrency(current3); await client2.editMessage(update.peer, { message: Number(update.msgId), - text: buildFileConcurrencyText(current2), - buttons: buildFileConcurrencyKeyboard(current2) + text: buildFileConcurrencyText(current3), + buttons: buildFileConcurrencyKeyboard(current3) }); await client2.invoke(new Api6.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5DF2\u53D6\u6D88" })); return; @@ -13030,8 +14395,9 @@ async function handleFileConcurrencyCallback(client2, update, data) { } } -// src/services/telegramBot.ts -init_db(); +// src/services/ytDlpProbe.ts +import os3 from "node:os"; +import { spawn as spawn2 } from "node:child_process"; // src/utils/networkSecurity.ts import dns from "dns/promises"; @@ -13053,9 +14419,25 @@ function isPrivateIPv4(ip) { ]; return ranges.some(([start, end]) => n >= ipv4ToInt(start) && n <= ipv4ToInt(end)); } +function embeddedIPv4FromIPv6(ip) { + 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 > 65535 || low < 0 || low > 65535) return null; + return `${high >>> 8}.${high & 255}.${low >>> 8}.${low & 255}`; +} function isPrivateIPv6(ip) { 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); } function isPrivateAddress(ip) { const version2 = net.isIP(ip); @@ -13092,40 +14474,309 @@ async function assertPublicStorageEndpoint(rawUrl) { return parsed; } +// src/services/ytDlpProbe.ts +var YTDLP_BIN2 = process.env.YTDLP_BIN || "yt-dlp"; +function buildYtDlpProbeArgs(url) { + return ["--dump-single-json", "--skip-download", "--no-playlist", "--no-warnings", "--", url]; +} +function parseYtDlpProbeOutput(output) { + let parsed; + try { + parsed = JSON.parse(output); + } catch { + throw new Error("yt-dlp \u8FD4\u56DE\u4E86\u65E0\u6548\u5143\u6570\u636E"); + } + const entries = Array.isArray(parsed?.entries) ? parsed.entries : null; + const isPlaylist = parsed?._type === "playlist" || Boolean(entries); + if (isPlaylist) throw new Error("\u64AD\u653E\u5217\u8868\u9ED8\u8BA4\u7981\u7528\uFF1B\u8BF7\u663E\u5F0F\u542F\u7528\u5E76\u6307\u5B9A\u8303\u56F4\u4E0E\u6700\u5927\u6761\u76EE"); + return { + id: String(parsed?.id || ""), + title: String(parsed?.title || "\u672A\u547D\u540D\u5A92\u4F53").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, 2e3), + isPlaylist: false, + playlistCount: null + }; +} +async function runYtDlpProbe(url, options = {}) { + await (options.assertUrl || assertPublicHttpUrl)(url); + const timeoutMs = Math.max(1e3, options.timeoutMs ?? 2e4); + const maxOutputBytes = Math.max(32, options.maxOutputBytes ?? 512 * 1024); + const spawnProcess = options.spawnProcess || spawn2; + const binLower = YTDLP_BIN2.toLowerCase(); + const shell = os3.platform() === "win32" && (binLower.endsWith(".cmd") || binLower.endsWith(".bat")); + const child = spawnProcess(YTDLP_BIN2, buildYtDlpProbeArgs(url), { windowsHide: true, shell }); + return new Promise((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, value) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + options.signal?.removeEventListener("abort", abortChild); + error ? reject(error) : resolve(value); + }; + const kill = (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 \u63A2\u6D4B\u8D85\u65F6\uFF08${timeoutMs}ms\uFF09`)), timeoutMs); + child.stdout.on("data", (chunk) => { + const bytes = Buffer.from(chunk); + if (stdout.length + bytes.length > maxOutputBytes) return kill(new Error("yt-dlp \u63A2\u6D4B\u8F93\u51FA\u8D85\u8FC7\u9650\u5236")); + stdout = Buffer.concat([stdout, bytes]); + }); + child.stderr.on("data", (chunk) => { + stderr += String(chunk); + if (stderr.length > 8e3) stderr = stderr.slice(-8e3); + }); + 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(void 0, parseYtDlpProbeOutput(stdout.toString("utf8"))); + } catch (error) { + finish(error); + } + }); + if (options.signal?.aborted) abortChild(); + else options.signal?.addEventListener("abort", abortChild, { once: true }); + }); +} + +// src/services/ytDlpConfirmation.ts +import crypto17 from "node:crypto"; +var YtDlpConfirmationStore = class { + values = /* @__PURE__ */ new Map(); + ttlMs; + maxEntries; + now; + tokenFactory; + constructor(options = {}) { + this.ttlMs = Math.max(1, options.ttlMs ?? 5 * 6e4); + this.maxEntries = Math.max(1, options.maxEntries ?? 500); + this.now = options.now ?? Date.now; + this.tokenFactory = options.tokenFactory ?? (() => crypto17.randomBytes(12).toString("base64url")); + } + cleanup() { + 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) { + this.cleanup(); + const token = this.tokenFactory(); + const expiresAt = this.now() + this.ttlMs; + this.values.set(token, { ...value, expiresAt }); + return { token, expiresAt }; + } + consume(token, binding) { + 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, binding) { + const result = this.consume(token, binding); + return result.status === "ok"; + } +}; + +// src/services/telegramBot.ts +init_db(); +init_authSettings(); + +// src/services/telegramCommandDispatcher.ts +var CATEGORY_LABELS = { + main: "\u9996\u9875", + files: "\u6587\u4EF6", + channels: "\u9891\u9053", + settings: "\u8BBE\u7F6E", + security: "\u5B89\u5168" +}; +function buildCommandHomePage(requestedPage) { + 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 = commands.map((command) => [{ + text: `/${command.command} \xB7 ${command.description}`, + data: `home_open_${command.command}` + }]); + const navigation = []; + if (page > 0) navigation.push({ text: "\u25C0\uFE0F", 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: "\u25B6\uFE0F", data: `home_page_${page + 1}` }); + buttons.push(navigation); + return { page, totalPages, category, commands, buttons }; +} + // src/services/telegramSubscriptionVisibility.ts var TELEGRAM_USER_CANCELLED_SUBSCRIPTION_REASON = "\u7528\u6237\u624B\u52A8\u53D6\u6D88\u8BA2\u9605"; function isTelegramSubscriptionVisibleInManagement(row) { return row.enabled || row.disabled_reason !== TELEGRAM_USER_CANCELLED_SUBSCRIPTION_REASON; } -// src/services/telegramSubscriptionManagement.ts -var TELEGRAM_SUBSCRIPTION_PAGE_SIZE = 5; -function buildTelegramSubscriptionPage(rows, requestedPage = 0) { - const totalPages = Math.max(1, Math.ceil(rows.length / TELEGRAM_SUBSCRIPTION_PAGE_SIZE)); - const page = Math.min(Math.max(0, Math.floor(requestedPage || 0)), totalPages - 1); - const startIndex = page * TELEGRAM_SUBSCRIPTION_PAGE_SIZE; - return { - page, - totalPages, - startIndex, - visibleRows: rows.slice(startIndex, startIndex + TELEGRAM_SUBSCRIPTION_PAGE_SIZE) - }; -} -function parseTelegramSubscriptionCallback(data) { - 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); - if (match) { - return { - kind: "action", - action: match[1], - id: match[2], - page: Number(match[3] || 0) - }; +// src/services/telegramInteractionState.ts +var TelegramInteractionStore = class { + entries = /* @__PURE__ */ new Map(); + ttlMs; + maxEntries; + now; + constructor(options = {}) { + this.ttlMs = Math.max(1, options.ttlMs ?? 15 * 6e4); + this.maxEntries = Math.max(1, options.maxEntries ?? 1e3); + this.now = options.now ?? Date.now; } - match = data.match(/^tsub_(confirm|back)_([A-Za-z0-9_-]{1,64})$/); - if (match) return { kind: match[1], token: match[2] }; - return null; + key(userId, chatKey) { + return `${userId}:${canonicalTelegramChatKey(chatKey)}`; + } + removeExpired(now = this.now()) { + let removed = 0; + for (const [key, value] of this.entries) { + if (value.expiresAt <= now) { + this.entries.delete(key); + removed += 1; + } + } + return removed; + } + enforceCapacity() { + while (this.entries.size >= this.maxEntries) { + let oldestKey; + 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) { + const now = this.now(); + this.removeExpired(now); + const key = this.key(input.userId, input.chatKey); + if (!this.entries.has(key)) this.enforceCapacity(); + const record = { + ...input, + chatKey: canonicalTelegramChatKey(input.chatKey), + createdAt: now, + expiresAt: now + this.ttlMs + }; + this.entries.set(key, record); + return record; + } + lookup(userId, chatKey) { + 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, chatKey) { + const result = this.lookup(userId, chatKey); + return result.status === "active" ? result.record : void 0; + } + update(userId, chatKey, input) { + const current3 = this.lookup(userId, chatKey); + if (current3.status !== "active") return void 0; + const now = this.now(); + const record = { + ...current3.record, + kind: input.kind, + step: input.step, + value: input.value, + originMessageId: input.originMessageId ?? current3.record.originMessageId, + expiresAt: now + this.ttlMs + }; + this.entries.set(this.key(userId, chatKey), record); + return record; + } + delete(userId, chatKey) { + return this.entries.delete(this.key(userId, chatKey)); + } + cleanup() { + return this.removeExpired(); + } + validateCallback(input) { + 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 }; + } + get size() { + this.removeExpired(); + return this.entries.size; + } +}; + +// src/bot/context.ts +function messageChatKey(message, senderId) { + return canonicalTelegramChatKey(message.chatId?.toString() || senderId); +} +function callbackChatKey(update, userId) { + return telegramChatKeyFromPeerParts(update.peer, userId); +} +function telegramSubscriptionPeerKey(peer) { + const value = peer?.userId || peer?.chatId || peer?.channelId; + return String(value?.toString?.() || value || peer?.toString?.() || "").replace(/^-100/, "").replace(/^-/, ""); +} + +// src/bot/presentation/subscription.ts +function buildSubscriptionDisplayLines(row, index) { + const status = row.enabled ? "\u2705" : "\u23F8\uFE0F"; + const sourceLine = row.source_original && row.source_original !== row.source ? ` ${row.source_original} \u2192 ${row.source} \xB7 last_id=${row.last_message_id || 0}` : ` ${row.source} \xB7 last_id=${row.last_message_id || 0}`; + return [ + `${index + 1}. ${status} ${row.title || row.source_original || row.source}`, + sourceLine, + row.folder_override ? ` \u{1F4C1} \u4E13\u5C5E\u76EE\u5F55\uFF1A${row.folder_override}` : " \u{1F4C1} \u4FDD\u5B58\u7B56\u7565\uFF1A\u9ED8\u8BA4\u81EA\u52A8\u5206\u7C7B", + ` \u{1F3AF} Target\uFF1A${row.target_mode === "fixed" ? `${row.target_provider} / ${row.target_account_id || "local"}\uFF08\u56FA\u5B9A\uFF09` : "\u8DDF\u968F\u7CFB\u7EDF\u9ED8\u8BA4\uFF08\u6BCF\u6B21 admission \u5FEB\u7167\uFF09"}`, + row.last_scan_at ? ` \u{1F50E} \u4E0A\u6B21\u626B\u63CF\uFF1A${new Date(row.last_scan_at).toLocaleString("zh-CN", { hour12: false })}` : " \u{1F50E} \u5C1A\u672A\u626B\u63CF", + row.last_success_at ? ` \u2705 \u4E0A\u6B21\u6210\u529F\uFF1A${new Date(row.last_success_at).toLocaleString("zh-CN", { hour12: false })}` : null, + row.next_scan_at ? ` \u23ED\uFE0F \u4E0B\u6B21\u626B\u63CF\u7EA6\uFF1A${new Date(row.next_scan_at).toLocaleString("zh-CN", { hour12: false })}` : null, + row.last_result ? ` \u{1F4CA} \u6700\u8FD1\u7ED3\u679C\uFF1A${row.last_result.status || "unknown"}${row.last_result.found !== void 0 ? `\uFF0C\u53D1\u73B0 ${row.last_result.found}` : ""}${row.last_result.failed ? `\uFF0C\u5931\u8D25 ${row.last_result.failed}` : ""}` : null, + row.last_error ? ` \u26A0\uFE0F \u6700\u8FD1\u9519\u8BEF\uFF1A${row.last_error}` : null, + !row.enabled && row.disabled_reason ? ` \u26A0\uFE0F ${row.disabled_reason}` : null, + !row.enabled && row.disabled_at ? ` \u6682\u505C\u65F6\u95F4\uFF1A${new Date(row.disabled_at).toLocaleString("zh-CN", { hour12: false })}` : null + ].filter(Boolean).join("\n"); +} +function buildSubscriptionManagePanel(rows, page) { + return [ + "\u{1F4E1} **\u9891\u9053\u8BA2\u9605\u7BA1\u7406**", + ...page.totalPages > 1 ? [`\u7B2C ${page.page + 1}/${page.totalPages} \u9875 \xB7 \u5171 ${rows.length} \u4E2A\u8BA2\u9605`] : [], + "", + page.visibleRows.length > 0 ? page.visibleRows.map((row, index) => buildSubscriptionDisplayLines(row, page.startIndex + index)).join("\n") : "\u5F53\u524D\u6CA1\u6709\u8BA2\u9605\u3002", + "", + rows.length > 0 ? "\u53EF\u4F7F\u7528\u64CD\u4F5C\u6309\u94AE\u540C\u6B65\u3001\u6682\u505C\u3001\u4FEE\u6539\u76EE\u6807\u3001\u8865\u6293\u6216\u91CD\u8BD5\uFF1B\u5DF2\u4FDD\u5B58\u6587\u4EF6\u4E0D\u4F1A\u968F\u53D6\u6D88\u8BA2\u9605\u5220\u9664\u3002" : "\u56DE\u590D\u9891\u9053\u7528\u6237\u540D\u6216\u94FE\u63A5\u53EF\u65B0\u589E\u8BA2\u9605\u3002", + "\u56DE\u590D\u9891\u9053\u7528\u6237\u540D\u6216\u94FE\u63A5\u4E5F\u53EF\u65B0\u589E\u8BA2\u9605\u3002", + "\u4F8B\u5982\uFF1A`@channel_username`\u3001`https://t.me/channel_username` \u6216\u5DF2\u52A0\u5165\u7684 `https://t.me/+hash` \u79C1\u5BC6\u94FE\u63A5", + "", + "\u65B0\u589E\u8BA2\u9605\u65F6\u53EF\u6307\u5B9A\u72EC\u7ACB\u76EE\u5F55\uFF0C\u4E0D\u4F1A\u6539\u53D8\u5168\u5C40 /path_rules\u3002", + "\u53D1\u9001\u201C\u53D6\u6D88\u201D\u53EF\u9000\u51FA\u3002" + ].join("\n"); } // src/services/telegramBot.ts @@ -13143,21 +14794,70 @@ function buildBotStartKeyboard() { ] }); } -function buildBotMoreKeyboard() { - const visible = BOT_COMMANDS.filter((command) => command.help && !["start", "tasks", "storage", "help"].includes(command.command)); +function homePageKeyboard(requestedPage) { + const page = buildCommandHomePage(requestedPage); return new Api7.ReplyInlineMarkup({ - rows: visible.slice(0, 12).map((command) => new Api7.KeyboardButtonRow({ - buttons: [new Api7.KeyboardButtonCallback({ text: `/${command.command} \xB7 ${command.description}`, data: Buffer.from(`home_cmd_${command.command}`) })] + rows: page.buttons.map((row) => new Api7.KeyboardButtonRow({ + buttons: row.map((button) => new Api7.KeyboardButtonCallback({ text: button.text, data: Buffer.from(button.data) })) })) }); } +function homePageText(requestedPage) { + const page = buildCommandHomePage(requestedPage); + const labels = { main: "\u5E38\u7528\u5165\u53E3", files: "\u6587\u4EF6\u4E0E\u4FDD\u5B58\u4F4D\u7F6E", channels: "\u9891\u9053\u4E0E\u8BA2\u9605", settings: "\u4EFB\u52A1\u4E0E\u7CFB\u7EDF\u8BBE\u7F6E", security: "\u5B89\u5168" }; + return [`\u2630 **${labels[page.category] || page.category}**`, `\u7B2C ${page.page + 1}/${page.totalPages} \u9875`, "", "\u70B9\u51FB\u6309\u94AE\u53EF\u76F4\u63A5\u6253\u5F00\u5BF9\u5E94\u529F\u80FD\u3002"].join("\n"); +} async function handleBotHomeCallback(update, data) { 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])); - if (data === "home_storage") return handleStorage(await client.getMessages(update.peer, { ids: Number(update.msgId) }).then((messages) => messages[0])); - const text = data === "home_upload" ? "\u{1F4E4} \u76F4\u63A5\u53D1\u9001\u6216\u8F6C\u53D1\u4EFB\u610F\u6587\u4EF6\u5373\u53EF\u4E0A\u4F20\u3002\n\n\u4F7F\u7528 /path_rules \u9009\u62E9\u4FDD\u5B58\u4F4D\u7F6E\uFF0C\u4F7F\u7528 /tasks \u67E5\u770B\u8FDB\u5EA6\u3002" : data === "home_more" ? "\u2630 **\u66F4\u591A\u80FD\u529B**\n\n\u8BF7\u9009\u62E9\u4E0B\u65B9\u547D\u4EE4\uFF0C\u6216\u53D1\u9001 /help \u67E5\u770B\u5B8C\u6574\u8BF4\u660E\u3002" : `\u8BF7\u53D1\u9001 /${data.replace(/^home_cmd_/, "")} \u4F7F\u7528\u6B64\u80FD\u529B\u3002`; - 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]); + 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: "\u{1F4E4} \u76F4\u63A5\u53D1\u9001\u6216\u8F6C\u53D1\u4EFB\u610F\u6587\u4EF6\u5373\u53EF\u4E0A\u4F20\u3002\n\n\u4F7F\u7528 /path_rules \u9009\u62E9\u4FDD\u5B58\u4F4D\u7F6E\uFF0C\u4F7F\u7528 /tasks \u67E5\u770B\u8FDB\u5EA6\u3002", 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: "\u53D1\u9001 /logout \u53EF\u7ACB\u5373\u64A4\u9500\u5F53\u524D Bot \u8BA4\u8BC1\u3002" }); + return; + } + const definition = BOT_COMMANDS.find((item) => item.command === command); + await client.sendMessage(update.peer, { message: `/${command}${definition?.usage ? ` ${definition.usage}` : ""}` }); } var SESSION_FILE = process.env.TELEGRAM_SESSION_FILE || "./data/telegram_session.txt"; var client = null; @@ -13191,7 +14891,34 @@ function buildTelegramCommentsKeyboard() { ] }); } -var telegramWizardStates = /* @__PURE__ */ new Map(); +var TELEGRAM_INTERACTION_TTL_MS = Math.max(6e4, parseInt(process.env.TELEGRAM_INTERACTION_TTL_MS || "900000", 10) || 9e5); +var TELEGRAM_INTERACTION_MAX_ENTRIES = Math.max(10, parseInt(process.env.TELEGRAM_INTERACTION_MAX_ENTRIES || "1000", 10) || 1e3); +var telegramWizardStates = new TelegramInteractionStore({ + ttlMs: TELEGRAM_INTERACTION_TTL_MS, + maxEntries: TELEGRAM_INTERACTION_MAX_ENTRIES +}); +var ytDlpConfirmations = new YtDlpConfirmationStore({ + ttlMs: Math.min(TELEGRAM_INTERACTION_TTL_MS, 5 * 6e4), + maxEntries: Math.min(TELEGRAM_INTERACTION_MAX_ENTRIES, 500) +}); +function putTelegramWizardState(userId, chatKey, state, originMessageId) { + telegramWizardStates.set({ + userId, + chatKey, + kind: state.kind, + step: state.step, + originMessageId, + value: state + }); +} +function refreshTelegramWizardState(userId, chatKey, state, originMessageId) { + telegramWizardStates.update(userId, chatKey, { + kind: state.kind, + step: state.step, + value: state, + originMessageId + }); +} var pendingSubscriptionCancels = /* @__PURE__ */ new Map(); var TELEGRAM_SUBSCRIPTION_CONFIRM_TTL_MS = 2 * 60 * 1e3; var telegramRateBuckets = /* @__PURE__ */ new Map(); @@ -13217,8 +14944,8 @@ function getPinLockSeconds(userId) { } function recordPinFailure(userId) { const now = Date.now(); - const current2 = pinFailureState.get(userId); - const state = !current2 || now - current2.windowStartedAt >= TELEGRAM_PIN_FAIL_WINDOW_MS ? { windowStartedAt: now, failed: 0 } : current2; + const current3 = pinFailureState.get(userId); + const state = !current3 || now - current3.windowStartedAt >= TELEGRAM_PIN_FAIL_WINDOW_MS ? { windowStartedAt: now, failed: 0 } : current3; state.failed += 1; if (state.failed >= TELEGRAM_PIN_FAIL_MAX) { state.lockedUntil = now + TELEGRAM_PIN_LOCK_MS; @@ -13319,12 +15046,17 @@ function buildTelegramWizardPrompt(state) { } if (state.step === "confirm") { const range = state.kind === "tg_tag" ? `\u6807\u7B7E\uFF1A#${state.tag}` : `\u65E5\u671F\uFF1A${state.startDate} \u2192 ${state.endDate}`; + const dateRangeLines = state.kind === "tg_date" ? [ + `\u{1F4C5} \u5171 ${state.dayCount || 0} \u5929\uFF08\u542B\u9996\u5C3E\u65E5\u671F\uFF09`, + state.requiresLargeRangeConfirmation ? "\u26A0\uFE0F \u8303\u56F4\u8F83\u5927\uFF1A\u786E\u8BA4\u540E\u5C06\u6267\u884C\u5206\u6BB5\u626B\u63CF\uFF0C\u8BF7\u6838\u5BF9\u65E5\u671F\u548C\u5B58\u50A8\u76EE\u6807\u3002" : null + ] : []; return [ title, "", "\u8BF7\u786E\u8BA4\u4EFB\u52A1\u8303\u56F4\uFF1A", `\u{1F4CD} \u9891\u9053\uFF1A${state.source}`, `\u{1F50E} ${range}`, + ...dateRangeLines, `\u{1F4AC} \u8BC4\u8BBA\u533A\uFF1A${state.includeComments ? `\u5305\u542B\uFF08\u6BCF\u5E16\u6700\u591A ${state.commentsMaxPerPost} \u6761\uFF09` : "\u4E0D\u5305\u542B"}`, `\u{1F4C1} \u76EE\u5F55\uFF1A${state.customFolder || "\u9ED8\u8BA4\u81EA\u52A8\u5206\u7C7B"}`, `\u2601\uFE0F \u56FA\u5B9A\u5B58\u50A8\uFF1A${state.targetProvider || "\u5F53\u524D\u5B58\u50A8"} / ${state.targetAccountName || state.targetAccountId || "\u5F53\u524D\u8D26\u6237"}`, @@ -13367,7 +15099,12 @@ function buildTelegramWizardPrompt(state) { ].join("\n"); } function isDateOnly(text) { - return /^\d{4}-\d{2}-\d{2}$/.test(text.trim()); + try { + parseDateOnlyStrict(text.trim()); + return true; + } catch { + return false; + } } function buildLegacyJobProgressPresentation(summary) { const totalDone = summary.completed + summary.failed + summary.skipped; @@ -13431,24 +15168,36 @@ ID: ${String(result.jobId).slice(0, 12)} } async function startTelegramWizard(message, senderId, kind) { const state = { kind, step: kind === "tg_download" ? "mode" : "source" }; - telegramWizardStates.set(senderId, state); + const chatKey = messageChatKey(message, senderId); + let originMessageId; if (kind === "tg_sub_manage") { const rows = await listManageableTelegramSubscriptions(senderId); - await message.reply({ message: buildSubscriptionManagePanel(rows, 0), buttons: buildSubscriptionActionKeyboard(rows, 0) }); + const reply2 = await message.reply({ message: buildSubscriptionManagePanel2(rows, 0), buttons: buildSubscriptionActionKeyboard(rows, 0) }); + originMessageId = reply2.id; + putTelegramWizardState(senderId, chatKey, state, originMessageId); return; } - await message.reply({ + const reply = await message.reply({ message: buildTelegramWizardPrompt(state), buttons: kind === "tg_download" ? buildTelegramDownloadModeKeyboard() : void 0 }); + originMessageId = reply.id; + putTelegramWizardState(senderId, chatKey, state, originMessageId); } async function handleTelegramWizardMessage(message, senderId, text) { - const state = telegramWizardStates.get(senderId); - if (!state) return false; + const chatKey = messageChatKey(message, senderId); + const lookup2 = telegramWizardStates.lookup(senderId, chatKey); + if (lookup2.status === "expired") { + await message.reply({ message: "\u231B \u5F53\u524D\u5411\u5BFC\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u6253\u5F00\u3002" }); + return true; + } + if (lookup2.status === "missing") return false; + const state = lookup2.record.value; const input = text.trim(); if (!input) return true; + refreshTelegramWizardState(senderId, chatKey, state, lookup2.record.originMessageId); if (isCancelInput(input)) { - telegramWizardStates.delete(senderId); + telegramWizardStates.delete(senderId, chatKey); await message.reply({ message: "\u5DF2\u53D6\u6D88 Telegram \u9891\u9053\u64CD\u4F5C\u5411\u5BFC\u3002" }); return true; } @@ -13489,7 +15238,7 @@ async function handleTelegramWizardMessage(message, senderId, text) { await message.reply({ message: "\u274C \u6CA1\u6709\u8FD9\u4E2A\u5E8F\u53F7\uFF0C\u8BF7\u56DE\u590D\u5217\u8868\u4E2D\u7684\u5E8F\u53F7\uFF0C\u6216\u53D1\u9001\u9891\u9053\u7528\u6237\u540D/\u94FE\u63A5\u6765\u65B0\u589E\u8BA2\u9605\u3002" }); return true; } - telegramWizardStates.delete(senderId); + telegramWizardStates.delete(senderId, chatKey); await sendSubscriptionCancelConfirmation(message, senderId, target2, 0); return true; } @@ -13520,7 +15269,7 @@ async function handleTelegramWizardMessage(message, senderId, text) { } } 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); @@ -13531,7 +15280,7 @@ async function handleTelegramWizardMessage(message, senderId, text) { sub && state.customFolder ? `\u{1F4C1} \u4E13\u5C5E\u76EE\u5F55\uFF1A${state.customFolder} ${buildPathPreviewLine(state.customFolder)}` : "\u{1F4C1} \u4FDD\u5B58\u7B56\u7565\uFF1A\u9ED8\u8BA4\u81EA\u52A8\u5206\u7C7B", "", - buildSubscriptionManagePanel(rowsAfterUpdate, 0) + buildSubscriptionManagePanel2(rowsAfterUpdate, 0) ].filter(Boolean).join("\n"), buttons: buildSubscriptionActionKeyboard(rowsAfterUpdate, 0) }); @@ -13577,7 +15326,7 @@ ${buildPathPreviewLine(state.customFolder)}` : "\u{1F4C1} \u672C\u8BA2\u9605\u4F await message.reply({ message: "\u8BF7\u53D1\u9001 `\u786E\u8BA4` \u5F00\u59CB\u4EFB\u52A1\uFF0C\u6216\u53D1\u9001 `\u53D6\u6D88` \u653E\u5F03\u3002" }); return true; } - telegramWizardStates.delete(senderId); + telegramWizardStates.delete(senderId, chatKey); try { if (state.kind === "tg_tag") { const queuedMsg = await message.reply({ message: `\u23F3 \u5DF2\u786E\u8BA4\uFF0C\u5F00\u59CB\u540E\u53F0\u626B\u63CF ${state.source} \u4E2D\u5E26\u6709 ${state.tag?.startsWith("#") ? state.tag : `#${state.tag}`} \u7684\u5A92\u4F53\u6D88\u606F\u2026` }); @@ -13586,8 +15335,7 @@ ${buildPathPreviewLine(state.customFolder)}` : "\u{1F4C1} \u672C\u8BA2\u9605\u4F commentsMaxPerPost: state.commentsMaxPerPost || TELEGRAM_COMMENTS_MAX_PER_POST, onScanComplete: (summary) => updateScanStatusMessage(queuedMsg, summary), onProgress: (summary) => updateJobProgressMessage(queuedMsg, summary), - targetProvider: state.targetProvider, - targetAccountId: state.targetAccountId + target: state.target }), "tag"); } else { const queuedMsg = await message.reply({ message: `\u23F3 \u5DF2\u786E\u8BA4\uFF0C\u5F00\u59CB\u540E\u53F0\u626B\u63CF ${state.source}\uFF1A${state.startDate} \u2192 ${state.endDate}\u2026` }); @@ -13596,8 +15344,7 @@ ${buildPathPreviewLine(state.customFolder)}` : "\u{1F4C1} \u672C\u8BA2\u9605\u4F commentsMaxPerPost: state.commentsMaxPerPost || TELEGRAM_COMMENTS_MAX_PER_POST, onScanComplete: (summary) => updateScanStatusMessage(queuedMsg, summary), onProgress: (summary) => updateJobProgressMessage(queuedMsg, summary), - targetProvider: state.targetProvider, - targetAccountId: state.targetAccountId + target: state.target }), "date"); } } catch (error) { @@ -13607,8 +15354,10 @@ ${buildPathPreviewLine(state.customFolder)}` : "\u{1F4C1} \u672C\u8BA2\u9605\u4F } if (state.step === "tag") { state.tag = input; - const target2 = storageManager.getActiveTarget(); + const selectedTarget2 = await consumeOrGetTelegramTargetState(chatKey); + const target2 = selectedTarget2 ? storageManager.getTarget(selectedTarget2.provider, selectedTarget2.accountId) : storageManager.getActiveTarget(); const accounts2 = await storageManager.getAccounts(); + state.target = target2; state.targetProvider = target2.provider.name; state.targetAccountId = target2.accountId; state.targetAccountName = accounts2.find((account) => String(account.id) === String(target2.accountId || ""))?.name || (target2.provider.name === "local" ? "\u670D\u52A1\u5668\u672C\u5730\u76EE\u5F55" : void 0); @@ -13630,9 +15379,20 @@ ${buildPathPreviewLine(state.customFolder)}` : "\u{1F4C1} \u672C\u8BA2\u9605\u4F await message.reply({ message: "\u274C \u65E5\u671F\u683C\u5F0F\u5FC5\u987B\u662F YYYY-MM-DD\uFF0C\u4F8B\u5982\uFF1A2026-06-27" }); return true; } + let dateRange; + try { + dateRange = parseTelegramDateRange(state.startDate, input); + } catch (error) { + await message.reply({ message: `\u274C ${error instanceof Error ? error.message : "\u65E5\u671F\u8303\u56F4\u65E0\u6548"}` }); + 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" ? "\u670D\u52A1\u5668\u672C\u5730\u76EE\u5F55" : void 0); @@ -13651,6 +15411,12 @@ function buildSubscriptionActionKeyboard(rows, requestedPage = 0) { new Api7.KeyboardButtonRow({ buttons: [new Api7.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 Api7.KeyboardButtonRow({ + buttons: buildSubscriptionOperations(row).slice(start, starts[index + 1]).map((operation) => new Api7.KeyboardButtonCallback({ + text: operation.label, + data: Buffer.from(`tsub_${operation.action}_${row.id}_${page.page}`) + })) + })), new Api7.KeyboardButtonRow({ buttons: [ new Api7.KeyboardButtonCallback({ text: "\u270F\uFE0F \u4FEE\u6539\u4E13\u5C5E\u76EE\u5F55", data: Buffer.from(`tsub_folder_${row.id}_${page.page}`) }), @@ -13667,38 +15433,8 @@ function buildSubscriptionActionKeyboard(rows, requestedPage = 0) { rows: [...actionRows, new Api7.KeyboardButtonRow({ buttons: navigation })] }); } -function buildSubscriptionDisplayLines(row, index) { - const status = row.enabled ? "\u2705" : "\u23F8\uFE0F"; - const sourceLine = row.source_original && row.source_original !== row.source ? ` ${row.source_original} \u2192 ${row.source} \xB7 last_id=${row.last_message_id || 0}` : ` ${row.source} \xB7 last_id=${row.last_message_id || 0}`; - return [ - `${index + 1}. ${status} ${row.title || row.source_original || row.source}`, - sourceLine, - row.folder_override ? ` \u{1F4C1} \u4E13\u5C5E\u76EE\u5F55\uFF1A${row.folder_override}` : " \u{1F4C1} \u4FDD\u5B58\u7B56\u7565\uFF1A\u9ED8\u8BA4\u81EA\u52A8\u5206\u7C7B", - row.last_scan_at ? ` \u{1F50E} \u4E0A\u6B21\u626B\u63CF\uFF1A${new Date(row.last_scan_at).toLocaleString("zh-CN", { hour12: false })}` : " \u{1F50E} \u5C1A\u672A\u626B\u63CF", - row.last_success_at ? ` \u2705 \u4E0A\u6B21\u6210\u529F\uFF1A${new Date(row.last_success_at).toLocaleString("zh-CN", { hour12: false })}` : null, - row.next_scan_at ? ` \u23ED\uFE0F \u4E0B\u6B21\u626B\u63CF\u7EA6\uFF1A${new Date(row.next_scan_at).toLocaleString("zh-CN", { hour12: false })}` : null, - row.last_result ? ` \u{1F4CA} \u6700\u8FD1\u7ED3\u679C\uFF1A${row.last_result.status || "unknown"}${row.last_result.found !== void 0 ? `\uFF0C\u53D1\u73B0 ${row.last_result.found}` : ""}${row.last_result.failed ? `\uFF0C\u5931\u8D25 ${row.last_result.failed}` : ""}` : null, - row.last_error ? ` \u26A0\uFE0F \u6700\u8FD1\u9519\u8BEF\uFF1A${row.last_error}` : null, - !row.enabled && row.disabled_reason ? ` \u26A0\uFE0F ${row.disabled_reason}` : null, - !row.enabled && row.disabled_at ? ` \u6682\u505C\u65F6\u95F4\uFF1A${new Date(row.disabled_at).toLocaleString("zh-CN", { hour12: false })}` : null - ].filter(Boolean).join("\n"); -} -function buildSubscriptionManagePanel(rows, requestedPage = 0) { - const page = buildTelegramSubscriptionPage(rows, requestedPage); - return [ - "\u{1F4E1} **\u9891\u9053\u8BA2\u9605\u7BA1\u7406**", - ...page.totalPages > 1 ? [`\u7B2C ${page.page + 1}/${page.totalPages} \u9875 \xB7 \u5171 ${rows.length} \u4E2A\u8BA2\u9605`] : [], - "", - page.visibleRows.length > 0 ? page.visibleRows.map((row, index) => buildSubscriptionDisplayLines(row, page.startIndex + index)).join("\n") : "\u5F53\u524D\u6CA1\u6709\u8BA2\u9605\u3002", - "", - rows.length > 0 ? "\u53EF\u76F4\u63A5\u70B9\u51FB\u8BA2\u9605\u4E0B\u65B9\u6309\u94AE\u4FEE\u6539/\u6E05\u9664\u4E13\u5C5E\u76EE\u5F55\u6216\u53D6\u6D88\u8BA2\u9605\uFF1B\u7CFB\u7EDF\u81EA\u52A8\u6682\u505C\u7684\u8BA2\u9605\u4F1A\u4FDD\u7559\u63D0\u9192\uFF0C\u91CD\u65B0\u6DFB\u52A0\u540C\u4E00\u6765\u6E90\u53EF\u6062\u590D\u3002" : "\u56DE\u590D\u9891\u9053\u7528\u6237\u540D\u6216\u94FE\u63A5\u53EF\u65B0\u589E\u8BA2\u9605\u3002", - "\u56DE\u590D\u9891\u9053\u7528\u6237\u540D\u6216\u94FE\u63A5\u4E5F\u53EF\u65B0\u589E\u8BA2\u9605\u3002", - "\u4F8B\u5982\uFF1A`@channel_username`\u3001`https://t.me/channel_username` \u6216\u5DF2\u52A0\u5165\u7684 `https://t.me/+hash` \u79C1\u5BC6\u94FE\u63A5", - "", - "\u65B0\u589E\u8BA2\u9605\u65F6\u4F1A\u8BE2\u95EE\u662F\u5426\u4E3A\u672C\u8BA2\u9605\u5355\u72EC\u6307\u5B9A\u4FDD\u5B58\u76EE\u5F55\uFF1B\u8BE5\u76EE\u5F55\u53EA\u5F71\u54CD\u8FD9\u4E2A\u8BA2\u9605\uFF0C\u4E0D\u4F1A\u6539\u53D8\u5168\u5C40 /path_rules\u3002", - "", - "\u53D1\u9001\u201C\u53D6\u6D88\u201D\u53EF\u9000\u51FA\u3002" - ].join("\n"); +function buildSubscriptionManagePanel2(rows, requestedPage = 0) { + return buildSubscriptionManagePanel(rows, buildTelegramSubscriptionPage(rows, requestedPage)); } function formatSubscriptionList(rows) { if (rows.length === 0) return "\u{1F4ED} \u6682\u65E0\u9891\u9053\u8BA2\u9605\u3002\n\n\u4F7F\u7528 `/tg_sub @\u9891\u9053` \u6DFB\u52A0\u8BA2\u9605\u3002"; @@ -13711,10 +15447,6 @@ function formatSubscriptionList(rows) { ].join("\n")) ].join("\n"); } -function telegramSubscriptionPeerKey(peer) { - const value = peer?.userId || peer?.chatId || peer?.channelId; - return String(value?.toString?.() || value || peer?.toString?.() || "").replace(/^-100/, "").replace(/^-/, ""); -} function buildSubscriptionCancelConfirm(target, token) { return { text: [ @@ -13738,7 +15470,7 @@ function buildSubscriptionCancelConfirm(target, token) { }; } async function sendSubscriptionCancelConfirmation(message, userId, target, page) { - const token = crypto15.randomBytes(12).toString("base64url"); + const token = crypto18.randomBytes(12).toString("base64url"); const confirm = buildSubscriptionCancelConfirm(target, token); const sent = await message.reply({ message: confirm.text, buttons: confirm.buttons }); const messageId = Number(sent.id); @@ -13752,7 +15484,7 @@ async function sendSubscriptionCancelConfirmation(message, userId, target, page) }); } async function editSubscriptionCancelConfirmation(update, userId, target, page) { - const token = crypto15.randomBytes(12).toString("base64url"); + const token = crypto18.randomBytes(12).toString("base64url"); pendingSubscriptionCancels.set(token, { userId, peerKey: telegramSubscriptionPeerKey(update.peer), @@ -13815,6 +15547,68 @@ function generatePasswordKeyboard(currentLength) { ] }); } +function buildYtDlpPreviewText(metadata, target, folder) { + const duration = metadata.durationSeconds == null ? "\u672A\u77E5" : `${Math.floor(metadata.durationSeconds / 60)}:${String(Math.floor(metadata.durationSeconds % 60)).padStart(2, "0")}`; + return [ + "\u{1F517} **\u4F5C\u4E3A\u94FE\u63A5\u4E0B\u8F7D\uFF1F**", + "", + `\u6807\u9898\uFF1A${metadata.title}`, + `\u7AD9\u70B9\uFF1A${metadata.site}`, + `\u65F6\u957F\uFF1A${duration}`, + `\u76EE\u6807\uFF1A${target.provider.name} / ${target.accountId || "local"}`, + `\u76EE\u5F55\uFF1A${folder}`, + "", + "\u8BF7\u9009\u62E9\u683C\u5F0F\uFF1B\u6CA1\u6709\u786E\u8BA4\u4E0D\u4F1A\u521B\u5EFA\u4E0B\u8F7D\u4EFB\u52A1\u3002Cookie \u53EA\u80FD\u5728 Web \u7AEF\u52A0\u5BC6\u914D\u7F6E\uFF0C\u804A\u5929\u4E2D\u4E0D\u4F1A\u63A5\u6536\u3002" + ].join("\n"); +} +function buildYtDlpConfirmKeyboard(token) { + return new Api7.ReplyInlineMarkup({ rows: [ + new Api7.KeyboardButtonRow({ buttons: [ + new Api7.KeyboardButtonCallback({ text: "\u6700\u4F73\u89C6\u9891", data: Buffer.from(`yp_best_${token}`) }), + new Api7.KeyboardButtonCallback({ text: "\u4EC5\u97F3\u9891", data: Buffer.from(`yp_audio_${token}`) }) + ] }), + new Api7.KeyboardButtonRow({ buttons: [new Api7.KeyboardButtonCallback({ text: "\u53D6\u6D88", data: Buffer.from(`yp_cancel_${token}`) })] }) + ] }); +} +async function createYtDlpPreview(message, userId, url) { + 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) }); + 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, data) { + 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 ? "\u5DF2\u53D6\u6D88\u94FE\u63A5\u4E0B\u8F7D\uFF0C\u672A\u521B\u5EFA\u4EFB\u52A1\u3002" : "\u786E\u8BA4\u5DF2\u5931\u6548\uFF0C\u8BF7\u91CD\u65B0\u53D1\u9001\u94FE\u63A5\u3002" }); + await client.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: cancelled ? "\u5DF2\u53D6\u6D88" : "\u5DF2\u5931\u6548", alert: !cancelled })); + return; + } + const consumed = ytDlpConfirmations.consume(token, binding); + if (consumed.status !== "ok") { + await client.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u786E\u8BA4\u5DF2\u5931\u6548\u6216\u4E0D\u5C5E\u4E8E\u5F53\u524D\u804A\u5929", alert: true })); + return; + } + const currentMessage = (await client.getMessages(update.peer, { ids: [Number(update.msgId)] }))[0]; + await handleYtDlpCommand(currentMessage, consumed.value.url, consumed.value.target, { + format: action === "audio" ? "audio" : "best", + folder: consumed.value.folder, + metadata: consumed.value.metadata + }); + await client.editMessage(update.peer, { message: update.msgId, text: `${buildYtDlpPreviewText(consumed.value.metadata, consumed.value.target, consumed.value.folder)} + +\u2705 \u5DF2\u786E\u8BA4\u5E76\u63D0\u4EA4\u3002` }); + await client.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u4EFB\u52A1\u5DF2\u63D0\u4EA4" })); +} function canTelegramUserAuthenticate(userId, allowedUsers) { return allowedUsers.length > 0 && allowedUsers.includes(userId); } @@ -13962,6 +15756,32 @@ async function handleCleanupButtonCallback(update, cleanupId) { } } } +async function handleUploadReceiptCallback(update, data) { + if (!client) return; + const userId = update.userId.toJSNumber(); + if (!await isAuthenticatedAsync(userId)) { + await client.invoke(new Api7.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 }, userId) || !canControlTask(taskId, chatId, userId)) { + await client.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u4EFB\u52A1\u5361\u5DF2\u5931\u6548\u6216\u4E0D\u5C5E\u4E8E\u5F53\u524D\u804A\u5929", alert: true })); + return; + } + if (action === "retry") { + const result = await retryFailedDownloadTasks(50, taskId, chatId, userId); + await refreshSilentProgress(client, update.peer, userId); + await client.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: result.retried > 0 ? `\u5DF2\u91CD\u8BD5 ${result.retried} \u9879` : "\u6CA1\u6709\u53EF\u91CD\u8BD5\u5931\u8D25\u9879" })); + return; + } + const details = listFailedDownloadTaskDetails(taskId, chatId, userId); + await client.sendMessage(update.peer, { message: ["\u274C **\u5931\u8D25\u660E\u7EC6**", "", ...details.length ? details.slice(0, 30).map((item) => `\u2022 ${item}`) : ["\u5931\u8D25\u8BB0\u5F55\u5DF2\u6E05\u7406\u6216\u4EFB\u52A1\u5DF2\u91CD\u8BD5\u3002"]].join("\n") }); + await client.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5DF2\u53D1\u9001\u5931\u8D25\u660E\u7EC6" })); +} async function handleTaskQueueCallback(update, data) { if (!client) return; const userId = update.userId.toJSNumber(); @@ -14022,17 +15842,31 @@ async function handleTelegramDownloadModeCallback(update, data) { await client.invoke(new Api7.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 Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5411\u5BFC\u5DF2\u5931\u6548\uFF0C\u8BF7\u91CD\u65B0\u6253\u5F00", 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: "\u5DF2\u53D6\u6D88\u9891\u9053\u6587\u4EF6\u4E0B\u8F7D\u5411\u5BFC\u3002" }); await client.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5DF2\u53D6\u6D88" })); return; } - const state = telegramWizardStates.get(userId) || { kind: "tg_download", step: "mode" }; 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 Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u6309\u65E5\u671F\u4E0B\u8F7D" })); return; @@ -14040,7 +15874,7 @@ async function handleTelegramDownloadModeCallback(update, data) { 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 Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u6309\u6807\u7B7E\u4E0B\u8F7D" })); return; @@ -14049,13 +15883,12 @@ async function handleTelegramDownloadModeCallback(update, data) { 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 Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: state.includeComments ? "\u5C06\u5305\u542B\u8BC4\u8BBA\u533A\u6587\u4EF6" : "\u4EC5\u4E0B\u8F7D\u9891\u9053\u6B63\u6587\u6587\u4EF6" })); - return; } } async function handleTelegramSubscriptionCallback(update, data) { @@ -14075,7 +15908,7 @@ async function handleTelegramSubscriptionCallback(update, data) { const page = buildTelegramSubscriptionPage(rows, parsed.page); await client.editMessage(update.peer, { message: update.msgId, - text: buildSubscriptionManagePanel(rows, page.page), + text: buildSubscriptionManagePanel2(rows, page.page), buttons: buildSubscriptionActionKeyboard(rows, page.page) }); await client.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u8BA2\u9605\u5217\u8868\u5DF2\u5237\u65B0" })); @@ -14098,7 +15931,7 @@ async function handleTelegramSubscriptionCallback(update, data) { text: [ sub ? `\u2705 \u5DF2\u53D6\u6D88\u8BA2\u9605 ${sub.title || sub.source}` : "\u274C \u8BA2\u9605\u4E0D\u5B58\u5728\u6216\u5DF2\u7ECF\u53D6\u6D88", "", - buildSubscriptionManagePanel(rowsAfterCancel, page2.page) + buildSubscriptionManagePanel2(rowsAfterCancel, page2.page) ].join("\n"), buttons: buildSubscriptionActionKeyboard(rowsAfterCancel, page2.page) }); @@ -14108,7 +15941,7 @@ async function handleTelegramSubscriptionCallback(update, data) { const page = buildTelegramSubscriptionPage(rows, pending.page); await client.editMessage(update.peer, { message: update.msgId, - text: buildSubscriptionManagePanel(rows, page.page), + text: buildSubscriptionManagePanel2(rows, page.page), buttons: buildSubscriptionActionKeyboard(rows, page.page) }); await client.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5DF2\u8FD4\u56DE\u8BA2\u9605\u5217\u8868" })); @@ -14123,6 +15956,48 @@ async function handleTelegramSubscriptionCallback(update, data) { await client.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u8BA2\u9605\u4E0D\u5B58\u5728\u6216\u5DF2\u53D6\u6D88", alert: true })); return; } + if (parsed.action === "sync") { + await requestTelegramSubscriptionSync(userId, parsed.id); + await client.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5DF2\u8BF7\u6C42\u7ACB\u5373\u540C\u6B65" })); + 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: buildSubscriptionManagePanel2(refreshed, parsed.page), buttons: buildSubscriptionActionKeyboard(refreshed, parsed.page) }); + await client.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: parsed.action === "resume" ? "\u5DF2\u6062\u590D\u8BA2\u9605" : "\u5DF2\u6682\u505C\u8BA2\u9605" })); + return; + } + if (parsed.action === "from_now") { + await setTelegramSubscriptionFromNow(userId, parsed.id); + await client.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u6E38\u6807\u5DF2\u66F4\u65B0\u4E3A\u5F53\u524D\u6700\u65B0\u6D88\u606F" })); + return; + } + if (parsed.action === "target") { + const current3 = target.target_mode === "fixed"; + await updateTelegramSubscriptionTarget(userId, parsed.id, current3 ? { 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: buildSubscriptionManagePanel2(refreshed, parsed.page), buttons: buildSubscriptionActionKeyboard(refreshed, parsed.page) }); + await client.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: current3 ? "\u5DF2\u6539\u4E3A\u8DDF\u968F\u5168\u5C40" : "\u5DF2\u56FA\u5B9A\u4E3A\u5F53\u524D\u76EE\u6807" })); + return; + } + if (parsed.action === "result") { + await client.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: target.last_result ? JSON.stringify(target.last_result).slice(0, 180) : "\u6682\u65E0\u8FD0\u884C\u7ED3\u679C", 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 Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: retried ? "\u5DF2\u91CD\u8BD5\u6700\u8FD1\u5931\u8D25\u9879" : "\u6CA1\u6709\u53EF\u91CD\u8BD5\u5931\u8D25\u9879" })); + return; + } + if (parsed.action === "backfill") { + const state = { 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 Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u8BF7\u8F93\u5165\u8865\u6293\u5F00\u59CB\u65E5\u671F" })); + return; + } if (parsed.action === "view") { await client.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, @@ -14140,8 +16015,8 @@ async function handleTelegramSubscriptionCallback(update, data) { 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 Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u8BF7\u53D1\u9001\u65B0\u7684\u4E13\u5C5E\u76EE\u5F55" })); return; } @@ -14151,7 +16026,7 @@ async function handleTelegramSubscriptionCallback(update, data) { const page = buildTelegramSubscriptionPage(rowsAfterClear, parsed.page); await client.editMessage(update.peer, { message: update.msgId, - text: buildSubscriptionManagePanel(rowsAfterClear, page.page), + text: buildSubscriptionManagePanel2(rowsAfterClear, page.page), buttons: buildSubscriptionActionKeyboard(rowsAfterClear, page.page) }); await client.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5DF2\u6E05\u9664\u4E13\u5C5E\u76EE\u5F55" })); @@ -14167,10 +16042,12 @@ async function initTelegramBot() { const apiHash = process.env.TELEGRAM_API_HASH || ""; const botToken = process.env.TELEGRAM_BOT_TOKEN || ""; if (!apiId || !apiHash || !botToken) { + resetTelegramBotStatus(false); console.log("\u26A0\uFE0F \u672A\u914D\u7F6E Telegram API \u51ED\u8BC1\uFF0CBot \u672A\u542F\u52A8"); console.log(" \u9700\u8981\u8BBE\u7F6E: TELEGRAM_API_ID, TELEGRAM_API_HASH, TELEGRAM_BOT_TOKEN"); return; } + markTelegramBotStarting(); try { console.log("\u{1F916} Telegram Bot \u6B63\u5728\u540C\u6B65\u5B58\u50A8\u914D\u7F6E..."); await storageManager.init(); @@ -14210,10 +16087,27 @@ async function initTelegramBot() { console.warn("\u{1F916} \u4FEE\u6B63 Telegram Bot session \u6587\u4EF6\u6743\u9650\u5931\u8D25:", e); } console.log("\u{1F916} Telegram Bot \u5DF2\u8FDE\u63A5!"); - 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(() => void 0); + }, 6e4); + digestTimer.unref?.(); try { await query(` CREATE TABLE IF NOT EXISTS telegram_auth ( @@ -14254,7 +16148,9 @@ async function initTelegramBot() { const stats = await cleanupOrphanFiles(); if (stats.deletedCount > 0) { console.log(`\u{1F9F9} \u542F\u52A8\u6E05\u7406: \u5220\u9664\u4E86 ${stats.deletedCount} \u4E2A\u5B64\u513F\u6587\u4EF6\uFF0C\u91CA\u653E ${stats.freedSpace}`); - for (const userId of authenticatedUsers.keys()) { + 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) @@ -14336,6 +16232,18 @@ async function initTelegramBot() { await handleHelp(message); 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: "\u2705 \u5F53\u524D Telegram \u7528\u6237\u7684 Bot \u8BA4\u8BC1\u5DF2\u64A4\u9500\u3002\u53D1\u9001 /start \u53EF\u91CD\u65B0\u8BA4\u8BC1\u3002" }); + return; + } { const match = text.match(/^\s*\/ytdlp(?:@\w+)?(?:\s+([\s\S]*))?\s*$/i); if (match) { @@ -14361,7 +16269,7 @@ async function initTelegramBot() { await message.reply({ message: `\u274C \u65E0\u6548\u94FE\u63A5\uFF1A${error instanceof Error ? error.message : "\u4E0D\u5141\u8BB8\u8BBF\u95EE\u8BE5\u5730\u5740"}` }); return; } - await handleYtDlpCommand(message, url); + await createYtDlpPreview(message, senderId, url); return; } } @@ -14398,6 +16306,22 @@ async function initTelegramBot() { return; } 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: `\u274C \u65E0\u6CD5\u9884\u53D6\u94FE\u63A5\uFF1A${error instanceof Error ? error.message : "\u672A\u77E5\u9519\u8BEF"}` }); + } + return; + } + try { + if (await applyPendingTelegramFileMutation(message, senderId, text)) return; + } catch (error) { + await message.reply({ message: `\u274C \u6587\u4EF6\u64CD\u4F5C\u5931\u8D25\uFF1A${error.message} +\u8BF7\u91CD\u65B0\u8F93\u5165\u6216\u53D1\u9001\u201C\u53D6\u6D88\u201D\u3002` }); + return; + } if (isCancelInput(text)) { const pendingMode = getPendingTelegramPathInput(chatId.toString(), senderId); if (pendingMode) { @@ -14569,6 +16493,14 @@ ${buildPathPreviewLine(appliedPath.folder)} await handleStorageSwitch(message); 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 }); @@ -14586,6 +16518,18 @@ ${buildPathPreviewLine(appliedPath.folder)} await handleDelete(message, args); return; } + if (text === "/notifications" || text.startsWith("/notifications ")) { + try { + await handleNotifications(message, text.split(/\s+/).slice(1)); + } catch (error) { + await message.reply({ message: `\u274C \u901A\u77E5\u8BBE\u7F6E\u5931\u8D25\uFF1A${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 }); @@ -14760,6 +16704,10 @@ ${buildPathPreviewLine(appliedPath.folder)} await handlePasswordCallback(callbackUpdate); return; } + if (data.startsWith("yp_")) { + await handleYtDlpPreviewCallback(callbackUpdate, data); + return; + } if (data.startsWith("cleanup_")) { await handleCleanupButtonCallback(callbackUpdate, data); return; @@ -14780,6 +16728,10 @@ ${buildPathPreviewLine(appliedPath.folder)} 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); return; @@ -14812,6 +16764,10 @@ ${buildPathPreviewLine(appliedPath.folder)} await handleChannelTaskQueueCallback(activeClient, callbackUpdate, data); return; } + if (data.startsWith("receipt_")) { + await handleUploadReceiptCallback(callbackUpdate, data); + return; + } if (data.startsWith("tq_")) { await handleTaskQueueCallback(callbackUpdate, data); return; @@ -14822,9 +16778,14 @@ ${buildPathPreviewLine(appliedPath.folder)} } } }, new Raw({})); + markTelegramBotReady(); console.log("\u{1F916} Telegram Bot \u542F\u52A8\u6210\u529F! (\u6700\u5927 2GB\uFF0C\u8D26\u53F7\u7EA7\u4E0B\u8F7D\u5668\u4E0D\u53D7\u6B64\u9650\u5236)"); } catch (error) { + const status = classifyTelegramBotStartupError(error); + const message = error instanceof Error ? error.message : String(error); + markTelegramBotError(status, message, status === "auth_failed" ? "\u68C0\u67E5 Telegram Bot token/API \u51ED\u8BC1\u540E\u91CD\u542F" : "\u68C0\u67E5\u7F51\u7EDC\u4E0E\u540E\u7AEF\u65E5\u5FD7\u540E\u91CD\u542F"); console.error("\u{1F916} Telegram Bot \u542F\u52A8\u5931\u8D25:", error); + throw error; } } async function sendSecurityNotification(message) { @@ -14832,8 +16793,10 @@ async function sendSecurityNotification(message) { console.warn("\u26A0\uFE0F Telegram Client \u672A\u8FDE\u63A5\uFF0C\u65E0\u6CD5\u53D1\u9001\u5B89\u5168\u901A\u77E5"); 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) { @@ -14843,6 +16806,7 @@ async function sendSecurityNotification(message) { } // src/routes/auth.ts +init_authSettings(); async function getIPLocation(ip) { try { if (ip === "::1" || ip === "127.0.0.1") return "\u672C\u5730\u56DE\u73AF"; @@ -15162,7 +17126,7 @@ function generateSignature(fileId, typeOrExpires, expires) { throw new Error("Missing signed URL expiration timestamp"); } const data = `${fileId}:${type}:${expiresTimestamp}`; - return crypto16.createHmac("sha256", SESSION_SECRET).update(data).digest("hex"); + return crypto19.createHmac("sha256", SESSION_SECRET).update(data).digest("hex"); } function getSignedUrl(fileId, type, expiresIn = 24 * 60 * 60) { const expires = Date.now() + expiresIn * 1e3; @@ -15190,7 +17154,7 @@ function verifySignedUrl(req) { try { const received = Buffer.from(sign, "hex"); const expected = Buffer.from(expectedSign, "hex"); - if (received.length !== expected.length || !crypto16.timingSafeEqual(received, expected)) { + if (received.length !== expected.length || !crypto19.timingSafeEqual(received, expected)) { console.log("[SignedURL] Signature mismatch:", { id, type }); return false; } @@ -15245,9 +17209,9 @@ function createFileDeletionService(dependencies) { } // src/services/webDestructiveConfirmation.ts -import crypto17 from "node:crypto"; +import crypto20 from "node:crypto"; function hash(value) { - return crypto17.createHash("sha256").update(value).digest("hex"); + return crypto20.createHash("sha256").update(value).digest("hex"); } var WebDestructiveConfirmationStore = class { constructor(ttlMs = 5 * 60 * 1e3, now = () => Date.now()) { @@ -15258,7 +17222,7 @@ var WebDestructiveConfirmationStore = class { now; values = /* @__PURE__ */ new Map(); issue(input) { - const token = crypto17.randomBytes(24).toString("base64url"); + const token = crypto20.randomBytes(24).toString("base64url"); const expiresAt = this.now() + this.ttlMs; this.values.set(token, { action: input.action, objectId: input.objectId, context: input.context ?? null, authTokenHash: hash(input.authToken), expiresAt }); return { confirmationToken: token, expiresAt }; @@ -15281,211 +17245,6 @@ var webDestructiveConfirmationStore = new WebDestructiveConfirmationStore(); // src/routes/files.ts init_localPath(); - -// src/services/fileQuery.ts -var FILE_TYPES = /* @__PURE__ */ new Set(["image", "video", "audio", "document", "other", "media"]); -var SORTS = /* @__PURE__ */ new Set(["date", "name"]); -var DIRECTIONS = /* @__PURE__ */ new Set(["asc", "desc"]); -var UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; -function single(value) { - if (Array.isArray(value)) throw new Error("query parameter must be singular"); - return typeof value === "string" ? value : void 0; -} -function normalizeFileQuery(input) { - const rawQ = single(input.q)?.trim() || null; - if (rawQ && rawQ.length > 200) throw new Error("q is too long"); - const rawType = single(input.type)?.trim() || null; - if (rawType && !FILE_TYPES.has(rawType)) throw new Error("invalid type"); - const rawFavorite = single(input.favorite); - let favorite = null; - if (rawFavorite !== void 0 && rawFavorite !== "") { - if (rawFavorite !== "true" && rawFavorite !== "false") throw new Error("invalid favorite"); - favorite = rawFavorite === "true"; - } - const rawSort = single(input.sort) || "date"; - if (!SORTS.has(rawSort)) throw new Error("invalid sort"); - const rawDirection = single(input.direction) || "desc"; - if (!DIRECTIONS.has(rawDirection)) throw new Error("invalid direction"); - const rawFolder = single(input.folder); - const folder = rawFolder === void 0 ? void 0 : rawFolder.trim() || null; - if (folder && folder.length > 500) throw new Error("folder is too long"); - const parsedLimit = Number.parseInt(single(input.limit) || "200", 10); - const limit = Math.min(500, Math.max(1, Number.isFinite(parsedLimit) ? parsedLimit : 200)); - return { - q: rawQ, - type: rawType, - folder, - favorite, - sort: rawSort, - direction: rawDirection, - limit, - cursor: single(input.cursor) || null - }; -} -function encodeFileQueryCursor(cursor) { - return Buffer.from(JSON.stringify(cursor), "utf8").toString("base64url"); -} -function decodeFileQueryCursor(encoded, sort, direction) { - if (!encoded) return null; - try { - const parsed = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")); - if (parsed.sort !== sort || parsed.direction !== direction || typeof parsed.value !== "string" || !UUID_PATTERN.test(parsed.id || "")) { - return null; - } - if (sort === "date" && Number.isNaN(Date.parse(parsed.value))) return null; - return parsed; - } catch { - return null; - } -} -function escapeLike(value) { - return value.replace(/[\\%_]/g, (match) => `\\${match}`); -} -function addScope(scope, where, params, alias = "") { - const prefix = alias ? `${alias}.` : ""; - if (scope.kind === "local") { - params.push("local"); - where.push(`${prefix}source = $${params.length}`); - } else { - params.push(scope.accountId); - where.push(`${prefix}storage_account_id = $${params.length}`); - } -} -function addFilters(options, where, params, alias = "", includeFolder = true) { - const prefix = alias ? `${alias}.` : ""; - if (options.q) { - params.push(`%${escapeLike(options.q)}%`); - where.push(`(${prefix}name ILIKE $${params.length} ESCAPE '\\' OR ${prefix}folder ILIKE $${params.length} ESCAPE '\\')`); - } - if (options.type === "media") { - where.push(`${prefix}type IN ('image', 'video', 'audio')`); - } else if (options.type === "document") { - where.push(`${prefix}type NOT IN ('image', 'video', 'audio')`); - } else if (options.type) { - params.push(options.type); - where.push(`${prefix}type = $${params.length}`); - } - if (includeFolder && options.folder !== void 0) { - if (options.folder === null) { - where.push(`${prefix}folder IS NULL`); - } else { - params.push(options.folder); - where.push(`${prefix}folder = $${params.length}`); - } - } - if (options.favorite !== null) { - params.push(options.favorite); - where.push(`${prefix}is_favorite = $${params.length}`); - } -} -function buildFilePageQuery(scope, options) { - const where = []; - const params = []; - addScope(scope, where, params); - addFilters(options, where, params); - const cursor = decodeFileQueryCursor(options.cursor, options.sort, options.direction); - const comparator = options.direction === "asc" ? ">" : "<"; - const direction = options.direction.toUpperCase(); - if (cursor) { - if (options.sort === "name") { - params.push(cursor.value.toLocaleLowerCase(), cursor.id); - where.push(`(LOWER(name), id) ${comparator} ($${params.length - 1}, $${params.length}::uuid)`); - } else { - params.push(cursor.value, cursor.id); - where.push(`(created_at, id) ${comparator} ($${params.length - 1}::timestamptz, $${params.length}::uuid)`); - } - } - params.push(options.limit + 1); - const sortColumn = options.sort === "name" ? "LOWER(name)" : "created_at"; - return { - text: `SELECT - id, name, stored_name, type, mime_type, size, thumbnail_path, preview_path, - width, height, source, folder, storage_account_id, is_favorite, created_at, updated_at -FROM files -WHERE ${where.join(" AND ")} -ORDER BY ${sortColumn} ${direction}, id ${direction} -LIMIT $${params.length}`, - params - }; -} -function buildFolderAggregationQuery(scope, options) { - const where = ["folder IS NOT NULL"]; - const params = []; - addScope(scope, where, params); - addFilters({ ...options, folder: void 0 }, where, params, "", false); - const direction = options.direction.toUpperCase(); - const order = options.sort === "name" ? `folder ${direction}` : `latest_at ${direction}, folder ${direction}`; - return { - text: `WITH filtered AS ( - SELECT * FROM files WHERE ${where.join(" AND ")} -), grouped AS ( - SELECT - folder, - COUNT(*) FILTER (WHERE name <> '.folder')::int AS file_count, - COALESCE(SUM(size) FILTER (WHERE name <> '.folder'), 0)::bigint AS total_size_bytes, - MAX(created_at) AS latest_at, - BOOL_AND(is_favorite)::boolean AS is_favorite - FROM filtered - GROUP BY folder -) -SELECT grouped.*, cover.id AS cover_id, cover.name AS cover_name, cover.type AS cover_type, - cover.mime_type AS cover_mime_type, cover.thumbnail_path AS cover_thumbnail_path, - cover.preview_path AS cover_preview_path, cover.created_at AS cover_created_at -FROM grouped -LEFT JOIN LATERAL ( - SELECT id, name, type, mime_type, thumbnail_path, preview_path, created_at - FROM filtered candidate - WHERE candidate.folder = grouped.folder AND candidate.name <> '.folder' - ORDER BY candidate.created_at DESC, candidate.id DESC - LIMIT 1 -) cover ON TRUE -ORDER BY ${order}`, - params - }; -} -function cursorForFile(file, sort, direction) { - const value = sort === "name" ? String(file.name || "").toLocaleLowerCase() : new Date(String(file.created_at)).toISOString(); - return encodeFileQueryCursor({ sort, direction, value, id: String(file.id) }); -} - -// src/utils/folderPath.ts -var INVALID_SEGMENT_CHARACTERS = /[\\:*?"<>|\x00-\x1f\x7f]/; -var MAX_FOLDER_PATH_LENGTH = 255; -function normalizeFolderPath(value) { - if (typeof value !== "string") throw new Error("\u6587\u4EF6\u5939\u8DEF\u5F84\u683C\u5F0F\u9519\u8BEF"); - const normalized = value.trim().replace(/^\/+|\/+$/g, ""); - if (!normalized) throw new Error("\u6587\u4EF6\u5939\u8DEF\u5F84\u4E0D\u80FD\u4E3A\u7A7A"); - if (normalized.length > MAX_FOLDER_PATH_LENGTH) throw new Error(`\u6587\u4EF6\u5939\u8DEF\u5F84\u4E0D\u80FD\u8D85\u8FC7 ${MAX_FOLDER_PATH_LENGTH} \u4E2A\u5B57\u7B26`); - const segments = normalized.split("/"); - if (segments.some((segment) => !segment || segment === "." || segment === "..")) { - throw new Error("\u6587\u4EF6\u5939\u8DEF\u5F84\u5305\u542B\u7A7A\u76EE\u5F55\u6216\u76F8\u5BF9\u8DEF\u5F84"); - } - if (segments.some((segment) => segment !== segment.trim() || INVALID_SEGMENT_CHARACTERS.test(segment))) { - throw new Error("\u6587\u4EF6\u5939\u8DEF\u5F84\u5305\u542B\u975E\u6CD5\u5B57\u7B26"); - } - return segments.join("/"); -} -function normalizeFolderName(value) { - const name = normalizeFolderPath(value); - if (name.includes("/")) throw new Error("\u6587\u4EF6\u5939\u540D\u79F0\u4E0D\u80FD\u5305\u542B\u8DEF\u5F84\u5206\u9694\u7B26"); - return name; -} -function folderBaseName(folder) { - const segments = folder.split("/"); - return segments[segments.length - 1]; -} -function folderParent(folder) { - const segments = folder.split("/"); - return segments.length > 1 ? segments.slice(0, -1).join("/") : null; -} -function joinFolderPath(parent, name) { - return normalizeFolderPath(parent ? `${parent}/${name}` : name); -} -function isFolderWithin(folder, ancestor) { - return folder === ancestor || folder.startsWith(`${ancestor}/`); -} - -// src/routes/files.ts init_mediaProxyError(); // src/services/cloudMediaResponse.ts @@ -16305,9 +18064,9 @@ function logOperationalEvent(event, requestId, data) { } // src/services/batchDeleteConfirmation.ts -import crypto18 from "node:crypto"; +import crypto21 from "node:crypto"; function hashAuthToken(token) { - return crypto18.createHash("sha256").update(token).digest("hex"); + return crypto21.createHash("sha256").update(token).digest("hex"); } function normalizeFileIds(fileIds) { return [...new Set(fileIds)].sort(); @@ -16320,7 +18079,7 @@ var BatchDeleteConfirmationStore = class { constructor(options = {}) { this.ttlMs = options.ttlMs ?? 5 * 60 * 1e3; this.now = options.now ?? (() => Date.now()); - this.tokenFactory = options.tokenFactory ?? (() => crypto18.randomBytes(24).toString("base64url")); + this.tokenFactory = options.tokenFactory ?? (() => crypto21.randomBytes(24).toString("base64url")); } issue(input) { const confirmationToken = this.tokenFactory(); @@ -16977,16 +18736,18 @@ var upload_default = router4; init_db(); import { Router as Router5 } from "express"; import checkDiskSpaceModule2 from "check-disk-space"; -import os3 from "os"; +init_settings(); +init_authSettings(); +import os4 from "os"; import path19 from "path"; import fs14 from "fs"; import axios3 from "axios"; -import crypto20 from "crypto"; +import crypto23 from "crypto"; // src/services/oauthFlowStore.ts init_db(); init_credentialCrypto(); -import crypto19 from "node:crypto"; +import crypto22 from "node:crypto"; var OAuthFlowError = class extends Error { code = "OAUTH_FLOW_INVALID"; constructor() { @@ -16995,7 +18756,7 @@ var OAuthFlowError = class extends Error { } }; function sha256(value) { - return crypto19.createHash("sha256").update(value).digest("hex"); + return crypto22.createHash("sha256").update(value).digest("hex"); } function parsePendingConfig(value) { const parsed = typeof value === "string" ? JSON.parse(value) : value; @@ -17012,8 +18773,8 @@ var OAuthFlowStore = class { this.db = options.db ?? pool; this.ttlMs = options.ttlMs ?? 10 * 60 * 1e3; this.now = options.now ?? (() => Date.now()); - this.stateFactory = options.stateFactory ?? (() => crypto19.randomBytes(32).toString("base64url")); - this.nonceFactory = options.nonceFactory ?? (() => crypto19.randomBytes(24).toString("base64url")); + this.stateFactory = options.stateFactory ?? (() => crypto22.randomBytes(32).toString("base64url")); + this.nonceFactory = options.nonceFactory ?? (() => crypto22.randomBytes(24).toString("base64url")); } async ensureSchema() { if (!this.schemaPromise) { @@ -17259,7 +19020,7 @@ function sendStorageEndpointValidationError(res, error) { res.status(400).json({ error: safeMessages.includes(message) ? message : "\u65E0\u6CD5\u89E3\u6790\u5B58\u50A8\u7AEF\u70B9\u5730\u5740" }); } function sendOAuthSuccessPage(res, input) { - const nonce = crypto20.randomBytes(16).toString("base64"); + const nonce = crypto23.randomBytes(16).toString("base64"); res.setHeader("Content-Security-Policy", [ "default-src 'self'", "style-src 'unsafe-inline'", @@ -17285,7 +19046,7 @@ function sendOAuthFlowError(res, error) { } router5.get("/stats", requireAuth, async (_req, res) => { try { - const diskPath = os3.platform() === "win32" ? "C:" : path19.resolve(UPLOAD_DIR6); + const diskPath = os4.platform() === "win32" ? "C:" : path19.resolve(UPLOAD_DIR6); const diskSpace = await checkDiskSpace2(diskPath); const scope = await getCurrentStorageScope(); const result = await query(` @@ -17404,6 +19165,7 @@ router5.get("/config", requireAuth, async (req, res) => { telegramUserDownloadEnabled: telegramUserDownloadEnabled === "true", telegramUserSessionReady, telegramUserClientStatus: getTelegramUserClientStatus(), + telegramBotStatus: getTelegramBotStatus(), telegramAllowedUserIds, telegramAllowedUserIdsFromEnv }); @@ -17476,8 +19238,15 @@ router5.post("/config/telegram-allowed-users", requireAuth, async (req, res) => if (userIds.length === 0) { return res.status(400).json({ error: "\u8BF7\u81F3\u5C11\u586B\u5199\u4E00\u4E2A 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 ? "\u5141\u8BB8\u5217\u8868\u5DF2\u4FDD\u5B58\uFF1B\u79FB\u9664\u7528\u6237\u7684 Bot \u8BA4\u8BC1\u5DF2\u7ACB\u5373\u64A4\u9500\u3002" : "\u5141\u8BB8\u5217\u8868\u5DF2\u4FDD\u5B58\u3002" + }); } catch (error) { console.error("\u66F4\u65B0 Telegram \u5141\u8BB8\u7528\u6237\u5217\u8868\u5931\u8D25:", error); res.status(500).json({ error: "\u66F4\u65B0 Telegram \u5141\u8BB8\u7528\u6237\u5217\u8868\u5931\u8D25" }); @@ -17898,7 +19667,7 @@ var storage_default = router5; // src/routes/chunkedUpload.ts init_db(); import { Router as Router6 } from "express"; -import crypto23 from "node:crypto"; +import crypto26 from "node:crypto"; import fs16 from "node:fs"; import fsPromises2 from "node:fs/promises"; import path21 from "node:path"; @@ -17909,7 +19678,7 @@ init_storage(); init_storageAccountLifecycle(); // src/services/chunkUploadReconciliation.ts -import crypto21 from "node:crypto"; +import crypto24 from "node:crypto"; async function ownsChunkReconciliationLease(db, operationId, leaseToken) { const result = await db.query( `UPDATE chunk_upload_reconciliations @@ -18008,7 +19777,7 @@ async function resolveClaimedChunkReconciliation(input) { return resolved ? "resolved" : "pending"; } async function beginChunkCompletionReconciliation(db, input) { - const operationId = crypto21.randomUUID(); + const operationId = crypto24.randomUUID(); const result = await db.query( `INSERT INTO chunk_upload_reconciliations (operation_id, upload_id, completion_token, provider, account_id, object_state, index_state, reason, status, created_at, updated_at) @@ -18098,7 +19867,7 @@ async function compensateChunkCompletionFailure(input) { } // src/services/chunkUploadSessions.ts -import crypto22 from "node:crypto"; +import crypto25 from "node:crypto"; import fs15 from "node:fs"; import fsPromises from "node:fs/promises"; import path20 from "node:path"; @@ -18111,7 +19880,7 @@ var ChunkUploadProtocolError = class extends Error { }; async function writeChunkAtomically(input) { await fsPromises.mkdir(path20.dirname(input.finalPath), { recursive: true }); - const committedPath = `${input.finalPath}.${crypto22.randomUUID()}.chunk`; + const committedPath = `${input.finalPath}.${crypto25.randomUUID()}.chunk`; const temporaryPath = `${committedPath}.part`; const lockPath = `${input.finalPath}.lock`; let lockHandle; @@ -18121,7 +19890,7 @@ async function writeChunkAtomically(input) { if (error?.code === "EEXIST") throw new ChunkUploadProtocolError("ChunkWriteBusyError", "\u540C\u4E00\u5206\u5757\u6B63\u5728\u5199\u5165\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5"); throw error; } - const hash2 = crypto22.createHash("sha256"); + const hash2 = crypto25.createHash("sha256"); let size = 0; const counter = new (await import("node:stream")).Transform({ transform(chunk, _encoding, callback) { @@ -18154,7 +19923,7 @@ async function verifyChunkIntegrity(chunk, expectedDirectory, maxChunkBytes) { if (stat.size !== chunk.size || stat.size < 1 || stat.size > maxChunkBytes) { throw new ChunkUploadProtocolError("ChunkSizeMismatchError", `\u5206\u5757 ${chunk.index} \u5927\u5C0F\u65E0\u6548`); } - const hash2 = crypto22.createHash("sha256"); + const hash2 = crypto25.createHash("sha256"); await pipeline(fs15.createReadStream(chunkPath), new (await import("node:stream")).Writable({ write(buffer, _encoding, callback) { hash2.update(buffer); @@ -18200,16 +19969,16 @@ var ChunkUploadSessionStore = class { const lockKey = `${input.uploadId}:${input.index}`; const previous = this.chunkWriteLocks.get(lockKey) || Promise.resolve(); let release; - const current2 = new Promise((resolve) => { + const current3 = new Promise((resolve) => { release = resolve; }); - this.chunkWriteLocks.set(lockKey, current2); + this.chunkWriteLocks.set(lockKey, current3); await previous; try { return await this.writeChunkUnlocked(input); } finally { release(); - if (this.chunkWriteLocks.get(lockKey) === current2) this.chunkWriteLocks.delete(lockKey); + if (this.chunkWriteLocks.get(lockKey) === current3) this.chunkWriteLocks.delete(lockKey); } } async writeChunkUnlocked(input) { @@ -18441,8 +20210,8 @@ var PostgresChunkUploadSessionRepository = class { [uploadId, chunk.index] ); await client2.query("ROLLBACK"); - const current2 = mapChunk(currentResult.rows[0]); - return current2.size === chunk.size && current2.sha256 === chunk.sha256 ? { status: "duplicate", chunk: current2 } : { status: "conflict", chunk: current2 }; + const current3 = mapChunk(currentResult.rows[0]); + return current3.size === chunk.size && current3.sha256 === chunk.sha256 ? { status: "duplicate", chunk: current3 } : { status: "conflict", chunk: current3 }; } const updated = await client2.query( `UPDATE chunk_upload_sessions SET received_bytes = received_bytes + $3, updated_at = NOW() @@ -18660,7 +20429,7 @@ var chunkStore = new ChunkUploadSessionStore(chunkRepository, { getDiskFreeBytes: async () => (await checkDiskSpace3(path21.resolve(CHUNK_DIR))).free }); var runChunkMaintenance = async () => { - const reconciliationLease = crypto23.randomUUID(); + const reconciliationLease = crypto26.randomUUID(); const pending = await claimChunkReconciliations(pool, reconciliationLease, 100); for (const row of pending) { const target = storageManager.getTarget(row.provider, row.accountId); @@ -18694,7 +20463,7 @@ function ownerId(req) { return stableWebAdminPrincipalId(); } function stableWebAdminPrincipalId() { - return crypto23.createHash("sha256").update("tg-vault:web-admin:v1").digest("hex"); + return crypto26.createHash("sha256").update("tg-vault:web-admin:v1").digest("hex"); } function decodeFilename2(filename) { try { @@ -18770,7 +20539,7 @@ router6.post("/init", async (req, res) => { } const now = /* @__PURE__ */ new Date(); const session = { - uploadId: crypto23.randomUUID(), + uploadId: crypto26.randomUUID(), ownerId: ownerId(req), filename: decodeFilename2(filename).slice(0, 255), mimeType: mimeType.slice(0, 100), @@ -18858,7 +20627,7 @@ router6.post("/chunk", async (req, res) => { } }); async function mergeChunks(uploadId, chunks, targetPath, expectedBytes) { - const temporary = `${targetPath}.${crypto23.randomUUID()}.part`; + const temporary = `${targetPath}.${crypto26.randomUUID()}.part`; await fsPromises2.mkdir(path21.dirname(targetPath), { recursive: true }); const output = fs16.createWriteStream(temporary, { flags: "wx" }); try { @@ -18893,10 +20662,10 @@ router6.post("/complete", async (req, res) => { let completionLeaseError = null; try { owner = ownerId(req); - const current2 = await chunkStore.status(uploadId, owner); - if (!current2) return res.status(404).json({ error: "\u4E0A\u4F20\u4F1A\u8BDD\u4E0D\u5B58\u5728" }); - if (current2.status === "completed" && current2.completedFileId) { - const existing = await query("SELECT id, name, type, size, created_at, source, thumbnail_path FROM files WHERE id = $1", [current2.completedFileId]); + const current3 = await chunkStore.status(uploadId, owner); + if (!current3) return res.status(404).json({ error: "\u4E0A\u4F20\u4F1A\u8BDD\u4E0D\u5B58\u5728" }); + if (current3.status === "completed" && current3.completedFileId) { + const existing = await query("SELECT id, name, type, size, created_at, source, thumbnail_path FROM files WHERE id = $1", [current3.completedFileId]); if (!existing.rows[0]) return res.status(409).json({ error: "\u5B8C\u6210\u8BB0\u5F55\u6307\u5411\u7684\u6587\u4EF6\u4E0D\u5B58\u5728" }); const file2 = existing.rows[0]; return res.json({ @@ -18914,10 +20683,10 @@ router6.post("/complete", async (req, res) => { } }); } - if (current2.status === "failed") { - return res.status(409).json({ error: "\u4E0A\u6B21\u5B8C\u6210\u5931\u8D25\uFF0C\u8BF7\u5148\u91CD\u8BD5\u4E0A\u4F20\u4F1A\u8BDD", retryable: true, lastError: current2.lastError }); + if (current3.status === "failed") { + return res.status(409).json({ error: "\u4E0A\u6B21\u5B8C\u6210\u5931\u8D25\uFF0C\u8BF7\u5148\u91CD\u8BD5\u4E0A\u4F20\u4F1A\u8BDD", retryable: true, lastError: current3.lastError }); } - token = crypto23.randomUUID(); + token = crypto26.randomUUID(); const claim = await chunkStore.claimCompletion(uploadId, owner, token, new Date(Date.now() + COMPLETION_LEASE_MS)); if (!claim) return res.status(409).json({ error: "\u4E0A\u4F20\u672A\u5B8C\u6574\u3001\u5DF2\u7531\u5176\u4ED6\u8BF7\u6C42\u5904\u7406\u6216\u72B6\u6001\u4E0D\u53EF\u5B8C\u6210" }); completionHeartbeat = setInterval(() => { @@ -19203,25 +20972,9 @@ async function saveTaskCenterDismissals(items) { } // src/routes/tasks.ts -import crypto24 from "node:crypto"; +import crypto27 from "node:crypto"; var router7 = Router7(); var CHUNK_DIR2 = process.env.CHUNK_DIR || "./data/chunks"; -function parseJsonObject(value) { - if (value && typeof value === "object" && !Array.isArray(value)) return value; - 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) { - if (status === "queued") return "pending"; - if (status === "cooling") return "waiting"; - if (status === "completed_with_errors") return "failed"; - return status; -} async function collectUnifiedTasks(limit) { const [transfers, channels, chunks, subscriptions, accounts] = await Promise.all([ listTransferTasks({ limit }), @@ -19252,66 +21005,8 @@ async function collectUnifiedTasks(limit) { query("SELECT id, name FROM storage_accounts") ]); const accountNames = new Map(accounts.rows.map((row) => [String(row.id), String(row.name)])); - const tasks = 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" ? "\u670D\u52A1\u5668\u672C\u5730\u76EE\u5F55" : 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 \u9891\u9053\u4EFB\u52A1", - 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" ? "\u670D\u52A1\u5668\u672C\u5730\u76EE\u5F55" : 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 = 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); @@ -19375,8 +21070,10 @@ router7.get("/", requireAuth, async (req, res) => { const tasks = filterDismissedTasks(await collectUnifiedTasks(limit), await loadTaskCenterDismissals()).map((task) => ({ ...task, dismissible: isTaskDismissible(task) })); const source = String(req.query.source || "").trim(); const status = String(req.query.status || "").trim(); - 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: (/* @__PURE__ */ new Date()).toISOString() }); + 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()); + const total = filtered.length; + const page = filtered.slice(0, limit); + res.json({ tasks: page, total, returned: page.length, generatedAt: (/* @__PURE__ */ new Date()).toISOString() }); } catch (error) { console.error("\u83B7\u53D6\u7EDF\u4E00\u4EFB\u52A1\u5217\u8868\u5931\u8D25:", error); res.status(500).json({ error: "\u83B7\u53D6\u4EFB\u52A1\u5217\u8868\u5931\u8D25" }); @@ -19394,7 +21091,7 @@ router7.post("/dismissals/prepare", requireAuth, async (req, res) => { const selected = all.filter((task) => isTaskDismissible(task)).filter((task) => requestedKeys.size > 0 ? requestedKeys.has(`${task.sourceType}:${task.id}`) : (!source || task.sourceType === source) && (!status || task.status === status)).map((task) => ({ sourceType: task.sourceType, id: task.id, status: task.status, title: task.title, updatedAt: task.updatedAt })); if (selected.length === 0) return res.status(409).json({ error: "\u5F53\u524D\u8303\u56F4\u6CA1\u6709\u53EF\u5220\u9664\u7684\u7EC8\u6001\u8BB0\u5F55" }); const context = JSON.stringify(selected); - const snapshotId = crypto24.createHash("sha256").update(context).digest("hex"); + const snapshotId = crypto27.createHash("sha256").update(context).digest("hex"); const bySource = Object.fromEntries([...new Set(selected.map((item) => item.sourceType))].map((key) => [key, selected.filter((item) => item.sourceType === key).length])); const byStatus = Object.fromEntries([...new Set(selected.map((item) => item.status))].map((key) => [key, selected.filter((item) => item.status === key).length])); res.json({ @@ -19523,9 +21220,10 @@ router7.post("/:sourceType/:id/:action", requireAuth, async (req, res) => { var tasks_default = router7; // src/index.ts +init_authSettings(); init_db(); import helmet from "helmet"; -import crypto25 from "node:crypto"; +import crypto28 from "node:crypto"; // src/utils/runtimeConfig.ts var NUMBER_SPECS = [ @@ -19728,7 +21426,7 @@ app.use(cors({ app.use(express.json({ limit: process.env.JSON_BODY_LIMIT || "2mb" })); app.use((req, res, next) => { const provided = normalizeRequestId(req.headers["x-request-id"]); - const requestId = provided || crypto25.randomUUID(); + const requestId = provided || crypto28.randomUUID(); res.locals.requestId = requestId; res.setHeader("X-Request-Id", requestId); next(); @@ -19787,7 +21485,15 @@ app.get("/readyz", async (_req, res) => { await storageManager2.assertReady(); const twoFactor = await get2FAReadiness(); if (!twoFactor.ready) throw new Error("2FA \u5BC6\u94A5\u4E0D\u53EF\u8BFB\u53D6"); - res.json({ status: "ready", timestamp: (/* @__PURE__ */ new Date()).toISOString() }); + const bot = getTelegramBotStatus(); + if (telegramBotBlocksReadiness(bot)) { + throw new Error(`Telegram Bot \u672A\u5C31\u7EEA\uFF1A${bot.status}${bot.action ? `\uFF1B${bot.action}` : ""}`); + } + res.json({ + status: bot.degraded ? "degraded" : "ready", + timestamp: (/* @__PURE__ */ new Date()).toISOString(), + components: { telegramBot: bot } + }); } catch (error) { res.status(503).json({ status: "not_ready", error: error instanceof Error ? error.message : String(error) }); } @@ -19810,7 +21516,14 @@ async function initializeApplication() { if (!twoFactor.ready) throw new Error("2FA \u5DF2\u542F\u7528\u4F46\u5BC6\u94A5\u4E0D\u53EF\u8BFB\u53D6"); 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, "\u68C0\u67E5 Telegram \u51ED\u8BC1/\u7F51\u7EDC\u5E76\u91CD\u542F\u540E\u7AEF"); + console.warn("Telegram Bot \u53EF\u9009\u7EC4\u4EF6\u542F\u52A8\u5931\u8D25\uFF0C\u5E94\u7528\u4EE5 degraded \u72B6\u6001\u7EE7\u7EED:", message); + } } await initializeYtDlpQueue(); applicationReady = true; diff --git a/backend/package-lock.json b/backend/package-lock.json index 515571e..3a50eea 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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" diff --git a/backend/package.json b/backend/package.json index 2d762fc..a911f2f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/scripts/generate_init_sql.py b/backend/scripts/generate_init_sql.py new file mode 100644 index 0000000..e809801 --- /dev/null +++ b/backend/scripts/generate_init_sql.py @@ -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}') diff --git a/backend/scripts/verify_db_optimizations.py b/backend/scripts/verify_db_optimizations.py index 96dc31e..d312f49 100644 --- a/backend/scripts/verify_db_optimizations.py +++ b/backend/scripts/verify_db_optimizations.py @@ -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), diff --git a/backend/scripts/verify_private_invite_support.py b/backend/scripts/verify_private_invite_support.py index 5bd167d..bc13c27 100644 --- a/backend/scripts/verify_private_invite_support.py +++ b/backend/scripts/verify_private_invite_support.py @@ -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), ] diff --git a/backend/src/bot/context.ts b/backend/src/bot/context.ts new file mode 100644 index 0000000..5903fc4 --- /dev/null +++ b/backend/src/bot/context.ts @@ -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(/^-/, ''); +} diff --git a/backend/src/bot/presentation/subscription.ts b/backend/src/bot/presentation/subscription.ts new file mode 100644 index 0000000..fb8985e --- /dev/null +++ b/backend/src/bot/presentation/subscription.ts @@ -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'); +} diff --git a/backend/src/db/index.ts b/backend/src/db/index.ts index 0c067f5..013307b 100644 --- a/backend/src/db/index.ts +++ b/backend/src/db/index.ts @@ -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 | 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 { + 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) { diff --git a/backend/src/db/schema.sql b/backend/src/db/schema.sql index 124f80b..c1dc665 100644 --- a/backend/src/db/schema.sql +++ b/backend/src/db/schema.sql @@ -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(), diff --git a/backend/src/index.ts b/backend/src/index.ts index a999395..8508950 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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 { 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; diff --git a/backend/src/routes/storage.ts b/backend/src/routes/storage.ts index 20a5aa2..fa0ce29 100644 --- a/backend/src/routes/storage.ts +++ b/backend/src/routes/storage.ts @@ -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 允许用户列表失败' }); diff --git a/backend/src/routes/tasks.ts b/backend/src/routes/tasks.ts index d5626ec..ffc4e34 100644 --- a/backend/src/routes/tasks.ts +++ b/backend/src/routes/tasks.ts @@ -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 { - if (value && typeof value === 'object' && !Array.isArray(value)) return value as Record; - 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 { const [transfers, channels, chunks, subscriptions, accounts] = await Promise.all([ listTransferTasks({ limit }), @@ -62,66 +45,8 @@ async function collectUnifiedTasks(limit: number): Promise { 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: '获取任务列表失败' }); diff --git a/backend/src/routes/tasksMapperContract.test.ts b/backend/src/routes/tasksMapperContract.test.ts new file mode 100644 index 0000000..9c7424c --- /dev/null +++ b/backend/src/routes/tasksMapperContract.test.ts @@ -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/); +}); diff --git a/backend/src/services/botModuleBoundary.test.ts b/backend/src/services/botModuleBoundary.test.ts new file mode 100644 index 0000000..031497b --- /dev/null +++ b/backend/src/services/botModuleBoundary.test.ts @@ -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/); +}); diff --git a/backend/src/services/fileQuery.test.ts b/backend/src/services/fileQuery.test.ts index f9fa71b..a7b6a01 100644 --- a/backend/src/services/fileQuery.test.ts +++ b/backend/src/services/fileQuery.test.ts @@ -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', () => { diff --git a/backend/src/services/fileQuery.ts b/backend/src/services/fileQuery.ts index 54ed56b..7341520 100644 --- a/backend/src/services/fileQuery.ts +++ b/backend/src/services/fileQuery.ts @@ -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): 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): 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 { diff --git a/backend/src/services/migrationLedger.test.ts b/backend/src/services/migrationLedger.test.ts new file mode 100644 index 0000000..0abf97a --- /dev/null +++ b/backend/src/services/migrationLedger.test.ts @@ -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/); +}); diff --git a/backend/src/services/reproducibleBuildContract.test.ts b/backend/src/services/reproducibleBuildContract.test.ts index c011f01..ed2e125 100644 --- a/backend/src/services/reproducibleBuildContract.test.ts +++ b/backend/src/services/reproducibleBuildContract.test.ts @@ -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\//); }); \ No newline at end of file diff --git a/backend/src/services/scopedInteractionMap.test.ts b/backend/src/services/scopedInteractionMap.test.ts new file mode 100644 index 0000000..19940e8 --- /dev/null +++ b/backend/src/services/scopedInteractionMap.test.ts @@ -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({ 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({ 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); +}); diff --git a/backend/src/services/scopedInteractionMap.ts b/backend/src/services/scopedInteractionMap.ts new file mode 100644 index 0000000..48d8c62 --- /dev/null +++ b/backend/src/services/scopedInteractionMap.ts @@ -0,0 +1,109 @@ +export interface ScopedInteractionMapOptions { + ttlMs?: number; + maxEntries?: number; + now?: () => number; +} + +interface Entry { + value: V; + createdAt: number; + expiresAt: number; +} + +export class ScopedInteractionMap { + private readonly entries = new Map>(); + 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 { + this.cleanup(); + return this.entries.keys(); + } + + values(): IterableIterator { + 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; + } +} diff --git a/backend/src/services/storageAccountLifecycle.test.ts b/backend/src/services/storageAccountLifecycle.test.ts index 883c1ec..7656b49 100644 --- a/backend/src/services/storageAccountLifecycle.test.ts +++ b/backend/src/services/storageAccountLifecycle.test.ts @@ -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') }, ]); diff --git a/backend/src/services/storageAccountLifecycle.ts b/backend/src/services/storageAccountLifecycle.ts index b347b94..720b0b9 100644 --- a/backend/src/services/storageAccountLifecycle.ts +++ b/backend/src/services/storageAccountLifecycle.ts @@ -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 diff --git a/backend/src/services/storageLifecycleProductionWiring.test.ts b/backend/src/services/storageLifecycleProductionWiring.test.ts index ae9f891..6ee4860 100644 --- a/backend/src/services/storageLifecycleProductionWiring.test.ts +++ b/backend/src/services/storageLifecycleProductionWiring.test.ts @@ -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'\)/); diff --git a/backend/src/services/telegramAllowlistReconciliation.test.ts b/backend/src/services/telegramAllowlistReconciliation.test.ts new file mode 100644 index 0000000..0049a79 --- /dev/null +++ b/backend/src/services/telegramAllowlistReconciliation.test.ts @@ -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([ + [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([ + [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]); +}); diff --git a/backend/src/services/telegramAuthAllowlist.test.ts b/backend/src/services/telegramAuthAllowlist.test.ts index 8aea338..25272ab 100644 --- a/backend/src/services/telegramAuthAllowlist.test.ts +++ b/backend/src/services/telegramAuthAllowlist.test.ts @@ -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'); diff --git a/backend/src/services/telegramBatchConfirmation.test.ts b/backend/src/services/telegramBatchConfirmation.test.ts index ecf4c47..6ccc63c 100644 --- a/backend/src/services/telegramBatchConfirmation.test.ts +++ b/backend/src/services/telegramBatchConfirmation.test.ts @@ -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/); }); diff --git a/backend/src/services/telegramBot.ts b/backend/src/services/telegramBot.ts index b715305..dba8bcd 100644 --- a/backend/src/services/telegramBot.ts +++ b/backend/src/services/telegramBot.ts @@ -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 = { 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 { 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(); +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({ + 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 { 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 { - 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 { + 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 { + 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, + }); + 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 { + 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 { 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 { 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 { 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 { 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 { 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 { const match = text.match(/^\s*\/ytdlp(?:@\w+)?(?:\s+([\s\S]*))?\s*$/i); @@ -1471,7 +1768,7 @@ export async function initTelegramBot(): Promise { return; } - await handleYtDlpCommand(message, url); + await createYtDlpPreview(message, senderId, url); return; } } @@ -1514,6 +1811,21 @@ export async function initTelegramBot(): Promise { } 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 { 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 { 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 { 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 { 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 { 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 { } }, 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 { 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) { diff --git a/backend/src/services/telegramBotHealthWiring.test.ts b/backend/src/services/telegramBotHealthWiring.test.ts new file mode 100644 index 0000000..6f2dd49 --- /dev/null +++ b/backend/src/services/telegramBotHealthWiring.test.ts @@ -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/); +}); diff --git a/backend/src/services/telegramBotReadiness.test.ts b/backend/src/services/telegramBotReadiness.test.ts new file mode 100644 index 0000000..62a3932 --- /dev/null +++ b/backend/src/services/telegramBotReadiness.test.ts @@ -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'); +}); diff --git a/backend/src/services/telegramBotStatus.ts b/backend/src/services/telegramBotStatus.ts new file mode 100644 index 0000000..6be36ca --- /dev/null +++ b/backend/src/services/telegramBotStatus.ts @@ -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, + 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 { + 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'; +} diff --git a/backend/src/services/telegramChannelJobAdmission.test.ts b/backend/src/services/telegramChannelJobAdmission.test.ts new file mode 100644 index 0000000..97e9342 --- /dev/null +++ b/backend/src/services/telegramChannelJobAdmission.test.ts @@ -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); +}); diff --git a/backend/src/services/telegramChannelJobAdmission.ts b/backend/src/services/telegramChannelJobAdmission.ts new file mode 100644 index 0000000..5bfdb41 --- /dev/null +++ b/backend/src/services/telegramChannelJobAdmission.ts @@ -0,0 +1,8 @@ +import type { StorageTargetSnapshot } from './storage.js'; + +export function resolveChannelJobTargetSnapshot( + explicitTarget: StorageTargetSnapshot | undefined, + getActiveTarget: () => StorageTargetSnapshot, +): StorageTargetSnapshot { + return explicitTarget ?? getActiveTarget(); +} diff --git a/backend/src/services/telegramChannelJobPersistence.test.ts b/backend/src/services/telegramChannelJobPersistence.test.ts new file mode 100644 index 0000000..59bb86b --- /dev/null +++ b/backend/src/services/telegramChannelJobPersistence.test.ts @@ -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'); +}); diff --git a/backend/src/services/telegramChannelJobs.ts b/backend/src/services/telegramChannelJobs.ts index fd0be91..4eb3418 100644 --- a/backend/src/services/telegramChannelJobs.ts +++ b/backend/src/services/telegramChannelJobs.ts @@ -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; onProgress?: (summary: TelegramJobProgressSummary) => Promise | void; onRefDiscovered?: (ref: TelegramDownloadMessageRef) => Promise | void; - targetProvider?: string; - targetAccountId?: string | null; + target?: StorageTargetSnapshot; +} + +export interface ChannelJobAdmissionInput { + userId: number; + chatId?: string; + kind: string; + source: string; + params: Record; + target?: StorageTargetSnapshot; +} + +export interface ChannelJobAdmissionDependencies { + getActiveTarget: () => StorageTargetSnapshot; + withTransaction: (operation: (client: Pick) => Promise) => Promise; + lockStorageAccount: (client: Pick, accountId: string) => Promise; +} + +export async function persistChannelJobAdmission( + input: ChannelJobAdmissionInput, + dependencies: ChannelJobAdmissionDependencies, +): Promise { + 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) { - 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); } } diff --git a/backend/src/services/telegramCommandDispatcher.test.ts b/backend/src/services/telegramCommandDispatcher.test.ts new file mode 100644 index 0000000..7c87d07 --- /dev/null +++ b/backend/src/services/telegramCommandDispatcher.test.ts @@ -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); + } +}); diff --git a/backend/src/services/telegramCommandDispatcher.ts b/backend/src/services/telegramCommandDispatcher.ts new file mode 100644 index 0000000..496e9f9 --- /dev/null +++ b/backend/src/services/telegramCommandDispatcher.ts @@ -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; + +export interface TelegramCommandDispatcherDependencies { + authenticate?: (userId: number) => Promise; + rateLimit?: (userId: number, command: BotCommandDefinition) => { limited: boolean; retryAfterSeconds: number }; + featureEnabled?: (feature: string) => boolean; + handlers: Record; +} + +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 { + 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 = { + 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 }; +} diff --git a/backend/src/services/telegramCommands.ts b/backend/src/services/telegramCommands.ts index 2275fa6..77abbcd 100644 --- a/backend/src/services/telegramCommands.ts +++ b/backend/src/services/telegramCommands.ts @@ -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(); +const pendingTelegramFileMutations = new Map(); const pendingStorageClearSnapshots = new Map(); const pendingBulkTaskCancellations = new Map(); 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 { await message.reply({ message: buildHelp() }); } +export async function handleNotifications(message: Api.Message, args: string[] = []): Promise { + 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 = { ...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 { + 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 { try { const scope = await getCurrentStorageScope(); @@ -517,6 +653,61 @@ export async function handleStorage(message: Api.Message): Promise { } } +export async function handleTarget(message: Api.Message, args: string[] = []): Promise { + 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 `、`/target session `、`/target clear`', + ].join('\n'), + }); + return; + } + if (!['once', 'session'].includes(modeInput) || !args[1]) { + await message.reply({ message: '❌ 用法:/target once 、/target session 、/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 { 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 { + const senderId = message.senderId?.toJSNumber(); + if (!senderId || !(await isAuthenticatedAsync(senderId))) { + await message.reply({ message: MSG.AUTH_REQUIRED }); + return; + } + const rawOptions: Record = { 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 { 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 { + 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 { + 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 { if (args.length === 0) { await message.reply({ diff --git a/backend/src/services/telegramDateRange.test.ts b/backend/src/services/telegramDateRange.test.ts new file mode 100644 index 0000000..d8c78ad --- /dev/null +++ b/backend/src/services/telegramDateRange.test.ts @@ -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); +}); diff --git a/backend/src/services/telegramDateRange.ts b/backend/src/services/telegramDateRange.ts new file mode 100644 index 0000000..c8582c3 --- /dev/null +++ b/backend/src/services/telegramDateRange.ts @@ -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, + }; +} diff --git a/backend/src/services/telegramFileBrowser.test.ts b/backend/src/services/telegramFileBrowser.test.ts new file mode 100644 index 0000000..6ce1693 --- /dev/null +++ b/backend/src/services/telegramFileBrowser.test.ts @@ -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, /删除确认/); +}); diff --git a/backend/src/services/telegramFileBrowser.ts b/backend/src/services/telegramFileBrowser.ts new file mode 100644 index 0000000..7905238 --- /dev/null +++ b/backend/src/services/telegramFileBrowser.ts @@ -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> { + const id = String(file.id); + const rows: Array> = [ + [{ 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 { + 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, + dependencies: { + runQuery?: typeof query; + scope?: FileQueryScope; + } = {}, +): Promise { + 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'); +} diff --git a/backend/src/services/telegramHomeNavigation.test.ts b/backend/src/services/telegramHomeNavigation.test.ts new file mode 100644 index 0000000..900aedb --- /dev/null +++ b/backend/src/services/telegramHomeNavigation.test.ts @@ -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/); +}); diff --git a/backend/src/services/telegramInteractionMigration.test.ts b/backend/src/services/telegramInteractionMigration.test.ts new file mode 100644 index 0000000..7a22d31 --- /dev/null +++ b/backend/src/services/telegramInteractionMigration.test.ts @@ -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/); + 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/); +}); diff --git a/backend/src/services/telegramInteractionState.test.ts b/backend/src/services/telegramInteractionState.test.ts new file mode 100644 index 0000000..70a6d20 --- /dev/null +++ b/backend/src/services/telegramInteractionState.test.ts @@ -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({ 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({ 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({ 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({ 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({ 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); +}); diff --git a/backend/src/services/telegramInteractionState.ts b/backend/src/services/telegramInteractionState.ts new file mode 100644 index 0000000..d1f4440 --- /dev/null +++ b/backend/src/services/telegramInteractionState.ts @@ -0,0 +1,155 @@ +import { canonicalTelegramChatKey } from '../utils/telegramChatKey.js'; + +export interface TelegramInteractionRecord { + userId: number; + chatKey: string; + kind: string; + step: string; + originMessageId?: number; + createdAt: number; + expiresAt: number; + value: T; +} + +export interface TelegramInteractionInput { + 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 } + | { ok: false; reason: 'expired-or-missing' | 'message-mismatch' | 'action-not-allowed' }; + +export class TelegramInteractionStore { + private readonly entries = new Map>(); + 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): TelegramInteractionRecord { + 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 = { + ...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 } + | { 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 | 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 | undefined { + const current = this.lookup(userId, chatKey); + if (current.status !== 'active') return undefined; + const now = this.now(); + const record: TelegramInteractionRecord = { + ...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 }; + } + + get size(): number { + this.removeExpired(); + return this.entries.size; + } +} diff --git a/backend/src/services/telegramInteractionWiring.test.ts b/backend/src/services/telegramInteractionWiring.test.ts new file mode 100644 index 0000000..b5f73b4 --- /dev/null +++ b/backend/src/services/telegramInteractionWiring.test.ts @@ -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/); + 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/); +}); + +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 频道操作向导/); +}); diff --git a/backend/src/services/telegramNotificationDelivery.test.ts b/backend/src/services/telegramNotificationDelivery.test.ts new file mode 100644 index 0000000..c81ff26 --- /dev/null +++ b/backend/src/services/telegramNotificationDelivery.test.ts @@ -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))); +}); diff --git a/backend/src/services/telegramNotificationDelivery.ts b/backend/src/services/telegramNotificationDelivery.ts new file mode 100644 index 0000000..dda6a13 --- /dev/null +++ b/backend/src/services/telegramNotificationDelivery.ts @@ -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; +} +type RunQuery = (sql: string, params?: unknown[]) => Promise>; +export interface TelegramNotificationDeliveryDeps { + getPreferences?: (userId: number, chatId: string) => Promise; + runQuery?: RunQuery; + send: (chatId: string, text: string) => Promise; + 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(); + +export async function claimTelegramNotificationDigest(userId: number, chatId: string, runQuery: RunQuery): Promise { + 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, +): Promise { + 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; + 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); + } +} diff --git a/backend/src/services/telegramNotificationDeliveryWiring.test.ts b/backend/src/services/telegramNotificationDeliveryWiring.test.ts new file mode 100644 index 0000000..00a63c3 --- /dev/null +++ b/backend/src/services/telegramNotificationDeliveryWiring.test.ts @@ -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/); +}); diff --git a/backend/src/services/telegramNotificationDigestClaim.test.ts b/backend/src/services/telegramNotificationDigestClaim.test.ts new file mode 100644 index 0000000..09f8cdf --- /dev/null +++ b/backend/src/services/telegramNotificationDigestClaim.test.ts @@ -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/); +}); diff --git a/backend/src/services/telegramNotificationOwner.test.ts b/backend/src/services/telegramNotificationOwner.test.ts new file mode 100644 index 0000000..585055e --- /dev/null +++ b/backend/src/services/telegramNotificationOwner.test.ts @@ -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); +}); diff --git a/backend/src/services/telegramNotificationPreferences.test.ts b/backend/src/services/telegramNotificationPreferences.test.ts new file mode 100644 index 0000000..29cf04b --- /dev/null +++ b/backend/src/services/telegramNotificationPreferences.test.ts @@ -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' }); +}); diff --git a/backend/src/services/telegramNotificationPreferences.ts b/backend/src/services/telegramNotificationPreferences.ts new file mode 100644 index 0000000..33558a2 --- /dev/null +++ b/backend/src/services/telegramNotificationPreferences.ts @@ -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): 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, + 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 { + 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): Promise { + 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; +} diff --git a/backend/src/services/telegramNotificationWiring.test.ts b/backend/src/services/telegramNotificationWiring.test.ts new file mode 100644 index 0000000..345e81c --- /dev/null +++ b/backend/src/services/telegramNotificationWiring.test.ts @@ -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, /安全告警仍始终即时/); +}); diff --git a/backend/src/services/telegramState.ts b/backend/src/services/telegramState.ts index acc7944..70cb9ad 100644 --- a/backend/src/services/telegramState.ts +++ b/backend/src/services/telegramState.ts @@ -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(); +}>({ ttlMs: INTERACTION_TTL_MS, maxEntries: INTERACTION_MAX_ENTRIES }); // Authenticated user storage (Cache) export const authenticatedUsers = new Map(); // Password input state -export const passwordInputState = new Map(); +export const passwordInputState = new ScopedInteractionMap({ ttlMs: INTERACTION_TTL_MS, maxEntries: INTERACTION_MAX_ENTRIES }); export async function revokeAuthenticatedUser(userId: number): Promise { authenticatedUsers.delete(userId); @@ -30,7 +35,44 @@ export async function revokeAuthenticatedUser(userId: number): Promise { } } -// 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>; + +export async function reconcileTelegramAllowedUsers( + userIds: number[], + dependencies: { + query?: TelegramStateQuery; + cache?: Map; + } = {}, +): Promise { + 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 { try { const allowedUsers = await getConfiguredTelegramAllowedUsers(); diff --git a/backend/src/services/telegramStatusPanel.test.ts b/backend/src/services/telegramStatusPanel.test.ts new file mode 100644 index 0000000..144313c --- /dev/null +++ b/backend/src/services/telegramStatusPanel.test.ts @@ -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'), '[已脱敏]'); +}); diff --git a/backend/src/services/telegramStatusPanel.ts b/backend/src/services/telegramStatusPanel.ts new file mode 100644 index 0000000..0da43c7 --- /dev/null +++ b/backend/src/services/telegramStatusPanel.ts @@ -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'); +} diff --git a/backend/src/services/telegramSubscriptionHealth.test.ts b/backend/src/services/telegramSubscriptionHealth.test.ts index 896f137..c8ddc8d 100644 --- a/backend/src/services/telegramSubscriptionHealth.test.ts +++ b/backend/src/services/telegramSubscriptionHealth.test.ts @@ -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, /最近结果/); }); diff --git a/backend/src/services/telegramSubscriptionManagement.ts b/backend/src/services/telegramSubscriptionManagement.ts index 191a0ee..18f3484 100644 --- a/backend/src/services/telegramSubscriptionManagement.ts +++ b/backend/src/services/telegramSubscriptionManagement.ts @@ -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 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['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), }; diff --git a/backend/src/services/telegramSubscriptionRunCenter.test.ts b/backend/src/services/telegramSubscriptionRunCenter.test.ts new file mode 100644 index 0000000..3716d06 --- /dev/null +++ b/backend/src/services/telegramSubscriptionRunCenter.test.ts @@ -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'); +}); diff --git a/backend/src/services/telegramTargetWiring.test.ts b/backend/src/services/telegramTargetWiring.test.ts new file mode 100644 index 0000000..5799b10 --- /dev/null +++ b/backend/src/services/telegramTargetWiring.test.ts @@ -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/); +}); diff --git a/backend/src/services/telegramTaskCenter.ts b/backend/src/services/telegramTaskCenter.ts index 7f6e013..a1fe611 100644 --- a/backend/src/services/telegramTaskCenter.ts +++ b/backend/src/services/telegramTaskCenter.ts @@ -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; if (typeof optionsSource === 'string') { diff --git a/backend/src/services/telegramUpload.ts b/backend/src/services/telegramUpload.ts index 52e8cc9..03c78b1 100644 --- a/backend/src/services/telegramUpload.ts +++ b/backend/src/services/telegramUpload.ts @@ -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), }); }); } diff --git a/backend/src/services/telegramUploadReceipt.test.ts b/backend/src/services/telegramUploadReceipt.test.ts new file mode 100644 index 0000000..2d3dc76 --- /dev/null +++ b/backend/src/services/telegramUploadReceipt.test.ts @@ -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 /); +}); + +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); +}); diff --git a/backend/src/services/telegramUploadReceipt.ts b/backend/src/services/telegramUploadReceipt.ts new file mode 100644 index 0000000..adeb39a --- /dev/null +++ b/backend/src/services/telegramUploadReceipt.ts @@ -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 }; +} diff --git a/backend/src/services/unifiedTaskMapper.test.ts b/backend/src/services/unifiedTaskMapper.test.ts new file mode 100644 index 0000000..acfabdd --- /dev/null +++ b/backend/src/services/unifiedTaskMapper.test.ts @@ -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 = {}) { + 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 }); +}); diff --git a/backend/src/services/unifiedTaskMapper.ts b/backend/src/services/unifiedTaskMapper.ts new file mode 100644 index 0000000..a41ffc4 --- /dev/null +++ b/backend/src/services/unifiedTaskMapper.ts @@ -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; + 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 { + if (value && typeof value === 'object' && !Array.isArray(value)) return value as Record; + 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): 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): 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, + }; +} diff --git a/backend/src/services/ytDlpConfirmation.test.ts b/backend/src/services/ytDlpConfirmation.test.ts new file mode 100644 index 0000000..0bea646 --- /dev/null +++ b/backend/src/services/ytDlpConfirmation.test.ts @@ -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'); +}); diff --git a/backend/src/services/ytDlpConfirmation.ts b/backend/src/services/ytDlpConfirmation.ts new file mode 100644 index 0000000..2dce5e0 --- /dev/null +++ b/backend/src/services/ytDlpConfirmation.ts @@ -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(); + 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): { 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'; + } +} diff --git a/backend/src/services/ytDlpDownload.ts b/backend/src/services/ytDlpDownload.ts index a0b6ce9..70f1d20 100644 --- a/backend/src/services/ytDlpDownload.ts +++ b/backend/src/services/ytDlpDownload.ts @@ -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(); let initialized = false; -type YtDlpNotifier = (chatId: string, message: string) => Promise; +type YtDlpNotifier = (task: TransferTaskRecord, message: string) => Promise; 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 { 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((resolve, reject) => { const binLower = YTDLP_BIN.toLowerCase(); @@ -289,7 +297,7 @@ function classifyYtDlpError(error: unknown): string { async function notifyTask(task: TransferTaskRecord, message: string): Promise { if (!task.chatId || !taskNotifier) return; - await taskNotifier(task.chatId, message).catch(() => undefined); + await taskNotifier(task, message).catch(() => undefined); } async function executeYtDlpTask(id: string): Promise { @@ -325,7 +333,7 @@ async function executeYtDlpTask(id: string): Promise { 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 { +export async function handleYtDlpCommand( + message: Api.Message, + url: string, + explicitTarget?: StorageTargetSnapshot, + options: { format?: 'best' | 'audio'; folder?: string; metadata?: Record; playlist?: YtDlpPlaylistSelectionInput } = {}, +): Promise { 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> | 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); diff --git a/backend/src/services/ytDlpPlaylistSelection.test.ts b/backend/src/services/ytDlpPlaylistSelection.test.ts new file mode 100644 index 0000000..04e5449 --- /dev/null +++ b/backend/src/services/ytDlpPlaylistSelection.test.ts @@ -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'); +}); diff --git a/backend/src/services/ytDlpPlaylistSelection.ts b/backend/src/services/ytDlpPlaylistSelection.ts new file mode 100644 index 0000000..cd0cd42 --- /dev/null +++ b/backend/src/services/ytDlpPlaylistSelection.ts @@ -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]; +} diff --git a/backend/src/services/ytDlpPlaylistWiring.test.ts b/backend/src/services/ytDlpPlaylistWiring.test.ts new file mode 100644 index 0000000..e7e74ce --- /dev/null +++ b/backend/src/services/ytDlpPlaylistWiring.test.ts @@ -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/); +}); diff --git a/backend/src/services/ytDlpProbe.test.ts b/backend/src/services/ytDlpProbe.test.ts new file mode 100644 index 0000000..39391d8 --- /dev/null +++ b/backend/src/services/ytDlpProbe.test.ts @@ -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']); +}); diff --git a/backend/src/services/ytDlpProbe.ts b/backend/src/services/ytDlpProbe.ts new file mode 100644 index 0000000..075c243 --- /dev/null +++ b/backend/src/services/ytDlpProbe.ts @@ -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; + } = {}, +): Promise { + 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((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 }); + }); +} diff --git a/backend/src/services/ytDlpWizardWiring.test.ts b/backend/src/services/ytDlpWizardWiring.test.ts new file mode 100644 index 0000000..9650113 --- /dev/null +++ b/backend/src/services/ytDlpWizardWiring.test.ts @@ -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); +}); diff --git a/backend/src/utils/authSettings.ts b/backend/src/utils/authSettings.ts index cf9b481..31b50fe 100644 --- a/backend/src/utils/authSettings.ts +++ b/backend/src/utils/authSettings.ts @@ -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 { return Number(result.rows[0]?.count || 0); } -export async function setTelegramAllowedUsers(userIds: number[]): Promise { +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 { + return (await setTelegramAllowedUsersAndReconcile(userIds)).allowed; } export async function addTelegramAllowedUser(userId: number): Promise { diff --git a/backend/src/utils/networkSecurity.test.ts b/backend/src/utils/networkSecurity.test.ts new file mode 100644 index 0000000..f1d6c22 --- /dev/null +++ b/backend/src/utils/networkSecurity.test.ts @@ -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); +}); diff --git a/backend/src/utils/networkSecurity.ts b/backend/src/utils/networkSecurity.ts index c00b66d..bb8d1a0 100644 --- a/backend/src/utils/networkSecurity.ts +++ b/backend/src/utils/networkSecurity.ts @@ -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 { diff --git a/backend/src/utils/telegramCommandRegistry.ts b/backend/src/utils/telegramCommandRegistry.ts index eacd959..dbfce41 100644 --- a/backend/src/utils/telegramCommandRegistry.ts +++ b/backend/src/utils/telegramCommandRegistry.ts @@ -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: '', 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(); 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)); } diff --git a/backend/src/utils/telegramMessages.ts b/backend/src/utils/telegramMessages.ts index 4c53190..25b2af2 100644 --- a/backend/src/utils/telegramMessages.ts +++ b/backend/src/utils/telegramMessages.ts @@ -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 可进入删除确认。'] : []), ].join('\n'); } diff --git a/backend/src/utils/telegramPathSettings.ts b/backend/src/utils/telegramPathSettings.ts index ed57ab6..ef6524b 100644 --- a/backend/src/utils/telegramPathSettings.ts +++ b/backend/src/utils/telegramPathSettings.ts @@ -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(); -const pendingPathInputState = new Map(); +const pendingPathInputState = new ScopedInteractionMap({ + 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(); const MAX_RECENT_PATHS = 6; const RECENT_PATH_SETTING_PREFIX = 'telegram_recent_paths:'; diff --git a/backend/src/utils/telegramTargetStateStore.test.ts b/backend/src/utils/telegramTargetStateStore.test.ts new file mode 100644 index 0000000..3bdd8c5 --- /dev/null +++ b/backend/src/utils/telegramTargetStateStore.test.ts @@ -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(); + 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')); +}); diff --git a/backend/src/utils/telegramTargetStateStore.ts b/backend/src/utils/telegramTargetStateStore.ts new file mode 100644 index 0000000..e9683a5 --- /dev/null +++ b/backend/src/utils/telegramTargetStateStore.ts @@ -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 { + 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 { + 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 { + 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 { + return await consumeTelegramTargetState(undefined, chatId) + || await getTelegramTargetState(undefined, chatId, 'session'); +} + +export async function clearTelegramTargetState(chatId: string): Promise { + await defaultQuery('DELETE FROM telegram_target_states WHERE chat_id = $1', [chatId]); +} diff --git a/deploy/DEPLOY.md b/deploy/DEPLOY.md index 442fcae..e7c02f8 100644 --- a/deploy/DEPLOY.md +++ b/deploy/DEPLOY.md @@ -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 diff --git a/deploy/install.sh b/deploy/install.sh index 194470a..331b894 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -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 diff --git a/docker-compose.staging.yml b/docker-compose.staging.yml new file mode 100644 index 0000000..88f3d2f --- /dev/null +++ b/docker-compose.staging.yml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 002c8de..f0afdf8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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:-} diff --git a/docs/superpowers/plans/2026-07-10-telegram-task-center.md b/docs/superpowers/plans/2026-07-10-telegram-task-center.md deleted file mode 100644 index 54bd354..0000000 --- a/docs/superpowers/plans/2026-07-10-telegram-task-center.md +++ /dev/null @@ -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`. 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_`, `tc_d___`, `tc_a____`, 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_* ` 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. diff --git a/docs/superpowers/plans/2026-07-12-first-round-audit-fixes.md b/docs/superpowers/plans/2026-07-12-first-round-audit-fixes.md deleted file mode 100644 index 340fa8d..0000000 --- a/docs/superpowers/plans/2026-07-12-first-round-audit-fixes.md +++ /dev/null @@ -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. diff --git a/docs/superpowers/plans/2026-07-13-second-round-audit-fixes.md b/docs/superpowers/plans/2026-07-13-second-round-audit-fixes.md deleted file mode 100644 index c4f3fbc..0000000 --- a/docs/superpowers/plans/2026-07-13-second-round-audit-fixes.md +++ /dev/null @@ -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. diff --git a/docs/superpowers/plans/2026-07-23-global-indeterminate-spinner.md b/docs/superpowers/plans/2026-07-23-global-indeterminate-spinner.md deleted file mode 100644 index a8e2268..0000000 --- a/docs/superpowers/plans/2026-07-23-global-indeterminate-spinner.md +++ /dev/null @@ -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. diff --git a/docs/superpowers/plans/2026-07-23-task-center-management.md b/docs/superpowers/plans/2026-07-23-task-center-management.md deleted file mode 100644 index d030f43..0000000 --- a/docs/superpowers/plans/2026-07-23-task-center-management.md +++ /dev/null @@ -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` - -- [ ] 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. diff --git a/docs/superpowers/plans/2026-07-23-web-url-routing.md b/docs/superpowers/plans/2026-07-23-web-url-routing.md deleted file mode 100644 index f8db2af..0000000 --- a/docs/superpowers/plans/2026-07-23-web-url-routing.md +++ /dev/null @@ -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. diff --git a/docs/superpowers/specs/2026-07-10-telegram-task-center-design.md b/docs/superpowers/specs/2026-07-10-telegram-task-center-design.md deleted file mode 100644 index 5e60937..0000000 --- a/docs/superpowers/specs/2026-07-10-telegram-task-center-design.md +++ /dev/null @@ -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 `; -- `/task_resume `; -- `/task_cancel `; -- 无 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. 不推送远端,除非用户另行明确要求。 diff --git a/docs/superpowers/specs/2026-07-23-global-indeterminate-spinner-design.md b/docs/superpowers/specs/2026-07-23-global-indeterminate-spinner-design.md deleted file mode 100644 index 9d15652..0000000 --- a/docs/superpowers/specs/2026-07-23-global-indeterminate-spinner-design.md +++ /dev/null @@ -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. diff --git a/docs/superpowers/specs/2026-07-23-task-center-management-design.md b/docs/superpowers/specs/2026-07-23-task-center-management-design.md deleted file mode 100644 index bd2c616..0000000 --- a/docs/superpowers/specs/2026-07-23-task-center-management-design.md +++ /dev/null @@ -1,59 +0,0 @@ -# Task Center Management and Safe Record Removal Design - -## Goal - -Upgrade the task center from a read-only status list into a mobile-friendly management surface with single removal, multi-select removal, and one-click cleanup of terminal records. - -## Removal Semantics - -“Delete” means hide the exact current version of a terminal task from the task center. It never deletes uploaded/downloaded files, cloud objects, file indexes, subscription configuration, underlying worker rows, reconciliation journals, or audit data. - -A dismissal is keyed by `source_type + task_id + task_updated_at`. If the underlying task later changes, retries, resumes, or is recreated with a newer `updated_at`, the dismissal no longer matches and the task appears again automatically. - -## Eligible States - -Dismissible states are `completed`, `failed`, `cancelled`, `disabled`, `interrupted`, and `retry_required`. Active or schedulable states such as `pending`, `running`, `paused`, `waiting`, `scheduled`, `open`, and `completing` are never dismissible. - -## Persistent Model - -Add `task_center_dismissals` with: - -- `source_type` -- `task_id` -- `task_updated_at` -- `dismissed_at` -- primary key `(source_type, task_id)` - -The unified list excludes a task only when the stored dismissal timestamp exactly matches the task’s current `updatedAt`. - -## Prepare/Confirm Protocol - -`POST /api/tasks/dismissals/prepare` accepts either: - -- selected immutable identities (`sourceType + id`), or -- the current source/status filter scope. - -The server rebuilds current task truth, keeps only dismissible tasks, freezes exact `sourceType + id + updatedAt` snapshots, and returns a session-bound one-time token plus impact counts by source/status. The response explicitly states that no files or cloud objects are affected. - -`POST /api/tasks/dismissals/confirm` consumes the token once and applies only the frozen snapshot. Changed or no-longer-terminal tasks are rejected item-by-item. The response is complete or HTTP 207 partial with dismissed and failed identities. - -## Task Center UX - -- Compact mobile filters: source and status share a row where space permits; refresh becomes a compact icon action. -- Clickable status summaries apply filters. -- Remove duplicated stage/status wording when both labels are identical. -- Task cards show clearer vertical metadata on mobile, two-line titles, and one status badge. -- A terminal task exposes “删除记录”. Active tasks never expose it. -- Selection mode allows only dismissible tasks to be checked. -- Bulk toolbar supports select-all eligible tasks in the current result and remove selected. -- “清理终态记录” previews all dismissible tasks within the current source/status filters. -- Confirmation clearly states that files, cloud objects, subscriptions, and underlying task data remain untouched. - -## Safety and Verification - -- Direct confirm without prepare is rejected. -- Tokens bind session, action, snapshot ID, frozen task versions, and expiry. -- Replay, wrong snapshot, and expired token are rejected. -- Newly completed tasks after prepare are not included. -- Tasks changed after prepare fail safely and reappear. -- Verify schema migration, backend unit/contract tests, frontend tests, typecheck/build, Docker deployment, live database schema, authenticated boundary, mobile browser flow, health, and logs. diff --git a/docs/superpowers/specs/2026-07-23-web-url-routing-design.md b/docs/superpowers/specs/2026-07-23-web-url-routing-design.md deleted file mode 100644 index fa6dde3..0000000 --- a/docs/superpowers/specs/2026-07-23-web-url-routing-design.md +++ /dev/null @@ -1,54 +0,0 @@ -# Web URL Routing Design - -## Goal - -Give every TG Vault Web page a stable URL so navigation changes the address bar, direct links survive refresh, and browser Back/Forward restores the selected page. - -## Route Map - -- `/` redirects with `history.replaceState` to `/files`. -- `/files`: all files. -- `/files/media`: all media. -- `/files/images`: images. -- `/files/videos`: videos. -- `/files/audio`: audio. -- `/files/documents`: documents. -- `/files/ytdlp`: YT-DLP files. -- `/files/favorites`: favorites. -- `/tasks`: task center. -- `/settings/general`: general settings. -- `/settings/security`: security settings. -- `/settings/telegram`: Telegram settings. -- `/settings/storage`: storage settings. -- `/settings/maintenance`: maintenance settings. - -Unknown paths are replaced with `/files`. File routes may preserve `folder` and `q` query parameters. Query changes use `replaceState` to avoid filling browser history on every keystroke; page/category/folder navigation uses `pushState`. - -## Architecture - -Create a dependency-free routing module in `frontend/src/services/appRoute.ts`. It owns route parsing, canonical path generation, category/section mapping, and query normalization. React components consume typed route state rather than duplicating pathname logic. - -`App.tsx` owns current route state and listens for `popstate`. `AppLayout` becomes controlled by the active category and delegates navigation to `App`. `SettingsPage` becomes controlled by the active settings section and delegates section changes to `App`. - -The existing Nginx `try_files $uri $uri/ /index.html` fallback already supports Browser History deep links, so no server configuration change is required. - -## Navigation Behavior - -- Sidebar navigation updates content and URL together. -- Settings tabs update the settings subpath. -- Reloading a valid deep link restores that page. -- Browser Back/Forward restores content, active sidebar item, and active settings tab. -- Folder navigation writes a `folder` query parameter. -- Search writes a `q` query parameter without adding one history entry per keystroke. -- Authentication does not discard the requested path; after authentication the same route renders. - -## Compatibility and Scope - -- Keep the existing SPA and do not add React Router. -- Preserve current API, authentication, mobile toolbar, and horizontally scrollable settings tabs. -- Do not change backend routes or database state. -- Do not push to GitHub. - -## Verification - -Use pure route tests for every path, invalid paths, query round-trips, and canonicalization. Use a real Chromium browser against the rebuilt public site to verify click navigation, direct deep-link refresh, unknown-path fallback, settings subroutes, and Back/Forward. Run the full frontend test suite, ESLint, production build, Docker rebuild/recreate, health checks, and recent-log inspection. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5599063..737f3b6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -21,6 +21,7 @@ import { fileApi, type BatchDeletePreview, type BatchDeleteResult, type ChunkUpl import { authService } from "./services/auth"; import type { QueueItem } from "./components/ui/UploadQueueModal"; import { LatestRequest } from "./services/latestRequest"; +import { FileQueryController } from "./services/fileQueryController"; import { BoundedUploadQueue } from "./services/boundedUploadQueue"; import { createUploadTelemetry, updateUploadTelemetry } from "./services/uploadTelemetry"; import { describeFileViewState } from "./services/fileViewState"; @@ -60,6 +61,7 @@ function App() { const [hasMoreFiles, setHasMoreFiles] = useState(false); const [loadingMoreFiles, setLoadingMoreFiles] = useState(false); const latestFileRequestRef = useRef(new LatestRequest()); + const fileQueryControllerRef = useRef(new FileQueryController({ debounceMs: 0 })); // 改用队列管理上传状态 const [uploadQueue, setUploadQueue] = useState([]); @@ -123,6 +125,7 @@ function App() { const [batchDeletePreview, setBatchDeletePreview] = useState(null); const [batchDeleteResult, setBatchDeleteResult] = useState(null); const [searchQuery, setSearchQuery] = useState(() => initialRoute.kind === 'files' ? initialRoute.query : ''); + const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(searchQuery); const [isMobileSearchOpen, setIsMobileSearchOpen] = useState(false); const [currentFolder, setCurrentFolder] = useState(() => initialRoute.kind === 'files' ? initialRoute.folder : null); // 当前选中的文件夹 const [settingsSection, setSettingsSection] = useState(() => initialRoute.kind === 'settings' ? initialRoute.section : 'general'); @@ -282,7 +285,7 @@ function App() { const folder = currentCategory === 'ytdlp' ? 'ytdlp' : (currentFolder ?? null); return { - q: searchQuery, + q: debouncedSearchQuery, type, folder, favorite: currentCategory === 'favorites' ? true : undefined, @@ -290,7 +293,7 @@ function App() { direction: sortConfig.direction, signal, }; - }, [currentCategory, currentFolder, searchQuery, sortConfig]); + }, [currentCategory, currentFolder, debouncedSearchQuery, sortConfig]); // 加载文件列表:新 generation 中止旧请求,且只有最新 generation 可提交。 const loadFiles = useCallback(async () => { @@ -387,21 +390,60 @@ function App() { } }, [isAuthenticated]); - // 认证成功后加载数据 + useEffect(() => { + const timer = window.setTimeout(() => setDebouncedSearchQuery(searchQuery), 250); + return () => window.clearTimeout(timer); + }, [searchQuery]); + + // 认证和查询条件由单一 effect 驱动;文本搜索 debounce,其他条件立即刷新。 + useEffect(() => { + if (!isAuthenticated) return; + const controller = fileQueryControllerRef.current; + const queryKey = JSON.stringify({ currentCategory, currentFolder, debouncedSearchQuery, sortConfig }); + controller.schedule(queryKey, async signal => { + const request = latestFileRequestRef.current.begin(); + signal.addEventListener('abort', () => latestFileRequestRef.current.cancel(), { once: true }); + const hadData = files.length > 0 || folderAggregations.length > 0; + try { + setLoading(true); + setQueryError(null); + const options = buildFileQueryOptions(request.signal); + const includeFolders = currentCategory !== 'ytdlp'; + const [page, globalFolders] = await Promise.all([ + fileApi.getFilesPage(options), + includeFolders ? fileApi.getFolderAggregations(options) : Promise.resolve([]), + ]); + if (!request.isCurrent()) throw new DOMException('superseded', 'AbortError'); + setFiles(page.files); + setFolderAggregations(globalFolders); + setFileCursor(page.nextCursor); + setHasMoreFiles(page.hasMore); + setIsStale(false); + return { page, globalFolders }; + } catch (error: any) { + if (error?.name !== 'AbortError' && request.isCurrent()) { + if (error.message === 'UNAUTHORIZED') { + authService.clearToken(); + setIsAuthenticated(false); + } else { + setQueryError(error.message || '加载文件失败'); + setIsStale(hadData); + } + } + throw error; + } finally { + if (request.isCurrent()) setLoading(false); + } + }).catch(() => undefined); + return () => controller.cancel(); + }, [isAuthenticated, currentCategory, currentFolder, debouncedSearchQuery, sortConfig, buildFileQueryOptions]); + useEffect(() => { if (isAuthenticated) { - loadFiles(); loadStorageStats(); loadStorageConfig(); } - }, [isAuthenticated, loadFiles, loadStorageStats, loadStorageConfig]); - - // 监听分类变化 - useEffect(() => { - if (isAuthenticated) { - loadFiles(); - } - }, [currentCategory, isAuthenticated, loadFiles]); + }, [isAuthenticated, loadStorageStats, loadStorageConfig]); useEffect(() => { if (currentCategory === 'ytdlp') { @@ -858,8 +900,13 @@ function App() { setIsAuthenticated(false); } else { console.error('重命名失败:', error); - alert(error.message || '重命名失败'); + setNotification({ + show: true, + message: error.message || '重命名失败', + type: 'error' + }); } + throw error; } }; @@ -880,8 +927,13 @@ function App() { setIsAuthenticated(false); } else { console.error('重命名文件夹失败:', error); - alert(error.message || '重命名文件夹失败'); + setNotification({ + show: true, + message: error.message || '重命名文件夹失败', + type: 'error' + }); } + throw error; } }; diff --git a/frontend/src/components/ui/ConfirmDialog.tsx b/frontend/src/components/ui/ConfirmDialog.tsx index 5339d35..07b3a58 100644 --- a/frontend/src/components/ui/ConfirmDialog.tsx +++ b/frontend/src/components/ui/ConfirmDialog.tsx @@ -1,14 +1,8 @@ import { AlertTriangle } from 'lucide-react'; import { Button } from './Button'; +import { Dialog } from './Dialog'; -export function ConfirmDialog({ - isOpen, - title, - description, - confirmLabel = '确认', - onClose, - onConfirm, -}: { +export function ConfirmDialog({ isOpen, title, description, confirmLabel = '确认', onClose, onConfirm }: { isOpen: boolean; title: string; description: string; @@ -16,29 +10,13 @@ export function ConfirmDialog({ onClose: () => void; onConfirm: () => void | Promise; }) { - if (!isOpen) return null; return ( -
-
event.stopPropagation()} - > -
- -
-

{title}

-

{description}

-
-
-
- - -
+ +
+
-
+
+ ); } diff --git a/frontend/src/components/ui/CreateFolderModal.tsx b/frontend/src/components/ui/CreateFolderModal.tsx index 3fd3823..735aa6e 100644 --- a/frontend/src/components/ui/CreateFolderModal.tsx +++ b/frontend/src/components/ui/CreateFolderModal.tsx @@ -1,9 +1,9 @@ import { useState } from "react"; -import { motion, AnimatePresence } from "framer-motion"; +import { motion } from "framer-motion"; import { FolderPlus } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Button } from "./Button"; -import { createPortal } from "react-dom"; +import { Dialog } from "./Dialog"; interface CreateFolderModalProps { isOpen: boolean; @@ -32,34 +32,15 @@ export const CreateFolderModal = ({ isOpen, onClose, onConfirm, currentFolder }: }; const modalContent = ( - -
- {/* Backdrop */} - - - {/* Modal Container */} - e.stopPropagation()} - > + + {/* Header */}
-

+

创建文件夹

@@ -107,10 +88,9 @@ export const CreateFolderModal = ({ isOpen, onClose, onConfirm, currentFolder }: 取消

- -
- +
+
); - return createPortal(modalContent, document.body); + return modalContent; }; diff --git a/frontend/src/components/ui/Dialog.tsx b/frontend/src/components/ui/Dialog.tsx new file mode 100644 index 0000000..b3ea35f --- /dev/null +++ b/frontend/src/components/ui/Dialog.tsx @@ -0,0 +1,61 @@ +import { useEffect, useRef, type ReactNode } from 'react'; +import { createPortal } from 'react-dom'; + +const focusSelector = 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; + +export function Dialog({ + open, + onClose, + children, + labelledBy, + describedBy, + alert = false, + closeOnEscape = true, + closeOnBackdrop = true, + className = '', +}: { + open: boolean; + onClose: () => void; + children: ReactNode; + labelledBy: string; + describedBy?: string; + alert?: boolean; + closeOnEscape?: boolean; + closeOnBackdrop?: boolean; + className?: string; +}) { + const contentRef = useRef(null); + useEffect(() => { + if (!open) return; + const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null; + const content = contentRef.current; + const focusables = () => Array.from(content?.querySelectorAll(focusSelector) || []); + focusables()[0]?.focus(); + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape' && closeOnEscape) { event.preventDefault(); onClose(); return; } + if (event.key !== 'Tab') return; + const items = focusables(); + if (!items.length) { event.preventDefault(); content?.focus(); return; } + const first = items[0], last = items[items.length - 1]; + if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); } + else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); } + }; + document.addEventListener('keydown', onKeyDown); + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { + document.removeEventListener('keydown', onKeyDown); + document.body.style.overflow = previousOverflow; + previousFocus?.focus(); + }; + }, [open, onClose, closeOnEscape]); + if (!open) return null; + return createPortal( +
{ if (closeOnBackdrop && event.target === event.currentTarget) onClose(); }}> +
event.stopPropagation()}> + {children} +
+
, + document.body, + ); +} diff --git a/frontend/src/components/ui/FileCard.tsx b/frontend/src/components/ui/FileCard.tsx index c8c57fd..7eebabc 100644 --- a/frontend/src/components/ui/FileCard.tsx +++ b/frontend/src/components/ui/FileCard.tsx @@ -6,6 +6,8 @@ import { Button } from "./Button"; import { fileApi, type FileData } from "../../services/api"; import { ContextMenu, createFileMenuItems } from "./ContextMenu"; import { useLongPress } from "../../hooks/useLongPress"; +import { ApiActionError, describeActionFailure } from "../../services/apiActionError"; +import { authService } from "../../services/auth"; // Re-export FileData type for convenience export type { FileData } from "../../services/api"; @@ -52,9 +54,10 @@ export const FileCard = ({ try { setDownloadError(null); await fileApi.downloadFile(file.id, file.name); - } catch (error: any) { + } catch (error: unknown) { console.error("下载失败", error); - setDownloadError(error?.message || "下载失败"); + if (error instanceof ApiActionError && error.kind === 'unauthorized') authService.clearToken(); + setDownloadError(describeActionFailure('下载', error)); } }; diff --git a/frontend/src/components/ui/PreviewModal.tsx b/frontend/src/components/ui/PreviewModal.tsx index 3a9ebad..5ec7ed6 100644 --- a/frontend/src/components/ui/PreviewModal.tsx +++ b/frontend/src/components/ui/PreviewModal.tsx @@ -5,9 +5,10 @@ import type { FileData } from "./FileCard"; import { Button } from "./Button"; import { useEffect, useRef, useState } from "react"; import { fileApi } from "../../services/api"; -import { API_BASE } from "../../services/config"; import { MobileMenu } from "./MobileMenu"; import { IndeterminateSpinner } from "./IndeterminateSpinner"; +import { ApiActionError, describeActionFailure } from "../../services/apiActionError"; +import { authService } from "../../services/auth"; interface PreviewModalProps { file: FileData | null; @@ -197,6 +198,7 @@ export const PreviewModal = ({ file, onClose, onToggleFavorite, files = [], onNa const [imageReloadKey, setImageReloadKey] = useState(0); const [detailsOpen, setDetailsOpen] = useState(false); const [idCopied, setIdCopied] = useState(false); + const [actionError, setActionError] = useState(null); const touchStartXRef = useRef(null); const openedAtRef = useRef(0); const [mobileMenu, setMobileMenu] = useState<{ @@ -250,9 +252,12 @@ export const PreviewModal = ({ file, onClose, onToggleFavorite, files = [], onNa e?.stopPropagation(); if (!file) return; try { + setActionError(null); await fileApi.downloadFile(file.id, file.name); } catch (error) { console.error("下载失败", error); + if (error instanceof ApiActionError && error.kind === 'unauthorized') authService.clearToken(); + setActionError(describeActionFailure('下载', error)); } }; @@ -275,18 +280,30 @@ export const PreviewModal = ({ file, onClose, onToggleFavorite, files = [], onNa setScale(1); }; - const handleOpenOriginal = (e: React.MouseEvent) => { + const handleOpenOriginal = async (e: React.MouseEvent) => { e.stopPropagation(); if (!file) return; - window.open(`${API_BASE}/api/files/${file.id}/original`, '_blank', 'noopener,noreferrer'); + try { + setActionError(null); + const url = await fileApi.getOriginalFileLink(file.id); + window.open(url, '_blank', 'noopener,noreferrer'); + } catch (error) { + if (error instanceof ApiActionError && error.kind === 'unauthorized') authService.clearToken(); + setActionError(describeActionFailure('打开原文件', error)); + } }; const handleCopyId = async (e: React.MouseEvent) => { e.stopPropagation(); if (!file) return; - await navigator.clipboard.writeText(file.id); - setIdCopied(true); - window.setTimeout(() => setIdCopied(false), 1500); + try { + setActionError(null); + await navigator.clipboard.writeText(file.id); + setIdCopied(true); + window.setTimeout(() => setIdCopied(false), 1500); + } catch (error) { + setActionError(describeActionFailure('复制文件 ID', error)); + } }; const handleTouchStart = (e: React.TouchEvent) => { @@ -506,6 +523,12 @@ export const PreviewModal = ({ file, onClose, onToggleFavorite, files = [], onNa
+ {actionError && ( +
+ {actionError} +
+ )} + {detailsOpen && ( { + assert.match(modal, /onConfirm: \(newName: string\) => void \| Promise/); + assert.match(modal, /await onConfirm\(newName\)/); + assert.match(modal, /catch \(confirmError/); + assert.match(modal, /setError\(confirmError instanceof Error/); +}); + +test('rename modal preserves the edited value and focus after a failed confirmation', () => { + const failureFlow = modal.match(/catch \(confirmError\) \{([\s\S]*?)\n {8}\}/)?.[1] || ''; + assert.doesNotMatch(failureFlow, /setBaseName\(/); + assert.match(modal, /finally \{[\s\S]*requestAnimationFrame\(\(\) => inputRef\.current\?\.focus\(\)\)/); +}); + +test('rename modal disables all form exits and duplicate submissions while confirmation is pending', () => { + assert.match(modal, /const \[isSubmitting, setIsSubmitting\] = useState\(false\)/); + assert.match(modal, /if \(isSubmitting\) return/); + assert.match(modal, /onClick=\{isSubmitting \? undefined : onClose\}/); + assert.match(modal, /onClick=\{\(\) => void handleConfirm\(\)\}[\s\S]*disabled=\{isSubmitting\}/); + assert.match(modal, /disabled=\{isSubmitting\}[\s\S]*renameConfirm/); +}); + +test('rename handlers surface failures in the modal instead of swallowing them', () => { + assert.match(app, /handleFileRename[\s\S]*setNotification\([\s\S]*throw error;/); + assert.match(app, /handleFolderRename[\s\S]*setNotification\([\s\S]*throw error;/); +}); diff --git a/frontend/src/components/ui/RenameModal.tsx b/frontend/src/components/ui/RenameModal.tsx index 4eb81a2..32de4d5 100644 --- a/frontend/src/components/ui/RenameModal.tsx +++ b/frontend/src/components/ui/RenameModal.tsx @@ -7,7 +7,7 @@ import { Button } from "./Button"; interface RenameModalProps { isOpen: boolean; onClose: () => void; - onConfirm: (newName: string) => void; + onConfirm: (newName: string) => void | Promise; currentName: string; type: "file" | "folder"; } @@ -30,6 +30,7 @@ export const RenameModal = ({ isOpen, onClose, onConfirm, currentName, type }: R const [baseName, setBaseName] = useState(getBaseName(currentName)); const [error, setError] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); const extension = getExtension(currentName); const inputRef = useRef(null); @@ -37,6 +38,7 @@ export const RenameModal = ({ isOpen, onClose, onConfirm, currentName, type }: R if (isOpen) { setBaseName(getBaseName(currentName)); setError(""); + setIsSubmitting(false); // Auto-focus and select text setTimeout(() => { if (inputRef.current) { @@ -47,7 +49,8 @@ export const RenameModal = ({ isOpen, onClose, onConfirm, currentName, type }: R } }, [isOpen, currentName]); - const handleConfirm = () => { + const handleConfirm = async () => { + if (isSubmitting) return; const trimmed = baseName.trim(); if (trimmed.length === 0) { setError(type === "file" ? "文件名不能为空" : "文件夹名不能为空"); @@ -62,13 +65,23 @@ export const RenameModal = ({ isOpen, onClose, onConfirm, currentName, type }: R onClose(); return; } - onConfirm(newName); + setIsSubmitting(true); + setError(""); + try { + await onConfirm(newName); + } catch (confirmError) { + setError(confirmError instanceof Error ? confirmError.message : "重命名失败"); + } finally { + setIsSubmitting(false); + requestAnimationFrame(() => inputRef.current?.focus()); + } }; const handleKeyDown = (e: React.KeyboardEvent) => { + if (isSubmitting) return; if (e.key === "Enter") { e.preventDefault(); - handleConfirm(); + void handleConfirm(); } else if (e.key === "Escape") { onClose(); } @@ -84,7 +97,7 @@ export const RenameModal = ({ isOpen, onClose, onConfirm, currentName, type }: R animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="absolute inset-0 bg-black/50 backdrop-blur-sm" - onClick={onClose} + onClick={isSubmitting ? undefined : onClose} /> {/* Modal */} @@ -143,15 +156,17 @@ export const RenameModal = ({ isOpen, onClose, onConfirm, currentName, type }: R diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index 490ff05..a3ea9e3 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -22,4 +22,10 @@ i18n } }); +const syncDocumentLanguage = (language?: string) => { + document.documentElement.lang = language?.toLowerCase().startsWith('zh') ? 'zh-CN' : 'en'; +}; +syncDocumentLanguage(i18n.resolvedLanguage || i18n.language); +i18n.on('languageChanged', syncDocumentLanguage); + export default i18n; diff --git a/frontend/src/index.css b/frontend/src/index.css index 165c80a..7bae0a2 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -80,5 +80,16 @@ } body { @apply bg-background text-foreground font-sans antialiased; + overflow-x: hidden; + max-width: 100vw; + } + + @media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + scroll-behavior: auto !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } } } diff --git a/frontend/src/services/actionErrorWiring.test.ts b/frontend/src/services/actionErrorWiring.test.ts new file mode 100644 index 0000000..b6a8032 --- /dev/null +++ b/frontend/src/services/actionErrorWiring.test.ts @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import test from 'node:test'; + +const api = fs.readFileSync(new URL('./api.ts', import.meta.url), 'utf8'); +const card = fs.readFileSync(new URL('../components/ui/FileCard.tsx', import.meta.url), 'utf8'); +const preview = fs.readFileSync(new URL('../components/ui/PreviewModal.tsx', import.meta.url), 'utf8'); + +test('download, share and original-source API failures use the unified response classifier', () => { + assert.match(api, /apiActionErrorFromResponse/); + const actionArea = api.slice(api.indexOf('async createShareLink'), api.indexOf('async getAdvancedTaskSettings')); + assert.ok((actionArea.match(/apiActionErrorFromResponse\(/g) || []).length >= 3); +}); + +test('file card and preview render actionable classified failure copy', () => { + assert.match(card, /describeActionFailure\('下载'/); + assert.match(preview, /describeActionFailure\('下载'/); + assert.match(preview, /describeActionFailure\('复制文件 ID'/); + assert.match(preview, /describeActionFailure\('打开原文件'/); +}); + +test('unauthorized action errors trigger the shared logout path', () => { + assert.match(card, /error instanceof ApiActionError && error.kind === 'unauthorized'/); + assert.match(preview, /error instanceof ApiActionError && error.kind === 'unauthorized'/); + assert.match(card, /authService\.clearToken/); +}); diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index cac69c0..b49e5e4 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -4,6 +4,7 @@ import { sha256Hex } from './chunkHash'; import { API_BASE } from './config'; import { classifyBatchDeleteResponse, type BatchDeleteResult } from './batchDeleteContract'; import { chunkBounds, parseChunkUploadInit } from './chunkUploadProtocol'; +import { apiActionErrorFromResponse } from './apiActionError'; export interface FileData { id: string; @@ -143,6 +144,7 @@ export interface UnifiedTask { export interface UnifiedTaskList { tasks: UnifiedTask[]; total: number; + returned: number; generatedAt: string; } @@ -811,11 +813,7 @@ class FileAPI { body: JSON.stringify({ password, expiration }), }); - if (response.status === 401) throw new Error('UNAUTHORIZED'); - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || '创建分享链接失败'); - } + if (!response.ok) throw await apiActionErrorFromResponse(response, '创建分享链接失败'); return response.json(); } @@ -825,8 +823,7 @@ class FileAPI { credentials: 'include', headers: getHeaders(), }); - if (response.status === 401) throw new Error('UNAUTHORIZED'); - if (!response.ok) throw new Error('获取下载链接失败'); + if (!response.ok) throw await apiActionErrorFromResponse(response, '获取下载链接失败'); const data = await response.json(); if (data.isRelative) { @@ -835,6 +832,12 @@ class FileAPI { return data.url; } + async getOriginalFileLink(id: string): Promise { + const response = await fetch(`${API_BASE}/api/files/${id}/original`, { credentials: 'include', headers: getHeaders(), redirect: 'manual' }); + if (!response.ok && response.type !== 'opaqueredirect') throw await apiActionErrorFromResponse(response, '打开原文件失败'); + return response.headers.get('Location') || `${API_BASE}/api/files/${id}/original`; + } + // 安全下载文件(使用直接链接,不经过 Blob 缓冲) async downloadFile(id: string, fileName: string): Promise { try { diff --git a/frontend/src/services/apiActionError.test.ts b/frontend/src/services/apiActionError.test.ts new file mode 100644 index 0000000..df1bb4a --- /dev/null +++ b/frontend/src/services/apiActionError.test.ts @@ -0,0 +1,22 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { ApiActionError, apiActionErrorFromResponse, describeActionFailure, formatRetryAfter } from './apiActionError.js'; +const response = (status: number, headers: Record = {}, body: Record = {}) => new Response(JSON.stringify(body), { status, headers }); + +test('401 expires session with actionable sign-in copy', async () => { + const error = await apiActionErrorFromResponse(response(401), '下载失败'); + assert.equal(error.kind, 'unauthorized'); + assert.equal(describeActionFailure('下载', error), '登录会话已失效,已为你退出,请重新登录后下载'); +}); +test('429 parses Retry-After', async () => { + const error = await apiActionErrorFromResponse(response(429, { 'Retry-After': '90' }), '分享失败'); + assert.equal(error.retryAfterSeconds, 90); + assert.equal(formatRetryAfter(90), '1 分 30 秒'); +}); +test('410 and 503 provide source/request guidance', async () => { + const gone = await apiActionErrorFromResponse(response(410), '打开失败'); + assert.equal(gone.kind, 'source_deleted'); + const unavailable = await apiActionErrorFromResponse(response(503, { 'X-Request-Id': 'req-a' }), '下载失败'); + assert.equal(unavailable.message, '服务暂时不可用,请稍后重试(请求 ID:req-a)'); + assert.ok(unavailable instanceof ApiActionError); +}); diff --git a/frontend/src/services/apiActionError.ts b/frontend/src/services/apiActionError.ts new file mode 100644 index 0000000..8813a04 --- /dev/null +++ b/frontend/src/services/apiActionError.ts @@ -0,0 +1,51 @@ +export type ApiActionErrorKind = 'unauthorized' | 'rate_limited' | 'source_deleted' | 'unavailable' | 'generic'; +interface ApiErrorPayload { error?: unknown; message?: unknown; code?: unknown } + +const appendRequestId = (message: string, requestId?: string) => requestId ? `${message}(请求 ID:${requestId})` : message; +const parseRetryAfter = (value: string | null, now: number): number | undefined => { + if (!value) return undefined; + const seconds = Number(value); + if (Number.isFinite(seconds) && seconds >= 0) return Math.ceil(seconds); + const retryAt = Date.parse(value); + return Number.isFinite(retryAt) ? Math.max(0, Math.ceil((retryAt - now) / 1000)) : undefined; +}; +export const formatRetryAfter = (seconds?: number): string | null => { + if (seconds === undefined || !Number.isFinite(seconds) || seconds < 0) return null; + const rounded = Math.ceil(seconds); + const minutes = Math.floor(rounded / 60), rest = rounded % 60; + return minutes && rest ? `${minutes} 分 ${rest} 秒` : minutes ? `${minutes} 分钟` : `${rest} 秒`; +}; + +export class ApiActionError extends Error { + readonly kind: ApiActionErrorKind; + readonly status: number; + readonly code?: string; + readonly requestId?: string; + readonly retryAfterSeconds?: number; + constructor(options: { kind: ApiActionErrorKind; status: number; message: string; code?: string; requestId?: string; retryAfterSeconds?: number }) { + super(options.message); this.name = 'ApiActionError'; Object.assign(this, options); + this.kind = options.kind; this.status = options.status; + } +} + +export async function apiActionErrorFromResponse(response: Response, fallback: string, now = Date.now()): Promise { + const payload = await response.json().catch(() => ({})) as ApiErrorPayload; + const code = typeof payload.code === 'string' ? payload.code : undefined; + const requestId = response.headers.get('X-Request-Id') || undefined; + if (response.status === 401 || response.status === 428) return new ApiActionError({ kind: 'unauthorized', status: response.status, message: '登录会话已失效,请重新登录', code, requestId }); + if (response.status === 429) { + const retryAfterSeconds = parseRetryAfter(response.headers.get('Retry-After'), now); + const retry = formatRetryAfter(retryAfterSeconds); + return new ApiActionError({ kind: 'rate_limited', status: 429, retryAfterSeconds, code, requestId, message: appendRequestId(retry ? `请求过于频繁,请在 ${retry}后重试` : '请求过于频繁,请稍后重试', requestId) }); + } + if (response.status === 410 || code === 'MEDIA_SOURCE_MISSING') return new ApiActionError({ kind: 'source_deleted', status: response.status, code, requestId, message: appendRequestId('源文件已删除或已移入回收站,无法继续操作', requestId) }); + if (response.status === 503) return new ApiActionError({ kind: 'unavailable', status: 503, code, requestId, message: appendRequestId('服务暂时不可用,请稍后重试', requestId) }); + const serverMessage = typeof payload.error === 'string' ? payload.error : typeof payload.message === 'string' ? payload.message : fallback; + return new ApiActionError({ kind: 'generic', status: response.status, code, requestId, message: appendRequestId(serverMessage || fallback, requestId) }); +} + +export function describeActionFailure(action: string, error: unknown): string { + if (error instanceof ApiActionError) return error.kind === 'unauthorized' ? `登录会话已失效,已为你退出,请重新登录后${action}` : `${action}失败:${error.message}`; + if (error instanceof DOMException && ['NotAllowedError', 'SecurityError'].includes(error.name)) return `${action}失败,请检查浏览器剪贴板权限后重试,也可手动选择内容复制`; + return error instanceof Error && error.message ? `${action}失败:${error.message}` : `${action}失败,请稍后重试`; +} diff --git a/frontend/src/services/dialogAccessibility.test.ts b/frontend/src/services/dialogAccessibility.test.ts new file mode 100644 index 0000000..67c9f05 --- /dev/null +++ b/frontend/src/services/dialogAccessibility.test.ts @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import test from 'node:test'; + +const dialog = fs.readFileSync(new URL('../components/ui/Dialog.tsx', import.meta.url), 'utf8'); +const confirm = fs.readFileSync(new URL('../components/ui/ConfirmDialog.tsx', import.meta.url), 'utf8'); +const create = fs.readFileSync(new URL('../components/ui/CreateFolderModal.tsx', import.meta.url), 'utf8'); +const i18n = fs.readFileSync(new URL('../i18n.ts', import.meta.url), 'utf8'); +const css = fs.readFileSync(new URL('../index.css', import.meta.url), 'utf8'); + +test('shared Dialog traps Tab, closes on Escape when allowed, and restores trigger focus', () => { + assert.match(dialog, /querySelectorAll/); + assert.match(dialog, /event\.key !== 'Tab'/); + assert.match(dialog, /event\.key === 'Escape'/); + assert.match(dialog, /previousFocus\?\.focus/); + assert.match(dialog, /aria-modal="true"/); +}); + +test('product dialogs consume the shared Dialog primitive', () => { + assert.match(confirm, /from '\.\/Dialog'/); + assert.match(create, /from "\.\/Dialog"/); +}); + +test('language, reduced motion, and 360px overflow protections are active', () => { + assert.match(i18n, /document\.documentElement\.lang/); + assert.match(css, /prefers-reduced-motion: reduce/); + assert.match(css, /overflow-x: hidden/); +}); diff --git a/frontend/src/services/fileQueryController.test.ts b/frontend/src/services/fileQueryController.test.ts new file mode 100644 index 0000000..100288c --- /dev/null +++ b/frontend/src/services/fileQueryController.test.ts @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { FileQueryController } from './fileQueryController.js'; + +const tick = () => new Promise(resolve => setTimeout(resolve, 0)); + +test('each settled query executes exactly one files and aggregation request group', async () => { + const calls: string[] = []; + const controller = new FileQueryController({ debounceMs: 5 }); + controller.schedule('alpha', async signal => { + await Promise.all([ + Promise.resolve().then(() => calls.push(`files:${signal.aborted}`)), + Promise.resolve().then(() => calls.push(`folders:${signal.aborted}`)), + ]); + return 'alpha-result'; + }); + await new Promise(resolve => setTimeout(resolve, 15)); + assert.deepEqual(calls, ['files:false', 'folders:false']); + assert.equal(controller.currentValue, 'alpha-result'); +}); + +test('debounce submits only the latest search text', async () => { + const submitted: string[] = []; + const controller = new FileQueryController({ debounceMs: 10 }); + for (const query of ['a', 'ab', 'abc']) { + controller.schedule(query, async () => { submitted.push(query); return query; }); + } + await new Promise(resolve => setTimeout(resolve, 25)); + assert.deepEqual(submitted, ['abc']); + assert.equal(controller.currentValue, 'abc'); +}); + +test('refresh starts a new generation, aborts old work, and retains prior value until success', async () => { + let resolveRefresh!: (value: string) => void; + const controller = new FileQueryController({ debounceMs: 0, initialValue: 'old-list' }); + controller.schedule('first', async signal => new Promise((_resolve, reject) => signal.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError'))))); + await tick(); + const pending = controller.refresh(async () => new Promise(resolve => { resolveRefresh = resolve; })); + assert.equal(controller.currentValue, 'old-list'); + resolveRefresh('new-list'); + await pending; + assert.equal(controller.currentValue, 'new-list'); +}); diff --git a/frontend/src/services/fileQueryController.ts b/frontend/src/services/fileQueryController.ts new file mode 100644 index 0000000..ec232b4 --- /dev/null +++ b/frontend/src/services/fileQueryController.ts @@ -0,0 +1,58 @@ +import { LatestRequest, type RequestGeneration } from './latestRequest.js'; + +export interface FileQueryControllerOptions { + debounceMs?: number; + initialValue?: T; +} + +export class FileQueryController { + private readonly latest = new LatestRequest(); + private readonly debounceMs: number; + private timer: ReturnType | null = null; + private pending: Promise = Promise.resolve(undefined); + currentValue: T | undefined; + + constructor(options: FileQueryControllerOptions = {}) { + this.debounceMs = Math.max(0, options.debounceMs ?? 250); + this.currentValue = options.initialValue; + } + + private async run(loader: (signal: AbortSignal, request: RequestGeneration) => Promise): Promise { + const request = this.latest.begin(); + try { + const value = await loader(request.signal, request); + if (!request.isCurrent()) return undefined; + this.currentValue = value; + return value; + } catch (error) { + if ((error as { name?: string })?.name === 'AbortError') return undefined; + throw error; + } + } + + schedule(_key: string, loader: (signal: AbortSignal, request: RequestGeneration) => Promise): Promise { + if (this.timer) clearTimeout(this.timer); + this.pending = new Promise((resolve, reject) => { + this.timer = setTimeout(() => { + this.timer = null; + this.run(loader).then(resolve, reject); + }, this.debounceMs); + }); + return this.pending; + } + + refresh(loader: (signal: AbortSignal, request: RequestGeneration) => Promise): Promise { + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + this.pending = this.run(loader); + return this.pending; + } + + cancel(): void { + if (this.timer) clearTimeout(this.timer); + this.timer = null; + this.latest.cancel(); + } +} diff --git a/frontend/src/services/fileQueryEffect.test.ts b/frontend/src/services/fileQueryEffect.test.ts new file mode 100644 index 0000000..d26087c --- /dev/null +++ b/frontend/src/services/fileQueryEffect.test.ts @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import test from 'node:test'; + +const app = fs.readFileSync(new URL('../App.tsx', import.meta.url), 'utf8'); + +test('file query is driven by one consolidated effect and one request group', () => { + const effect = app.slice(app.indexOf('// 认证和查询条件由单一 effect'), app.indexOf("if (currentCategory === 'ytdlp')")); + assert.equal((effect.match(/fileApi\.getFilesPage\(/g) || []).length, 1); + assert.equal((effect.match(/fileApi\.getFolderAggregations\(/g) || []).length, 1); + assert.match(effect, /Promise\.all/); +}); + +test('search input is debounced for 250ms before query options change', () => { + assert.match(app, /setTimeout\(\(\) => setDebouncedSearchQuery\(searchQuery\), 250\)/); + assert.match(app, /q: debouncedSearchQuery/); +}); + +test('refresh keeps existing list while latest generation is pending', () => { + const loader = app.slice(app.indexOf('const loadFiles = useCallback'), app.indexOf('const loadMoreFiles')); + assert.doesNotMatch(loader, /setFiles\(\[\]\)/); + assert.match(loader, /latestFileRequestRef\.current\.begin/); + assert.match(loader, /request\.isCurrent\(\)/); +}); diff --git a/frontend/src/services/nativeDialogContract.test.ts b/frontend/src/services/nativeDialogContract.test.ts index d184471..e3a634c 100644 --- a/frontend/src/services/nativeDialogContract.test.ts +++ b/frontend/src/services/nativeDialogContract.test.ts @@ -3,6 +3,16 @@ import fs from 'node:fs'; import path from 'node:path'; import test from 'node:test'; +const nativeDialogCall = /(? { + assert.match(`${receiver}${dialog}("message")`, nativeDialogCall); + }); + } +} + test('frontend product flows do not use browser alert confirm or prompt', () => { const root = path.resolve(new URL('../', import.meta.url).pathname); const files: string[] = []; @@ -14,5 +24,5 @@ test('frontend product flows do not use browser alert confirm or prompt', () => } }; walk(root); - for (const file of files) assert.doesNotMatch(fs.readFileSync(file, 'utf8'), /window\.(alert|confirm|prompt)\(/, file); -}); \ No newline at end of file + for (const file of files) assert.doesNotMatch(fs.readFileSync(file, 'utf8'), nativeDialogCall, file); +}); diff --git a/init.sql b/init.sql index 39e90a0..e422bfa 100644 --- a/init.sql +++ b/init.sql @@ -1,3 +1,12 @@ +-- GENERATED FROM backend/src/db/schema.sql; DO NOT EDIT init.sql DIRECTLY. +-- 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 扩展 @@ -12,24 +21,75 @@ BEGIN END; $$ LANGUAGE plpgsql; --- 存储账户表 (父表,需先创建) +-- 存储账户表 CREATE TABLE IF NOT EXISTS storage_accounts ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - type VARCHAR(50) NOT NULL, -- 'onedrive' - name VARCHAR(255) NOT NULL, -- 用户显示名称或邮箱 - config JSONB NOT NULL, -- {clientId, clientSecret, refreshToken, tenantId} + type VARCHAR(50) NOT NULL, + name VARCHAR(255) NOT NULL, + config JSONB NOT NULL, is_active BOOLEAN DEFAULT false, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() ); +ALTER TABLE storage_accounts ADD COLUMN IF NOT EXISTS last_probe_status VARCHAR(20); +ALTER TABLE storage_accounts ADD COLUMN IF NOT EXISTS last_probe_error TEXT; +ALTER TABLE storage_accounts ADD COLUMN IF NOT EXISTS last_probed_at TIMESTAMPTZ; --- storage_accounts 表更新时间触发器 CREATE OR REPLACE TRIGGER storage_accounts_updated_at BEFORE UPDATE ON storage_accounts FOR EACH ROW EXECUTE FUNCTION update_updated_at(); --- 文件表 (子表,依赖 storage_accounts) +-- 存储账户使用租约:上传等外部副作用在账户删除期间必须持有未释放租约。 +CREATE TABLE IF NOT EXISTS storage_account_leases ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + storage_account_id UUID NOT NULL REFERENCES storage_accounts(id) ON DELETE CASCADE, + purpose VARCHAR(50) NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + released_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +ALTER TABLE storage_account_leases DROP CONSTRAINT IF EXISTS storage_account_leases_storage_account_id_fkey; +ALTER TABLE storage_account_leases ADD CONSTRAINT storage_account_leases_storage_account_id_fkey + FOREIGN KEY (storage_account_id) REFERENCES storage_accounts(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS idx_storage_account_leases_active + ON storage_account_leases(storage_account_id, expires_at) + WHERE released_at IS NULL; + +-- OAuth pending flow records are hashed/session-bound, encrypted-config, TTL and one-time consumed. +CREATE TABLE IF NOT EXISTS oauth_pending_flows ( + state_hash VARCHAR(64) PRIMARY KEY, + provider VARCHAR(32) NOT NULL, + auth_session_hash VARCHAR(64) NOT NULL, + redirect_uri TEXT NOT NULL, + pending_config JSONB NOT NULL, + flow_nonce VARCHAR(128) NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_oauth_pending_flows_expiry ON oauth_pending_flows(expires_at); + +-- 存储账户冷却表(如 Google Drive 每日上传限额触发后暂停 24 小时) +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) +); + +CREATE INDEX IF NOT EXISTS idx_storage_account_cooldowns_until ON storage_account_cooldowns(cooldown_until); + +CREATE OR REPLACE TRIGGER storage_account_cooldowns_updated_at + BEFORE UPDATE ON storage_account_cooldowns + FOR EACH ROW + EXECUTE FUNCTION update_updated_at(); + +-- 文件表 CREATE TABLE IF NOT EXISTS files ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name VARCHAR(255) NOT NULL, @@ -39,23 +99,30 @@ CREATE TABLE IF NOT EXISTS files ( size BIGINT NOT NULL, path VARCHAR(500) NOT NULL, thumbnail_path VARCHAR(500), + preview_path VARCHAR(500), width INT, height INT, source VARCHAR(50) DEFAULT 'web', folder VARCHAR(255), - storage_account_id UUID REFERENCES storage_accounts(id), + storage_account_id UUID REFERENCES storage_accounts(id) ON DELETE CASCADE, is_favorite BOOLEAN DEFAULT false, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() ); --- 索引 CREATE INDEX IF NOT EXISTS idx_files_type ON files(type); CREATE INDEX IF NOT EXISTS idx_files_created_at ON files(created_at DESC); CREATE INDEX IF NOT EXISTS idx_files_folder ON files(folder); CREATE INDEX IF NOT EXISTS idx_files_is_favorite ON files(is_favorite); +CREATE INDEX IF NOT EXISTS idx_files_storage_account_id ON files(storage_account_id); +CREATE INDEX IF NOT EXISTS idx_files_account_created ON files(storage_account_id, created_at DESC, id DESC); +CREATE INDEX IF NOT EXISTS idx_files_source_created ON files(source, created_at DESC, id DESC); +CREATE INDEX IF NOT EXISTS idx_files_account_fav_created ON files(storage_account_id, is_favorite, created_at DESC, id DESC); +CREATE INDEX IF NOT EXISTS idx_files_source_fav_created ON files(source, is_favorite, created_at DESC, id DESC); +CREATE INDEX IF NOT EXISTS idx_files_account_folder_created ON files(storage_account_id, folder, created_at DESC, id DESC); +CREATE INDEX IF NOT EXISTS idx_files_source_folder_created ON files(source, folder, created_at DESC, id DESC); +ALTER TABLE files ADD COLUMN IF NOT EXISTS preview_path VARCHAR(500); --- files 表更新时间触发器 CREATE OR REPLACE TRIGGER files_updated_at BEFORE UPDATE ON files FOR EACH ROW @@ -81,8 +148,410 @@ CREATE TABLE IF NOT EXISTS system_settings ( updated_at TIMESTAMPTZ DEFAULT NOW() ); --- system_settings 表更新时间触发器 CREATE OR REPLACE TRIGGER system_settings_updated_at BEFORE UPDATE ON system_settings FOR EACH ROW EXECUTE FUNCTION update_updated_at(); + +-- 持久 Web 会话(仅存 token SHA-256,不存原始 token) +CREATE TABLE IF NOT EXISTS web_sessions ( + token_hash VARCHAR(64) PRIMARY KEY, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_web_sessions_expires ON web_sessions(expires_at); + +-- 持久、可恢复的分块上传会话。owner_id 是认证 token 的 SHA-256,不保存原始凭据。 +CREATE TABLE IF NOT EXISTS chunk_upload_sessions ( + upload_id UUID PRIMARY KEY, + owner_id VARCHAR(64) NOT NULL, + filename VARCHAR(255) NOT NULL, + mime_type VARCHAR(100) NOT NULL, + folder VARCHAR(255), + total_size BIGINT NOT NULL CHECK (total_size > 0), + total_chunks INT NOT NULL CHECK (total_chunks > 0), + received_bytes BIGINT NOT NULL DEFAULT 0 CHECK (received_bytes >= 0 AND received_bytes <= total_size), + status VARCHAR(20) NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'completing', 'completed', 'cancelled', 'failed')), + target_provider VARCHAR(50) NOT NULL, + target_account_id UUID REFERENCES storage_accounts(id) ON DELETE SET NULL, + expires_at TIMESTAMPTZ NOT NULL, + completion_token UUID, + completion_expires_at TIMESTAMPTZ, + completed_file_id UUID REFERENCES files(id) ON DELETE SET NULL, + last_error TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +ALTER TABLE chunk_upload_sessions ADD COLUMN IF NOT EXISTS completion_expires_at TIMESTAMPTZ; +ALTER TABLE chunk_upload_sessions DROP CONSTRAINT IF EXISTS chunk_upload_sessions_target_account_id_fkey; +ALTER TABLE chunk_upload_sessions ADD CONSTRAINT chunk_upload_sessions_target_account_id_fkey + FOREIGN KEY (target_account_id) REFERENCES storage_accounts(id) ON DELETE SET NULL; +CREATE INDEX IF NOT EXISTS idx_chunk_upload_sessions_owner ON chunk_upload_sessions(owner_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_chunk_upload_sessions_budget ON chunk_upload_sessions(status, expires_at); +CREATE INDEX IF NOT EXISTS idx_chunk_upload_sessions_completion_lease ON chunk_upload_sessions(status, completion_expires_at); + +CREATE TABLE IF NOT EXISTS chunk_upload_chunks ( + upload_id UUID NOT NULL REFERENCES chunk_upload_sessions(upload_id) ON DELETE CASCADE, + chunk_index INT NOT NULL CHECK (chunk_index >= 0), + size BIGINT NOT NULL CHECK (size > 0), + sha256 VARCHAR(64) NOT NULL CHECK (sha256 ~ '^[0-9a-f]{64}$'), + path VARCHAR(1000) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (upload_id, chunk_index) +); + +-- 永久对象/文件索引补偿出现不确定结果时的持久对账证据。 +CREATE TABLE IF NOT EXISTS chunk_upload_reconciliations ( + operation_id UUID PRIMARY KEY, + upload_id UUID NOT NULL REFERENCES chunk_upload_sessions(upload_id) ON DELETE CASCADE, + completion_token UUID NOT NULL, + provider VARCHAR(50) NOT NULL, + account_id UUID REFERENCES storage_accounts(id) ON DELETE SET NULL, + stored_path VARCHAR(2000), + file_id UUID, + object_state VARCHAR(20) NOT NULL CHECK (object_state IN ('unknown', 'present', 'deleted')), + index_state VARCHAR(20) NOT NULL CHECK (index_state IN ('unknown', 'present', 'deleted')), + reason TEXT NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'resolved')), + resolved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +ALTER TABLE chunk_upload_reconciliations ALTER COLUMN stored_path DROP NOT NULL; +ALTER TABLE chunk_upload_reconciliations ALTER COLUMN file_id DROP NOT NULL; +ALTER TABLE chunk_upload_reconciliations DROP CONSTRAINT IF EXISTS chunk_upload_reconciliations_upload_id_fkey; +ALTER TABLE chunk_upload_reconciliations ADD CONSTRAINT chunk_upload_reconciliations_upload_id_fkey + FOREIGN KEY (upload_id) REFERENCES chunk_upload_sessions(upload_id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS idx_chunk_upload_reconciliations_pending + ON chunk_upload_reconciliations(status, created_at); +ALTER TABLE chunk_upload_reconciliations ADD COLUMN IF NOT EXISTS resolution VARCHAR(30); +ALTER TABLE chunk_upload_reconciliations ADD COLUMN IF NOT EXISTS lease_token UUID; +ALTER TABLE chunk_upload_reconciliations ADD COLUMN IF NOT EXISTS lease_expires_at TIMESTAMPTZ; +ALTER TABLE chunk_upload_reconciliations ADD COLUMN IF NOT EXISTS attempts INT NOT NULL DEFAULT 0; +CREATE INDEX IF NOT EXISTS idx_chunk_upload_reconciliations_claim + ON chunk_upload_reconciliations(status, lease_expires_at, created_at); +CREATE UNIQUE INDEX IF NOT EXISTS idx_chunk_upload_reconciliations_pending_completion + ON chunk_upload_reconciliations(upload_id, completion_token) WHERE status = 'pending'; + +CREATE OR REPLACE TRIGGER chunk_upload_sessions_updated_at + BEFORE UPDATE ON chunk_upload_sessions + FOR EACH ROW + EXECUTE FUNCTION update_updated_at(); + +-- Cross-entry-point task records. Channel jobs and browser chunk sessions retain +-- their specialized tables, while this table persists ordinary Bot and yt-dlp tasks. +CREATE TABLE IF NOT EXISTS transfer_tasks ( + source_type VARCHAR(30) NOT NULL, + id VARCHAR(128) NOT NULL, + kind VARCHAR(50) NOT NULL, + title TEXT NOT NULL, + status VARCHAR(30) NOT NULL, + stage VARCHAR(50) NOT NULL DEFAULT 'waiting', + progress NUMERIC(5,2) NOT NULL DEFAULT 0, + owner_user_id BIGINT, + chat_id TEXT, + source TEXT, + target_provider VARCHAR(50), + target_account_id UUID REFERENCES storage_accounts(id) ON DELETE SET NULL, + target_folder TEXT, + total_items INT NOT NULL DEFAULT 0, + completed_items INT NOT NULL DEFAULT 0, + failed_items INT NOT NULL DEFAULT 0, + total_bytes BIGINT NOT NULL DEFAULT 0, + transferred_bytes BIGINT NOT NULL DEFAULT 0, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + error TEXT, + retryable BOOLEAN NOT NULL DEFAULT false, + cancel_requested BOOLEAN NOT NULL DEFAULT false, + started_at TIMESTAMPTZ, + finished_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (source_type, id) +); +CREATE INDEX IF NOT EXISTS idx_transfer_tasks_updated ON transfer_tasks(updated_at DESC); +CREATE INDEX IF NOT EXISTS idx_transfer_tasks_status ON transfer_tasks(status, updated_at DESC); +CREATE INDEX IF NOT EXISTS idx_transfer_tasks_owner ON transfer_tasks(owner_user_id, chat_id, updated_at DESC); +ALTER TABLE transfer_tasks ADD COLUMN IF NOT EXISTS execution_generation BIGINT NOT NULL DEFAULT 0; +ALTER TABLE transfer_tasks ADD COLUMN IF NOT EXISTS snapshot_version BIGINT NOT NULL DEFAULT 0; +ALTER TABLE transfer_tasks ADD COLUMN IF NOT EXISTS lease_token UUID; +ALTER TABLE transfer_tasks ADD COLUMN IF NOT EXISTS lease_expires_at TIMESTAMPTZ; +CREATE INDEX IF NOT EXISTS idx_transfer_tasks_claim ON transfer_tasks(source_type, status, lease_expires_at, updated_at); + +-- Task-center dismissals hide only an exact terminal snapshot. Source rows and files remain untouched. +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) +); +CREATE INDEX IF NOT EXISTS idx_task_center_dismissals_version + ON task_center_dismissals(source_type, task_id, task_updated_at); + +CREATE TABLE IF NOT EXISTS ytdlp_write_reconciliations ( + operation_id UUID PRIMARY KEY, + source_type VARCHAR(30) NOT NULL DEFAULT 'ytdlp' CHECK (source_type = 'ytdlp'), + task_id VARCHAR(128) NOT NULL, + execution_generation BIGINT NOT NULL, + task_lease_token UUID NOT NULL, + provider VARCHAR(50) NOT NULL, + account_id UUID REFERENCES storage_accounts(id) ON DELETE SET NULL, + stored_path VARCHAR(2000), + file_id UUID, + object_state VARCHAR(20) NOT NULL DEFAULT 'unknown' CHECK (object_state IN ('unknown', 'present', 'deleted')), + index_state VARCHAR(20) NOT NULL DEFAULT 'unknown' CHECK (index_state IN ('unknown', 'present', 'deleted')), + status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'resolved')), + resolution VARCHAR(30), + reason TEXT NOT NULL, + resolved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + FOREIGN KEY (source_type, task_id) REFERENCES transfer_tasks(source_type, id) ON DELETE CASCADE +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_ytdlp_reconciliation_pending_task + ON ytdlp_write_reconciliations(task_id) WHERE status = 'pending'; +CREATE INDEX IF NOT EXISTS idx_ytdlp_reconciliation_status + ON ytdlp_write_reconciliations(status, created_at); + +CREATE OR REPLACE TRIGGER transfer_tasks_updated_at + BEFORE UPDATE ON transfer_tasks + FOR EACH ROW + EXECUTE FUNCTION update_updated_at(); + +-- 持久化 Telegram /p(下一次)和 /ps(会话)路径状态。 +CREATE TABLE IF NOT EXISTS telegram_path_states ( + chat_id TEXT NOT NULL, + mode VARCHAR(10) NOT NULL CHECK (mode IN ('once', 'session')), + folder TEXT NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (chat_id, mode) +); +CREATE INDEX IF NOT EXISTS idx_telegram_path_states_expiry ON telegram_path_states(expires_at); + +CREATE OR REPLACE TRIGGER telegram_path_states_updated_at + BEFORE UPDATE ON telegram_path_states + 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(), + user_id BIGINT NOT NULL, + chat_id BIGINT, + source TEXT NOT NULL, + source_original TEXT, + source_type TEXT DEFAULT 'public', + title TEXT, + last_message_id INT DEFAULT 0, + folder_override TEXT, + enabled BOOLEAN DEFAULT true, + disabled_reason TEXT, + disabled_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(user_id, source) +); + +CREATE INDEX IF NOT EXISTS idx_tg_channel_subscriptions_enabled ON telegram_channel_subscriptions(enabled); +CREATE INDEX IF NOT EXISTS idx_tg_channel_subscriptions_user_id ON telegram_channel_subscriptions(user_id); +ALTER TABLE telegram_channel_subscriptions ADD COLUMN IF NOT EXISTS source_original TEXT; +ALTER TABLE telegram_channel_subscriptions ADD COLUMN IF NOT EXISTS source_type TEXT DEFAULT 'public'; +ALTER TABLE telegram_channel_subscriptions ADD COLUMN IF NOT EXISTS folder_override TEXT; +ALTER TABLE telegram_channel_subscriptions ADD COLUMN IF NOT EXISTS disabled_reason TEXT; +ALTER TABLE telegram_channel_subscriptions ADD COLUMN IF NOT EXISTS disabled_at TIMESTAMPTZ; +ALTER TABLE telegram_channel_subscriptions ADD COLUMN IF NOT EXISTS last_scan_at TIMESTAMPTZ; +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(), + user_id BIGINT NOT NULL, + chat_id BIGINT, + kind VARCHAR(50) NOT NULL, + source TEXT NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'pending', + scan_status TEXT DEFAULT 'pending', + download_status TEXT DEFAULT 'pending', + scan_cursor JSONB DEFAULT '{}'::jsonb, + cooldown_until TIMESTAMPTZ, + paused_at TIMESTAMPTZ, + cancelled_at TIMESTAMPTZ, + params JSONB DEFAULT '{}'::jsonb, + total_count INT DEFAULT 0, + enqueued_count INT DEFAULT 0, + skipped_count INT DEFAULT 0, + duplicate_count INT DEFAULT 0, + error TEXT, + started_at TIMESTAMPTZ, + finished_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_tg_background_jobs_user_created ON telegram_background_jobs(user_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_tg_background_jobs_status ON telegram_background_jobs(status); +ALTER TABLE telegram_background_jobs ADD COLUMN IF NOT EXISTS scan_status TEXT DEFAULT 'pending'; +ALTER TABLE telegram_background_jobs ADD COLUMN IF NOT EXISTS download_status TEXT DEFAULT 'pending'; +ALTER TABLE telegram_background_jobs ADD COLUMN IF NOT EXISTS scan_cursor JSONB DEFAULT '{}'::jsonb; +ALTER TABLE telegram_background_jobs ADD COLUMN IF NOT EXISTS cooldown_until TIMESTAMPTZ; +ALTER TABLE telegram_background_jobs ADD COLUMN IF NOT EXISTS paused_at TIMESTAMPTZ; +ALTER TABLE telegram_background_jobs ADD COLUMN IF NOT EXISTS cancelled_at TIMESTAMPTZ; +CREATE INDEX IF NOT EXISTS idx_tg_background_jobs_pipeline ON telegram_background_jobs(status, scan_status, download_status); +CREATE INDEX IF NOT EXISTS idx_tg_background_jobs_cooldown ON telegram_background_jobs(cooldown_until); + +CREATE OR REPLACE TRIGGER telegram_background_jobs_updated_at + BEFORE UPDATE ON telegram_background_jobs + FOR EACH ROW + EXECUTE FUNCTION update_updated_at(); + +-- Telegram 下载条目表(用于任务条目审计 / 失败统计) +CREATE TABLE IF NOT EXISTS telegram_download_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + job_id UUID REFERENCES telegram_background_jobs(id) ON DELETE CASCADE, + source TEXT NOT NULL, + source_peer TEXT, + origin TEXT DEFAULT 'channel', + message_id INT NOT NULL, + grouped_id TEXT, + shared_caption TEXT, + group_index INT, + group_size INT, + channel_post_id INT, + file_name TEXT, + mime_type TEXT, + generated_name BOOLEAN DEFAULT false, + total_size BIGINT DEFAULT 0, + folder_override TEXT, + status VARCHAR(20) NOT NULL DEFAULT 'pending', + attempts INT DEFAULT 0, + error TEXT, + last_error TEXT, + locked_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(job_id, message_id) +); + +ALTER TABLE telegram_download_items ADD COLUMN IF NOT EXISTS source_peer TEXT; +ALTER TABLE telegram_download_items ADD COLUMN IF NOT EXISTS grouped_id TEXT; +ALTER TABLE telegram_download_items ADD COLUMN IF NOT EXISTS shared_caption TEXT; +ALTER TABLE telegram_download_items ADD COLUMN IF NOT EXISTS group_index INT; +ALTER TABLE telegram_download_items ADD COLUMN IF NOT EXISTS group_size INT; +ALTER TABLE telegram_download_items ADD COLUMN IF NOT EXISTS origin TEXT DEFAULT 'channel'; +ALTER TABLE telegram_download_items ADD COLUMN IF NOT EXISTS channel_post_id INT; +ALTER TABLE telegram_download_items ADD COLUMN IF NOT EXISTS file_name TEXT; +ALTER TABLE telegram_download_items ADD COLUMN IF NOT EXISTS mime_type TEXT; +ALTER TABLE telegram_download_items ADD COLUMN IF NOT EXISTS generated_name BOOLEAN DEFAULT false; +ALTER TABLE telegram_download_items ADD COLUMN IF NOT EXISTS total_size BIGINT DEFAULT 0; +ALTER TABLE telegram_download_items ADD COLUMN IF NOT EXISTS folder_override TEXT; +ALTER TABLE telegram_download_items ADD COLUMN IF NOT EXISTS attempts INT DEFAULT 0; +ALTER TABLE telegram_download_items ADD COLUMN IF NOT EXISTS last_error TEXT; +ALTER TABLE telegram_download_items ADD COLUMN IF NOT EXISTS locked_at TIMESTAMPTZ; +ALTER TABLE telegram_download_items ADD COLUMN IF NOT EXISTS completed_at TIMESTAMPTZ; +ALTER TABLE telegram_download_items ADD COLUMN IF NOT EXISTS lease_token UUID; +ALTER TABLE telegram_download_items ADD COLUMN IF NOT EXISTS lease_expires_at TIMESTAMPTZ; +UPDATE telegram_download_items SET source_peer = COALESCE(source_peer, source) WHERE source_peer IS NULL; +ALTER TABLE telegram_download_items DROP CONSTRAINT IF EXISTS telegram_download_items_job_id_message_id_key; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_tg_download_items_job_peer_msg + ON telegram_download_items(job_id, source_peer, message_id); +CREATE INDEX IF NOT EXISTS idx_tg_download_items_job_status ON telegram_download_items(job_id, status); +CREATE INDEX IF NOT EXISTS idx_tg_download_items_recover ON telegram_download_items(status, locked_at); +CREATE INDEX IF NOT EXISTS idx_tg_download_items_lease_expiry ON telegram_download_items(status, lease_expires_at); + +-- Telegram provider save/index 与 child settlement 的 write-ahead 对账 journal。 +CREATE TABLE IF NOT EXISTS telegram_write_reconciliations ( + operation_id UUID PRIMARY KEY, + job_id UUID NOT NULL REFERENCES telegram_background_jobs(id) ON DELETE CASCADE, + item_id UUID NOT NULL REFERENCES telegram_download_items(id) ON DELETE CASCADE, + child_lease_token UUID NOT NULL, + provider VARCHAR(50) NOT NULL, + account_id UUID REFERENCES storage_accounts(id) ON DELETE SET NULL, + stored_path VARCHAR(2000), + file_id UUID, + object_state VARCHAR(20) NOT NULL CHECK (object_state IN ('unknown', 'present', 'deleted')), + index_state VARCHAR(20) NOT NULL CHECK (index_state IN ('unknown', 'present', 'deleted')), + reason TEXT NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'resolved')), + resolution VARCHAR(30), + lease_token UUID, + lease_expires_at TIMESTAMPTZ, + attempts INT NOT NULL DEFAULT 0, + resolved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +ALTER TABLE telegram_write_reconciliations ADD COLUMN IF NOT EXISTS resolution VARCHAR(30); +ALTER TABLE telegram_write_reconciliations ADD COLUMN IF NOT EXISTS lease_token UUID; +ALTER TABLE telegram_write_reconciliations ADD COLUMN IF NOT EXISTS lease_expires_at TIMESTAMPTZ; +ALTER TABLE telegram_write_reconciliations ADD COLUMN IF NOT EXISTS attempts INT NOT NULL DEFAULT 0; +CREATE INDEX IF NOT EXISTS idx_tg_write_reconciliations_claim + ON telegram_write_reconciliations(status, lease_expires_at, created_at); +CREATE UNIQUE INDEX IF NOT EXISTS idx_tg_write_reconciliations_pending_item + ON telegram_write_reconciliations(item_id) WHERE status = 'pending'; + +CREATE OR REPLACE TRIGGER telegram_download_items_updated_at + BEFORE UPDATE ON telegram_download_items + FOR EACH ROW + EXECUTE FUNCTION update_updated_at();