From e427532fdf8121b3eb50d960ba89e5798ac9387c Mon Sep 17 00:00:00 2001 From: hicocos Date: Fri, 16 Oct 2026 18:01:22 +0800 Subject: [PATCH] release: publish Telegram reliability, storage and security improvements in v2.4.9 --- .dockerignore | 48 +- .github/workflows/docker-publish.yml | 21 +- .gitignore | 46 +- README.md | 191 +- README_EN.md | 184 +- backend/.dockerignore | 24 +- backend/Dockerfile | 6 +- backend/package-lock.json | 4 +- backend/package.json | 4 +- backend/scripts/runtime-supervisor.mjs | 39 + backend/src/db/index.ts | 4 + ...101401_telegram_download_file_category.sql | 1 + ...026101402_telegram_download_exclusions.sql | 33 + ...501_telegram_download_attempt_recovery.sql | 11 + .../2026101502_telegram_compacted_bytes.sql | 19 + .../2026101503_telegram_compacted_counts.sql | 18 + backend/src/db/schema.sql | 2 + backend/src/i18n/telegram.ts | 126 +- backend/src/i18n/telegramRussian.json | 63 +- backend/src/index.ts | 10 +- backend/src/routes/storage.ts | 31 +- backend/src/routes/tasks.ts | 23 +- backend/src/scripts/login_telegram_user.ts | 7 +- backend/src/services/googleDriveUpload.ts | 66 + backend/src/services/oneDriveUpload.ts | 269 ++ backend/src/services/orphanCleanup.ts | 3 +- backend/src/services/rollingDownloadWindow.ts | 32 + backend/src/services/storage.ts | 228 +- backend/src/services/storage/contracts.ts | 9 +- .../services/telegramAccountAccessSweep.ts | 5 +- .../src/services/telegramAccountRepository.ts | 75 +- .../src/services/telegramAccountScheduler.ts | 4 + backend/src/services/telegramBot.ts | 63 +- backend/src/services/telegramChannelJobs.ts | 450 ++- .../src/services/telegramChannelProgress.ts | 89 +- .../src/services/telegramChannelScanner.ts | 200 +- backend/src/services/telegramCommands.ts | 144 +- .../services/telegramDownloadHistoryPolicy.ts | 51 +- .../src/services/telegramDownloadRefStore.ts | 59 + .../src/services/telegramDownloadStream.ts | 52 +- .../src/services/telegramFileConcurrency.ts | 70 + backend/src/services/telegramHashtag.ts | 32 + .../src/services/telegramMultiAccountLogin.ts | 32 +- .../telegramMultiAccountLoginAdapter.ts | 46 +- .../telegramMultiAccountLoginFlows.ts | 60 +- .../services/telegramMultiAccountRuntime.ts | 2 +- .../src/services/telegramOperationContext.ts | 7 + backend/src/services/telegramScanAccount.ts | 118 + backend/src/services/telegramScanReader.ts | 66 + backend/src/services/telegramTaskCenter.ts | 60 +- backend/src/services/telegramTaskFailures.ts | 121 + backend/src/services/telegramUpload.ts | 435 ++- backend/src/services/telegramUserClient.ts | 21 +- .../src/services/telegramUserClientPool.ts | 343 ++- backend/src/services/telegramUserSocket.ts | 180 ++ backend/src/services/telegramUserTransport.ts | 816 +++++ backend/src/services/telegramUserWebLogin.ts | 7 +- backend/src/services/telegramUserWorker.ts | 132 + .../src/services/telegramUserWorkerClient.ts | 221 ++ .../src/services/telegramWorkerProtocol.ts | 99 + .../services/telegramWriteReconciliation.ts | 13 +- backend/src/services/unifiedTaskMapper.ts | 21 +- backend/src/utils/advancedSettings.ts | 11 +- backend/src/utils/runtimeConfig.ts | 2 +- backend/src/utils/telegramBatchMediaFilter.ts | 16 + backend/src/utils/telegramCommandRegistry.ts | 2 +- backend/src/utils/telegramMessages.ts | 14 +- backend/src/utils/temporaryUploadFiles.ts | 29 + backend/src/utils/typeGuards.ts | 4 + deploy/DEPLOY.md | 202 +- deploy/install.sh | 6 +- deploy/nginx-site-init.conf | 12 +- deploy/nginx-site.conf | 18 +- docker-compose.yml | 4 + docs/configuration.md | 278 +- docs/local-storage.md | 57 +- frontend/.dockerignore | 23 + frontend/Dockerfile | 8 +- frontend/nginx.conf | 17 +- frontend/package-lock.json | 2616 ++++++++--------- frontend/package.json | 8 +- frontend/postcss.config.js | 3 +- frontend/public/fonts/geist/index.css | 10 +- frontend/scripts/build-nginx-config.mjs | 15 + frontend/src/App.css | 42 - frontend/src/assets/react.svg | 1 - .../src/components/pages/SettingsPage.tsx | 58 +- .../components/pages/TaskFailureDetails.tsx | 64 + frontend/src/components/pages/TasksPage.tsx | 21 +- .../pages/TelegramUserAccountsPanel.tsx | 92 +- frontend/src/components/pages/tasks-audit.css | 9 + frontend/src/index.css | 49 +- frontend/src/locales/en.json | 21 + frontend/src/locales/en/management.ts | 10 + frontend/src/locales/en/remainingPages.ts | 2 +- frontend/src/locales/ru.json | 34 +- frontend/src/locales/zh-CN/management.ts | 10 + frontend/src/locales/zh-CN/remainingPages.ts | 2 +- frontend/src/locales/zh.json | 21 + frontend/src/services/api.ts | 15 +- frontend/src/services/apiTypes.ts | 19 + frontend/src/services/clients/tasksClient.ts | 12 + frontend/src/services/taskQuickFilters.ts | 2 +- frontend/tailwind.config.js | 18 +- init.sql | 2 + 105 files changed, 6496 insertions(+), 2979 deletions(-) create mode 100644 backend/scripts/runtime-supervisor.mjs create mode 100644 backend/src/db/migrations/2026101401_telegram_download_file_category.sql create mode 100644 backend/src/db/migrations/2026101402_telegram_download_exclusions.sql create mode 100644 backend/src/db/migrations/2026101501_telegram_download_attempt_recovery.sql create mode 100644 backend/src/db/migrations/2026101502_telegram_compacted_bytes.sql create mode 100644 backend/src/db/migrations/2026101503_telegram_compacted_counts.sql create mode 100644 backend/src/services/googleDriveUpload.ts create mode 100644 backend/src/services/oneDriveUpload.ts create mode 100644 backend/src/services/rollingDownloadWindow.ts create mode 100644 backend/src/services/telegramDownloadRefStore.ts create mode 100644 backend/src/services/telegramFileConcurrency.ts create mode 100644 backend/src/services/telegramHashtag.ts create mode 100644 backend/src/services/telegramOperationContext.ts create mode 100644 backend/src/services/telegramScanAccount.ts create mode 100644 backend/src/services/telegramScanReader.ts create mode 100644 backend/src/services/telegramTaskFailures.ts create mode 100644 backend/src/services/telegramUserSocket.ts create mode 100644 backend/src/services/telegramUserTransport.ts create mode 100644 backend/src/services/telegramUserWorker.ts create mode 100644 backend/src/services/telegramUserWorkerClient.ts create mode 100644 backend/src/services/telegramWorkerProtocol.ts create mode 100644 backend/src/utils/temporaryUploadFiles.ts create mode 100644 backend/src/utils/typeGuards.ts create mode 100644 frontend/scripts/build-nginx-config.mjs delete mode 100644 frontend/src/App.css delete mode 100644 frontend/src/assets/react.svg create mode 100644 frontend/src/components/pages/TaskFailureDetails.tsx diff --git a/.dockerignore b/.dockerignore index 1d176d7..1767e8e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,11 +1,39 @@ -node_modules .git -*.log -.env.local -dist -tg-vault.tar.gz -verify_sanitization.js -verify_sanitization.ts -backend/*.sql -frontend/.env* -nginx/ +.hermes +.env +.env.* +**/node_modules +**/dist +**/build +**/data +**/uploads +**/thumbnails +**/previews +**/chunks +**/secrets +**/backups +**/coverage +**/__tests__ +**/*.test.* +**/*.spec.* +**/*.tsbuildinfo +**/*.log +**/*.dump +**/*.backup +**/*.bak +**/*.pem +**/*.key +**/*.p12 +**/*.pfx +**/*.session +**/*.session-journal +**/telegram_session*.txt +**/telegram_user_session*.txt +**/*.tar +**/*.tar.gz +**/*.tgz +**/*.zip +.vscode +.idea +.DS_Store +Thumbs.db diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 87bd340..966eba7 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -13,9 +13,14 @@ on: - master workflow_dispatch: +permissions: + contents: read + jobs: quality: runs-on: ubuntu-latest + outputs: + publish_enabled: ${{ steps.publish-config.outputs.enabled }} steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 @@ -25,9 +30,21 @@ jobs: cache-dependency-path: | backend/package-lock.json frontend/package-lock.json + - name: Check Docker Hub publishing configuration + id: publish-config + env: + DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} + DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} + run: | + if [[ -n "$DOCKER_USERNAME" && -n "$DOCKER_PASSWORD" ]]; then + echo "enabled=true" >> "$GITHUB_OUTPUT" + else + echo "enabled=false" >> "$GITHUB_OUTPUT" + echo "Docker Hub credentials are not configured; publishing is skipped." + fi - name: Verify backend working-directory: backend - run: npm ci && npm audit --omit=dev --audit-level=high && npm run typecheck && npm run build + run: npm ci && npm audit --omit=dev --audit-level=high && npm run schema:check && npm run typecheck && npm run build - name: Verify frontend working-directory: frontend run: npm ci && npm audit --omit=dev --audit-level=high && npm run lint && npm run build @@ -47,6 +64,7 @@ jobs: # 构建 Backend 镜像 build-backend: needs: quality + if: github.event_name == 'pull_request' || needs.quality.outputs.publish_enabled == 'true' runs-on: ubuntu-latest steps: - name: Checkout code @@ -106,6 +124,7 @@ jobs: # 构建 Frontend 镜像 build-frontend: needs: quality + if: github.event_name == 'pull_request' || needs.quality.outputs.publish_enabled == 'true' runs-on: ubuntu-latest steps: - name: Checkout code diff --git a/.gitignore b/.gitignore index 24116d0..8bb2513 100644 --- a/.gitignore +++ b/.gitignore @@ -1,26 +1,45 @@ +# Private configuration (only sanitized examples belong in Git) .env .env.* -**/.env !.env.example +!**/.env.example node_modules/ **/node_modules/ -# runtime data +# Runtime data and credentials data/ -**/data/ +uploads/ +thumbnails/ +previews/ +chunks/ +secrets/ +*.session +*.session-journal +telegram_session*.txt +telegram_user_session*.txt +*.pem +*.key +*.p12 +*.pfx -# build artifacts -backend/dist/* -frontend/dist/ +# Build artifacts dist/ +build/ +*.tsbuildinfo # local agent workspace and reports .hermes/ /audit-report-*.md -# archives, local backups, and release-note drafts +# Archives, database dumps, and local backups +*.tar *.tar.gz +*.tgz +*.zip +*.dump +*.backup +*.bak backups/ /RELEASE_NOTES_v*.md @@ -35,8 +54,17 @@ __pycache__/ .DS_Store Thumbs.db -# Local-only automated tests +# Local-only tests, coverage, and tool caches **/*.test.* **/*.spec.* **/__tests__/ -**/coverage/ +coverage/ +.nyc_output/ +.pytest_cache/ +.eslintcache +.vite/ +.vscode/ +.idea/ +*.swp +*.swo +*~ diff --git a/README.md b/README.md index c09a95c..1c84928 100644 --- a/README.md +++ b/README.md @@ -1,184 +1,97 @@

- 简体中文 | English + 简体中文 | English

-
- TG Vault Logo +

TG Vault Logo

-

TG Vault

+# TG Vault -

- Telegram 文件转存与归档 -

-

- 面向个人与小团队的 Telegram 转存、媒体归档和多存储源文件管理系统。 -

+面向个人与小团队的 Telegram 文件转存、媒体归档和多存储源文件管理系统。在 Web 中管理文件,将 Telegram 私聊、频道或群组中有权保存的内容归档到自己的存储。 -

- 快速部署 - · - 功能概览 - · - Bot 命令 - · - 部署指南 -

+[快速部署](#快速部署) · [Bot 命令](#telegram-bot-命令) · [部署指南](deploy/DEPLOY.md) · [English](README_EN.md) -

- Latest Release - License - Stars - Forks -

-

- Telegram Bot - Docker Compose - React TypeScript - PostgreSQL 16 -

-
+## 功能概览 -从 Telegram 私聊、频道或群组保存文件,在 Web 中浏览和管理。支持本地磁盘、OneDrive、Google Drive、OSS、S3 和 WebDAV。 +- 文件上传、文件夹整理、搜索和媒体预览。 +- Telegram Bot 文件转存,频道/群组按日期或标签批量下载,频道订阅同步。 +- Web 与 Bot 任务管理:查看进度、保存去向和失败明细,暂停、继续、取消及重试。 +- 本地磁盘、OneDrive、Google Drive、阿里云 OSS、S3 和 WebDAV 存储,按来源与目录规则归档。 +- 管理员密码、Bot 用户允许列表与 PIN、TOTP 双重验证。 ---- +TG Vault 不是 Telegram 权限或下载限制的绕过工具。频道下载与订阅需要登录能访问来源的 Telegram 用户账号;仅向 Bot 发文件不要求登录个人账号。本项目的 Bot 下载使用 MTProto,而非 HTTP Bot API 的 `getFile`;不能套用后者的文件大小限制,也不能保证任意文件可下载。直接发送给 Bot 且没有可重读来源的文件仍由 Bot 下载,登录个人账号不会自动解除该路径的限制。详见[配置说明](docs/configuration.md#telegram-配置与能力)。 -## ✨ 功能概览 +## 快速部署 -- **文件管理**:上传、预览、整理文件夹,在浏览器中管理归档内容;文件夹列表始终完整显示,无需展开或收起。语言与主题等组合控件的焦点框沿完整外框显示;普通输入、下拉框不叠加重复描边,保留键盘焦点提示。 -- **媒体预览**:打开、关闭文件详情保留视频与音频播放器,播放位置和播放/暂停状态不会因详情切换重置。 -- **Telegram 转存**:发送文件给 Bot,或从频道、群组批量保存媒体。 -- **订阅同步**:订阅频道,自动保存后续更新。 -- **任务中心**:Web 删除展示记录后,Bot `/tasks` 同步隐藏对应频道任务;任务状态或下载明细更新后会重新显示。失败任务单独统计,不计入等待数量。 -- **下载进度卡**:相册任务显示实时下载速度与已下载字节,文件大小完整时按字节计算百分比;静默卡显示汇总速度。暂停、继续与取消按钮支持完整任务 ID,停传或速度采样过期后不保留旧速度。 -- **条目计数同步**:普通 Telegram 任务的完成数、失败数及暂停状态变化不受进度写入节流限制;Web 在任务仍处于内存队列时,条目计数与字节进度统一读取实时快照,避免暂停后保留旧条目数。 -- **静默任务控制**:私聊、群组和频道的按钮回调使用真实聊天 ID 刷新原进度卡;后台刷新保留所属用户的暂停状态。取消先终止会话刷新,再显示取消结果并移除按钮,避免旧进度覆盖取消提示。 -- **保存去向**:下载、保存和完成卡显示任务锁定的存储账户名称、存储类型、账户短 ID 与保存目录;相册完成摘要包含已成功保存文件的总大小,保留 v2.0.0 的成功/失败统计、完成时间与文件记录入口。目录以任务实际目标为准,不从相册标题推测。 -- **限流与暂停**:账号下载使用 Telegram MTProto API。文件分片遇到 FLOOD_WAIT 时保留已写入偏移、等待后续传;同账号共享冷却,触发限流后一分钟内串行读取。暂停阻止后续读取并保留临时文件,已发出的请求或存储写入可能仍需收尾。多账号按文件分配,不将一个文件跨账号分片。 -- **启动等待**:Bot 可先于用户账号下载器就绪。启动隔离窗口内提交的账号下载会等待账号恢复完成,不再直接报“无可用账号”;等待期间可取消任务。 -- **网页任务控制**:普通 Telegram 单文件和相册任务与频道任务一样支持暂停、继续;Bot 聊天范围暂停同步更新正在下载任务的状态与快照版本,Web 下一次刷新显示已暂停。网页单独继续一个任务不会恢复同聊天的其他暂停任务,再次暂停聊天会重新覆盖该任务。控制使用任务自身的聊天与用户范围,已结束或重启后失效的任务不会误操作其他任务。 -- **多种存储**:支持本地磁盘、OneDrive、Google Drive、OSS、S3 和 WebDAV。 -- **自动归档**:按来源、频道和文件类型整理文件。 -- **访问保护**:管理员登录、Bot 用户权限和双重验证。 - ---- - -## 🚀 快速部署 (Docker Compose) - -准备一台 Linux 服务器,以及用于网页和 API 的两个域名。 - -### 1. 下载项目 +准备 Linux 服务器、Docker Compose,以及同一主域名下的 Web/API 两个 HTTPS 地址,例如 `cloud.example.com` 与 `api.example.com`。证书和反向代理由宿主机或管理面板提供。 ```bash git clone https://github.com/hicocos/tg-vault.git cd tg-vault -``` - -### 2. 运行一键脚本 - -```bash ./deploy/install.sh ``` -按提示检查环境,填写 Web 和 API 地址,确认后开始安装。 +首次安装无需预先复制 `.env.example`。按提示填写 Web/API 地址和新的专用文件目录;安装器生成数据库密码及应用密钥,构建并启动 PostgreSQL、后端与前端。文件目录默认是安装目录下的 `data`,已有部署保留原挂载,不自动迁移文件。 -### 3. 开始使用 +1. 按[部署指南](deploy/DEPLOY.md#4-配置宿主机反向代理)配置 Web → `127.0.0.1:47832`、API → `127.0.0.1:51947` 的 HTTPS 反向代理。 +2. **首次初始化前限制两个入口的访问,仅允许自己访问。** 第一个完成初始化的人会设置管理员密码;没有默认管理员密码。通过 HTTPS 创建至少 8 位的强密码后再开放服务。 +3. 在 **设置 → Telegram** 填写 Bot Token、API ID、API Hash,设置 Bot PIN 与数字用户 ID 允许列表。频道下载或订阅还需要在同页登录个人 Telegram 账号。 +4. 在 **设置 → 存储** 添加其他存储账户;妥善备份配置、数据库和完整 `/data`。 -按照[部署指南](deploy/DEPLOY.md#4-配置宿主机反向代理)为两个域名配置 HTTPS,然后打开网页创建管理员账号。 +安装器选项、较旧 Compose 的 `--compat` 模式和故障排查见[完整部署指南](deploy/DEPLOY.md)。 -进入 **设置 → Telegram** 连接 Bot;需要频道转存或订阅同步时,再登录 Telegram 账号。其他存储服务可在 **设置 → 存储** 中添加。 +## Telegram Bot 命令 -[查看完整部署指南](deploy/DEPLOY.md) · [Telegram 配置说明](docs/configuration.md#-telegram-配置与能力) - ---- - -## 🧭 Telegram Bot 命令 - -连接 Bot 后,发送 `/start` 开始使用,发送 `/help` 查看帮助。 +连接后发送 `/start` 验证身份,发送 `/help` 查看完整命令和参数。文件浏览与搜索使用 Web。 | 命令 | 用途 | | --- | --- | -| `/tasks` | 查看和管理任务 | +| `/tasks` | 查看任务、进度与控制按钮 | | `/storage` | 查看存储状态 | -| `/path_rules` | 设置保存位置 | -| `/tg_download` | 打开频道、群组下载向导 | -| `/tg_sub` | 添加频道订阅 | -| `/tg_subs` | 查看订阅 | +| `/path_rules` | 设置保存位置与目录规则 | +| `/target` | 设置当前聊天的存储目标 | +| `/tg_download` | 打开频道/群组日期或标签下载向导 | +| `/tg_sub`、`/tg_subs` | 管理和查看订阅 | +| `/tg_retry` | 重试失败任务 | +| `/setup_2fa` | 设置 TOTP 双重验证 | -频道下载与订阅需要登录有访问权限的 Telegram 账号。 +频道任务的处理进度包括成功、失败和跳过,100% 不等于全部成功。扫描总量可能未知;排队或 Telegram 冷却不代表正在传输。暂停阻止后续读取,但已发出的请求和存储写入可能仍需收尾。 -Bot 不再提供 `/list` 和 `/find`,文件浏览与搜索请使用 Web 文件管理。 +## 备份与更新 -任务中心的频道进度按已处理条目统计(成功、失败和跳过均计入),同时分别显示各项数量;100% 不代表全部成功。扫描或收尾尚未完成时不会显示 100%。数据量显示已成功保存的文件大小与已发现的文件总大小;已压缩的历史明细不再显示不完整的数据量。Bot 的失败完成卡持久保存频道任务关联,清理界面缓存或重启服务后仍可查看失败明细、重试失败项。 - -频道失败项重试入队后,两端任务列表均保留该任务并显示等待中;只有条目已被下载器领取,才显示下载中。后台恢复扫描默认每 5 分钟执行一次,因此排队期间不代表文件正在传输。 - -日期/标签下载新增扫描过程展示:已检查消息数、发现文件数、评论扫描计数及扫描到的时间。Bot 任务卡、任务详情和 Web 任务中心均可查看;无媒体及不匹配消息也计入已检查数量,相册文件去重计数。无需额外 Telegram 统计请求,不展示未知总量的扫描百分比。 - -升级时需应用 `2026101201_telegram_background_job_status_text` 数据库迁移(后端启动自动执行),修复 `completed_with_errors` 超过旧状态列长度而无法收尾的问题,并创建完成卡记录表。 - -
-更多命令 - -- **文件管理**:`/delete <至少 8 位 ID 前缀>` 删除文件;`/setup_2fa` 设置双重验证。 -- **任务控制**:`/task_pause [任务ID]`、`/task_resume [任务ID]`、`/task_cancel <任务ID或all>`、`/stop_tasks`。 -- **保存目录**:`/p <目录>` 仅用于下一次下载;`/ps <目录>` 用于当前会话;`/pc` 清除自定义目录。 -- **批量下载**:`/tg_download date <频道> <开始日期> <结束日期>`、`/tg_download tag <频道> <#标签>`。 -- **订阅与重试**:`/tg_unsub <频道或订阅ID前缀>` 取消订阅;`/tg_retry [数量] [任务ID]` 重试失败任务。 -- **下载设置**:`/download_workers`、`/file_concurrency`、`/duplicate_mode`、`/cleanup_settings`。 - -
- ---- - -## 🔄 维护与更新 - -进入自己的 TG Vault 项目目录,运行一键升级脚本: +从项目根目录执行协调备份,再运行安装器更新: ```bash +BACKUP_DIR=/path/to/secure-backups ./deploy/backup.sh +./deploy/restore-verify.sh /path/to/secure-backups/ ./deploy/install.sh ``` ---- +备份脚本在归档期间停止 backend,结束时恢复其原运行状态。**另行安全备份 `.env` 和反向代理配置**;脚本只包含数据库、完整 `/data` 与校验清单,不包含远端存储的文件。归档校验不是恢复演练。升级自动应用数据库迁移,回退时必须考虑数据库与文件的一致性,不要直接覆盖旧版本。不要用 `docker compose down -v` 排障。详见[备份与恢复](deploy/DEPLOY.md#7-备份与恢复)。 -## 📚 文档 +## 本地构建与贡献 -- [部署指南](deploy/DEPLOY.md):安装、域名配置、环境兼容与故障排查。 -- [配置与使用](docs/configuration.md):Telegram 接入、访问权限与高级配置。 -- [本地存储位置](docs/local-storage.md):选择和查看文件保存目录。 -- [版本更新](https://github.com/hicocos/tg-vault/releases):查看新增功能与修复。 +容器构建使用 Node.js 24;本地构建也建议使用 Node.js 24 和 npm。两个子项目分别安装依赖与构建: ---- - -## 📂 项目结构 - -```text -TG Vault/ -├── frontend/ # React 网页前端 -├── backend/ # Node.js API 与 Telegram 服务 -├── init.sql # 数据库初始化脚本 -├── docker-compose.yml # Docker Compose 部署配置 -├── .env.example # 环境变量模板 -└── LICENSE # MIT License +```bash +npm --prefix backend ci +npm --prefix frontend ci +npm --prefix backend run build +npm --prefix frontend run build ``` ---- +本地运行后端还需要 PostgreSQL 和 `DATABASE_URL`;缩略图/转码需要 ffmpeg。开发启动与环境要求见[配置说明](docs/configuration.md#本地开发)。提交变更时说明用途、复现步骤与实际执行的检查;保留锁文件,并同步中英文说明。数据库结构变更应同步 schema、迁移和 `init.sql`,不要改写已发布迁移。 -## 📄 开源协议 +GitHub Actions 的质量检查不需要生产凭证;公共 fork 无需配置 Docker Hub 即可参与开发。仅发布镜像时配置仓库 secrets `DOCKER_USERNAME`、`DOCKER_PASSWORD`,以及按需使用的变量 `VITE_API_URL`;未配置发布凭证时跳过推送。API 地址会进入前端静态资源,不能把秘密放进 `VITE_*` 变量。 -基于 [MIT License](LICENSE) 开源。 +发布、提交或共享问题日志前,确认不含 `.env`、Token、API Hash、密码、Cookie、Telegram session、数据库/备份、用户文件或服务器私有信息。不要提交 `node_modules`、`dist`、覆盖率、临时调试/冒烟脚本及本地执行报告;忽略规则不能移除已经进入 Git 历史的秘密,泄露后应撤销/轮换并清理历史。 -作者:[hicocos](https://github.com/hicocos) · 仓库:[hicocos/tg-vault](https://github.com/hicocos/tg-vault) +## 文档 ---- +- [部署指南](deploy/DEPLOY.md):安装、HTTPS、备份、恢复与更新。 +- [配置与使用](docs/configuration.md):环境变量、Telegram、存储与权限。 +- [本地存储位置](docs/local-storage.md):挂载、路径确认与数据保护。 +- [版本发布](https://github.com/hicocos/tg-vault/releases)。 -## 📊 项目数据 +## 开源协议 - -
- - - - TG Vault Star History Chart - - -
+[MIT License](LICENSE) · [hicocos/tg-vault](https://github.com/hicocos/tg-vault) diff --git a/README_EN.md b/README_EN.md index 62117e3..8ce933d 100644 --- a/README_EN.md +++ b/README_EN.md @@ -1,177 +1,97 @@

- 简体中文 | English + 简体中文 | English

-
- TG Vault Logo +

TG Vault Logo

-

TG Vault

+# TG Vault -

- Telegram File Transfer & Archive -

-

- A Telegram-based file transfer, media archiving and multi-storage file management system for individuals and small teams. -

+Telegram file transfer, media archiving and multi-storage file management for individuals and small teams. Manage files in the web UI and archive content you are entitled to save from Telegram private chats, channels and groups to your own storage. -

- Quick Start - · - Features - · - Bot Commands - · - Deployment Guide -

+[Quick start](#quick-start) · [Bot commands](#telegram-bot-commands) · [Deployment guide](deploy/DEPLOY.md) · [简体中文](README.md) -

- Latest Release - License - Stars - Forks -

-

- Telegram Bot - Docker Compose - React TypeScript - PostgreSQL 16 -

-
+## Features -Save files from Telegram private chats, channels or groups, then browse and manage them in the web UI. Supports local disk, OneDrive, Google Drive, OSS, S3 and WebDAV. +- File uploads, folder organization, search and media previews. +- Telegram Bot transfers, channel/group batch downloads by date or tag, and channel subscription sync. +- Web and Bot task management: progress, destinations, failure details, pause, resume, cancel and retry. +- Local disk, OneDrive, Google Drive, Aliyun OSS, S3 and WebDAV storage, with source and directory rules for archiving. +- Administrator password, Bot user allowlist and PIN, and TOTP two-factor authentication. ---- +TG Vault does not bypass Telegram permissions or download restrictions. Channel downloads and subscriptions require a Telegram user account with access to the source; sending files directly to the Bot does not require a personal account. Bot downloads in this project use MTProto, not the HTTP Bot API's `getFile`: its size limit cannot simply be applied here, and arbitrary files are not guaranteed to download. Direct Bot messages without a readable original source still use Bot downloads; adding a personal account does not automatically remove that path's restrictions. See [configuration](docs/configuration.md#telegram-配置与能力). -## ✨ Features +## Quick start -- **File management**: Upload, preview and organize files; all folders remain visible without expanding the list. Storage accounts show safe configuration summaries and support renaming. -- **Media previews**: Opening or closing file details preserves audio/video playback and the current position. -- **Telegram transfer**: Send files to the Bot, or batch-save media from channels and groups. -- **Subscription sync**: Subscribe to channels and automatically save future updates. -- **Task controls**: Pause, resume, cancel and retry downloads from the Web or Bot; both show live item counts, download speed, save destinations and scan progress. -- **Account scheduling**: Downloads use eligible Telegram accounts with shared cooldowns; channel scans are serialized per account and paced to reduce FloodWait errors. -- **Multiple storage backends**: Local disk, OneDrive, Google Drive, OSS, S3 and WebDAV. -- **Automatic archiving**: Organize files by source, channel and file type. -- **Access protection**: Admin login, Bot user permissions and two-factor authentication. - ---- - -## 🚀 Quick Start (Docker Compose) - -You will need a Linux server and two domain names — one for the web UI and one for the API. - -### 1. Clone the project +Prepare a Linux server, Docker Compose, and two HTTPS addresses under the same parent domain, such as `cloud.example.com` and `api.example.com`. The host or your management panel provides certificates and the reverse proxy. ```bash git clone https://github.com/hicocos/tg-vault.git cd tg-vault -``` - -### 2. Run the one-click script - -```bash ./deploy/install.sh ``` -Follow the prompts to check the environment and fill in the web and API addresses, then confirm to start the installation. +Do not copy `.env.example` before a fresh installer run. Enter your Web/API addresses and a new dedicated storage directory. The installer generates the database password and application keys, builds the application, and starts PostgreSQL, the backend and the frontend. The default file directory is `/data`; existing deployments retain their original mount, with no automatic file migration. -### 3. Get started +1. Configure HTTPS reverse proxies for Web → `127.0.0.1:47832` and API → `127.0.0.1:51947` as described in the [deployment guide](deploy/DEPLOY.md#4-配置宿主机反向代理). +2. **Restrict both endpoints to your own access until initialization is complete.** The first person to finish setup chooses the administrator password; there is no default password. Set a strong password of at least eight characters over HTTPS before opening access. +3. Under **Settings → Telegram**, enter the Bot Token, API ID and API Hash, set a Bot PIN, and configure an allowlist of numeric user IDs. For channel downloads or subscriptions, also log in with a personal Telegram account on that page. +4. Add other storage accounts under **Settings → Storage** and back up the configuration, database and complete `/data` directory. -Set up HTTPS for both domains as described in the [deployment guide](deploy/DEPLOY.md#4-配置宿主机反向代理), then open the web page and create your admin account. +See the [full deployment guide](deploy/DEPLOY.md) for installer options, `--compat` mode for older Compose environments and troubleshooting. Detailed guides are currently in Chinese. -Go to **Settings → Telegram** to connect a Bot. If you need channel transfers or subscription sync, also log in with your Telegram account. Other storage backends can be added under **Settings → Storage**. +## Telegram Bot commands -[View the full deployment guide](deploy/DEPLOY.md) · [Telegram configuration guide](docs/configuration.md#-telegram-配置与能力) - -> Note: The deployment and configuration docs are currently available in Chinese only. - ---- - -## 🧭 Telegram Bot Commands - -After connecting the Bot, send `/start` to begin and `/help` to view the help message. +After connecting, send `/start` to authenticate and `/help` for the full command list and arguments. Browse and search files in the web UI. | Command | Purpose | | --- | --- | -| `/tasks` | View and manage tasks | +| `/tasks` | View tasks, progress and controls | | `/storage` | View storage status | -| `/path_rules` | Set the save location | -| `/tg_download` | Open the channel/group download wizard | -| `/tg_sub` | Add a channel subscription | -| `/tg_subs` | View subscriptions | +| `/path_rules` | Configure save locations and directory rules | +| `/target` | Set the storage target for the current chat | +| `/tg_download` | Open the channel/group date or tag download wizard | +| `/tg_sub`, `/tg_subs` | Manage and list subscriptions | +| `/tg_retry` | Retry failed tasks | +| `/setup_2fa` | Configure TOTP two-factor authentication | -Channel downloads and subscriptions require logging in with a Telegram account that has access to them. +Channel processing progress includes successful, failed and skipped items; 100% does not mean all files succeeded. Scan totals may be unknown. Queued tasks and Telegram cooldowns do not indicate active transfer. Pausing stops subsequent reads, but in-flight requests or storage writes may still finish. -Date/tag downloads now show scan progress in Bot cards, task details, and the Web task center: messages checked, files discovered, comment counts, and the timestamp reached by the scan. Checked messages include non-matching and non-downloadable messages; album files are counted uniquely. No extra Telegram requests are made to estimate scan totals, and no scan percentage is shown when the total is unknown. +## Backup and updates -The download wizard lets you edit the source, date range or tag, save directory and comment options before confirming. Failed submissions retain the draft; one-time storage targets are consumed only after a task is created. Completed cards retain failure details and retry links after a restart. Bot `/list` and `/find` are no longer available; use the Web file manager to browse and search files. - -Backend startup automatically applies migration `2026101201_telegram_background_job_status_text`, fixing finalization of tasks with partial failures and adding persistent task receipts. No manual database changes are needed. - -
-More commands - -- **File management**: `/delete ` deletes a file; `/setup_2fa` sets up two-factor authentication. -- **Task control**: `/task_pause [task ID]`, `/task_resume [task ID]`, `/task_cancel `, `/stop_tasks`. -- **Save directory**: `/p ` applies to the next download only; `/ps ` applies to the current session; `/pc` clears the custom directory. -- **Batch downloads**: `/tg_download date `, `/tg_download tag <#tag>`. -- **Subscriptions & retries**: `/tg_unsub ` cancels a subscription; `/tg_retry [count] [task ID]` retries failed tasks. -- **Download settings**: `/download_workers`, `/file_concurrency`, `/duplicate_mode`, `/cleanup_settings`. - -
- ---- - -## 🔄 Maintenance & Updates - -Go to your TG Vault project directory and run the one-click upgrade script: +Create a coordinated backup from the project root before updating with the installer: ```bash +BACKUP_DIR=/path/to/secure-backups ./deploy/backup.sh +./deploy/restore-verify.sh /path/to/secure-backups/ ./deploy/install.sh ``` -The container build and backend runtime now use Node.js 24 LTS. The installer rebuilds the application containers while retaining configuration, database and file volumes; the host Node.js installation does not need to change. +The backup script stops the backend while archiving, then restores its previous running state. **Back up `.env` and reverse-proxy configuration separately.** The script includes the database, complete `/data` and a checksum manifest, not files held by remote storage providers. Archive verification is not a restore drill. Upgrades automatically apply database migrations; rollback must preserve database/file consistency rather than simply running an older image. Do not troubleshoot with `docker compose down -v`. See [backup and recovery](deploy/DEPLOY.md#7-备份与恢复). ---- +## Local builds and contributions -## 📚 Documentation +Container builds use Node.js 24; Node.js 24 and npm are also recommended for local builds. Install and build each subproject independently: -- [Deployment guide](deploy/DEPLOY.md): installation, domain configuration, environment compatibility and troubleshooting. -- [Configuration & usage](docs/configuration.md): Telegram integration, access permissions and advanced configuration. -- [Local storage locations](docs/local-storage.md): choosing and inspecting file save directories. -- [Releases](https://github.com/hicocos/tg-vault/releases): new features and fixes. - ---- - -## 📂 Project Structure - -```text -TG Vault/ -├── frontend/ # React web frontend -├── backend/ # Node.js API and Telegram service -├── init.sql # Database initialization script -├── docker-compose.yml # Docker Compose deployment configuration -├── .env.example # Environment variable template -└── LICENSE # MIT License +```bash +npm --prefix backend ci +npm --prefix frontend ci +npm --prefix backend run build +npm --prefix frontend run build ``` ---- +Running the backend locally also requires PostgreSQL and `DATABASE_URL`; thumbnails/transcoding require ffmpeg. See [local development](docs/configuration.md#本地开发) for startup and environment details. Describe the purpose, reproduction steps and checks actually performed in contributions. Keep lockfiles and Chinese/English documentation in sync. Database changes should update the schema, migrations and `init.sql`; do not rewrite released migrations. -## 📄 License +GitHub Actions quality checks need no production credentials; public forks can contribute without Docker Hub configuration. To publish images, configure repository secrets `DOCKER_USERNAME` and `DOCKER_PASSWORD`, and optionally the `VITE_API_URL` repository variable. Publishing is skipped without credentials. The API address is embedded in frontend assets: never put secrets in `VITE_*` variables. -Open-sourced under the [MIT License](LICENSE). +Before publishing commits or sharing issue logs, exclude `.env`, tokens, API hashes, passwords, cookies, Telegram sessions, databases/backups, user files and private server details. Do not commit `node_modules`, `dist`, coverage, temporary debug/smoke scripts or local execution reports. Ignore rules do not remove secrets already committed to Git history; revoke/rotate leaked credentials and clean the history. -Author: [hicocos](https://github.com/hicocos) · Repository: [hicocos/tg-vault](https://github.com/hicocos/tg-vault) +## Documentation ---- +- [Deployment guide](deploy/DEPLOY.md): installation, HTTPS, backups, recovery and updates. +- [Configuration and usage](docs/configuration.md): environment variables, Telegram, storage and permissions. +- [Local storage locations](docs/local-storage.md): mounts, path verification and data protection. +- [Releases](https://github.com/hicocos/tg-vault/releases). -## 📊 Project Data +## License -
- - - - TG Vault Star History Chart - - -
+[MIT License](LICENSE) · [hicocos/tg-vault](https://github.com/hicocos/tg-vault) diff --git a/backend/.dockerignore b/backend/.dockerignore index 3004b83..b798583 100644 --- a/backend/.dockerignore +++ b/backend/.dockerignore @@ -29,8 +29,9 @@ Thumbs.db # Tests coverage/ .nyc_output/ -*.test.ts -*.spec.ts +**/__tests__/ +**/*.test.* +**/*.spec.* # Logs logs/ @@ -41,6 +42,25 @@ data/ uploads/ thumbnails/ chunks/ +previews/ +secrets/ +*.session +*.session-journal +telegram_session*.txt +telegram_user_session*.txt +*.pem +*.key +*.p12 +*.pfx +*.dump +*.backup +*.bak +*.tar +*.tar.gz +*.tgz +*.zip +backups/ +.hermes/ # Git .git/ diff --git a/backend/Dockerfile b/backend/Dockerfile index 26f5163..2d05193 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -10,8 +10,7 @@ RUN npm ci # 复制源代码并构建 COPY . . -RUN npm run build \ - && npx esbuild src/scripts/login_telegram_user.ts --bundle --platform=node --format=esm --outfile=dist/scripts/login_telegram_user.js --packages=external +RUN npm run build # Production stage @@ -35,6 +34,7 @@ RUN npm ci --omit=dev # 复制构建产物 COPY --from=builder /app/dist ./dist +COPY scripts/runtime-supervisor.mjs ./runtime-supervisor.mjs # 复制数据库方案文件 (esbuild 模式下与 index.js 同级) COPY --from=builder /app/src/db/schema.sql ./dist/schema.sql COPY --from=builder /app/src/db/migrations ./dist/migrations @@ -49,4 +49,4 @@ USER node EXPOSE 51947 -CMD ["node", "dist/index.js"] +CMD ["node", "runtime-supervisor.mjs"] diff --git a/backend/package-lock.json b/backend/package-lock.json index b4287fb..d663985 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "tg-vault-backend", - "version": "2.4.8", + "version": "2.4.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tg-vault-backend", - "version": "2.4.8", + "version": "2.4.9", "dependencies": { "@aws-sdk/client-s3": "^3.1075.0", "@aws-sdk/s3-request-presigner": "^3.1075.0", diff --git a/backend/package.json b/backend/package.json index 1e2192c..83d7efb 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,11 +1,11 @@ { "name": "tg-vault-backend", - "version": "2.4.8", + "version": "2.4.9", "type": "module", "scripts": { "dev": "tsx watch src/index.ts", "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 && node scripts/sync_dist_schema.mjs && node scripts/sync_dist_schema.mjs --check", + "build": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --packages=external && esbuild src/services/telegramUserWorker.ts --bundle --platform=node --format=esm --outfile=dist/telegramUserWorker.js --packages=external && esbuild src/scripts/login_telegram_user.ts --bundle --platform=node --format=esm --outfile=dist/scripts/login_telegram_user.js --packages=external && node scripts/sync_dist_schema.mjs && node scripts/sync_dist_schema.mjs --check", "start": "npm run build && node dist/index.js", "login:telegram-user": "node dist/scripts/login_telegram_user.js", "schema:generate-init": "python3 scripts/generate_init_sql.py", diff --git a/backend/scripts/runtime-supervisor.mjs b/backend/scripts/runtime-supervisor.mjs new file mode 100644 index 0000000..4f8f7b8 --- /dev/null +++ b/backend/scripts/runtime-supervisor.mjs @@ -0,0 +1,39 @@ +import { spawn } from 'node:child_process'; +import { setTimeout as delay } from 'node:timers/promises'; + +const child = spawn(process.execPath, ['dist/index.js'], { stdio: 'inherit', detached: true }); +let stopping = false; +let exited = false; +const started = Date.now(); +let failures = 0; +let forceTimer; +function terminate(signal = 'SIGTERM') { + if (stopping) return; + stopping = true; + try { process.kill(-child.pid, signal); } catch { /* already exited */ } + forceTimer = setTimeout(() => { try { process.kill(-child.pid, 'SIGKILL'); } catch { /* already exited */ } }, 30_000); +} +process.once('SIGTERM', () => terminate()); +process.once('SIGINT', () => terminate('SIGINT')); +child.once('error', () => { process.exitCode = 1; }); +child.once('exit', (code, signal) => { + exited = true; + clearTimeout(forceTimer); + // A failed backend must also release its descendant download workers. + try { process.kill(-child.pid, 'SIGKILL'); } catch { /* no remaining workers */ } + process.exit(code ?? (signal ? 1 : 0)); +}); +while (!exited && !stopping) { + await delay(10_000); + if (stopping || exited) break; + if (Date.now() - started < 120_000) continue; + try { + const response = await fetch(`http://127.0.0.1:${process.env.PORT || '51947'}/livez`, { signal: AbortSignal.timeout(5_000) }); + if (!response.ok) throw new Error('LIVENESS_FAILED'); + failures = 0; + } catch { + if (++failures < 3) continue; + console.error(JSON.stringify({ event: 'backend_watchdog_unresponsive', failures })); + terminate(); + } +} diff --git a/backend/src/db/index.ts b/backend/src/db/index.ts index 90201c3..b1b6a4b 100644 --- a/backend/src/db/index.ts +++ b/backend/src/db/index.ts @@ -115,6 +115,10 @@ async function initializeDatabase(): Promise { if (!exists.rows[0]?.ledger) await bootstrapNewDatabase(client); await ensureMigrationLedger(client); await applyMigrations(client); + // Initialization precedes worker creation; no previous-process attempt can still be live. + await client.query(`UPDATE telegram_download_attempts + SET status = 'cancelled', error = '服务重启,旧下载执行已结束', finished_at = NOW() + WHERE status = 'running'`); console.log('✅ 数据库表结构初始化完成'); } catch (error) { console.error('❌ 数据库初始化失败:', error); diff --git a/backend/src/db/migrations/2026101401_telegram_download_file_category.sql b/backend/src/db/migrations/2026101401_telegram_download_file_category.sql new file mode 100644 index 0000000..72fb743 --- /dev/null +++ b/backend/src/db/migrations/2026101401_telegram_download_file_category.sql @@ -0,0 +1 @@ +ALTER TABLE telegram_download_items ADD COLUMN IF NOT EXISTS file_category TEXT; diff --git a/backend/src/db/migrations/2026101402_telegram_download_exclusions.sql b/backend/src/db/migrations/2026101402_telegram_download_exclusions.sql new file mode 100644 index 0000000..919f329 --- /dev/null +++ b/backend/src/db/migrations/2026101402_telegram_download_exclusions.sql @@ -0,0 +1,33 @@ +-- Type-filtered files never entered the download queue. Keep their audit status separate. +UPDATE telegram_download_items i +SET status = 'excluded' +FROM telegram_background_jobs j +WHERE i.job_id = j.id + AND j.kind IN ('date_range', 'tag_download') + AND j.params->>'fileSelectionConfirmed' = 'true' + AND jsonb_typeof(j.params->'selectedFileTypes') = 'array' + AND i.status = 'skipped' + AND i.file_category IS NOT NULL + AND NOT ((j.params->'selectedFileTypes') ? i.file_category); + +-- Available-type counts survive history compaction, unlike the individual excluded items. +WITH selection AS ( + SELECT j.id, + COALESCE(SUM((category->>'count')::int) FILTER ( + WHERE NOT ((j.params->'selectedFileTypes') ? (category->>'fileType')) + ), 0)::int AS excluded_count + FROM telegram_background_jobs j + CROSS JOIN LATERAL jsonb_array_elements(j.params->'availableFileTypes') category + WHERE j.kind IN ('date_range', 'tag_download') + AND j.params->>'fileSelectionConfirmed' = 'true' + AND jsonb_typeof(j.params->'selectedFileTypes') = 'array' + AND NOT (j.params ? 'excludedCount') + GROUP BY j.id +) +UPDATE telegram_background_jobs j +SET total_count = GREATEST(0, j.total_count - s.excluded_count), + skipped_count = GREATEST(0, j.skipped_count - s.excluded_count), + params = j.params || jsonb_build_object('excludedCount', s.excluded_count), + updated_at = NOW() +FROM selection s +WHERE j.id = s.id; diff --git a/backend/src/db/migrations/2026101501_telegram_download_attempt_recovery.sql b/backend/src/db/migrations/2026101501_telegram_download_attempt_recovery.sql new file mode 100644 index 0000000..d8fe821 --- /dev/null +++ b/backend/src/db/migrations/2026101501_telegram_download_attempt_recovery.sql @@ -0,0 +1,11 @@ +-- Repair task attribution created before job identity reached the download/save stages. +UPDATE telegram_download_attempts a +SET job_id = i.job_id +FROM telegram_download_items i +WHERE a.item_id = i.id AND a.job_id IS NULL AND i.job_id IS NOT NULL; + +-- Migrations run before the new process starts any download workers. Previous +-- process attempts cannot still be live; leave successful/failed history intact. +UPDATE telegram_download_attempts +SET status = 'cancelled', error = '服务重启,旧下载执行已结束', finished_at = NOW() +WHERE status = 'running'; diff --git a/backend/src/db/migrations/2026101502_telegram_compacted_bytes.sql b/backend/src/db/migrations/2026101502_telegram_compacted_bytes.sql new file mode 100644 index 0000000..04b6651 --- /dev/null +++ b/backend/src/db/migrations/2026101502_telegram_compacted_bytes.sql @@ -0,0 +1,19 @@ +-- Older compactions kept successful bytes but discarded the total/count aggregates. +-- Missing skipped sizes cannot be recovered; retain an explicit lower bound for those. +WITH retained AS ( + SELECT j.id, + GREATEST(0, j.total_count - COUNT(i.id)) AS missing_count, + GREATEST(0, j.skipped_count - COUNT(i.id) FILTER (WHERE i.status = 'skipped')) AS missing_skipped + FROM telegram_background_jobs j + LEFT JOIN telegram_download_items i ON i.job_id = j.id AND i.status <> 'excluded' + WHERE j.params ? 'completedBytes' + AND NOT (j.params ? 'compactedItemCount') + GROUP BY j.id +) +UPDATE telegram_background_jobs j +SET params = j.params || jsonb_build_object( + 'compactedItemCount', r.missing_count, + 'compactedTotalBytes', COALESCE((j.params->>'completedBytes')::bigint, 0), + 'compactedUnknownSizeCount', COALESCE((j.params->>'savedUnknownCount')::bigint, 0) + r.missing_skipped) +FROM retained r +WHERE j.id = r.id AND r.missing_count > 0; diff --git a/backend/src/db/migrations/2026101503_telegram_compacted_counts.sql b/backend/src/db/migrations/2026101503_telegram_compacted_counts.sql new file mode 100644 index 0000000..c800dab --- /dev/null +++ b/backend/src/db/migrations/2026101503_telegram_compacted_counts.sql @@ -0,0 +1,18 @@ +-- Compacted item totals survive retries; split them into success/skipped counts. +WITH archived AS ( + SELECT j.id, + (j.params->>'compactedItemCount')::bigint AS item_count, + LEAST((j.params->>'compactedItemCount')::bigint, + GREATEST(0, j.skipped_count - COUNT(i.id) FILTER (WHERE i.status = 'skipped'))) AS skipped_count + FROM telegram_background_jobs j + LEFT JOIN telegram_download_items i ON i.job_id = j.id + WHERE j.params ? 'compactedItemCount' + AND NOT (j.params ? 'compactedSuccessCount') + GROUP BY j.id +) +UPDATE telegram_background_jobs j +SET params = j.params || jsonb_build_object( + 'compactedSuccessCount', a.item_count - a.skipped_count, + 'compactedSkippedCount', a.skipped_count) +FROM archived a +WHERE j.id = a.id; diff --git a/backend/src/db/schema.sql b/backend/src/db/schema.sql index 49e1cb9..e7957a2 100644 --- a/backend/src/db/schema.sql +++ b/backend/src/db/schema.sql @@ -585,6 +585,7 @@ CREATE TABLE IF NOT EXISTS telegram_download_items ( channel_post_id INT, file_name TEXT, mime_type TEXT, + file_category TEXT, generated_name BOOLEAN DEFAULT false, total_size BIGINT DEFAULT 0, folder_override TEXT, @@ -608,6 +609,7 @@ ALTER TABLE telegram_download_items ADD COLUMN IF NOT EXISTS origin TEXT DEFAULT 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 file_category 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; diff --git a/backend/src/i18n/telegram.ts b/backend/src/i18n/telegram.ts index cbc6f75..4b67aeb 100644 --- a/backend/src/i18n/telegram.ts +++ b/backend/src/i18n/telegram.ts @@ -22,6 +22,39 @@ export const resources = { 'zh-CN': { 'bot.wizard.confirmSource': '📡 来源:{source}', 'bot.wizard.confirmComments': '💬 评论区:{value}', + 'bot.wizard.fileType.all': '全部', + 'bot.wizard.fileType.images': '图片', + 'bot.wizard.fileType.raw-images': 'RAW 图片', + 'bot.wizard.fileType.videos': '视频', + 'bot.wizard.fileType.audio': '音频', + 'bot.wizard.fileType.archives': '压缩包', + 'bot.wizard.fileType.apps': '应用程序', + 'bot.wizard.fileType.text': '纯文本文档', + 'bot.wizard.fileType.word-docs': 'Word 文档', + 'bot.wizard.fileType.pdfs': 'PDF 文档', + 'bot.wizard.fileType.spreadsheets': '电子表格', + 'bot.wizard.fileType.presentations': '演示文稿', + 'bot.wizard.fileType.ebooks': '电子书', + 'bot.wizard.fileType.code': '代码', + 'bot.wizard.fileType.fonts': '字体', + 'bot.wizard.fileType.design': '设计文件', + 'bot.wizard.fileType.torrents': '种子文件', + 'bot.wizard.fileType.documents': '其他文档', + 'bot.wizard.fileType.other': '其他文件', + 'channelCard.selectionTitle': '范围扫描已完成,请选择要下载的文件类型', + 'channelCard.selectionCategory': '{type}:{count} 个', + 'channelCard.selectionSummary': '已选择:{types}', + 'channelCard.selectionInstructions': '仅列出本次扫描实际发现的类型,默认全部选中。点击类型可多选或取消选择;“全部”重新选中所有类型。至少选择一种,点击“开始下载”确认后才会筛选下载。', + 'channelCard.selectionConfirm': '开始下载', + 'channelCard.selectionNone': '未选择任何类型', + 'channelCard.selectionExpired': '这张选择卡已失效,或尚未选择文件类型。请打开当前任务卡,至少选择一种类型。', + 'channelCard.selectionStarted': '文件类型已确认,正在开始下载', + 'channelCard.selectionUpdated': '文件类型选择已更新', + 'taskCenter.state.awaitingSelection': '等待选择文件类型', + 'taskCenter.note.awaitingSelection': '范围扫描已完成。请在独立任务卡选择文件类型,再点击“开始下载”。', + 'bot.wizard.typeCategoryNote': '按现有文件分类规则筛选,可组合纯文本、Word、PDF 等类别;不会将纯聊天文字生成文件。', + 'bot.wizard.imageTypeNote': '图片仍受全局“批量下载跳过普通图片”(skip_telegram_photos_in_batch)开关约束。', + 'bot.wizard.fileTypeExcluded': '文件类型不匹配(仅下载{types})', 'bot.wizard.folder.defaultValue': '默认分类规则', 'bot.wizard.storage.current': '当前存储', 'bot.wizard.storage.currentAccount': '当前账户', @@ -147,11 +180,12 @@ export const resources = { 'channels.errors.subscriptionDisabled': '订阅已停用', 'channels.errors.noDownloadableMessages': '没有可下载的消息', 'channels.errors.sourceMessageUnavailable': '原消息不存在或没有可下载媒体', + 'channels.errors.historyUnverified': '无法确认频道历史可见性:已尝试可用账号及按需加入公开频道。请检查加入审批、账号限制或频道是否为空;本次未按无匹配完成。', 'channels.errors.fixedTargetProviderRequired': '请选择订阅固定存储的提供商', - 'channels.storageCooldown': '⏸️ Google Drive 今日上传额度已达上限\n\n任务已暂停,将在额度恢复后自动继续。无需手动继续。\n\n恢复时间:{retryAt}\n任务:{jobId}', - 'channels.recoveryComplete': '♻️ 已恢复并完成任务 {jobId}:成功 {successful},跳过 {skipped},失败 {failed}', - 'subscriptions.syncComplete': '✅ 订阅 {source} 已同步 {found} 个新文件,跳过 {skipped} 条,失败 {failed} 条。', - 'subscriptions.syncCompleteContinues': '✅ 订阅 {source} 已同步 {found} 个新文件,跳过 {skipped} 条,失败 {failed} 条。本轮达到扫描上限或存在失败项,剩余将在后续继续处理。', + 'channels.storageCooldown': '⏸️ Google Drive 今日上传额度已达上限\n\n任务已暂停,将在额度恢复后自动继续。无需手动继续。\n\n恢复时间:{retryAt}\n任务:{jobId}\n📍 存储:{storage}\n📁 保存目录:{folder}', + 'channels.recoveryComplete': '♻️ 已恢复并完成任务 {jobId}:成功 {successful},跳过 {skipped},失败 {failed}\n📍 存储:{storage}\n📁 保存目录:{folder}', + 'subscriptions.syncComplete': '✅ 订阅 {source} 已同步 {found} 个新文件,跳过 {skipped} 条,失败 {failed} 条。\n📍 存储:{storage}\n📁 保存目录:{folder}', + 'subscriptions.syncCompleteContinues': '✅ 订阅 {source} 已同步 {found} 个新文件,跳过 {skipped} 条,失败 {failed} 条。本轮达到扫描上限或存在失败项,剩余将在后续继续处理。\n📍 存储:{storage}\n📁 保存目录:{folder}', 'subscriptions.disabled.inviteExpired': '订阅已暂停:私密频道/群邀请链接已过期,无法继续解析或下载。请重新加入/更新链接后再订阅。', 'subscriptions.disabled.inviteInvalid': '订阅已暂停:私密频道/群邀请链接无效,无法继续解析或下载。请检查链接后重新订阅。', 'subscriptions.disabled.notParticipant': '订阅已暂停:当前 Telegram 用户账号已不在该私密频道/群内,无法继续下载。请先重新加入后再订阅。', @@ -261,6 +295,7 @@ export const resources = { 'taskCenter.detail.progress': '进度:{value}', 'taskCenter.detail.currentFile': '当前文件:{value}', 'taskCenter.detail.targetFolder': '保存位置:{value}', + 'taskCenter.detail.storage': '固定存储:{value}', 'taskCenter.detail.reason': '原因:{value}', 'taskCenter.detail.created': '创建:{value}', 'taskCenter.detail.updated': '最近活动:{value}', @@ -402,7 +437,7 @@ export const resources = { 'bot.wizard.cancelled': '已取消 Telegram 频道操作向导。', 'bot.wizard.invalidMode': '❌ 请发送 `date`/`日期` 或 `tag`/`标签`,也可以发送“取消”退出。', 'bot.wizard.invalidComments': '❌ 请发送 `开`/`关`,或点击按钮选择是否包含评论区文件。', - 'bot.wizard.confirmInput': '检查后点击“确认”,或选择项目修改。也可回复这张卡片“确认”;取消不会创建任务。', + 'bot.wizard.confirmInput': '检查后点击“确认”开始扫描,或选择项目修改。也可回复这张卡片“确认”;取消不会创建任务。', 'bot.wizard.invalidDate': '❌ 日期格式必须是 YYYY-MM-DD,例如:{example}', 'bot.wizard.invalidRange': '日期范围无效', 'bot.wizard.callbackExpired': '向导已失效,请重新打开', @@ -413,8 +448,8 @@ export const resources = { 'bot.wizard.defaultFolderButton': '使用默认目录', 'bot.wizard.sourceUnavailable': '暂时无法读取 {source}。输入已保留,请检查链接和账号权限,再发送频道重试。', 'bot.wizard.submitting': '正在提交,请稍候…', - 'bot.wizard.preparing': '正在准备任务,创建成功后这里显示下载进度。', - 'bot.wizard.submitted': '任务已创建:{jobId}\n下载进度见独立任务卡,也可打开 /tasks 查看。', + 'bot.wizard.preparing': '正在准备扫描任务,创建成功后这里显示扫描进度和文件类型选择。', + 'bot.wizard.submitted': '任务已创建:{jobId}\n扫描进度和扫描后的文件类型选择见独立任务卡,也可打开 /tasks 查看。', 'bot.wizard.submitRetry': '还没有创建任务,设置已保留。请点击“确认”重试,或修改后提交。', 'bot.wizard.commandSubmitFailed': '任务未创建。请检查频道、日期或标签后重发命令,也可用 /tg_download 逐步设置。', 'bot.wizard.downloadCancelled': '已取消频道文件下载向导。', @@ -475,11 +510,14 @@ export const resources = { 'bot.legacy.download': '⬇️ 下载:{status}', 'bot.legacy.speed': '下载速度:{speed}/s', 'channelCard.progress': '已处理 {done}/{total}', + 'channelCard.noMatches': '扫描完成,无匹配消息', 'channelCard.queue': '处理中 {active} · 排队 {pending}', - 'channelCard.failures': '失败 {count},可重试失败项', + 'channelCard.failures': '失败 {count}', 'channelCard.skipped': '跳过 {count}', + 'channelCard.excluded': '筛选已排除 {count} 个文件(不计入下载进度)', 'channelCard.updated': '更新 {time}', 'channelCard.scanDate': '扫描到:{time}', + 'channelCard.scanWait': '账号遇到 Telegram 限流,冷却至 {time};正在等待或切换可用账号,已发现文件会保留。', 'bot.legacy.state.scanning': '扫描中', 'bot.legacy.state.downloading': '下载中', 'bot.legacy.counts': '✅ 成功 {completed} ⏳ 待下载 {pending} 🔄 下载中 {downloading} ❌ 失败 {failed} ⏭ 跳过 {skipped}', @@ -533,7 +571,9 @@ export const resources = { 'commands.auto006': '当前同时下载文件数:**{value0}**', 'commands.auto007': '当前队列:进行中 {value0},等待中 {value1}', 'commands.auto008': '同时下载的文件数。', - 'commands.auto009': '默认 2;1 降低并发,3 提高并发;4 需要二次确认。', + 'commands.auto009': '范围 1–10;每个启用账号提供 2 个名额,无账号时最多 2,最高 10。4 及以上需要二次确认。', + 'commands.fileConcurrencyCapacity': '已启用 {count} 个用户账号;当前最多同时下载 {limit} 个文件。', + 'commands.fileConcurrencyLimit': '已启用 {count} 个用户账号,最多允许 {limit} 个文件并发。每账号 2 个名额,无账号最多 2,最高 10。', 'commands.auto010': '修改后只影响新开始的文件,正在下载的文件不会中断。', 'commands.auto011': '{value0} 跳过重复', 'commands.auto012': '{value0} 生成副本', @@ -657,7 +697,7 @@ export const resources = { 'commands.auto135': '已设置为 {value0}', 'commands.auto136': '{value0}\n\n⚠️ 已确认并切换为 {value1} 个分片。若出现断流、限速、风控提示,请立即降回 4 或 8。', 'commands.auto137': '已确认 {value0} 个分片', - 'commands.auto138': '⚠️ **确认同时下载 4 个文件?**', + 'commands.auto138': '⚠️ **确认同时下载 {value0} 个文件?**', 'commands.auto139': '高文件并发可能导致:', 'commands.auto140': '- Telegram 风控或限流', 'commands.auto141': '- 云盘上传限速 / 失败重试增多', @@ -666,7 +706,6 @@ export const resources = { 'commands.auto144': '{value0}\n\n✅ 已切换为同时下载 {value1} 个文件。', 'commands.auto145': '已设置为 {value0}', 'commands.auto146': '{value0}\n\n⚠️ 已确认并切换为同时下载 {value1} 个文件。若出现限流、断流或上传失败,请立即降回 2 或 3。', - 'commands.auto147': '已确认 4 个文件并发', 'commands.authRequired': '🔐 请先发送 /start 验证密码', 'commands.helpUnavailable': '❌ 暂时无法显示帮助。', 'commands.settingsSaved': '✅ 设置已保存。', @@ -856,7 +895,7 @@ export const resources = { 'bot.wizard.confirmCommentsOff': '不包含', 'bot.wizard.confirmFolder': '📁 目录:{folder}', 'bot.wizard.confirmStorage': '☁️ 固定存储:{provider} / {account}', - 'bot.wizard.confirmNote': '扫描进度会更新;可在任务中心取消。', + 'bot.wizard.confirmNote': '先完整扫描整个日期/标签范围并分类;完成后在任务卡中多选实际发现的文件类型,默认全部。确认选择后才筛选下载;可在任务中心取消。', 'bot.wizard.subscriptionIndexInvalid': '❌ 没有这个序号,请回复列表中的序号,或发送频道用户名/链接来新增订阅。', 'bot.wizard.subscriptionInputInvalid': '❌ 请回复订阅序号来取消,或发送频道用户名/链接来新增订阅,例如:`@channel_username`。', 'bot.wizard.pathInvalid': '❌ 路径无效:{error}\n\n请重新发送目录,或发送“跳过”使用默认保存路径规则。', @@ -917,7 +956,7 @@ export const resources = { 'bot.wizard.confirmCommentsOff': 'Not included', 'bot.wizard.confirmFolder': '📁 Folder: {folder}', 'bot.wizard.confirmStorage': '☁️ Fixed storage: {provider} / {account}', - 'bot.wizard.confirmNote': 'Scan progress will update. Cancel from the task center.', + 'bot.wizard.confirmNote': 'The entire date/tag range is scanned and categorized first. Then choose from the file types actually found on the task card; all are selected by default. Downloading begins only after you confirm the selection. Cancel from the task center.', 'bot.wizard.subscriptionIndexInvalid': '❌ No subscription has that number. Reply with a list number, or send a channel username/link to add one.', 'bot.wizard.subscriptionInputInvalid': '❌ Reply with a subscription number to cancel, or send a channel username/link to add one, for example `@channel_username`.', 'bot.wizard.pathInvalid': '❌ Invalid path: {error}\n\nSend the folder again, or send “skip” to use the default save rule.', @@ -969,6 +1008,39 @@ export const resources = { 'bot.callback.cleanupFailed': '❌ Cleanup failed', 'bot.wizard.confirmSource': '📡 Source: {source}', 'bot.wizard.confirmComments': '💬 Comments: {value}', + 'bot.wizard.fileType.all': 'All', + 'bot.wizard.fileType.images': 'Images', + 'bot.wizard.fileType.raw-images': 'RAW images', + 'bot.wizard.fileType.videos': 'Videos', + 'bot.wizard.fileType.audio': 'Audio', + 'bot.wizard.fileType.archives': 'Archives', + 'bot.wizard.fileType.apps': 'Applications', + 'bot.wizard.fileType.text': 'Plain text documents', + 'bot.wizard.fileType.word-docs': 'Word documents', + 'bot.wizard.fileType.pdfs': 'PDF documents', + 'bot.wizard.fileType.spreadsheets': 'Spreadsheets', + 'bot.wizard.fileType.presentations': 'Presentations', + 'bot.wizard.fileType.ebooks': 'E-books', + 'bot.wizard.fileType.code': 'Code', + 'bot.wizard.fileType.fonts': 'Fonts', + 'bot.wizard.fileType.design': 'Design files', + 'bot.wizard.fileType.torrents': 'Torrent files', + 'bot.wizard.fileType.documents': 'Other documents', + 'bot.wizard.fileType.other': 'Other files', + 'channelCard.selectionTitle': 'Range scan complete. Choose file types to download', + 'channelCard.selectionCategory': '{type}: {count} files', + 'channelCard.selectionSummary': 'Selected: {types}', + 'channelCard.selectionInstructions': 'Only types actually found in this scan are listed; all are selected by default. Tap types to select or deselect multiple categories. All selects every type again. Select at least one, then tap Start download to confirm and download matching files.', + 'channelCard.selectionConfirm': 'Start download', + 'channelCard.selectionNone': 'No types selected', + 'channelCard.selectionExpired': 'This selection card expired, or no file type is selected. Open the current task card and select at least one type.', + 'channelCard.selectionStarted': 'File types confirmed. Starting download', + 'channelCard.selectionUpdated': 'File type selection updated', + 'taskCenter.state.awaitingSelection': 'Awaiting file type selection', + 'taskCenter.note.awaitingSelection': 'Range scan complete. Choose file types on the separate task card, then tap Start download.', + 'bot.wizard.typeCategoryNote': 'Uses the existing file categorization rules. Combine plain text, Word, PDF and other categories as needed. Plain chat text is not turned into files.', + 'bot.wizard.imageTypeNote': 'Images remain subject to the global “Skip ordinary photos in batch downloads” setting (skip_telegram_photos_in_batch).', + 'bot.wizard.fileTypeExcluded': 'Excluded by file type filter ({types} only)', 'bot.wizard.folder.defaultValue': 'Default categorization rules', 'bot.wizard.storage.current': 'Current storage', 'bot.wizard.storage.currentAccount': 'Current account', @@ -1094,11 +1166,12 @@ export const resources = { 'channels.errors.subscriptionDisabled': 'This subscription is disabled', 'channels.errors.noDownloadableMessages': 'There are no messages available to download', 'channels.errors.sourceMessageUnavailable': 'The original message no longer exists or has no downloadable media', + 'channels.errors.historyUnverified': 'Channel history visibility could not be verified after trying available accounts and joining the public channel when needed. Check pending approval, account restrictions, or an empty channel; this was not treated as no matches.', 'channels.errors.fixedTargetProviderRequired': 'Choose a provider for the subscription’s fixed storage target', - 'channels.storageCooldown': '⏸️ Google Drive daily upload limit reached\n\nTask paused. It will resume automatically when the quota resets; no manual resume is needed.\n\nRetry time: {retryAt}\nTask: {jobId}', - 'channels.recoveryComplete': '♻️ Recovered and completed task {jobId}: {successful} succeeded, {skipped} skipped, {failed} failed', - 'subscriptions.syncComplete': '✅ Subscription {source} synced {found} new files; {skipped} skipped and {failed} failed.', - 'subscriptions.syncCompleteContinues': '✅ Subscription {source} synced {found} new files; {skipped} skipped and {failed} failed. This scan reached its limit or encountered failures, so the remaining items will be handled in a later scan.', + 'channels.storageCooldown': '⏸️ Google Drive daily upload limit reached\n\nTask paused. It will resume automatically when the quota resets; no manual resume is needed.\n\nRetry time: {retryAt}\nTask: {jobId}\n📍 Storage: {storage}\n📁 Save folder: {folder}', + 'channels.recoveryComplete': '♻️ Recovered and completed task {jobId}: {successful} succeeded, {skipped} skipped, {failed} failed\n📍 Storage: {storage}\n📁 Save folder: {folder}', + 'subscriptions.syncComplete': '✅ Subscription {source} synced {found} new files; {skipped} skipped and {failed} failed.\n📍 Storage: {storage}\n📁 Save folder: {folder}', + 'subscriptions.syncCompleteContinues': '✅ Subscription {source} synced {found} new files; {skipped} skipped and {failed} failed. This scan reached its limit or encountered failures, so the remaining items will be handled in a later scan.\n📍 Storage: {storage}\n📁 Save folder: {folder}', 'subscriptions.disabled.inviteExpired': 'Subscription paused: the private channel or group invite has expired, so content can no longer be resolved or downloaded. Join again or update the link before resubscribing.', 'subscriptions.disabled.inviteInvalid': 'Subscription paused: the private channel or group invite is invalid, so content can no longer be resolved or downloaded. Check the link before resubscribing.', 'subscriptions.disabled.notParticipant': 'Subscription paused: the Telegram user account is no longer a member of this private channel or group. Join it again before resubscribing.', @@ -1208,6 +1281,7 @@ export const resources = { 'taskCenter.detail.progress': 'Progress: {value}', 'taskCenter.detail.currentFile': 'Current file: {value}', 'taskCenter.detail.targetFolder': 'Save location: {value}', + 'taskCenter.detail.storage': 'Fixed storage: {value}', 'taskCenter.detail.reason': 'Reason: {value}', 'taskCenter.detail.created': 'Created: {value}', 'taskCenter.detail.updated': 'Last activity: {value}', @@ -1349,7 +1423,7 @@ export const resources = { 'bot.wizard.cancelled': 'Telegram channel wizard cancelled.', 'bot.wizard.invalidMode': '❌ Send `date` or `tag`, or send “Cancel” to exit.', 'bot.wizard.invalidComments': '❌ Send `on` or `off`, or tap a button to choose whether to include comment files.', - 'bot.wizard.confirmInput': 'Check the settings, then tap Confirm or edit an item. You can also reply to this card with “confirm”. Cancel creates no task.', + 'bot.wizard.confirmInput': 'Check the settings, then tap Confirm to start scanning or edit an item. You can also reply to this card with “confirm”. Cancel creates no task.', 'bot.wizard.invalidDate': '❌ Use YYYY-MM-DD, for example {example}.', 'bot.wizard.invalidRange': 'Invalid date range', 'bot.wizard.callbackExpired': 'This wizard expired. Open it again.', @@ -1360,8 +1434,8 @@ export const resources = { 'bot.wizard.defaultFolderButton': 'Use default folder', 'bot.wizard.sourceUnavailable': 'Cannot read {source} right now. Your input is saved. Check the link and account access, then send the channel again.', 'bot.wizard.submitting': 'Submitting, please wait…', - 'bot.wizard.preparing': 'Preparing the task. Download progress will appear here after it is created.', - 'bot.wizard.submitted': 'Task created: {jobId}\nSee the separate task card for progress, or open /tasks.', + 'bot.wizard.preparing': 'Preparing the scan task. Scan progress and file type selection will appear here after it is created.', + 'bot.wizard.submitted': 'Task created: {jobId}\nSee the separate task card for scan progress and file type selection after scanning, or open /tasks.', 'bot.wizard.commandSubmitFailed': 'No task was created. Check the channel, dates or tag and resend the command, or use /tg_download for guided setup.', 'bot.wizard.submitRetry': 'No task was created. Your settings are saved. Tap Confirm to retry, or edit the settings.', 'bot.wizard.downloadCancelled': 'Channel download wizard cancelled.', @@ -1422,11 +1496,14 @@ export const resources = { 'bot.legacy.download': '⬇️ Download: {status}', 'bot.legacy.speed': 'Download speed: {speed}/s', 'channelCard.progress': 'Processed {done}/{total}', + 'channelCard.noMatches': 'Scan complete; no matching messages', 'channelCard.queue': 'Processing {active} · Queued {pending}', - 'channelCard.failures': '{count} failed; retry failed items', + 'channelCard.failures': 'Failed {count}', 'channelCard.skipped': 'Skipped {count}', + 'channelCard.excluded': 'Filtered out {count} files (not included in download progress)', 'channelCard.updated': 'Updated {time}', 'channelCard.scanDate': 'Scanned through: {time}', + 'channelCard.scanWait': 'Telegram account rate limit until {time}; waiting or switching accounts. Discovered files are retained.', 'bot.legacy.state.scanning': 'Scanning', 'bot.legacy.state.downloading': 'Downloading', 'bot.legacy.counts': '✅ Succeeded {completed} ⏳ Pending {pending} 🔄 Downloading {downloading} ❌ Failed {failed} ⏭ Skipped {skipped}', @@ -1480,7 +1557,9 @@ export const resources = { 'commands.auto006': 'Files downloading at once: **{value0}**', 'commands.auto007': 'Current queue: {value0} active, {value1} waiting', 'commands.auto008': 'Files downloaded at once.', - 'commands.auto009': 'Default: 2. Use 1 for lower concurrency or 3 for higher concurrency; 4 requires confirmation.', + 'commands.auto009': 'Range: 1–10; 2 slots per enabled account, 2 without accounts, maximum 10. Values 4 and above require confirmation.', + 'commands.fileConcurrencyCapacity': '{count} user accounts enabled; current limit: {limit} simultaneous files.', + 'commands.fileConcurrencyLimit': '{count} user accounts enabled; at most {limit} simultaneous files. Two slots per account, two without accounts, maximum ten.', 'commands.auto010': 'Changes apply only to newly started files; files already downloading will not be interrupted.', 'commands.auto011': '{value0} Skip duplicates', 'commands.auto012': '{value0} Create copies', @@ -1604,7 +1683,7 @@ export const resources = { 'commands.auto135': 'Set to {value0}', 'commands.auto136': '{value0}\n\n⚠️ Confirmed and switched to {value1} chunks. If downloads disconnect, slow down, or trigger restrictions, immediately return to 4 or 8.', 'commands.auto137': 'Confirmed {value0} chunks', - 'commands.auto138': '⚠️ **Download 4 files at the same time?**', + 'commands.auto138': '⚠️ **Download {value0} files at the same time?**', 'commands.auto139': 'High file concurrency may cause:', 'commands.auto140': '- Telegram rate limits or anti-abuse restrictions', 'commands.auto141': '- Cloud upload throttling and more retries', @@ -1613,7 +1692,6 @@ export const resources = { 'commands.auto144': '{value0}\n\n✅ Switched to downloading {value1} files at the same time.', 'commands.auto145': 'Set to {value0}', 'commands.auto146': '{value0}\n\n⚠️ Confirmed and switched to downloading {value1} files at the same time. If rate limits, disconnects, or upload failures occur, immediately return to 2 or 3.', - 'commands.auto147': 'Confirmed 4-file concurrency', 'commands.authRequired': '🔐 Send /start and verify your PIN first', 'commands.helpUnavailable': '❌ Help is temporarily unavailable.', 'commands.settingsSaved': '✅ Settings saved.', diff --git a/backend/src/i18n/telegramRussian.json b/backend/src/i18n/telegramRussian.json index 16293ed..ae4aca1 100644 --- a/backend/src/i18n/telegramRussian.json +++ b/backend/src/i18n/telegramRussian.json @@ -1,6 +1,39 @@ { "bot.wizard.confirmSource": "📡 Источник: {source}", "bot.wizard.confirmComments": "💬 Комментарии: {value}", + "bot.wizard.fileType.all": "Все", + "bot.wizard.fileType.images": "Изображения", + "bot.wizard.fileType.raw-images": "Изображения RAW", + "bot.wizard.fileType.videos": "Видео", + "bot.wizard.fileType.audio": "Аудио", + "bot.wizard.fileType.archives": "Архивы", + "bot.wizard.fileType.apps": "Приложения", + "bot.wizard.fileType.text": "Текстовые файлы", + "bot.wizard.fileType.word-docs": "Документы Word", + "bot.wizard.fileType.pdfs": "Документы PDF", + "bot.wizard.fileType.spreadsheets": "Электронные таблицы", + "bot.wizard.fileType.presentations": "Презентации", + "bot.wizard.fileType.ebooks": "Электронные книги", + "bot.wizard.fileType.code": "Код", + "bot.wizard.fileType.fonts": "Шрифты", + "bot.wizard.fileType.design": "Файлы дизайна", + "bot.wizard.fileType.torrents": "Торрент-файлы", + "bot.wizard.fileType.documents": "Другие документы", + "bot.wizard.fileType.other": "Другие файлы", + "channelCard.selectionTitle": "Сканирование диапазона завершено. Выберите типы файлов для скачивания", + "channelCard.selectionCategory": "{type}: {count} файлов", + "channelCard.selectionSummary": "Выбрано: {types}", + "channelCard.selectionInstructions": "Показаны только типы, найденные при этом сканировании; по умолчанию выбраны все. Нажимайте на типы, чтобы выбрать несколько категорий или снять выбор. «Все» снова выбирает все типы. Выберите хотя бы один тип и нажмите «Начать скачивание», чтобы подтвердить выбор и скачать подходящие файлы.", + "channelCard.selectionConfirm": "Начать скачивание", + "channelCard.selectionNone": "Ни один тип не выбран", + "channelCard.selectionExpired": "Карточка выбора устарела либо ни один тип не выбран. Откройте текущую карточку задачи и выберите хотя бы один тип.", + "channelCard.selectionStarted": "Типы файлов подтверждены. Начинаем скачивание", + "channelCard.selectionUpdated": "Выбор типов файлов обновлён", + "taskCenter.state.awaitingSelection": "Ожидание выбора типов файлов", + "taskCenter.note.awaitingSelection": "Сканирование диапазона завершено. Выберите типы файлов в отдельной карточке задачи и нажмите «Начать скачивание».", + "bot.wizard.typeCategoryNote": "Используются существующие правила классификации файлов. Можно сочетать текстовые файлы, Word, PDF и другие категории. Обычный текст чата не преобразуется в файлы.", + "bot.wizard.imageTypeNote": "На изображения по-прежнему действует глобальная настройка «Пропускать обычные фотографии при пакетной загрузке» (skip_telegram_photos_in_batch).", + "bot.wizard.fileTypeExcluded": "Исключено фильтром типа файлов (только {types})", "bot.wizard.folder.defaultValue": "Правила автоматической категоризации", "bot.wizard.storage.current": "Текущее хранилище", "bot.wizard.storage.currentAccount": "Текущая учётная запись", @@ -18,7 +51,7 @@ "bot.wizard.confirmCommentsOff": "Не включено", "bot.wizard.confirmFolder": "📁 Папка: {folder}", "bot.wizard.confirmStorage": "☁️ Фиксированное хранилище: {provider} / {account}", - "bot.wizard.confirmNote": "Прогресс сканирования будет обновляться. Отменить задачу можно в списке задач.", + "bot.wizard.confirmNote": "Сначала полностью сканируется весь диапазон дат или тега с классификацией файлов. Затем в карточке задачи можно выбрать несколько типов из фактически найденных; по умолчанию выбраны все. Скачивание начинается только после подтверждения выбора. Отмена — в списке задач.", "bot.wizard.subscriptionIndexInvalid": "❌ Такого номера нет. Ответьте номером из списка или отправьте имя пользователя/ссылку на канал для новой подписки.", "bot.wizard.subscriptionInputInvalid": "❌ Ответьте номером подписки, чтобы отменить ее, или отправьте имя пользователя/ссылку на канал, чтобы добавить ее, например `@channel_username`.", "bot.wizard.pathInvalid": "❌ Неверный путь: {error}\n\nОтправьте имя папки снова или `skip` для правил сохранения по умолчанию.", @@ -185,11 +218,12 @@ "channels.errors.subscriptionDisabled": "Эта подписка отключена", "channels.errors.noDownloadableMessages": "Нет сообщений, доступных для скачивания", "channels.errors.sourceMessageUnavailable": "Исходное сообщение удалено или не содержит доступных для скачивания файлов", + "channels.errors.historyUnverified": "Не удалось подтвердить доступ к истории после проверки аккаунтов и вступления в публичный канал по необходимости. Проверьте одобрение заявки, ограничения аккаунтов или отсутствие истории. Задача не отмечена как завершённая без совпадений.", "channels.errors.fixedTargetProviderRequired": "Выберите провайдера для фиксированного хранилища подписки", - "channels.storageCooldown": "⏸️ Достигнут дневной лимит загрузки на Google Диск\n\nЗадача приостановлена и возобновится автоматически после сброса квоты. Ручное возобновление не требуется.\n\nВремя повтора: {retryAt}\nЗадача: {jobId}", - "channels.recoveryComplete": "♻️ Задача {jobId} восстановлена и завершена: успешно {successful}, пропущено {skipped}, ошибок {failed}", - "subscriptions.syncComplete": "✅ Подписка {source}: новых файлов {found}, пропущено {skipped}, ошибок {failed}.", - "subscriptions.syncCompleteContinues": "✅ Подписка {source}: новых файлов {found}, пропущено {skipped}, ошибок {failed}. Достигнут лимит сканирования или есть ошибки; оставшиеся файлы будут обработаны при следующем сканировании.", + "channels.storageCooldown": "⏸️ Достигнут дневной лимит загрузки на Google Диск\n\nЗадача приостановлена и возобновится автоматически после сброса квоты. Ручное возобновление не требуется.\n\nВремя повтора: {retryAt}\nЗадача: {jobId}\n📍 Хранилище: {storage}\n📁 Папка: {folder}", + "channels.recoveryComplete": "♻️ Задача {jobId} восстановлена и завершена: успешно {successful}, пропущено {skipped}, ошибок {failed}\n📍 Хранилище: {storage}\n📁 Папка: {folder}", + "subscriptions.syncComplete": "✅ Подписка {source}: новых файлов {found}, пропущено {skipped}, ошибок {failed}.\n📍 Хранилище: {storage}\n📁 Папка: {folder}", + "subscriptions.syncCompleteContinues": "✅ Подписка {source}: новых файлов {found}, пропущено {skipped}, ошибок {failed}. Достигнут лимит сканирования или есть ошибки; оставшиеся файлы будут обработаны при следующем сканировании.\n📍 Хранилище: {storage}\n📁 Папка: {folder}", "subscriptions.disabled.inviteExpired": "Подписка приостановлена. Срок действия приглашения в закрытый канал или группу истёк. Доступ и скачивание недоступны. Обновите ссылку и проверьте доступ аккаунта перед повторным добавлением подписки.", "subscriptions.disabled.inviteInvalid": "Подписка приостановлена. Приглашение в закрытый канал или группу недействительно. Доступ и скачивание недоступны. Проверьте ссылку и доступ аккаунта перед повторным добавлением подписки.", "subscriptions.disabled.notParticipant": "Подписка приостановлена. Аккаунт Telegram больше не состоит в этом закрытом канале или группе. Скачивание остановлено. Вступите снова и проверьте доступ аккаунта перед повторным добавлением подписки.", @@ -299,6 +333,7 @@ "taskCenter.detail.progress": "Прогресс: {value}", "taskCenter.detail.currentFile": "Текущий файл: {value}", "taskCenter.detail.targetFolder": "Место сохранения: {value}", + "taskCenter.detail.storage": "Фиксированное хранилище: {value}", "taskCenter.detail.reason": "Причина: {value}", "taskCenter.detail.created": "Создано: {value}", "taskCenter.detail.updated": "Последняя активность: {value}", @@ -440,7 +475,7 @@ "bot.wizard.cancelled": "Мастер каналов Telegram отменен.", "bot.wizard.invalidMode": "❌ Отправьте `date` или `tag`. «Отмена» — выход.", "bot.wizard.invalidComments": "❌ Отправьте `on` или `off` либо выберите вариант кнопкой.", - "bot.wizard.confirmInput": "Проверьте настройки и нажмите «Подтвердить» или измените нужный пункт. Можно ответить на эту карточку словом «confirm». Отмена не создаёт задачу.", + "bot.wizard.confirmInput": "Проверьте настройки и нажмите «Подтвердить», чтобы начать сканирование, или измените нужный пункт. Можно ответить на эту карточку словом «confirm». Отмена не создаёт задачу.", "bot.wizard.invalidDate": "❌ Формат даты: YYYY-MM-DD, например {example}.", "bot.wizard.invalidRange": "Неверный диапазон дат.", "bot.wizard.callbackExpired": "Срок действия этого мастера истек. Откройте его снова.", @@ -451,8 +486,8 @@ "bot.wizard.defaultFolderButton": "Папка по умолчанию", "bot.wizard.sourceUnavailable": "Сейчас не удаётся прочитать {source}. Ввод сохранён. Проверьте ссылку и доступ аккаунта, затем отправьте канал снова.", "bot.wizard.submitting": "Отправляем, подождите…", - "bot.wizard.preparing": "Подготовка задачи. После создания здесь появится прогресс загрузки.", - "bot.wizard.submitted": "Задача создана: {jobId}\nПрогресс — в отдельной карточке задачи или в /tasks.", + "bot.wizard.preparing": "Подготовка задачи сканирования. После создания здесь появятся прогресс и выбор типов файлов.", + "bot.wizard.submitted": "Задача создана: {jobId}\nПрогресс сканирования и выбор типов файлов после его завершения — в отдельной карточке задачи или в /tasks.", "bot.wizard.submitRetry": "Задача не создана. Настройки сохранены. Нажмите «Подтвердить» для повтора или измените настройки.", "bot.wizard.commandSubmitFailed": "Задача не создана. Проверьте канал, даты или тег и повторите команду либо откройте /tg_download для пошаговой настройки.", "bot.wizard.downloadCancelled": "Мастер загрузки каналов отменен.", @@ -513,11 +548,14 @@ "bot.legacy.download": "⬇️ Скачивание: {status}", "bot.legacy.speed": "Скорость скачивания: {speed}/s", "channelCard.progress": "Обработано {done}/{total}", + "channelCard.noMatches": "Сканирование завершено; совпадений нет", "channelCard.queue": "В обработке {active} · В очереди {pending}", - "channelCard.failures": "Ошибок {count}; можно повторить неудачные файлы", + "channelCard.failures": "Ошибок {count}", "channelCard.skipped": "Пропущено {count}", + "channelCard.excluded": "Фильтром исключено {count} файлов (не учитываются в прогрессе скачивания)", "channelCard.updated": "Обновлено {time}", "channelCard.scanDate": "Проверено до: {time}", + "channelCard.scanWait": "Лимит Telegram до {time}; ожидание или смена аккаунта. Найденные файлы сохранены.", "bot.legacy.state.scanning": "Сканирование", "bot.legacy.state.downloading": "Скачивание", "bot.legacy.counts": "✅ Успешно {completed} ⏳ В очереди {pending} 🔄 Скачивается {downloading} ❌ Ошибок {failed} ⏭ Пропущено {skipped}", @@ -571,7 +609,9 @@ "commands.auto006": "Одновременная загрузка файлов: **{value0}**", "commands.auto007": "Очередь: активных {value0}, ожидают {value1}", "commands.auto008": "Число одновременно скачиваемых файлов.", - "commands.auto009": "По умолчанию: 2. Значение 1 снижает параллелизм, 3 повышает; 4 требует подтверждения.", + "commands.auto009": "Диапазон: 1–10; по 2 места на включённый аккаунт, без аккаунтов — 2, максимум 10. Значения от 4 требуют подтверждения.", + "commands.fileConcurrencyCapacity": "Включено аккаунтов: {count}; текущий предел одновременных файлов: {limit}.", + "commands.fileConcurrencyLimit": "Включено аккаунтов: {count}; допускается не более {limit} файлов. По 2 места на аккаунт, без аккаунтов — 2, максимум 10.", "commands.auto010": "Изменения применяются только к вновь запущенным файлам; уже загружаемые файлы не будут прерваны.", "commands.auto011": "{value0} Пропустить дубликаты", "commands.auto012": "{value0} Создание копий", @@ -695,7 +735,7 @@ "commands.auto135": "Установить {value0}", "commands.auto136": "{value0}\n\n⚠️ Подтверждено фрагментов: {value1}. При обрывах, замедлении или ограничениях вернитесь к 4 или 8.", "commands.auto137": "Подтверждено фрагментов: {value0}", - "commands.auto138": "⚠️ **Загрузить 4 файла одновременно?**", + "commands.auto138": "⚠️ **Загрузить {value0} файлов одновременно?**", "commands.auto139": "Высокий параллелизм файлов может вызвать:", "commands.auto140": "- Лимиты запросов Telegram или антиспам-ограничения", "commands.auto141": "- Ограничение скорости облачного хранилища и дополнительные повторы", @@ -704,7 +744,6 @@ "commands.auto144": "{value0}\n\n✅ Установлено одновременных скачиваний файлов: {value1}.", "commands.auto145": "Установить {value0}", "commands.auto146": "{value0}\n\n⚠️ Подтверждено одновременных скачиваний файлов: {value1}. При ограничениях, обрывах или ошибках загрузки вернитесь к 2 или 3.", - "commands.auto147": "Подтверждено одновременное скачивание 4 файлов", "commands.authRequired": "🔐 Сначала отправьте /start и подтвердите свой PIN-код.", "commands.helpUnavailable": "❌ Помощь временно недоступна.", "commands.settingsSaved": "✅ Настройки сохранены.", diff --git a/backend/src/index.ts b/backend/src/index.ts index 81fb25a..9a1f925 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -14,12 +14,13 @@ import subscriptionsRouter from './routes/subscriptions.js'; import authRouter, { requireAuth } from './routes/auth.js'; import { createSystemRouter } from './routes/system.js'; import { requireAuthOrSignedUrl } from './middleware/signedUrl.js'; -import { initTelegramBot, sendUpdateNotificationToUser } from './services/telegramBot.js'; +import { initTelegramBot, stopTelegramBot, sendUpdateNotificationToUser } from './services/telegramBot.js'; import { applyEffectiveTelegramBotConfig } from './services/telegramBotConfig.js'; import { classifyTelegramBotStartupError, getTelegramBotStatus, markTelegramBotError, resetTelegramBotStatus, telegramBotBlocksReadiness } from './services/telegramBotStatus.js'; import { createInitialTelegramRecovery } from './services/telegramInitialRecovery.js'; import { isTelegramUserClientReady, restoreEnabledTelegramUserAccountsAfterRestart } from './services/telegramUserClient.js'; import { installTelegramMultiAccountRuntimeAdapters } from './services/telegramMultiAccountRuntime.js'; +import { shutdownTelegramUserClientPool, getTelegramUserClientPoolState } from './services/telegramUserClientPool.js'; import { isInitialSetupRequired } from './utils/authSettings.js'; import { get2FAReadiness } from './utils/security.js'; import { ensureDatabaseInitialized, pool } from './db/index.js'; @@ -30,6 +31,7 @@ import { markTransferTasksAfterRestart } from './services/transferTasks.js'; import { recoverMediaDerivativeJobs } from './services/mediaDerivatives.js'; import { applyPersistedOrphanCleanupSetting, startPeriodicCleanup } from './services/orphanCleanup.js'; +import { loadFileDownloadConcurrencySetting } from './services/telegramFileConcurrency.js'; import { logRuntimeConfigSummary, validateRuntimeConfig } from './utils/runtimeConfig.js'; import { APP_VERSION } from './services/appVersion.js'; import { createUpdateChecker } from './services/updateChecker.js'; @@ -208,7 +210,7 @@ app.get('/readyz', (_req, res) => { res.json({ status: bot.degraded ? 'degraded' : 'ready', timestamp: new Date().toISOString(), - components: { dependencies: dependencyReadiness, telegramBot: bot }, + components: { dependencies: dependencyReadiness, telegramBot: bot, telegramUsers: getTelegramUserClientPoolState() }, }); } catch (error) { res.status(503).json({ status: 'not_ready', error: error instanceof Error ? error.message : String(error) }); @@ -221,7 +223,7 @@ app.get('/deepz', async (_req, res) => { res.status(ready ? 200 : 503).json({ status: ready ? (bot.degraded ? 'degraded' : 'ready') : 'not_ready', timestamp: new Date().toISOString(), - components: { dependencies, telegramBot: bot }, + components: { dependencies, telegramBot: bot, telegramUsers: getTelegramUserClientPoolState() }, }); }); // Backward-compatible liveness alias. Deployments should route traffic based on /readyz. @@ -270,6 +272,7 @@ async function startTelegramRuntime(telegramConfig: Awaited { await ensureDatabaseInitialized(); + await loadFileDownloadConcurrencySetting(); const telegramConfig = await applyEffectiveTelegramBotConfig(); const telegramEnabled = telegramConfig.configured && telegramConfig.enabled; await installTelegramMultiAccountRuntimeAdapters(); @@ -350,6 +353,7 @@ async function shutdown(signal: string) { readinessProbeTimer = null; const forceTimer = setTimeout(() => process.exit(1), 30_000); forceTimer.unref(); + await Promise.allSettled([stopTelegramBot(), shutdownTelegramUserClientPool()]); if (server) { server.close(async () => { try { diff --git a/backend/src/routes/storage.ts b/backend/src/routes/storage.ts index 51093df..f873d72 100644 --- a/backend/src/routes/storage.ts +++ b/backend/src/routes/storage.ts @@ -48,7 +48,7 @@ import { withTelegramBotLifecycle, retryTelegramBot } from '../services/telegram import { maintenanceImpact } from '../utils/maintenanceActions.js'; import { buildStorageCapabilities, buildStorageScopeForTarget, buildStorageStatsPayload } from '../utils/storageProductContract.js'; import { buildAdvancedSettings, normalizeAdvancedSettingsPatch } from '../utils/advancedSettings.js'; -import { getFileDownloadConcurrency, setFileDownloadConcurrency } from '../services/telegramUpload.js'; +import { assertFileConcurrencyCapacity, FileConcurrencyLimitError, getFileConcurrencyCapacity, loadFileDownloadConcurrencySetting, setFileDownloadConcurrency } from '../services/telegramFileConcurrency.js'; import { startPeriodicCleanup, stopPeriodicCleanup } from '../services/orphanCleanup.js'; import { compactTelegramDownloadHistory, @@ -81,7 +81,7 @@ const telegramPinChangeLimiter = rateLimit({ const telegramUserLoginLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 10, message: { error: 'Telegram 用户账号登录请求过于频繁,请稍后再试' }, standardHeaders: true, legacyHeaders: false }); const telegramUserLoginStatusLimiter = rateLimit({ windowMs: 60 * 1000, max: 60, message: { error: 'Telegram 登录状态查询过于频繁,请稍后再试' }, standardHeaders: true, legacyHeaders: false }); function getTelegramUserLoginSessionKey(req: Request): string { const token = getAuthToken(req); if (!token) throw new Error('UNAUTHORIZED'); return crypto.createHash('sha256').update(token).digest('base64url'); } -function sendTelegramUserLoginError(res: Response, error: unknown): void { const candidate = error as { code?: string; message?: string }; const status = candidate.code === 'ACCOUNT_ALREADY_BOUND' ? 409 : candidate.code === 'FLOW_NOT_FOUND' || candidate.code === 'FLOW_EXPIRED' ? 404 : candidate.code === 'TOO_MANY_ERRORS' ? 429 : 400; res.status(status).json({ error: candidate.message || 'Telegram 登录失败', code: candidate.code || 'TELEGRAM_ERROR' }); } +function sendTelegramUserLoginError(res: Response, error: unknown): void { const candidate = error as { code?: string; message?: string }; const status = candidate.code === 'ACCOUNT_ALREADY_BOUND' || candidate.code === 'ACCOUNT_MISMATCH' ? 409 : candidate.code === 'ACCOUNT_NOT_FOUND' || candidate.code === 'FLOW_NOT_FOUND' || candidate.code === 'FLOW_EXPIRED' ? 404 : candidate.code === 'TOO_MANY_ERRORS' ? 429 : 400; res.status(status).json({ error: candidate.message || 'Telegram 登录失败', code: candidate.code || 'TELEGRAM_ERROR' }); } function noStore(res: Response): void { res.setHeader('Cache-Control', 'no-store'); @@ -301,9 +301,12 @@ router.get('/config', requireAuth, async (req: Request, res: Response) => { router.get('/config/advanced-tasks', requireAuth, async (_req: Request, res: Response) => { try { + const concurrency = await loadFileDownloadConcurrencySetting(); res.json(buildAdvancedSettings({ telegramDownloadWorkers: await getSetting('telegram_download_workers', process.env.TELEGRAM_DOWNLOAD_WORKERS || '4'), - telegramFileConcurrency: await getSetting('telegram_file_download_concurrency', String(getFileDownloadConcurrency())), + telegramFileConcurrency: concurrency.current, + telegramFileConcurrencyLimit: concurrency.maxConcurrency, + telegramEnabledAccounts: concurrency.enabledAccounts, duplicateMode: await getSetting('duplicate_file_mode', process.env.DUPLICATE_FILE_MODE || 'copy'), autoCleanupOrphans: await getSetting('auto_cleanup_orphans', process.env.AUTO_CLEANUP_ORPHANS || 'true'), skipTelegramPhotosInBatch: await getSetting('skip_telegram_photos_in_batch', 'false'), @@ -322,6 +325,9 @@ router.patch('/config/advanced-tasks', requireAuth, async (req: Request, res: Re try { const { confirmed, ...requestedPatch } = req.body || {}; const patch = normalizeAdvancedSettingsPatch(requestedPatch); + if ('telegramFileConcurrency' in patch) { + assertFileConcurrencyCapacity(Number(patch.telegramFileConcurrency), await getFileConcurrencyCapacity()); + } if (patch.highRisk && confirmed !== true) { const photoFilter = patch.skipTelegramPhotosInBatch === true; return res.status(409).json({ @@ -333,8 +339,7 @@ router.patch('/config/advanced-tasks', requireAuth, async (req: Request, res: Re await setSetting('telegram_download_workers', String(patch.telegramDownloadWorkers)); process.env.TELEGRAM_DOWNLOAD_WORKERS = String(patch.telegramDownloadWorkers); } else if ('telegramFileConcurrency' in patch) { - await setSetting('telegram_file_download_concurrency', String(patch.telegramFileConcurrency)); - setFileDownloadConcurrency(Number(patch.telegramFileConcurrency)); + await setFileDownloadConcurrency(Number(patch.telegramFileConcurrency)); } else if ('duplicateMode' in patch) { await setSetting('duplicate_file_mode', String(patch.duplicateMode)); process.env.DUPLICATE_FILE_MODE = String(patch.duplicateMode); @@ -353,7 +358,7 @@ router.patch('/config/advanced-tasks', requireAuth, async (req: Request, res: Re } return res.json({ success: true, ...patch }); } catch (error) { - res.status(400).json({ error: (error as Error).message }); + res.status(400).json({ error: (error as Error).message, ...(error instanceof FileConcurrencyLimitError ? { code: error.code, maxConcurrency: error.maxConcurrency, enabledAccounts: error.enabledAccounts } : {}) }); } }); @@ -594,18 +599,18 @@ function normalizeMultiAccountLoginPayload(payload: any): any { } // QR/phone aliases consumed by the current Web multi-account panel. -router.post('/config/telegram-user/accounts/login/qr', requireAuth, telegramUserLoginLimiter, async (req: Request, res: Response) => { noStore(res); try { return res.json(normalizeMultiAccountLoginPayload(await telegramMultiAccountLoginFlows.startQr(getTelegramUserLoginSessionKey(req)))); } catch (error) { return sendTelegramUserLoginError(res, error); } }); -router.post('/config/telegram-user/login/qr', requireAuth, telegramUserLoginLimiter, async (req: Request, res: Response) => { noStore(res); try { return res.json(normalizeMultiAccountLoginPayload(await telegramMultiAccountLoginFlows.startQr(getTelegramUserLoginSessionKey(req)))); } catch (error) { return sendTelegramUserLoginError(res, error); } }); +router.post('/config/telegram-user/accounts/login/qr', requireAuth, telegramUserLoginLimiter, async (req: Request, res: Response) => { noStore(res); try { return res.json(normalizeMultiAccountLoginPayload(await telegramMultiAccountLoginFlows.startQr(getTelegramUserLoginSessionKey(req), req.body?.accountId))); } catch (error) { return sendTelegramUserLoginError(res, error); } }); +router.post('/config/telegram-user/login/qr', requireAuth, telegramUserLoginLimiter, async (req: Request, res: Response) => { noStore(res); try { return res.json(normalizeMultiAccountLoginPayload(await telegramMultiAccountLoginFlows.startQr(getTelegramUserLoginSessionKey(req), req.body?.accountId))); } catch (error) { return sendTelegramUserLoginError(res, error); } }); router.get('/config/telegram-user/login/:flowId', requireAuth, telegramUserLoginStatusLimiter, async (req: Request, res: Response) => { noStore(res); try { return res.json(normalizeMultiAccountLoginPayload(await telegramMultiAccountLoginFlows.getQrStatus(getTelegramUserLoginSessionKey(req), req.params.flowId))); } catch (error) { return sendTelegramUserLoginError(res, error); } }); router.delete('/config/telegram-user/login/:flowId', requireAuth, telegramUserLoginLimiter, async (req: Request, res: Response) => { noStore(res); try { return res.json(await telegramMultiAccountLoginFlows.cancel(getTelegramUserLoginSessionKey(req), req.params.flowId)); } catch (error) { return sendTelegramUserLoginError(res, error); } }); -router.post('/config/telegram-user/login/phone', requireAuth, telegramUserLoginLimiter, async (req: Request, res: Response) => { noStore(res); try { const started = await telegramMultiAccountLoginFlows.startPhone(getTelegramUserLoginSessionKey(req), req.body?.phone); return res.json({ ...started, status: 'code_required', message: started.delivery === 'app' ? '验证码已发送到 Telegram 应用' : '验证码已通过短信发送' }); } catch (error) { return sendTelegramUserLoginError(res, error); } }); +router.post('/config/telegram-user/login/phone', requireAuth, telegramUserLoginLimiter, async (req: Request, res: Response) => { noStore(res); try { const started = await telegramMultiAccountLoginFlows.startPhone(getTelegramUserLoginSessionKey(req), req.body?.phone, req.body?.accountId); return res.json({ ...started, status: 'code_required', message: started.delivery === 'app' ? '验证码已发送到 Telegram 应用' : '验证码已通过短信发送' }); } catch (error) { return sendTelegramUserLoginError(res, error); } }); router.post('/config/telegram-user/login/:flowId/code', requireAuth, telegramUserLoginLimiter, async (req: Request, res: Response) => { noStore(res); try { return res.json(normalizeMultiAccountLoginPayload(await telegramMultiAccountLoginFlows.submitCode(getTelegramUserLoginSessionKey(req), req.params.flowId, req.body?.code))); } catch (error) { return sendTelegramUserLoginError(res, error); } }); router.post('/config/telegram-user/login/:flowId/password', requireAuth, telegramUserLoginLimiter, async (req: Request, res: Response) => { noStore(res); try { return res.json(normalizeMultiAccountLoginPayload(await telegramMultiAccountLoginFlows.submitPassword(getTelegramUserLoginSessionKey(req), req.params.flowId, req.body?.password))); } catch (error) { return sendTelegramUserLoginError(res, error); } }); router.post('/config/telegram-accounts/login/phone', requireAuth, telegramUserLoginLimiter, async (req: Request, res: Response) => { noStore(res); try { - return res.json(await telegramMultiAccountLoginFlows.startPhone(getTelegramUserLoginSessionKey(req), req.body?.phone)); + return res.json(await telegramMultiAccountLoginFlows.startPhone(getTelegramUserLoginSessionKey(req), req.body?.phone, req.body?.accountId)); } catch (error) { return sendTelegramUserLoginError(res, error); } @@ -629,7 +634,7 @@ router.post('/config/telegram-accounts/login/password', requireAuth, telegramUse router.post('/config/telegram-accounts/login/qr', requireAuth, telegramUserLoginLimiter, async (req: Request, res: Response) => { noStore(res); try { - return res.json(await telegramMultiAccountLoginFlows.startQr(getTelegramUserLoginSessionKey(req))); + return res.json(await telegramMultiAccountLoginFlows.startQr(getTelegramUserLoginSessionKey(req), req.body?.accountId)); } catch (error) { return sendTelegramUserLoginError(res, error); } @@ -717,7 +722,7 @@ router.post('/maintenance/download-items/cleanup', requireAuth, async (req: Requ const retentionDays = Math.min(365, Math.max(1, parseInt(String(req.body?.retentionDays ?? '7'), 10) || 7)); const preview = await query( `SELECT COUNT(*)::int AS count FROM telegram_download_items - WHERE status IN ('success', 'failed', 'skipped') + WHERE status IN ('success', 'failed', 'skipped', 'excluded') AND COALESCE(completed_at, updated_at, created_at) < NOW() - ($1::int * INTERVAL '1 day')`, [retentionDays] ); @@ -727,7 +732,7 @@ router.post('/maintenance/download-items/cleanup', requireAuth, async (req: Requ } const result = await query( `DELETE FROM telegram_download_items - WHERE status IN ('success', 'failed', 'skipped') + WHERE status IN ('success', 'failed', 'skipped', 'excluded') AND COALESCE(completed_at, updated_at, created_at) < NOW() - ($1::int * INTERVAL '1 day')`, [retentionDays] ); diff --git a/backend/src/routes/tasks.ts b/backend/src/routes/tasks.ts index 82f6bf5..1eae025 100644 --- a/backend/src/routes/tasks.ts +++ b/backend/src/routes/tasks.ts @@ -11,6 +11,7 @@ import { cancelTelegramBackgroundJob, retryTelegramBackgroundJob, pauseTelegramB import { filterDismissedTasks, isTaskDismissible, loadTaskCenterDismissals, saveTaskCenterDismissals } from '../services/taskCenterDismissals.js'; import { mapTelegramChannelJob, mapTransferTask } from '../services/unifiedTaskMapper.js'; import { channelRuntimeView } from '../services/telegramChannelRuntimeView.js'; +import { getTelegramTaskFailures, parseTaskFailurePage } from '../services/telegramTaskFailures.js'; import crypto from 'node:crypto'; const router = Router(); @@ -34,7 +35,7 @@ async function collectUnifiedTasks(limit: number, accountId?: string): Promise 'excluded' WHERE ($2::uuid IS NULL OR (j.params->>'storageAccountId') = $2::text) GROUP BY j.id ORDER BY j.updated_at DESC LIMIT $1`, @@ -249,6 +250,26 @@ router.post('/dismissals/confirm', requireAuth, async (req: Request, res: Respon } }); +router.get('/:sourceType/:id/failures', requireAuth, async (req: Request, res: Response) => { + res.set('Cache-Control', 'no-store'); + if (String(req.params.sourceType) !== 'telegram_channel') { + return res.status(400).json({ error: '该任务类型暂不支持逐条失败明细' }); + } + const id = String(req.params.id); + const page = parseTaskFailurePage(req.query.offset, req.query.limit); + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id) || !page) { + return res.status(400).json({ error: '无效的任务 ID 或分页参数' }); + } + try { + const failures = await getTelegramTaskFailures(id, page, query); + if (!failures) return res.status(404).json({ error: '任务不存在' }); + return res.json(failures); + } catch (error) { + console.error('获取任务失败明细失败:', error); + return res.status(500).json({ error: '获取任务失败明细失败' }); + } +}); + router.post('/:sourceType/:id/cancel-confirmation', requireAuth, async (req: Request, res: Response) => { const sourceType = String(req.params.sourceType); const id = String(req.params.id); diff --git a/backend/src/scripts/login_telegram_user.ts b/backend/src/scripts/login_telegram_user.ts index bc1f276..2c782e8 100644 --- a/backend/src/scripts/login_telegram_user.ts +++ b/backend/src/scripts/login_telegram_user.ts @@ -3,6 +3,7 @@ import path from 'path'; import readline from 'readline'; import { TelegramClient } from 'telegram'; import { StringSession } from 'telegram/sessions/index.js'; +import { ownUserTransport } from '../services/telegramUserTransport.js'; async function main() { const apiId = parseInt(process.env.TELEGRAM_API_ID || '0'); @@ -21,10 +22,10 @@ async function main() { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); const ask = (question: string) => new Promise(resolve => rl.question(question, resolve)); - const client = new TelegramClient(new StringSession(''), apiId, apiHash, { - connectionRetries: 5, + const client = ownUserTransport(new TelegramClient(new StringSession(''), apiId, apiHash, { + connectionRetries: 1, useWSS: false, - }); + }), { accountId: 'cli-login' }); try { await client.start({ diff --git a/backend/src/services/googleDriveUpload.ts b/backend/src/services/googleDriveUpload.ts new file mode 100644 index 0000000..99cc8bf --- /dev/null +++ b/backend/src/services/googleDriveUpload.ts @@ -0,0 +1,66 @@ +import fs from 'node:fs'; +import { createHash } from 'node:crypto'; +import { setTimeout as delay } from 'node:timers/promises'; +import { isRecord } from '../utils/typeGuards.js'; + +function uploadErrorStatus(error: unknown): number { + if (!isRecord(error)) return 0; + const value = isRecord(error.response) ? error.response.status : error.status ?? error.code; + return typeof value === 'number' || typeof value === 'string' && /^\d{3}$/.test(value) ? Number(value) : 0; +} + +/** Retry only the same preallocated Drive ID, reopening the local stream each time. */ +export async function uploadGoogleDriveFile(input: { + tempPath: string; + fileId: string; + fileName: string; + folderId: string; + create: (body: fs.ReadStream) => Promise<{ id?: string | null }>; + inspect: () => Promise<{ id?: string | null; name?: string | null; size?: string | null; md5Checksum?: string | null; parents?: string[] | null; trashed?: boolean | null }>; + signal?: AbortSignal; +}): Promise { + let firstError: unknown; + let failed = false; + let checksum: string | undefined; + for (let attempt = 0; attempt < 3; attempt++) { + input.signal?.throwIfAborted(); + const body = fs.createReadStream(input.tempPath); + try { + const result = await input.create(body); + input.signal?.throwIfAborted(); + if (result.id !== input.fileId) throw new Error('Google Drive upload returned an unexpected file ID'); + return input.fileId; + } catch (error) { + body.destroy(); + input.signal?.throwIfAborted(); + if (!failed) { firstError = error; failed = true; } + const status = uploadErrorStatus(error); + const conflict = status === 409; + const code = isRecord(error) && typeof error.code === 'string' ? error.code : ''; + const transient = status === 408 || status === 429 || status >= 500 && status <= 599 + || ['ECONNRESET', 'ETIMEDOUT', 'EPIPE', 'EAI_AGAIN', 'ENETUNREACH', 'ECONNREFUSED'].includes(code); + if (conflict || transient) { + try { + const remote = await input.inspect(); + const { size } = await fs.promises.stat(input.tempPath); + if (!remote.trashed && remote.id === input.fileId && remote.name === input.fileName + && Number(remote.size) === size && remote.parents?.includes(input.folderId) && remote.md5Checksum) { + if (!checksum) { + const hash = createHash('md5'); + const source = fs.createReadStream(input.tempPath); + try { for await (const chunk of source) { input.signal?.throwIfAborted(); hash.update(chunk); } } + finally { source.destroy(); } + checksum = hash.digest('hex'); + } + if (checksum === remote.md5Checksum) return input.fileId; + } + } catch { input.signal?.throwIfAborted(); } + } + if (!transient || attempt === 2) throw firstError; + } finally { + body.destroy(); + } + await delay(30_000 * (attempt + 1), undefined, { signal: input.signal }); + } + throw firstError; +} diff --git a/backend/src/services/oneDriveUpload.ts b/backend/src/services/oneDriveUpload.ts new file mode 100644 index 0000000..b6a0a2e --- /dev/null +++ b/backend/src/services/oneDriveUpload.ts @@ -0,0 +1,269 @@ +import fs from 'node:fs'; +import { createHash } from 'node:crypto'; +import { setTimeout as delay } from 'node:timers/promises'; +import axios from 'axios'; +import { isRecord } from '../utils/typeGuards.js'; + +const FRAGMENT_BYTES = 320 * 1024 * 10; +const MAX_ATTEMPTS = 3; + +interface UploadInput { + tempPath: string; + resourceUrl: string; + fileName: string; + mimeType: string; + getAccessToken: () => Promise; + signal?: AbortSignal; +} + +interface DriveItem { + id: string; + name: string; + size: number; + sha1?: string; + quickXor?: string; +} + +function errorDetails(error: unknown) { + const response = axios.isAxiosError(error) ? error.response : undefined; + const status = response?.status || 0; + const code = isRecord(error) && typeof error.code === 'string' ? error.code : ''; + const header = response?.headers['retry-after']; + let retryAfter: number | undefined; + if (typeof header === 'string' || typeof header === 'number') { + const seconds = Number(header); + const milliseconds = Number.isFinite(seconds) ? seconds * 1000 : Date.parse(String(header)) - Date.now(); + if (Number.isFinite(milliseconds)) retryAfter = Math.max(0, milliseconds); + } + return { + status, retryAfter, + transient: status === 408 || status === 429 || status >= 500 && status <= 599 + || ['ECONNRESET', 'ETIMEDOUT', 'ECONNABORTED', 'EPIPE', 'EAI_AGAIN', 'ENETUNREACH', 'ECONNREFUSED', 'UPLOAD_NO_PROGRESS'].includes(code), + }; +} + +async function retryRequest(operation: () => Promise, signal?: AbortSignal): Promise { + let firstError: { error: unknown } | undefined; + for (let attempt = 0; ; attempt++) { + signal?.throwIfAborted(); + try { return await operation(); } + catch (error) { + signal?.throwIfAborted(); + firstError ??= { error }; + const details = errorDetails(error); + if (!details.transient || attempt + 1 >= MAX_ATTEMPTS) throw firstError.error; + await delay(details.retryAfter ?? 1000 * 2 ** attempt, undefined, { signal }); + } + } +} + +function parseItem(value: unknown): DriveItem { + if (!isRecord(value) || typeof value.id !== 'string' || !value.id || typeof value.name !== 'string' + || typeof value.size !== 'number' || !Number.isSafeInteger(value.size) || value.size < 0 + || !isRecord(value.file) || value.deleted) throw new Error('OneDrive returned invalid file metadata'); + const hashes = isRecord(value.file.hashes) ? value.file.hashes : {}; + return { + id: value.id, name: value.name, size: value.size, + sha1: typeof hashes.sha1Hash === 'string' && /^[a-f\d]{40}$/i.test(hashes.sha1Hash) ? hashes.sha1Hash : undefined, + quickXor: typeof hashes.quickXorHash === 'string' && /^[A-Za-z\d+/]{27}=$/.test(hashes.quickXorHash) ? hashes.quickXorHash : undefined, + }; +} + +function nextRange(value: unknown, size: number): { offset: number; end: number } | undefined { + if (!isRecord(value) || !Array.isArray(value.nextExpectedRanges)) throw new Error('OneDrive returned invalid upload session status'); + let next: { offset: number; end: number } | undefined; + for (const range of value.nextExpectedRanges) { + const match = typeof range === 'string' ? /^(\d+)-(\d*)$/.exec(range) : null; + if (!match) throw new Error('OneDrive returned invalid missing byte range'); + const offset = Number(match[1]); + const end = match[2] ? Number(match[2]) : size - 1; + if (!Number.isSafeInteger(offset) || !Number.isSafeInteger(end) || offset < 0 || offset >= size + || end < offset || end >= size || offset % (320 * 1024) !== 0) throw new Error('OneDrive returned out-of-bounds missing byte range'); + if (!next || offset < next.offset) next = { offset, end }; + } + return next; +} + +/** Reads local bytes only when an ambiguous response requires content verification. */ +async function matchesLocalFile(item: DriveItem, input: UploadInput, size: number): Promise { + if (item.name !== input.fileName || item.size !== size || !item.sha1 && !item.quickXor) return false; + const source = fs.createReadStream(input.tempPath); + const sha1 = item.sha1 ? createHash('sha1') : undefined; + const lanes = sha1 ? undefined : new Uint8Array(160); + let phase = 0; + try { + for await (const chunk of source) { + input.signal?.throwIfAborted(); + if (sha1) sha1.update(chunk); + else if (lanes) { + for (let lane = 0; lane < Math.min(160, chunk.length); lane++) { + let value = 0; + for (let index = lane; index < chunk.length; index += 160) value ^= chunk[index]; + lanes[(phase + lane) % 160] ^= value; + } + phase = (phase + chunk.length) % 160; + } + } + } finally { source.destroy(); } + if (sha1) return sha1.digest('hex').toUpperCase() === item.sha1!.toUpperCase(); + const hash = Buffer.alloc(20); + for (let lane = 0; lane < 160; lane++) { + const bit = lane * 11 % 160; + const index = bit >>> 3; + const shift = bit & 7; + hash[index] ^= lanes![lane] << shift; + if (shift) hash[(index + 1) % 20] ^= lanes![lane] >>> (8 - shift); + } + let length = BigInt(size); + for (let index = 12; index < 20; index++) { hash[index] ^= Number(length & 255n); length >>= 8n; } + return hash.toString('base64') === item.quickXor; +} + +/** Resume the same session after errors; never blindly create a renamed replacement. */ +export async function uploadOneDriveFile(input: UploadInput): Promise { + const { size } = await fs.promises.stat(input.tempPath); + const readTarget = async (): Promise => { + try { + const response = await retryRequest(async () => axios.get(input.resourceUrl, { + headers: { Authorization: `Bearer ${await input.getAccessToken()}` }, + params: { $select: 'id,name,size,file,deleted' }, timeout: 30_000, signal: input.signal, + }), input.signal); + return parseItem(response.data); + } catch (error) { + if (errorDetails(error).status === 404) return undefined; + throw error; + } + }; + if (await readTarget()) throw new Error('OneDrive upload target already exists; refusing to overwrite or rename'); + if (size < 4 * 1024 * 1024) { + const bytes = await fs.promises.readFile(input.tempPath); + let firstError: { error: unknown } | undefined; + for (let attempt = 0; ; attempt++) { + input.signal?.throwIfAborted(); + try { + const response = await axios.put(`${input.resourceUrl}:/content`, bytes, { + headers: { Authorization: `Bearer ${await input.getAccessToken()}`, 'Content-Type': input.mimeType || 'application/octet-stream', 'Content-Length': String(size) }, + params: { '@microsoft.graph.conflictBehavior': 'fail' }, + maxBodyLength: Infinity, maxContentLength: Infinity, timeout: 60_000, signal: input.signal, + }); + const item = parseItem(response.data); + if (item.name !== input.fileName || item.size !== size) throw new Error('OneDrive simple upload result does not match the local file'); + return item.id; + } catch (error) { + input.signal?.throwIfAborted(); + firstError ??= { error }; + const details = errorDetails(error); + if (details.transient || details.status === 409 || details.status === 412) { + await delay(details.retryAfter ?? 1000 * 2 ** attempt, undefined, { signal: input.signal }); + try { + const item = await readTarget(); + if (item) { + if (await matchesLocalFile(item, input, size)) return item.id; + throw firstError.error; + } + } catch { input.signal?.throwIfAborted(); throw firstError.error; } + } + if (!details.transient || attempt + 1 >= MAX_ATTEMPTS) throw firstError.error; + } + } + } + + let uploadUrl: string | undefined; + let restarts = 0; + const createSession = async (): Promise => { + const response = await retryRequest(async () => axios.post(`${input.resourceUrl}:/createUploadSession`, { + item: { '@microsoft.graph.conflictBehavior': 'fail', name: input.fileName }, + }, { headers: { Authorization: `Bearer ${await input.getAccessToken()}` }, timeout: 30_000, signal: input.signal }), input.signal); + if (!isRecord(response.data) || typeof response.data.uploadUrl !== 'string') throw new Error('OneDrive returned invalid upload session'); + const url = new URL(response.data.uploadUrl); + if (url.protocol !== 'https:' && url.protocol !== new URL(input.resourceUrl).protocol) throw new Error('OneDrive returned an insecure upload session'); + return url.href; + }; + const handle = await fs.promises.open(input.tempPath, 'r'); + const buffer = Buffer.allocUnsafe(FRAGMENT_BYTES); + let range = { offset: 0, end: size - 1 }; + let firstError: { error: unknown } | undefined; + let failures = 0; + try { + uploadUrl = await createSession(); + while (range.offset < size) { + input.signal?.throwIfAborted(); + const length = Math.min(buffer.length, range.end - range.offset + 1, size - range.offset); + if (range.offset + length < size && length % (320 * 1024) !== 0) throw new Error('OneDrive missing range cannot be uploaded in aligned fragments'); + let read = 0; + while (read < length) { + const result = await handle.read(buffer, read, length - read, range.offset + read); + if (result.bytesRead === 0) throw new Error('OneDrive local upload file ended unexpectedly'); + read += result.bytesRead; + } + try { + const response = await axios.put(uploadUrl, buffer.subarray(0, length), { + headers: { 'Content-Length': String(length), 'Content-Range': `bytes ${range.offset}-${range.offset + length - 1}/${size}` }, + maxBodyLength: Infinity, maxContentLength: Infinity, timeout: 120_000, signal: input.signal, + }); + if (response.status === 200 || response.status === 201) { + const item = parseItem(response.data); + if (item.name !== input.fileName || item.size !== size) throw new Error('OneDrive upload result does not match the local file'); + return item.id; + } + const next = nextRange(response.data, size); + if (!next || next.offset <= range.offset) throw Object.assign(new Error('OneDrive upload made no confirmed progress'), { code: 'UPLOAD_NO_PROGRESS' }); + range = next; + firstError = undefined; + failures = 0; + } catch (error) { + input.signal?.throwIfAborted(); + firstError ??= { error }; + const originalError = firstError.error; + const details = errorDetails(error); + if (!details.transient && ![404, 410, 416].includes(details.status)) throw originalError; + failures++; + await delay(details.retryAfter ?? 1000 * 2 ** (failures - 1), undefined, { signal: input.signal }); + let sessionStatus: unknown; + let expired = false; + try { + const response = await retryRequest(() => axios.get(uploadUrl!, { timeout: 30_000, signal: input.signal }), input.signal); + sessionStatus = response.data; + } catch (statusError) { + input.signal?.throwIfAborted(); + if (![404, 410].includes(errorDetails(statusError).status)) throw originalError; + expired = true; + } + if (expired) { + let item: DriveItem | undefined; + try { item = await readTarget(); } + catch { input.signal?.throwIfAborted(); throw originalError; } + if (item) { + if (await matchesLocalFile(item, input, size)) return item.id; + throw originalError; + } + if (restarts++ >= 1) throw originalError; + try { uploadUrl = await createSession(); } + catch { input.signal?.throwIfAborted(); throw originalError; } + range = { offset: 0, end: size - 1 }; + failures = 0; + continue; + } + const next = nextRange(sessionStatus, size); + if (!next) { + let item: DriveItem | undefined; + try { item = await readTarget(); } + catch { input.signal?.throwIfAborted(); throw originalError; } + if (item && await matchesLocalFile(item, input, size)) return item.id; + throw originalError; + } + const progressed = next.offset > range.offset; + if (next.offset < range.offset || failures >= MAX_ATTEMPTS && !progressed) throw originalError; + range = next; + if (progressed) { failures = 0; firstError = undefined; } + } + } + throw new Error('OneDrive upload completion was not confirmed'); + } catch (error) { + if (uploadUrl) { + try { await axios.delete(uploadUrl, { timeout: 10_000 }); } catch { /* Expired sessions clean themselves up. */ } + } + input.signal?.throwIfAborted(); + throw error; + } finally { await handle.close(); } +} diff --git a/backend/src/services/orphanCleanup.ts b/backend/src/services/orphanCleanup.ts index 4626ae0..d4392f6 100644 --- a/backend/src/services/orphanCleanup.ts +++ b/backend/src/services/orphanCleanup.ts @@ -11,6 +11,7 @@ import { getRelativeStoragePath, safeUnlink } from '../utils/localPath.js'; import { formatBytes } from '../utils/fileMetadata.js'; import { getSetting } from '../utils/settings.js'; import { cleanupPreviewOrphans } from '../utils/previewMaintenance.js'; +import { isTemporaryUploadFileActive } from '../utils/temporaryUploadFiles.js'; const UPLOAD_DIR = path.resolve(process.env.UPLOAD_DIR || './data/uploads'); const ORPHAN_MIN_AGE_MS = Math.max(60_000, parseInt(process.env.ORPHAN_CLEANUP_MIN_AGE_MS || '600000', 10) || 600_000); @@ -158,7 +159,7 @@ async function runCleanup(): Promise { for await (const file of walkFiles(UPLOAD_DIR)) { scannedCount += 1; const relativePath = getRelativeStoragePath(UPLOAD_DIR, file.path); - if (!relativePath || dbFileSet.has(relativePath) || now - file.mtimeMs < ORPHAN_MIN_AGE_MS) continue; + if (!relativePath || dbFileSet.has(relativePath) || now - file.mtimeMs < ORPHAN_MIN_AGE_MS || isTemporaryUploadFileActive(file.path)) continue; try { await safeUnlink(file.path, UPLOAD_DIR); stats.deletedCount += 1; diff --git a/backend/src/services/rollingDownloadWindow.ts b/backend/src/services/rollingDownloadWindow.ts new file mode 100644 index 0000000..d56eef8 --- /dev/null +++ b/backend/src/services/rollingDownloadWindow.ts @@ -0,0 +1,32 @@ +/** Keep a bounded window full without waiting for its slowest item. */ +export async function runRollingDownloadWindow( + windowSize: number, + load: (limit: number) => Promise, + execute: (item: T) => Promise, +): Promise { + const active = new Set>(); + let failure: { error: unknown } | undefined; + let exhausted = false; + try { + while (!exhausted || active.size > 0) { + if (failure) break; + if (!exhausted && active.size < windowSize) { + const items = await load(windowSize - active.size); + if (failure) break; + exhausted = items.length === 0; + for (const item of items) { + const operation = Promise.resolve().then(() => execute(item)).catch(error => { + failure ??= { error }; + }).finally(() => active.delete(operation)); + active.add(operation); + } + } + if (active.size > 0) await Promise.race(active); + } + } catch (error) { + failure ??= { error }; + } finally { + await Promise.all(active); + } + if (failure) throw failure.error; +} diff --git a/backend/src/services/storage.ts b/backend/src/services/storage.ts index b71710e..125a9c7 100644 --- a/backend/src/services/storage.ts +++ b/backend/src/services/storage.ts @@ -4,7 +4,7 @@ import OSS from 'ali-oss'; import { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand, HeadObjectCommand, ListObjectsV2Command } from '@aws-sdk/client-s3'; import { getSignedUrl as getS3SignedUrl } from '@aws-sdk/s3-request-presigner'; import { createClient, WebDAVClient } from 'webdav'; -import { google } from 'googleapis'; +import { google, type drive_v3 } from 'googleapis'; import { query, pool } from '../db/index.js'; import { summarizeStorageProbeFailure } from '../utils/storageProbe.js'; import { MediaSourceMissingError } from './mediaProxyError.js'; @@ -26,6 +26,8 @@ import { DEFAULT_STORAGE_COOLDOWN_MS, STORAGE_COOLDOWN_REASON_DAILY_UPLOAD_LIMIT import { switchStorageAccountWithClient, switchStorageToLocalWithClient } from './storageAccountLifecycle.js'; import { validateConfiguredStorageTarget } from './storageTargetReadiness.js'; import { OpenListStorageProvider } from './openListStorage.js'; +import { uploadGoogleDriveFile } from './googleDriveUpload.js'; +import { uploadOneDriveFile } from './oneDriveUpload.js'; export class StorageQuotaCooldownError extends Error { provider: string; @@ -648,148 +650,23 @@ export class OneDriveStorageProvider implements IStorageProvider { } } - /** - * 保存文件到 OneDrive - */ - async saveFile(tempPath: string, fileName: string, mimeType: string, folder?: string | null): Promise { + async saveFile(tempPath: string, fileName: string, mimeType: string, folder?: string | null, options?: { signal?: AbortSignal }): Promise { const token = await this.getAccessToken(); - const stats = await fs.promises.stat(tempPath); - const fileSize = stats.size; - - console.log(`[OneDrive] Uploading file: ${fileName}, size: ${fileSize} bytes, type: ${mimeType}`); - - // 确保文件夹存在 const uploadFolder = await this.ensureFolderExists(token, folder); - - // URL编码文件名,处理特殊字符 const targetPath = uploadFolder ? `${uploadFolder}/${fileName}` : fileName; - try { - // 小于 4MB 使用简单上传 - if (fileSize < 4 * 1024 * 1024) { - console.log('[OneDrive] Using simple upload for small file'); - const fileBuffer = await fs.promises.readFile(tempPath); - - const response = await axios.put( - `https://graph.microsoft.com/v1.0/me/drive/root:/${this.encodeOneDrivePath(targetPath)}:/content`, - fileBuffer, - { - headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': mimeType || 'application/octet-stream', - 'Content-Length': fileSize.toString() - }, - maxBodyLength: Infinity, - maxContentLength: Infinity, - timeout: 60000 - } - ); - - console.log('[OneDrive] Simple upload successful, file ID:', response.data.id); - return response.data.id; - } else { - // 大文件使用分片上传会话 - console.log('[OneDrive] Using chunked upload session for large file'); - - // 1. 创建上传会话 - const sessionRes = await axios.post( - `https://graph.microsoft.com/v1.0/me/drive/root:/${this.encodeOneDrivePath(targetPath)}:/createUploadSession`, - { - item: { - "@microsoft.graph.conflictBehavior": "rename", - name: fileName - } - }, - { - headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json' - }, - timeout: 30000 - } - ); - - const uploadUrl = sessionRes.data.uploadUrl; - console.log('[OneDrive] Upload session created'); - - // 2. 分片上传 - 使用流式读取避免内存溢出 - const CHUNK_SIZE = 320 * 1024 * 10; // 3.2MB (必须是 320KB 的倍数) - let uploadedBytes = 0; - let lastResponse: any = null; - - const fd = await fs.promises.open(tempPath, 'r'); - try { - while (uploadedBytes < fileSize) { - const chunkSize = Math.min(CHUNK_SIZE, fileSize - uploadedBytes); - const buffer = Buffer.alloc(chunkSize); - await fd.read(buffer, 0, chunkSize, uploadedBytes); - - const rangeEnd = uploadedBytes + chunkSize - 1; - const contentRange = `bytes ${uploadedBytes}-${rangeEnd}/${fileSize}`; - - console.log(`[OneDrive] Uploading chunk: ${contentRange}`); - - lastResponse = await axios.put(uploadUrl, buffer, { - headers: { - 'Content-Length': chunkSize.toString(), - 'Content-Range': contentRange - }, - maxBodyLength: Infinity, - maxContentLength: Infinity, - timeout: 120000 - }); - - uploadedBytes += chunkSize; - const progress = Math.round((uploadedBytes / fileSize) * 100); - console.log(`[OneDrive] Upload progress: ${progress}%`); - } - } catch (chunkError: any) { - await fd.close(); - // 分片上传失败,尝试取消会话 - console.error('[OneDrive] Chunk upload failed, cancelling session...'); - await this.cancelUploadSession(uploadUrl); - throw chunkError; - } finally { - try { await fd.close(); } catch { /* ignore */ } - } - - // 最后一个分片的响应包含完整的文件信息 - if (lastResponse?.data?.id) { - console.log('[OneDrive] Chunked upload successful, file ID:', lastResponse.data.id); - return lastResponse.data.id; - } - - // 如果最后响应没有ID,查询文件信息 - const itemRes = await axios.get( - `https://graph.microsoft.com/v1.0/me/drive/root:/${this.encodeOneDrivePath(targetPath)}`, - { - headers: { 'Authorization': `Bearer ${token}` }, - timeout: 30000 - } - ); - console.log('[OneDrive] File ID retrieved:', itemRes.data.id); - return itemRes.data.id; - } - } catch (error: any) { - console.error('[OneDrive] Upload failed:', { - status: error.response?.status, - error: error.response?.data?.error, - message: error.message + const id = await uploadOneDriveFile({ + tempPath, fileName, mimeType, + resourceUrl: `https://graph.microsoft.com/v1.0/me/drive/root:/${this.encodeOneDrivePath(targetPath)}`, + getAccessToken: () => this.getAccessToken(), + signal: options?.signal, }); - throw new Error(`OneDrive upload failed: ${error.response?.data?.error?.message || error.message}`); - } - } - - /** - * 取消上传会话(清理服务器上的未完成上传) - */ - async cancelUploadSession(uploadUrl: string): Promise { - try { - await axios.delete(uploadUrl, { timeout: 10000 }); - console.log('[OneDrive] Upload session cancelled successfully'); - } catch (error: any) { - // 会话可能已过期或不存在,忽略错误 - console.warn('[OneDrive] Failed to cancel upload session (may already be expired):', error.message); + console.log('[OneDrive] Upload successful, file ID:', id); + return id; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + console.error('[OneDrive] Upload failed:', message); + throw new Error(`OneDrive upload failed: ${message}`, { cause: error }); } } @@ -1136,40 +1013,55 @@ export class GoogleDriveStorageProvider implements IStorageProvider { return parentId!; } - async saveFile(tempPath: string, fileName: string, mimeType: string, folder?: string | null): Promise { + async prepareSaveFile(tempPath: string, fileName: string, mimeType: string, folder?: string | null) { await this.ensureAuthenticated(); const folderId = await this.ensureFolderExists(folder); - - const fileMetadata = { - name: fileName, - parents: [folderId] + const drive: drive_v3.Drive = this.drive; + const generated = await drive.files.generateIds({ count: 1, space: 'drive', type: 'files' }); + const fileId = generated.data.ids?.[0]; + if (typeof fileId !== 'string' || !fileId) throw new Error('Google Drive did not allocate an upload file ID'); + return { + storedPath: fileId, + save: async (signal?: AbortSignal): Promise => { + try { + const storedPath = await uploadGoogleDriveFile({ + tempPath, fileId, fileName, folderId, signal, + create: async body => { + const file = await drive.files.create(this.withSharedDriveSupport({ + requestBody: { id: fileId, name: fileName, parents: [folderId] }, + media: { mimeType, body }, + fields: 'id', + }), { retry: false, signal }); + return file.data; + }, + inspect: async () => { + const file = await drive.files.get(this.withSharedDriveSupport({ + fileId, fields: 'id,name,size,md5Checksum,parents,trashed', + }), { retry: false, signal }); + return file.data; + }, + }); + console.log('[GoogleDrive] Upload successful, file ID:', storedPath); + return storedPath; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + console.error('[GoogleDrive] Upload failed:', message); + if (isGoogleDriveDailyUploadLimitError(error)) { + throw new StorageQuotaCooldownError('Google Drive 今日上传额度已达上限,任务将自动暂停 24 小时后继续。', { + provider: 'google_drive', + reason: STORAGE_COOLDOWN_REASON_DAILY_UPLOAD_LIMIT, + storageAccountId: this.id, + originalError: error, + }); + } + throw new Error(`Google Drive upload failed: ${message}`, { cause: error }); + } + }, }; + } - const media = { - mimeType: mimeType, - body: fs.createReadStream(tempPath) - }; - - try { - const file = await this.drive.files.create(this.withSharedDriveSupport({ - resource: fileMetadata, - media: media, - fields: 'id' - })); - console.log('[GoogleDrive] Upload successful, file ID:', file.data.id); - return file.data.id; - } catch (error: any) { - console.error('[GoogleDrive] Upload failed:', error.message); - if (isGoogleDriveDailyUploadLimitError(error)) { - throw new StorageQuotaCooldownError('Google Drive 今日上传额度已达上限,任务将自动暂停 24 小时后继续。', { - provider: 'google_drive', - reason: STORAGE_COOLDOWN_REASON_DAILY_UPLOAD_LIMIT, - storageAccountId: this.id, - originalError: error, - }); - } - throw new Error(`Google Drive upload failed: ${error.message}`); - } + async saveFile(tempPath: string, fileName: string, mimeType: string, folder?: string | null): Promise { + return (await this.prepareSaveFile(tempPath, fileName, mimeType, folder)).save(); } async getFileAvailability(storedPath: string): Promise<{ available: true }> { diff --git a/backend/src/services/storage/contracts.ts b/backend/src/services/storage/contracts.ts index 0c2fec2..837bfe9 100644 --- a/backend/src/services/storage/contracts.ts +++ b/backend/src/services/storage/contracts.ts @@ -1,3 +1,8 @@ +export interface PreparedStorageWrite { + storedPath: string; + save(signal?: AbortSignal): Promise; +} + export interface IStorageProvider { id?: string; // 对于云盘,这是账户 ID name: string; @@ -10,7 +15,9 @@ export interface IStorageProvider { * @param mimeType 文件类型 * @returns 存储后的路径或标识符 */ - saveFile(tempPath: string, fileName: string, mimeType: string, folder?: string | null): Promise; + saveFile(tempPath: string, fileName: string, mimeType: string, folder?: string | null, options?: { signal?: AbortSignal }): Promise; + /** Allocates a stable object identity before any upload side effect. */ + prepareSaveFile?(tempPath: string, fileName: string, mimeType: string, folder?: string | null): Promise; /** * 获取文件流(用于下载) diff --git a/backend/src/services/telegramAccountAccessSweep.ts b/backend/src/services/telegramAccountAccessSweep.ts index fd3d64a..8f6afdb 100644 --- a/backend/src/services/telegramAccountAccessSweep.ts +++ b/backend/src/services/telegramAccountAccessSweep.ts @@ -169,7 +169,10 @@ export async function probeTelegramAccountSource( const entity = await input.client.getEntity(input.source); const [latest] = await input.client.getMessages(entity, { limit: 1 }); const latestMessageId = typeof latest?.id === 'number' ? latest.id : null; - if (input.scope === 'comments' && latestMessageId !== null) { + if (latestMessageId === null) { + return failureResult(input, new Error('TELEGRAM_HISTORY_UNVERIFIED'), now); + } + if (input.scope === 'comments') { // GramJS's replyTo history lookup accepts the original source; // this mirrors telegramChannelJobs and resolves linked discussions. await input.client.getMessages(input.source, { limit: 1, replyTo: latestMessageId }); diff --git a/backend/src/services/telegramAccountRepository.ts b/backend/src/services/telegramAccountRepository.ts index 6061b6a..a72b252 100644 --- a/backend/src/services/telegramAccountRepository.ts +++ b/backend/src/services/telegramAccountRepository.ts @@ -2,6 +2,7 @@ import { query } from '../db/index.js'; import { encryptCredential } from '../utils/credentialCrypto.js'; import type { TelegramAccountHealthState, TelegramSourceAccessState } from './telegramAccountScheduler.js'; import { normalizeTelegramAccountSourceKey } from './telegramAccountScheduler.js'; +import { loadFileDownloadConcurrencySetting } from './telegramFileConcurrency.js'; export type TelegramAccountSourceScope = 'download' | 'scan' | 'metadata'; export type TelegramDownloadAttemptStatus = 'running' | 'succeeded' | 'failed' | 'cancelled'; @@ -43,6 +44,10 @@ export interface TelegramAccountSourceAccessRecord { // Read legacy aliases too; the latest probe wins until all keys are rewritten. const canonicalSourceKeySql = `CASE WHEN BTRIM(source_key) ~ '^-100[0-9]+$' THEN SUBSTRING(BTRIM(source_key) FROM 5) + WHEN BTRIM(source_key) ~* '^(?:https?://)?t[.]me/c/([0-9]+)(?:/[0-9]+)?/?$' + THEN SUBSTRING(LOWER(BTRIM(source_key)) FROM '^(?:https?://)?t[.]me/c/([0-9]+)(?:/[0-9]+)?/?$') + WHEN BTRIM(source_key) ~* '^(?:https?://)?t[.]me/(?:s/)?(?!joinchat(?:/|$))([a-z][a-z0-9_]*)(?:/[0-9]+)?/?$' + THEN SUBSTRING(LOWER(BTRIM(source_key)) FROM '^(?:https?://)?t[.]me/(?:s/)?(?!joinchat(?:/|$))([a-z][a-z0-9_]*)(?:/[0-9]+)?/?$') WHEN BTRIM(source_key) ~ '^@?[A-Za-z0-9_]+$' THEN LOWER(LTRIM(BTRIM(source_key), '@')) ELSE BTRIM(source_key) END`; @@ -64,19 +69,31 @@ function mapAccount(row: any): TelegramUserAccountRecord { }; } -function mapAccess(row: any): TelegramAccountSourceAccessRecord { +function mapAccess(row: { + account_id: string; + source_key: string; + scope: TelegramAccountSourceScope; + access_state: TelegramSourceAccessState; + last_error?: string | null; + checked_at?: Date | string | null; +}, now: number): TelegramAccountSourceAccessRecord { + const checkedAt = row.checked_at ? new Date(row.checked_at) : null; + const recent = checkedAt !== null && now - checkedAt.getTime() <= 15 * 60 * 1000; return { accountId: String(row.account_id), sourceKey: String(row.source_key), scope: row.scope, - accessState: row.access_state, + accessState: recent ? row.access_state : 'unknown', lastError: row.last_error || null, - checkedAt: row.checked_at || null, + checkedAt, }; } export class TelegramAccountRepository { - constructor(private readonly db: TelegramAccountQueryClient = { query }) {} + constructor( + private readonly db: TelegramAccountQueryClient = { query }, + private readonly now: () => Date = () => new Date(), + ) {} async migrateLegacySystemSettings(): Promise { const result = await this.db.query(` @@ -131,6 +148,7 @@ export class TelegramAccountRepository { encryptCredential(input.session), input.enabled ?? true, Math.max(0.01, input.weight ?? 1), input.priority ?? 0, Math.max(1, input.maxConnections ?? 4), input.isLegacy ?? false, ]); + if (input.enabled === false) await loadFileDownloadConcurrencySetting(); return mapAccount(result.rows[0]); } @@ -161,6 +179,7 @@ export class TelegramAccountRepository { `UPDATE telegram_user_accounts SET enabled = $2, updated_at = NOW() WHERE id = $1 AND deleted_at IS NULL`, [accountId, enabled], ); + if (result.rowCount === 1 && !enabled) await loadFileDownloadConcurrencySetting(); return result.rowCount === 1; } @@ -173,6 +192,7 @@ export class TelegramAccountRepository { WHERE id = $1 AND deleted_at IS NULL`, [accountId], ); + if (result.rowCount === 1) await loadFileDownloadConcurrencySetting(); return result.rowCount === 1; } @@ -191,8 +211,9 @@ export class TelegramAccountRepository { WHERE account_id = ANY($1::uuid[]) ORDER BY account_id, checked_at DESC NULLS LAST, source_key, scope `, [accountIds]); + const now = this.now().getTime(); for (const row of result.rows) { - const permission = mapAccess(row); + const permission = mapAccess(row, now); const access = accessByAccount.get(permission.accountId); if (!access) continue; access.permissions.push(permission); @@ -210,10 +231,32 @@ export class TelegramAccountRepository { return accessByAccount; } + async reauthenticateAccount(input: { + accountId: string; + telegramUserId: string; + username: string | null; + displayName: string | null; + session: string; + }): Promise { + const result = await this.db.query(` + UPDATE telegram_user_accounts SET session_ciphertext = $3, + username = $4, display_name = $5, + health_state = CASE WHEN cooldown_until > NOW() THEN 'degraded' ELSE 'healthy' END, + session_expired_at = NULL, + last_error = CASE WHEN cooldown_until > NOW() THEN last_error ELSE NULL END, + updated_at = NOW() + WHERE id = $1 AND telegram_user_id = $2 AND deleted_at IS NULL + RETURNING * + `, [input.accountId, input.telegramUserId, encryptCredential(input.session), input.username, input.displayName]); + return result.rows[0] ? mapAccount(result.rows[0]) : null; + } + async updateSession(accountId: string, session: string): Promise { const result = await this.db.query(` - UPDATE telegram_user_accounts SET session_ciphertext = $2, health_state = 'healthy', - session_expired_at = NULL, last_error = NULL, updated_at = NOW() + UPDATE telegram_user_accounts SET session_ciphertext = $2, + health_state = CASE WHEN cooldown_until > NOW() THEN 'degraded' ELSE 'healthy' END, + session_expired_at = NULL, + last_error = CASE WHEN cooldown_until > NOW() THEN last_error ELSE NULL END, updated_at = NOW() WHERE id = $1 AND deleted_at IS NULL `, [accountId, encryptCredential(session)]); return result.rowCount === 1; @@ -221,7 +264,9 @@ export class TelegramAccountRepository { async recordHealthy(accountId: string): Promise { const result = await this.db.query(` - UPDATE telegram_user_accounts SET health_state = 'healthy', last_error = NULL, + UPDATE telegram_user_accounts SET + health_state = CASE WHEN cooldown_until > NOW() THEN 'degraded' ELSE 'healthy' END, + last_error = CASE WHEN cooldown_until > NOW() THEN last_error ELSE NULL END, last_connected_at = NOW(), updated_at = NOW() WHERE id = $1 AND deleted_at IS NULL `, [accountId]); return result.rowCount === 1; @@ -235,13 +280,14 @@ export class TelegramAccountRepository { return result.rowCount === 1; } - async markCooldown(accountId: string, seconds: number, error: string | null = null): Promise { + async markCooldown(accountId: string, seconds: number, error: string | null = null, until?: Date): Promise { const cooldownSeconds = Number.isFinite(seconds) ? Math.max(1, Math.ceil(seconds)) : 60; const result = await this.db.query(` - UPDATE telegram_user_accounts SET cooldown_until = NOW() + ($2::double precision * INTERVAL '1 second'), + UPDATE telegram_user_accounts SET cooldown_until = GREATEST(cooldown_until, + COALESCE($4::timestamptz, NOW() + ($2::double precision * INTERVAL '1 second'))), health_state = 'degraded', last_error = $3, last_failure_at = NOW(), updated_at = NOW() WHERE id = $1 AND deleted_at IS NULL - `, [accountId, cooldownSeconds, error]); + `, [accountId, cooldownSeconds, error, until || null]); return result.rowCount === 1; } @@ -270,7 +316,7 @@ export class TelegramAccountRepository { checked_at = NOW(), updated_at = NOW() RETURNING * `, [accountId, normalizeTelegramAccountSourceKey(sourceKey), scope, accessState, error]); - return mapAccess(result.rows[0]); + return mapAccess(result.rows[0], this.now().getTime()); } async probeSourceAccess( @@ -289,7 +335,7 @@ export class TelegramAccountRepository { WHERE account_id = $1 AND ${canonicalSourceKeySql} = $2 AND scope = $3 ORDER BY checked_at DESC NULLS LAST, (source_key = $2) DESC LIMIT 1 `, [accountId, normalizeTelegramAccountSourceKey(sourceKey), scope]); - return result.rows[0] ? mapAccess(result.rows[0]) : null; + return result.rows[0] ? mapAccess(result.rows[0], this.now().getTime()) : null; } async listSourceAccess(sourceKey: string, scope: TelegramAccountSourceScope): Promise { @@ -298,7 +344,8 @@ export class TelegramAccountRepository { WHERE ${canonicalSourceKeySql} = $1 AND scope = $2 ORDER BY account_id, checked_at DESC NULLS LAST, (source_key = $1) DESC `, [normalizeTelegramAccountSourceKey(sourceKey), scope]); - return result.rows.map(mapAccess); + const now = this.now().getTime(); + return result.rows.map(row => mapAccess(row, now)); } async getLatestAccessCheckForAccount(accountId: string): Promise { diff --git a/backend/src/services/telegramAccountScheduler.ts b/backend/src/services/telegramAccountScheduler.ts index 754c01e..389fd2d 100644 --- a/backend/src/services/telegramAccountScheduler.ts +++ b/backend/src/services/telegramAccountScheduler.ts @@ -59,5 +59,9 @@ export function selectWeightedLeastConnectedTelegramAccount { const locale = await getTelegramUserLocaleOrDefault(senderId); const state: TelegramWizardState = { kind, step: kind === 'tg_download' ? 'mode' : 'source' }; @@ -705,6 +714,7 @@ async function handleTelegramWizardMessage(message: Api.Message, senderId: numbe } + if (state.step === 'confirm') { if (!/^(确认|confirm|yes|y|подтвердить)$/i.test(input) || (callbackMessageId === undefined && replyToId !== lookup.record.originMessageId)) { await message.reply({ message: t(locale, 'bot.wizard.confirmInput') }); @@ -731,7 +741,14 @@ async function handleTelegramWizardMessage(message: Api.Message, senderId: numbe if (telegramWizardStates.get(senderId, chatKey)?.value === state) telegramWizardStates.delete(senderId, chatKey); const effects = await Promise.allSettled([ consumeConfirmedTelegramTargetState(chatKey, state.targetSelection), - editConfirmation(t(locale, 'bot.wizard.submitted', { jobId })), + editConfirmation([ + t(locale, 'bot.wizard.submitted', { jobId }), + t(locale, 'bot.wizard.confirmStorage', { + provider: state.targetProvider === 'local' ? t(locale, 'commands.localStorage') : getProviderDisplayName(state.targetProvider || ''), + account: state.targetAccountName || state.targetAccountId?.slice(0, 8) || t(locale, 'commands.localStorage'), + }), + t(locale, 'bot.wizard.confirmFolder', { folder: state.customFolder || t(locale, 'bot.wizard.folder.defaultValue') }), + ].join('\n')), ...(state.customFolder ? [rememberRecentTelegramPathPersistent(chatKey, state.customFolder, locale)] : []), ]); if (effects.some(result => result.status === 'rejected')) console.warn('Telegram wizard post-creation update failed', { jobId }); @@ -1252,6 +1269,36 @@ async function handleTaskQueueCallback(update: Api.UpdateBotCallbackQuery, data: } } +async function handleTelegramFileSelectionCallback(update: Api.UpdateBotCallbackQuery, data: string): Promise { + if (!client) return; + const userId = update.userId.toJSNumber(); + const locale = await getTelegramUserLocaleOrDefault(userId); + if (!(await isAuthenticatedAsync(userId))) { + await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: t(locale, 'auth.required'), alert: true })); + return; + } + const match = data.match(/^tgfs_([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})_([a-z-]+)$/i); + const action = match?.[2]; + const fileType = TELEGRAM_BATCH_FILE_TYPES.find(value => value === action); + if (!match || (action !== 'all' && action !== 'confirm' && !fileType)) { + await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: t(locale, 'channelCard.selectionExpired'), alert: true })); + return; + } + try { + const updated = await updateTelegramJobFileSelection(userId, match[1], callbackChatKey(update, userId), Number(update.msgId), (fileType || action) as TelegramBatchFileType | 'all' | 'confirm'); + await client.invoke(new Api.messages.SetBotCallbackAnswer({ + queryId: update.queryId, + message: updated ? t(locale, action === 'confirm' ? 'channelCard.selectionStarted' : 'channelCard.selectionUpdated') + : t(locale, 'channelCard.selectionExpired'), + alert: !updated, + })); + if (updated) await syncTelegramChannelCards(client); + } catch (error) { + await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, + message: t(locale, 'bot.callback.operationFailed', { error: error instanceof Error ? error.message : String(error) }), alert: true })); + } +} + async function handleTelegramDownloadModeCallback(update: Api.UpdateBotCallbackQuery, data: string): Promise { if (!client) return; const userId = update.userId.toJSNumber(); @@ -1295,7 +1342,8 @@ async function handleTelegramDownloadModeCallback(update: Api.UpdateBotCallbackQ return; } await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId })); - await handleTelegramWizardMessage(prompt, userId, action === 'confirm' ? 'confirm' : 'skip', Number(update.msgId), expectedStep); + const input = action === 'confirm' ? 'confirm' : 'skip'; + await handleTelegramWizardMessage(prompt, userId, input, Number(update.msgId), expectedStep); return; } if (action.startsWith('edit_')) { @@ -1622,7 +1670,7 @@ async function initTelegramBotInternal(credentialsOverride?: TelegramBotCredenti } try { - const fileConcurrency = await loadFileDownloadConcurrencySetting(); + const { current: fileConcurrency } = await loadFileDownloadConcurrencySetting(); console.log(`🤖 Telegram 文件级并发: ${fileConcurrency}`); } catch (e) { console.warn('🤖 读取文件级并发设置失败,使用环境变量默认值:', e); @@ -2307,6 +2355,11 @@ async function initTelegramBotInternal(credentialsOverride?: TelegramBotCredenti return; } + if (data.startsWith('tgfs_')) { + await handleTelegramFileSelectionCallback(callbackUpdate, data); + return; + } + // 处理频道订阅管理回调 if (data.startsWith('tsub_')) { await handleTelegramSubscriptionCallback(callbackUpdate, data); diff --git a/backend/src/services/telegramChannelJobs.ts b/backend/src/services/telegramChannelJobs.ts index 3ea06e8..c9aaf0c 100644 --- a/backend/src/services/telegramChannelJobs.ts +++ b/backend/src/services/telegramChannelJobs.ts @@ -18,20 +18,27 @@ import { triggerTelegramAccountAccessSweep } from './telegramAccountAccessSweep. import { abortChannelExecutionForLeaseLoss, downloadTelegramChannelRange, getTelegramDownloadPreview, getChannelExecutionGroup, pauseChannelExecutionGroup, resumeChannelExecutionGroup, cancelChannelExecutionGroup, getChannelTaskAbortSignal, releaseChannelTaskAbortSignal, type TelegramDownloadMessageRef } from './telegramUpload.js'; import { getSetting } from '../utils/settings.js'; import { extractFileInfo, getEstimatedFileSize, type TelegramFileInfo } from '../utils/telegramMedia.js'; -import { filterTelegramBatchMessages } from '../utils/telegramBatchMediaFilter.js'; +import { filterTelegramBatchMessages, getTelegramBatchFileType, TELEGRAM_BATCH_FILE_TYPES, type TelegramBatchFileType } from '../utils/telegramBatchMediaFilter.js'; 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 { expandTelegramMediaGroups, messageGroupId, scanTelegramChannelPage } from './telegramChannelScanner.js'; +import { getTelegramMessagesWithAccess } from './telegramScanAccount.js'; +import { createTelegramScanReader, type TelegramScanReader } from './telegramScanReader.js'; +import { normalizeHashtag, createTelegramHashtagMatcher, type TelegramHashtagMatcher } from './telegramHashtag.js'; +import { markTelegramAccountSourceAccess, NoEligibleTelegramAccountError } from './telegramMultiAccountRuntime.js'; +import { persistTelegramDownloadRefs, TELEGRAM_REF_WRITE_BATCH_SIZE } from './telegramDownloadRefStore.js'; import { enqueueTelegramNotification } from './telegramNotificationDelivery.js'; import { resolveSubscriptionTarget } from './telegramSubscriptionManagement.js'; import { compactTelegramDownloadHistory } from './telegramDownloadHistoryPolicy.js'; import { filterTelegramSubscriptionAdvertisements } from './telegramSubscriptionAdFilter.js'; import type { TelegramAdFilterMode } from './telegramAdClassifier.js'; import { DEFAULT_LOCALE, t, type TelegramLocale } from '../i18n/telegram.js'; +import { getProviderDisplayName } from '../utils/providerMetadata.js'; import { getTelegramUserLocaleOrDefault } from './telegramLocalePreferences.js'; import { channelRuntimeView } from './telegramChannelRuntimeView.js'; +import { telegramChannelJobCounts } from './unifiedTaskMapper.js'; import { startTelegramChannelCards, stopTelegramChannelCards } from './telegramChannelProgress.js'; const SUBSCRIPTION_INTERVAL_MS = Math.max(60_000, parseInt(process.env.TELEGRAM_SUBSCRIPTION_INTERVAL_MS || '300000', 10) || 300_000); @@ -189,13 +196,6 @@ function messageHasMedia(message: Api.Message | undefined): boolean { return Boolean(message.media || message.document || message.photo || message.video || message.audio || message.voice || message.sticker); } -export function normalizeHashtag(tagInput: string, locale: TelegramLocale = DEFAULT_LOCALE): string { - const trimmed = tagInput.trim(); - if (!trimmed) throw new Error(t(locale, 'channels.errors.hashtagRequired')); - const withoutHash = trimmed.replace(/^#/, ''); - if (!/^[\p{L}\p{N}_]+$/u.test(withoutHash)) throw new Error(t(locale, 'channels.errors.hashtagInvalid')); - return `#${withoutHash}`; -} export async function validateTelegramSourceInput(sourceInput: string, locale: TelegramLocale = DEFAULT_LOCALE): Promise { const resolved = await resolveTelegramSource(sourceInput, locale); @@ -215,22 +215,6 @@ export async function validateTelegramSourceInput(sourceInput: string, locale: T }); } -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -function messageTextForTag(message: Api.Message | undefined): string { - if (!message) return ''; - return [message.message, (message as any).text, (message as any).caption].filter(Boolean).join('\n'); -} - -function messageMatchesHashtag(message: Api.Message | undefined, normalizedTag: string): boolean { - const body = messageTextForTag(message); - if (!body) return false; - const tag = escapeRegExp(normalizedTag.slice(1)); - const pattern = new RegExp(`(^|[^\\p{L}\\p{N}_])#${tag}(?![\\p{L}\\p{N}_])`, 'iu'); - return pattern.test(body); -} async function getLatestMessageId(source: string): Promise { const [latest] = await withTelegramAccount(String(source), 'scan', client => client.getMessages(String(source), { limit: 1 })); @@ -242,7 +226,7 @@ async function expandMessagesWithMediaGroups(source: string, messages: Api.Messa return expandTelegramMediaGroups( messages, pageMessages, - ids => withTelegramAccount(source, 'scan', client => client.getMessages(source, { ids }), { signal }), + ids => withTelegramAccount(source, 'scan', client => getTelegramMessagesWithAccess(client, source, { ids }), { signal }), messageHasMedia, ); } @@ -252,46 +236,6 @@ function sourcePeerKey(value: unknown, fallback: string): string { return String(value); } -async function persistDownloadRefs(jobId: string, source: string, refs: TelegramDownloadMessageRef[], folderOverride?: string | null) { - for (const ref of refs) { - await query( - `INSERT INTO telegram_download_items ( - job_id, source, source_peer, origin, message_id, grouped_id, channel_post_id, - file_name, mime_type, generated_name, total_size, folder_override, shared_caption, group_index, group_size, - status, error, last_error, locked_at, completed_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, 'pending', NULL, NULL, NULL, NULL) - ON CONFLICT (job_id, source_peer, message_id) - DO UPDATE SET - file_name = COALESCE(EXCLUDED.file_name, telegram_download_items.file_name), - mime_type = COALESCE(EXCLUDED.mime_type, telegram_download_items.mime_type), - generated_name = COALESCE(EXCLUDED.generated_name, telegram_download_items.generated_name), - total_size = COALESCE(EXCLUDED.total_size, telegram_download_items.total_size), - grouped_id = COALESCE(EXCLUDED.grouped_id, telegram_download_items.grouped_id), - shared_caption = COALESCE(EXCLUDED.shared_caption, telegram_download_items.shared_caption), - group_index = COALESCE(EXCLUDED.group_index, telegram_download_items.group_index), - group_size = COALESCE(EXCLUDED.group_size, telegram_download_items.group_size), - folder_override = EXCLUDED.folder_override, - updated_at = NOW()`, - [ - jobId, - source, - sourcePeerKey(ref.source, source), - ref.origin || 'channel', - ref.id, - ref.groupedId || null, - ref.channelPostId || null, - ref.fileInfo?.fileName || null, - ref.fileInfo?.mimeType || null, - ref.fileInfo?.generatedName || false, - ref.totalSize || 0, - folderOverride || null, - ref.sharedCaption || null, - ref.groupIndex || null, - ref.groupSize || null, - ] - ); - } -} interface TelegramDownloadScanSummary { source: string; @@ -307,6 +251,8 @@ interface TelegramDownloadScanSummary { interface TelegramCommentScanOptions { signal?: AbortSignal; + scanReader?: TelegramScanReader; + matchesTag?: TelegramHashtagMatcher; locale?: TelegramLocale; includeComments?: boolean; commentsMaxPerPost?: number; @@ -349,7 +295,9 @@ export async function persistChannelJobAdmission( 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, ad_decision_id) - VALUES ($1, $2, $3, $4, $5, 'queued', 'pending', 'pending', '{}'::jsonb, $6) + VALUES ($1, $2, $3, $4, $5::jsonb || jsonb_build_object('storageAccountName', + (SELECT name FROM storage_accounts WHERE id = ($5::jsonb->>'storageAccountId')::uuid)), + 'queued', 'pending', 'pending', '{}'::jsonb, $6) RETURNING id`, [input.userId, input.chatId || null, input.kind, input.source, JSON.stringify(persistedParams), input.adDecisionId || null], ); @@ -409,11 +357,10 @@ async function getDiscussionMediaRefs( let offsetId = 0; let scannedForPost = 0; while (scannedForPost < maxPerPost) { - const batch = await withTelegramAccount(String(source), 'scan', client => client.getMessages(String(source), { - limit: Math.min(100, maxPerPost - scannedForPost), - offsetId, - replyTo: post.id, - }), { signal: options.signal }); + const request = { limit: Math.min(100, maxPerPost - scannedForPost), offsetId, replyTo: post.id }; + const batch = options.scanReader + ? await options.scanReader.read(request, post.id) + : await withTelegramAccount(source, 'scan', client => client.getMessages(source, request), { signal: options.signal }); if (!batch.length) break; for (const comment of batch) { @@ -427,7 +374,7 @@ async function getDiscussionMediaRefs( if (options.startDate && commentDate < options.startDate) continue; if (options.endDate && commentDate > options.endDate) continue; } - if (options.tag && !messageMatchesHashtag(comment, options.tag)) continue; + if (options.matchesTag && !options.matchesTag(comment)) continue; const fileInfo = extractFileInfo(comment); if (!fileInfo) continue; @@ -659,7 +606,7 @@ async function persistDownloadMessages(jobId: string, source: string, messages: .map(message => toChannelDownloadRef(source, message)) .filter((ref): ref is TelegramDownloadMessageRef => Boolean(ref)); propagateTelegramDownloadGroupContext(refs); - await persistDownloadRefs(jobId, source, refs, folderOverride); + await persistTelegramDownloadRefs(jobId, source, refs, folderOverride); } async function updateDownloadItemsStatus(jobId: string, messageIds: number[] | undefined, status: 'success' | 'failed' | 'skipped', error?: string) { @@ -796,7 +743,7 @@ async function hydratePendingDownloadRefs(jobId: string, signal?: AbortSignal): let hydrated = 0; for (const row of result.rows) { try { - const messages = await withTelegramAccount(String(row.source_peer), 'metadata', client => client.getMessages(String(row.source_peer), { ids: [Number(row.message_id)] }), { signal }); + const messages = await withTelegramAccount(String(row.source_peer), 'metadata', client => getTelegramMessagesWithAccess(client, String(row.source_peer), { ids: [Number(row.message_id)] }), { signal }); const message = messages?.[0] as Api.Message | undefined; if (!message) { await query( @@ -820,7 +767,7 @@ async function hydratePendingDownloadRefs(jobId: string, signal?: AbortSignal): await query( `UPDATE telegram_download_items SET file_name = $2, mime_type = $3, total_size = $4, - generated_name = $5, grouped_id = $6, updated_at = NOW() + generated_name = $5, grouped_id = $6, file_category = $7, updated_at = NOW() WHERE id = $1`, [ row.id, @@ -829,6 +776,7 @@ async function hydratePendingDownloadRefs(jobId: string, signal?: AbortSignal): getEstimatedFileSize(message), fileInfo.generatedName, messageGroupId(message) || null, + getTelegramBatchFileType(fileInfo), ] ); hydrated += 1; @@ -1044,20 +992,26 @@ function subscriptionPausedNotification(error: unknown): { key: string; params: async function getJobItemStats(jobId: string) { const result = await query( - `SELECT status, COUNT(*)::int AS count, - COUNT(*) FILTER (WHERE origin = 'channel')::int AS channel_count, - COUNT(*) FILTER (WHERE origin = 'comment')::int AS comment_count - FROM telegram_download_items - WHERE job_id = $1 - GROUP BY status`, + `SELECT i.status, COUNT(i.id)::int AS count, + COUNT(i.id) FILTER (WHERE i.origin = 'channel')::int AS channel_count, + COUNT(i.id) FILTER (WHERE i.origin = 'comment')::int AS comment_count, + j.params + FROM telegram_background_jobs j + LEFT JOIN telegram_download_items i ON i.job_id = j.id + WHERE j.id = $1 + GROUP BY j.id, i.status`, [jobId] ); - const stats: Record = { pending: 0, downloading: 0, success: 0, failed: 0, skipped: 0, channelMediaFound: 0, commentMediaFound: 0 }; + const stats: Record = { pending: 0, downloading: 0, success: 0, failed: 0, skipped: 0, excluded: 0, channelMediaFound: 0, commentMediaFound: 0 }; for (const row of result.rows) { - stats[row.status] = Number(row.count || 0); + if (row.status) stats[row.status] = Number(row.count || 0); stats.channelMediaFound += Number(row.channel_count || 0); stats.commentMediaFound += Number(row.comment_count || 0); } + const counts = telegramChannelJobCounts({ params: result.rows[0]?.params, + success_count: stats.success, skipped_count_items: stats.skipped }); + stats.success = counts.completed; + stats.skipped = counts.skipped; return stats; } @@ -1080,7 +1034,7 @@ async function getJobProgress(jobId: string): Promise }, locale: TelegramLocale): Promise<{ storage: string; folder: string }> { + const params = job.params || {}; + let accountName = params.storageAccountName; + if (!accountName && params.storageAccountId) { + const account = await query('SELECT name FROM storage_accounts WHERE id=$1', [params.storageAccountId]); + accountName = account.rows[0]?.name || String(params.storageAccountId).slice(0, 8); + } + return { + storage: params.storageProvider === 'local' ? t(locale, 'commands.localStorage') + : `${getProviderDisplayName(params.storageProvider || '')} / ${accountName || t(locale, 'commands.unnamedAccount')}`, + folder: params.folderOverride || t(locale, 'bot.wizard.folder.defaultValue'), + }; +} + async function notifyStorageCooldownOnce(botClient: TelegramClient, job: any, cooldownUntil: Date): Promise { const params = job.params || {}; if (params.storageQuotaNoticeSentAt) return; @@ -1136,7 +1104,10 @@ async function notifyStorageCooldownOnce(botClient: TelegramClient, job: any, co const targetChat = job.chat_id || job.user_id; const locale = await getTelegramUserLocaleOrDefault(Number(job.user_id)); await botClient.sendMessage(targetChat, { - message: t(locale, 'channels.storageCooldown', { retryAt: cooldownUntil.toISOString(), jobId: String(job.id).slice(0, 12) }), + message: t(locale, 'channels.storageCooldown', { + retryAt: cooldownUntil.toISOString(), jobId: String(job.id).slice(0, 12), + ...await getTelegramJobDestination(job, locale), + }), }).catch(() => undefined); } @@ -1196,6 +1167,7 @@ async function claimPendingDownloadRefs(jobId: string, limit = TG_JOB_DOWNLOAD_B AND j.finished_at IS NULL AND (j.cooldown_until IS NULL OR j.cooldown_until <= NOW()) AND j.status NOT IN ('cancelled', 'paused', 'cooling') + AND (j.kind NOT IN ('date_range', 'tag_download') OR j.params->>'fileSelectionConfirmed' = 'true') FOR UPDATE OF j ), candidates AS ( SELECT i.id @@ -1225,6 +1197,7 @@ async function claimPendingDownloadRefs(jobId: string, limit = TG_JOB_DOWNLOAD_B AND j.finished_at IS NULL AND (j.cooldown_until IS NULL OR j.cooldown_until <= NOW()) AND j.status NOT IN ('cancelled', 'paused', 'cooling') + AND (j.kind NOT IN ('date_range', 'tag_download') OR j.params->>'fileSelectionConfirmed' = 'true') RETURNING i.id, i.source, i.source_peer, i.origin, i.message_id, i.grouped_id, i.channel_post_id, i.file_name, i.mime_type, i.generated_name, i.total_size, i.folder_override, i.shared_caption, i.group_index, i.group_size, i.lease_token`, @@ -1235,6 +1208,7 @@ async function claimPendingDownloadRefs(jobId: string, limit = TG_JOB_DOWNLOAD_B .map(row => ({ id: Number(row.message_id), itemId: String(row.id), + jobId, source: row.source_peer || row.source, origin: row.origin === 'comment' ? 'comment' : 'channel', channelPostId: row.channel_post_id || undefined, @@ -1337,7 +1311,7 @@ function startClaimHeartbeat(jobId: string, refs: TelegramDownloadMessageRef[]): return () => clearInterval(timer); } -async function downloadClaimedRefs(botClient: TelegramClient, requestMessage: Api.Message, jobId: string, source: string, refs: TelegramDownloadMessageRef[], folderOverride: string | null | undefined, options: TelegramCommentScanOptions) { +async function downloadClaimedRefs(botClient: TelegramClient, requestMessage: Api.Message, jobId: string, source: string, refs: TelegramDownloadMessageRef[], folderOverride: string | null | undefined, options: TelegramCommentScanOptions, drain: boolean) { if (refs.length === 0) return { found: 0, skipped: 0, @@ -1406,13 +1380,24 @@ async function downloadClaimedRefs(botClient: TelegramClient, requestMessage: Ap const settlement = await markDownloadRefStatus(jobId, ref, status, error); if (settlement === 'lease-lost') throw new TelegramDownloadLeaseLostError(jobId, ref); } + const index = refs.findIndex(claimed => claimed.itemId === ref.itemId); + if (index >= 0) refs.splice(index, 1); await notifyProgress(jobId, options); }, jobId, () => ensureJobCanRun(jobId), taskSignal, ownerUserId, storageTarget, - (ref, operation) => withTelegramDownloadRefLease(pool as unknown as TelegramTransactionPool, jobId, Object.assign(ref, { jobId }), async () => { + (ref, operation) => withTelegramDownloadRefLease(pool as unknown as TelegramTransactionPool, jobId, ref, async () => { const persisted = await operation(); if (taskSignal.aborted) throw new Error('Telegram 下载 lease heartbeat 失败,已停止保存'); return persisted; - })); + }), drain ? async limit => { + taskSignal.throwIfAborted(); + if (await ensureJobCanRun(jobId) !== 'run') return []; + let additional = await claimPendingDownloadRefs(jobId, limit); + if (additional.length === 0 && await hydratePendingDownloadRefs(jobId, taskSignal) > 0) { + additional = await claimPendingDownloadRefs(jobId, limit); + } + refs.push(...additional); + return additional; + } : undefined); const latestState = await ensureJobCanRun(jobId); if (latestState !== 'run') { await restoreUnfinishedClaimedRefs( @@ -1461,7 +1446,7 @@ async function downloadPendingForJob(botClient: TelegramClient, requestMessage: } const refs = await claimPendingDownloadRefs(jobId); if (refs.length === 0) break; - const result = await downloadClaimedRefs(botClient, requestMessage, jobId, source, refs, folderOverride, options); + const result = await downloadClaimedRefs(botClient, requestMessage, jobId, source, refs, folderOverride, options, drain); aggregate = { found: aggregate.found + (result.found || 0), skipped: aggregate.skipped + (result.skipped || 0), @@ -1524,13 +1509,125 @@ async function finalizeTelegramJob(jobId: string, options: TelegramCommentScanOp } +async function prepareTelegramJobFileSelection(jobId: string): Promise | null> { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + const jobResult = await client.query(`SELECT * FROM telegram_background_jobs WHERE id=$1 FOR UPDATE`, [jobId]); + const job = jobResult.rows[0]; + if (!job || job.cancelled_at || job.paused_at || job.finished_at) { + await client.query('ROLLBACK'); + return null; + } + // Backfill metadata already persisted by tasks interrupted before the category migration. + const uncategorized = await client.query(`SELECT id, file_name, mime_type FROM telegram_download_items + WHERE job_id=$1 AND status='pending' AND file_category IS NULL AND file_name IS NOT NULL AND mime_type IS NOT NULL`, [jobId]); + for (const item of uncategorized.rows) { + await client.query(`UPDATE telegram_download_items SET file_category=$2 WHERE id=$1`, + [item.id, getTelegramBatchFileType({ fileName: item.file_name, mimeType: item.mime_type })]); + } + const counts = await client.query(`SELECT file_category, COUNT(*)::int AS count FROM telegram_download_items + WHERE job_id=$1 AND status='pending' AND file_category IS NOT NULL GROUP BY file_category`, [jobId]); + const availableFileTypes = TELEGRAM_BATCH_FILE_TYPES.flatMap(fileType => { + const count = Number(counts.rows.find(row => row.file_category === fileType)?.count || 0); + return count > 0 ? [{ fileType, count }] : []; + }); + if (availableFileTypes.length > 0) { + const params = { ...job.params, availableFileTypes, + selectedFileTypes: availableFileTypes.map(item => item.fileType), fileSelectionConfirmed: false }; + await client.query(`UPDATE telegram_background_jobs SET status='awaiting_selection', download_status='pending', + params=$2::jsonb, updated_at=NOW() WHERE id=$1`, [jobId, JSON.stringify(params)]); + } + await client.query('COMMIT'); + return availableFileTypes; + } catch (error) { + await client.query('ROLLBACK').catch(() => undefined); + throw error; + } finally { + client.release(); + } +} + +export async function updateTelegramJobFileSelection( + userId: number, + jobId: string, + chatId: string, + messageId: number, + action: TelegramBatchFileType | 'all' | 'confirm', +): Promise { + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(jobId) || !/^-?\d+$/.test(chatId) + || (action !== 'all' && action !== 'confirm' && !TELEGRAM_BATCH_FILE_TYPES.includes(action))) return false; + const client = await pool.connect(); + let confirmed = false; + try { + await client.query('BEGIN'); + const result = await client.query(`SELECT * FROM telegram_background_jobs + WHERE id=$1 AND user_id=$2 AND COALESCE(chat_id, user_id)=$3::bigint + AND status='awaiting_selection' AND paused_at IS NULL AND cancelled_at IS NULL AND finished_at IS NULL + AND (params->>'progressMessageId')::bigint=$4 FOR UPDATE`, [jobId, userId, chatId, messageId]); + const job = result.rows[0]; + if (!job) { + await client.query('ROLLBACK'); + return false; + } + const available: TelegramBatchFileType[] = job.params.availableFileTypes.map((item: { fileType: TelegramBatchFileType }) => item.fileType); + let selected: TelegramBatchFileType[] = job.params.selectedFileTypes || available; + if (action === 'all') selected = available; + else if (action !== 'confirm') { + if (!available.includes(action)) { + await client.query('ROLLBACK'); + return false; + } + selected = selected.includes(action) ? selected.filter(type => type !== action) : [...selected, action]; + } else { + if (selected.length === 0) { + await client.query('ROLLBACK'); + return false; + } + const locale: TelegramLocale = job.params.locale || DEFAULT_LOCALE; + const reason = t(locale, 'bot.wizard.fileTypeExcluded', { + types: selected.map(type => t(locale, `bot.wizard.fileType.${type}`)).join(', '), + }); + await client.query(`UPDATE telegram_download_items SET status='excluded', error=$3, last_error=$3, + completed_at=NOW(), updated_at=NOW() WHERE job_id=$1 AND status='pending' + AND file_category IS NOT NULL AND NOT (file_category=ANY($2::text[]))`, [jobId, selected, reason]); + confirmed = true; + } + const params: Record = { ...job.params, selectedFileTypes: selected, fileSelectionConfirmed: confirmed }; + if (confirmed) { + const excluded = await client.query(`SELECT COUNT(*)::int AS count FROM telegram_download_items + WHERE job_id=$1 AND status='excluded'`, [jobId]); + params.excludedCount = Number(excluded.rows[0].count); + } + await client.query(`UPDATE telegram_background_jobs SET params=$2::jsonb, + total_count=CASE WHEN $3 THEN (SELECT COUNT(*) FROM telegram_download_items WHERE job_id=$1 AND status<>'excluded') ELSE total_count END, + status=CASE WHEN $3 THEN 'queued' ELSE 'awaiting_selection' END, updated_at=NOW() WHERE id=$1`, + [jobId, JSON.stringify(params), confirmed]); + await client.query('COMMIT'); + } catch (error) { + await client.query('ROLLBACK').catch(() => undefined); + throw error; + } finally { + client.release(); + } + if (confirmed) { + const running = runningChannelJobs.get(jobId); + if (running) void running.then(requestTelegramJobRecovery, requestTelegramJobRecovery); + else requestTelegramJobRecovery(); + } + return true; +} + async function runSegmentedTelegramJob(botClient: TelegramClient, requestMessage: Api.Message, jobId: string, source: string, folderOverride: string | null | undefined, options: TelegramCommentScanOptions) { const job = await getJob(jobId); const params = job?.params || {}; let cursor = job?.scan_cursor || {}; - const discoveredRefKeys = new Set(); let totals = { found: 0, skipped: 0, failed: 0, successful: 0 }; + if (job?.status === 'awaiting_selection' && params.fileSelectionConfirmed !== true) { + return { jobId, awaitingSelection: true, ...totals, requested: 0, + commentMessagesScanned: Number(cursor.commentMessagesScanned || 0), commentMediaFound: Number(cursor.commentMediaFound || 0) }; + } const initialState = await ensureJobCanRun(jobId); if (initialState === 'cancelled') { return { jobId, cancelled: true, ...totals, requested: 0, commentMessagesScanned: 0, commentMediaFound: 0 }; @@ -1541,7 +1638,8 @@ async function runSegmentedTelegramJob(botClient: TelegramClient, requestMessage } const started = await query( `UPDATE telegram_background_jobs - SET status = 'running', scan_status = 'scanning', download_status = 'active', + SET status = 'running', scan_status = CASE WHEN scan_status = 'done' THEN 'done' ELSE 'scanning' END, + download_status = 'pending', started_at = COALESCE(started_at, NOW()), error = NULL, updated_at = NOW() WHERE id = $1 AND cancelled_at IS NULL @@ -1562,50 +1660,102 @@ async function runSegmentedTelegramJob(botClient: TelegramClient, requestMessage return { jobId, deferred: true, ...totals, requested: 0, commentMessagesScanned: 0, commentMediaFound: 0 }; } + const matchesTag = params.mode === 'tag' ? createTelegramHashtagMatcher(params.tag || '') : undefined; + const skipPhotos = await shouldSkipTelegramPhotosInBatch(); while (await waitUntilRunnable(jobId, options)) { const current = await getJob(jobId); cursor = current?.scan_cursor || cursor || {}; if (current?.scan_status === 'done') break; const signal = getChannelTaskAbortSignal(jobId); try { + const checkpointId = Number(cursor.offsetId || 0); + const progress = { + phase: 'channel', + channelMessagesScanned: Number(cursor.channelMessagesScanned || 0), + channelMessagesMatched: Number(cursor.channelMessagesMatched || 0), + channelMediaFound: Number(cursor.channelMediaFound || 0), + commentMessagesScanned: Number(cursor.commentMessagesScanned || 0), + commentMediaFound: Number(cursor.commentMediaFound || 0), + lastMessageDate: cursor.lastMessageDate as string | undefined, + albumBatchesRead: 0, albumIdsRead: 0, waitUntil: 0, + boundaryGroups: 0, uncertainGroups: 0, expandedGroups: 0, + }; + let progressPublishedAt = 0; + const publishScanProgress = async (force = false) => { + if (!force && Date.now() - progressPublishedAt < 2000) return; + const stats = await getJobItemStats(jobId); + progress.channelMediaFound = stats.channelMediaFound; + progress.commentMediaFound = stats.commentMediaFound; + await updateJob(jobId, { scan_cursor: JSON.stringify({ ...cursor, progress: { ...progress, requests: reader.stats } }) }); + progressPublishedAt = Date.now(); + await notifyProgress(jobId, options); + }; + const reader = createTelegramScanReader({ + source, signal, preferredAccountId: current?.assigned_account_id, + onAccount: async (accountId, visibility) => { + await markTelegramAccountSourceAccess(accountId, source, 'scan', 'allowed'); + await updateJob(jobId, { assigned_account_id: accountId }); + console.info(JSON.stringify({ event: 'telegram_scan_account', jobId, accountId, + previousAccountId: current?.assigned_account_id || null, checkpointId, ...visibility })); + }, + onFloodWait: async ({ retryAt }) => { progress.waitUntil = retryAt; await publishScanProgress(true); }, + }); const segment = await scanTelegramChannelPage( params, Number(cursor.offsetId || 0), TG_JOB_SCAN_SEGMENT_SIZE, - request => withTelegramAccount(String(source), 'scan', client => client.getMessages(String(source), request), { signal }), - message => messageHasMedia(message) && (params.mode === 'date' || messageMatchesHashtag(message, params.tag || '')), + request => reader.read(request, checkpointId), + message => !matchesTag || matchesTag(message), ); - const progress = { - phase: 'channel', - channelMessagesScanned: Number(cursor.channelMessagesScanned || 0) + segment.messagesScanned, - channelMediaFound: Number(cursor.channelMediaFound || 0), - commentMessagesScanned: Number(cursor.commentMessagesScanned || 0), - commentMediaFound: Number(cursor.commentMediaFound || 0), - lastMessageDate: segment.lastMessageDate || cursor.lastMessageDate, - }; - let progressPublishedAt = 0; - const publishScanProgress = async (force = false) => { - if (!force && Date.now() - progressPublishedAt < 5000) return; - const stats = await getJobItemStats(jobId); - progress.channelMediaFound = stats.channelMediaFound; - progress.commentMediaFound = stats.commentMediaFound; - // Keep the resume offset unchanged until the entire segment is committed. - await updateJob(jobId, { scan_cursor: JSON.stringify({ ...cursor, progress }) }); - progressPublishedAt = Date.now(); - await notifyProgress(jobId, options); - }; + progress.waitUntil = 0; + progress.channelMessagesScanned += segment.messagesScanned; + progress.channelMessagesMatched += segment.messages.length; + progress.lastMessageDate = segment.lastMessageDate || progress.lastMessageDate; + progress.phase = 'albums'; await publishScanProgress(true); - const messages = await expandMessagesWithMediaGroups(source, segment.messages, signal, segment.pageMessages); + const messages = await expandTelegramMediaGroups(segment.messages, segment.pageMessages, + ids => reader.read({ ids }, checkpointId || segment.messages[0]?.id || 0), messageHasMedia, { + onMedia: async (discovered, album) => { + const refs = filterTelegramBatchMessages(discovered, skipPhotos) + .map(message => toChannelDownloadRef(source, message)) + .filter((ref): ref is TelegramDownloadMessageRef => Boolean(ref)); + await persistTelegramDownloadRefs(jobId, source, refs, folderOverride); + progress.albumBatchesRead = album.batchesRead; + progress.albumIdsRead = album.idsRead; + progress.boundaryGroups = album.boundaryGroups || 0; + progress.uncertainGroups = album.uncertainGroups || 0; + progress.expandedGroups = album.expandedGroups || 0; + progress.waitUntil = 0; + await publishScanProgress(true); + }, + }); + // Posts without attachments may still have downloadable files in their discussion thread. + for (const message of segment.messages) { + if (!messageHasMedia(message)) messages.push(message); + } + const pendingRefs = new Map(); + const discoveredRefKeys = new Set(); + const flushRefs = async () => { + if (pendingRefs.size === 0) return; + await persistTelegramDownloadRefs(jobId, source, Array.from(pendingRefs.values()), folderOverride); + for (const key of pendingRefs.keys()) discoveredRefKeys.add(key); + pendingRefs.clear(); + await publishScanProgress(); + }; const onRefDiscovered = async (ref: TelegramDownloadMessageRef) => { + if (ref.origin === 'channel') return; const key = `${sourcePeerKey(ref.source, source)}:${ref.id}`; if (discoveredRefKeys.has(key)) return; - await persistDownloadRefs(jobId, source, [ref], folderOverride); - discoveredRefKeys.add(key); - await publishScanProgress(); + pendingRefs.set(key, ref); + if (pendingRefs.size >= TELEGRAM_REF_WRITE_BATCH_SIZE) await flushRefs(); }; const scan = await buildDownloadScanResult(source, messages, { ...options, + includeComments: Boolean(params.includeComments), + commentsMaxPerPost: params.commentsMaxPerPost || TELEGRAM_COMMENTS_MAX_PER_POST, signal, + scanReader: reader, + matchesTag, tag: params.tag, startDate: params.startDateIso ? new Date(params.startDateIso) : undefined, endDate: params.endDateIso ? new Date(params.endDateIso) : undefined, @@ -1616,6 +1766,10 @@ async function runSegmentedTelegramJob(botClient: TelegramClient, requestMessage await publishScanProgress(); }, }); + // Persist the final partial batch before advancing the resume cursor. + await flushRefs(); + // Enrich streamed rows with complete album captions/order before selection. + await persistTelegramDownloadRefs(jobId, source, scan.refs, folderOverride); const stats = await getJobItemStats(jobId); const { progress: _partialProgress, ...checkpoint } = cursor; cursor = { @@ -1623,16 +1777,31 @@ async function runSegmentedTelegramJob(botClient: TelegramClient, requestMessage phase: segment.done ? 'done' : 'channel', offsetId: segment.nextOffsetId, channelMessagesScanned: progress.channelMessagesScanned, + channelMessagesMatched: progress.channelMessagesMatched, channelMediaFound: stats.channelMediaFound, commentMessagesScanned: Number(cursor.commentMessagesScanned || 0) + scan.commentMessagesScanned, commentMediaFound: stats.commentMediaFound, lastMessageDate: progress.lastMessageDate, + accountId: reader.accountId, + outcome: segment.done && progress.channelMessagesMatched === 0 ? 'no_matches' : undefined, + albumBatchesRead: progress.albumBatchesRead, albumIdsRead: progress.albumIdsRead, + boundaryGroups: progress.boundaryGroups, uncertainGroups: progress.uncertainGroups, + expandedGroups: progress.expandedGroups, requests: reader.stats, + scanCompletedAt: segment.done ? new Date().toISOString() : undefined, }; await updateJob(jobId, { scan_cursor: JSON.stringify(cursor), total_count: Number(stats.pending || 0) + Number(stats.downloading || 0) + Number(stats.success || 0) + Number(stats.failed || 0) + Number(stats.skipped || 0), scan_status: segment.done ? 'done' : 'scanning' }); await notifyProgress(jobId, options); - const partial = await downloadPendingForJob(botClient, requestMessage, jobId, source, folderOverride, options, false); - totals = { found: totals.found + partial.found, skipped: totals.skipped + partial.skipped, failed: totals.failed + partial.failed, successful: totals.successful + partial.successful }; + console.info(JSON.stringify({ event: 'telegram_scan_page', jobId, accountId: reader.accountId, + offsetId: checkpointId, nextOffsetId: segment.nextOffsetId, returned: segment.messagesScanned, + albumBatchesRead: progress.albumBatchesRead, albumIdsRead: progress.albumIdsRead, + boundaryGroups: progress.boundaryGroups, uncertainGroups: progress.uncertainGroups, + expandedGroups: progress.expandedGroups, requests: reader.stats, done: segment.done })); if (segment.done) break; + } catch (error) { + if (error instanceof NoEligibleTelegramAccountError) { + throw new Error(t(options.locale || DEFAULT_LOCALE, 'channels.errors.historyUnverified')); + } + throw error; } finally { releaseChannelTaskAbortSignal(jobId, signal); } @@ -1644,6 +1813,14 @@ async function runSegmentedTelegramJob(botClient: TelegramClient, requestMessage return { jobId, cancelled: true, ...totals, requested: 0, commentMessagesScanned: Number(cursor.commentMessagesScanned || 0), commentMediaFound: Number(cursor.commentMediaFound || 0) }; } await updateJob(jobId, { scan_status: 'done' }); + if (params.fileSelectionConfirmed !== true) { + const availableFileTypes = await prepareTelegramJobFileSelection(jobId); + if (availableFileTypes === null || availableFileTypes.length > 0) { + await notifyProgress(jobId, options); + return { jobId, awaitingSelection: true, ...totals, requested: 0, + commentMessagesScanned: Number(cursor.commentMessagesScanned || 0), commentMediaFound: Number(cursor.commentMediaFound || 0) }; + } + } const drained = await downloadPendingForJob(botClient, requestMessage, jobId, source, folderOverride, options, true); totals = { found: totals.found + drained.found, skipped: totals.skipped + drained.skipped, failed: totals.failed + drained.failed, successful: totals.successful + drained.successful }; await finalizeTelegramJob(jobId, options); @@ -1677,6 +1854,7 @@ export async function enqueueTelegramDateDownload(botClient: TelegramClient, req folderOverride: folderOverride || null, includeComments: Boolean(options.includeComments), commentsMaxPerPost: options.commentsMaxPerPost || TELEGRAM_COMMENTS_MAX_PER_POST, + fileSelectionConfirmed: false, progressMessageId: options.progressMessageId, locale, }, @@ -1707,6 +1885,7 @@ export async function enqueueTelegramTagDownload(botClient: TelegramClient, requ folderOverride: folderOverride || null, includeComments: Boolean(options.includeComments), commentsMaxPerPost: options.commentsMaxPerPost || TELEGRAM_COMMENTS_MAX_PER_POST, + fileSelectionConfirmed: false, progressMessageId: options.progressMessageId, locale, }, @@ -1731,6 +1910,7 @@ export async function listTelegramActiveTaskQueues(userId: number, limit = 10) { MAX(updated_at) FILTER (WHERE status IN ('pending', 'downloading')) AS queue_updated_at, MAX(updated_at) AS progress_updated_at FROM telegram_download_items + WHERE status <> 'excluded' GROUP BY job_id ) SELECT @@ -1738,6 +1918,7 @@ export async function listTelegramActiveTaskQueues(userId: number, limit = 10) { j.scan_cursor, j.cooldown_until, j.paused_at, j.cancelled_at, j.params, j.total_count, j.enqueued_count, j.skipped_count, j.duplicate_count, j.error, j.started_at, j.finished_at, j.created_at, j.updated_at, + a.name AS storage_account_name, COALESCE(s.item_count, 0)::int AS item_count, COALESCE(s.pending_count, 0)::int AS pending_count, COALESCE(s.downloading_count, 0)::int AS downloading_count, @@ -1767,6 +1948,7 @@ export async function listTelegramActiveTaskQueues(userId: number, limit = 10) { ) AS is_actively_running FROM telegram_background_jobs j LEFT JOIN item_stats s ON s.job_id = j.id + LEFT JOIN storage_accounts a ON a.id::text = j.params->>'storageAccountId' WHERE j.user_id = $1 AND j.cancelled_at IS NULL AND (j.finished_at IS NULL OR j.status IN ('failed', 'completed_with_errors')) @@ -1819,8 +2001,14 @@ export async function resumeTelegramBackgroundJob(userId: number, selector: stri if (!jobId) return null; const result = await query( `UPDATE telegram_background_jobs - SET status = CASE WHEN cooldown_until > NOW() THEN 'cooling' ELSE 'running' END, paused_at = NULL, finished_at = NULL, error = NULL, - download_status = CASE WHEN cooldown_until IS NOT NULL AND cooldown_until > NOW() THEN 'cooling' ELSE 'active' END, + SET status = CASE + WHEN kind IN ('date_range', 'tag_download') AND params ? 'availableFileTypes' + AND params->>'fileSelectionConfirmed' IS DISTINCT FROM 'true' THEN 'awaiting_selection' + WHEN cooldown_until > NOW() THEN 'cooling' ELSE 'running' END, + paused_at = NULL, finished_at = NULL, error = NULL, + download_status = CASE + WHEN kind IN ('date_range', 'tag_download') AND params->>'fileSelectionConfirmed' IS DISTINCT FROM 'true' THEN 'pending' + WHEN cooldown_until IS NOT NULL AND cooldown_until > NOW() THEN 'cooling' ELSE 'active' END, updated_at = NOW() WHERE user_id = $1 AND id = $2::uuid AND ($3::bigint IS NULL OR chat_id = $3::bigint) @@ -2050,7 +2238,8 @@ async function runSubscriptionScan(botClient: TelegramClient) { kind: 'subscription_sync', source: row.source, target: subscriptionTarget, - params: { subscriptionId: String(row.id), fromId: lastMessageId + 1, toId: latestMessageId, targetMode: row.target_mode || 'follow_global' }, + params: { subscriptionId: String(row.id), fromId: lastMessageId + 1, toId: latestMessageId, + targetMode: row.target_mode || 'follow_global', folderOverride: row.folder_override || null }, }); const batchMessages = await filterConfiguredTelegramBatchMessages( await expandMessagesWithMediaGroups(row.source, (await withTelegramAccount(String(row.source), 'scan', client => client.getMessages(String(row.source), { ids }))).filter(Boolean) as Api.Message[]), @@ -2118,6 +2307,7 @@ async function runSubscriptionScan(botClient: TelegramClient) { found: downloadResult.found, skipped: downloadResult.skipped, failed: downloadResult.failed, + ...await getTelegramJobDestination(latestJob || {}, locale), }; await enqueueTelegramNotification({ userId: Number(row.user_id), @@ -2238,8 +2428,8 @@ async function recoverTelegramJob(botClient: TelegramClient, job: any): Promise< ? Math.max(Number(job.params?.fromId || 1) - 1, firstFailedId - 1) : contiguousProcessedMessageId(Number(job.params?.fromId || 1) - 1, result.successfulMessageIds, result.skippedMessageIds, result.failedMessageIds)) : targetMessageId, - enqueuedCount: result.found, - skippedCount: result.skipped, + enqueuedCount: Number(remainingStats.success || 0), + skippedCount: Number(remainingStats.skipped || 0), error: recoveryHasFailures ? `${persistedFailed || result.failed} 个文件下载失败` : null, }); } else { @@ -2262,6 +2452,7 @@ async function recoverTelegramJob(botClient: TelegramClient, job: any): Promise< successful: result.successful, skipped: result.skipped, failed: result.failed, + ...await getTelegramJobDestination(job, locale), }), }).catch(() => undefined); } catch (error) { @@ -2327,6 +2518,14 @@ export async function recoverInterruptedTelegramJobs(botClient: TelegramClient): const lockResult = await client.query(`SELECT pg_try_advisory_lock(hashtext('tg-vault:telegram-job-recovery')) AS locked`); lockHeld = Boolean(lockResult.rows[0]?.locked); if (!lockHeld) return; + await query(`UPDATE telegram_download_attempts a + SET status = 'cancelled', error = '下载租约已结束或由新的执行器接管', finished_at = NOW() + WHERE a.status = 'running' AND a.item_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM telegram_download_items i + WHERE i.id = a.item_id AND i.status = 'downloading' + AND i.lease_token = a.lease_token AND i.lease_expires_at > NOW() + )`); const reconciliationLease = crypto.randomUUID(); const pendingWrites = await claimTelegramWriteReconciliations(pool, reconciliationLease, 100); for (const pendingWrite of pendingWrites) { @@ -2376,12 +2575,13 @@ export async function recoverInterruptedTelegramJobs(botClient: TelegramClient): AND (j.cooldown_until IS NULL OR j.cooldown_until <= NOW()) AND j.id::text <> ALL($1::text[]) AND (EXISTS (SELECT 1 FROM telegram_download_items i WHERE i.job_id=j.id AND i.status='pending') - OR (j.kind IN ('date_range','tag_download') AND COALESCE(j.scan_status,'pending') <> 'done')) + OR (j.kind IN ('date_range','tag_download') AND + (COALESCE(j.scan_status,'pending') <> 'done' OR j.params->>'fileSelectionConfirmed' IS DISTINCT FROM 'true'))) ORDER BY j.created_at ASC LIMIT 5`, [Array.from(runningChannelJobs.keys())] ); for (const job of jobs.rows) { - if (job.scan_status !== 'done' && (job.kind === 'date_range' || job.kind === 'tag_download') && job.params?.mode) { + if ((job.kind === 'date_range' || job.kind === 'tag_download') && job.params?.mode) { const targetChat = job.chat_id || job.user_id; const requestMessage = ({ chatId: targetChat, id: 0 } as unknown) as Api.Message; void runChannelJob(String(job.id), () => runSegmentedTelegramJob(botClient, requestMessage, job.id, job.source, job.params?.folderOverride || null, {})).catch(error => console.error('Telegram scan recovery failed:', error)); diff --git a/backend/src/services/telegramChannelProgress.ts b/backend/src/services/telegramChannelProgress.ts index 724a455..b719991 100644 --- a/backend/src/services/telegramChannelProgress.ts +++ b/backend/src/services/telegramChannelProgress.ts @@ -6,33 +6,77 @@ import { channelTaskCenterItem, buildTaskCenterDetail } from './telegramTaskCent import { registerProgressCard, taskCenterCardKey, taskCenterCardOwners } from './telegramProgressControls.js'; import { getTelegramUserLocaleOrDefault } from './telegramLocalePreferences.js'; import { formatBytes, t, type TelegramLocale } from '../i18n/telegram.js'; +import { TELEGRAM_BATCH_FILE_TYPES, type TelegramBatchFileType } from '../utils/telegramBatchMediaFilter.js'; +import { telegramChannelJobCounts } from './unifiedTaskMapper.js'; + +function channelFileSelection(row: Record) { + const params = row.params && typeof row.params === 'object' ? row.params as Record : {}; + const available = Array.isArray(params.availableFileTypes) + ? params.availableFileTypes.filter((entry): entry is { fileType: TelegramBatchFileType; count: number } => + Boolean(entry && typeof entry === 'object' && TELEGRAM_BATCH_FILE_TYPES.includes(entry.fileType) + && Number.isFinite(entry.count) && entry.count > 0)) + : []; + const selected = Array.isArray(params.selectedFileTypes) + ? available.filter(entry => (params.selectedFileTypes as unknown[]).includes(entry.fileType)).map(entry => entry.fileType) + : available.map(entry => entry.fileType); + return { available, selected }; +} + +export function buildTelegramChannelFileSelectionKeyboard(row: Record, locale: TelegramLocale): Api.KeyboardButtonRow[] { + const { available, selected } = channelFileSelection(row); + const id = String(row.id); + const rows: Api.KeyboardButtonRow[] = []; + for (let index = 0; index < available.length; index += 2) { + rows.push(new Api.KeyboardButtonRow({ buttons: available.slice(index, index + 2).map(entry => new Api.KeyboardButtonCallback({ + text: `${selected.includes(entry.fileType) ? '☑ ' : '☐ '}${t(locale, `bot.wizard.fileType.${entry.fileType}`)} (${entry.count})`, + data: Buffer.from(`tgfs_${id}_${entry.fileType}`), + })) })); + } + rows.push(new Api.KeyboardButtonRow({ buttons: [ + new Api.KeyboardButtonCallback({ text: `${selected.length === available.length ? '● ' : ''}${t(locale, 'bot.wizard.fileType.all')}`, data: Buffer.from(`tgfs_${id}_all`) }), + new Api.KeyboardButtonCallback({ text: t(locale, 'channelCard.selectionConfirm'), data: Buffer.from(`tgfs_${id}_confirm`) }), + ] })); + return rows; +} export function channelProgressText(row: Record, locale: TelegramLocale): string { const runtime = String(row.runtime_status || row.status); + const noMatches = runtime === 'completed' && row.scan_cursor && typeof row.scan_cursor === 'object' + && 'outcome' in row.scan_cursor && row.scan_cursor.outcome === 'no_matches'; const state = ['completed', 'completed_with_errors'].includes(runtime) - ? t(locale, runtime === 'completed' ? 'bot.subscription.result.completed' : 'bot.subscription.result.partial') + ? t(locale, noMatches ? 'channelCard.noMatches' : runtime === 'completed' ? 'bot.subscription.result.completed' : 'bot.subscription.result.partial') : t(locale, runtime === 'cancelled' ? 'bot.callback.cancelled' : runtime === 'paused' ? 'taskCenter.state.paused' : runtime === 'pausing' ? 'taskCenter.state.pausing' : runtime === 'failed' ? 'taskCenter.state.failed' - : runtime === 'cooling' ? 'taskCenter.state.cooling' : row.scan_status !== 'done' ? 'bot.legacy.state.scanning' + : runtime === 'cooling' ? 'taskCenter.state.cooling' : runtime === 'awaiting_selection' ? 'taskCenter.state.awaitingSelection' + : row.scan_status !== 'done' ? 'bot.legacy.state.scanning' : Number(row.downloading_count) > 0 ? 'bot.legacy.state.downloading' : 'taskCenter.state.waiting'); const params = row.params && typeof row.params === 'object' ? row.params as Record : {}; - const total = Math.max(Number(row.total_count || 0), Number(row.item_count || 0)); - const completed = Math.max(Number(row.success_count || 0), row.finished_at ? Number(row.enqueued_count || 0) : 0); - const skipped = Math.max(Number(row.skipped_count_items || 0), row.finished_at ? Number(row.skipped_count || 0) : 0); - const done = completed + Number(row.failed_count || 0) + skipped; - const scanProgressText = channelTaskCenterItem(row, locale)?.scanProgressText; + const { total, processed: done, failed, skipped, active, pending } = telegramChannelJobCounts(row); + const item = channelTaskCenterItem(row, locale); + const scanProgressText = item?.scanProgressText; const lines = [ `${state} · ${String(row.source)}`, params.tag || (params.startDate && params.endDate ? `${params.startDate} → ${params.endDate}` : ''), + item?.storageLabel ? t(locale, 'taskCenter.detail.storage', { value: item.storageLabel }) : '', + item?.targetFolder ? t(locale, 'taskCenter.detail.targetFolder', { value: item.targetFolder }) : '', scanProgressText, t(locale, 'channelCard.progress', { done, total }), - t(locale, 'channelCard.queue', { active: Number(row.downloading_count || 0), pending: Number(row.pending_count || 0) }), - Number(row.failed_count) > 0 ? t(locale, 'channelCard.failures', { count: Number(row.failed_count) }) : '', - Number(row.skipped_count_items) > 0 ? t(locale, 'channelCard.skipped', { count: Number(row.skipped_count_items) }) : '', - Number(row.downloading_count) > 0 ? t(locale, 'bot.legacy.speed', { speed: formatBytes(Number(row.speed_bytes_per_second || 0), locale) }) : '', + [t(locale, 'channelCard.queue', { active, pending }), failed > 0 ? t(locale, 'channelCard.failures', { count: failed }) : ''].filter(Boolean).join(' · '), + skipped > 0 ? t(locale, 'channelCard.skipped', { count: skipped }) : '', + active > 0 ? t(locale, 'bot.legacy.speed', { speed: formatBytes(Number(row.speed_bytes_per_second || 0), locale) }) : '', runtime === 'pausing' ? t(locale, 'taskCenter.note.pausing') : '', row.error ? String(row.error) : '', t(locale, 'channelCard.updated', { time: new Date().toLocaleTimeString(locale, { hour12: false, timeZone: 'Asia/Shanghai' }) }), + ...(runtime === 'awaiting_selection' ? (() => { + const { available, selected } = channelFileSelection(row); + const types = selected.length === available.length ? t(locale, 'bot.wizard.fileType.all') + : selected.length ? selected.map(fileType => t(locale, `bot.wizard.fileType.${fileType}`)).join(', ') + : t(locale, 'channelCard.selectionNone'); + return ['', t(locale, 'channelCard.selectionTitle'), + ...available.map(entry => t(locale, 'channelCard.selectionCategory', { type: t(locale, `bot.wizard.fileType.${entry.fileType}`), count: entry.count })), + t(locale, 'channelCard.selectionSummary', { types }), t(locale, 'channelCard.selectionInstructions'), + t(locale, 'bot.wizard.imageTypeNote')]; + })() : []), ]; return lines.filter(Boolean).join('\n'); } @@ -42,18 +86,19 @@ let updating: Promise | undefined; const rendered = new Map(); export async function syncTelegramChannelCards(client: TelegramClient): Promise { - const rows = await query(`SELECT j.*, + const rows = await query(`SELECT j.*, a.name AS storage_account_name, COUNT(i.id)::int AS item_count, COUNT(i.id) FILTER (WHERE i.status='success')::int AS success_count, COUNT(i.id) FILTER (WHERE i.status='failed')::int AS failed_count, COUNT(i.id) FILTER (WHERE i.status='skipped')::int AS skipped_count_items, COUNT(i.id) FILTER (WHERE i.status='pending')::int AS pending_count, COUNT(i.id) FILTER (WHERE i.status='downloading')::int AS downloading_count - FROM telegram_background_jobs j LEFT JOIN telegram_download_items i ON i.job_id=j.id + FROM telegram_background_jobs j LEFT JOIN telegram_download_items i ON i.job_id=j.id AND i.status<>'excluded' + LEFT JOIN storage_accounts a ON a.id::text=j.params->>'storageAccountId' WHERE j.kind IN ('date_range','tag_download') AND (j.params->>'progressMessageId' IS NOT NULL OR j.finished_at IS NULL) AND (j.finished_at IS NULL OR j.updated_at > NOW()-INTERVAL '24 hours') - GROUP BY j.id ORDER BY j.created_at`); + GROUP BY j.id, a.name ORDER BY j.created_at`); const seen = new Set(); for (const original of rows.rows) { const id = String(original.id); @@ -67,15 +112,25 @@ export async function syncTelegramChannelCards(client: TelegramClient): Promise< if (owner) owner.interactive = false; const signature = JSON.stringify([row.status, row.runtime_status, row.scan_status, row.total_count, row.success_count, row.failed_count, row.skipped_count_items, row.pending_count, row.downloading_count, - Math.round(row.speed_bytes_per_second / 1024), row.scan_cursor, row.error]); + Math.round(row.speed_bytes_per_second / 1024), row.scan_cursor, row.error, locale, + row.params?.availableFileTypes, row.params?.selectedFileTypes, row.params?.fileSelectionConfirmed, + row.params?.excludedCount, row.params?.storageProvider, row.params?.storageAccountName, + row.params?.compactedSuccessCount, row.params?.compactedSkippedCount, + row.params?.storageAccountId, row.storage_account_name, row.params?.folderOverride]); const previous = rendered.get(id); if (previous && (previous.retryAt > Date.now() || (previous.signature === signature && (row.finished_at || Date.now() - previous.at < 30_000)))) continue; const text = channelProgressText(row, locale); const terminal = ['completed','cancelled'].includes(row.status); const item = terminal ? null : channelTaskCenterItem(row, locale); - const buttons = item ? new Api.ReplyInlineMarkup({ rows: buildTaskCenterDetail(item, 0, { locale }).rows.map(buttons => - new Api.KeyboardButtonRow({ buttons: buttons.map(button => new Api.KeyboardButtonCallback({ text: button.text, data: Buffer.from(button.data) })) })) }) : undefined; + const detailRows = item ? buildTaskCenterDetail(item, 0, { locale }).rows : []; + const selection = row.status === 'awaiting_selection'; + const taskRows = selection ? detailRows.map(buttons => buttons.filter(button => button.data.startsWith('tc_a_x_'))).filter(buttons => buttons.length) : detailRows; + const keyboardRows = [ + ...(selection ? buildTelegramChannelFileSelectionKeyboard(row, locale) : []), + ...taskRows.map(buttons => new Api.KeyboardButtonRow({ buttons: buttons.map(button => new Api.KeyboardButtonCallback({ text: button.text, data: Buffer.from(button.data) })) })), + ]; + const buttons = keyboardRows.length ? new Api.ReplyInlineMarkup({ rows: keyboardRows }) : undefined; try { if (messageId) { try { await client.editMessage(chatId, { message: messageId, text, buttons, linkPreview: false }); } diff --git a/backend/src/services/telegramChannelScanner.ts b/backend/src/services/telegramChannelScanner.ts index 607fbda..bf242a8 100644 --- a/backend/src/services/telegramChannelScanner.ts +++ b/backend/src/services/telegramChannelScanner.ts @@ -64,37 +64,203 @@ export function messageGroupId(message: Api.Message | undefined): string | undef return groupedId ? groupedId.toString() : undefined; } +export interface TelegramAlbumScanProgress { + batchesRead: number; + idsRead: number; + mediaFound: number; + boundaryGroups?: number; + uncertainGroups?: number; + expandedGroups?: number; +} + +export interface TelegramAlbumScanOptions { + onMedia?: (messages: Api.Message[], progress: TelegramAlbumScanProgress) => Promise; +} + export async function expandTelegramMediaGroups( messages: Api.Message[], pageMessages: Api.Message[], getMessages: (ids: number[]) => Promise>, hasMedia: (message: Api.Message) => boolean, + options: TelegramAlbumScanOptions = {}, ): Promise { const byId = new Map(); - const groups = new Set(); - const nearbyIds = new Set(); - const pageIds = new Set(pageMessages.map(message => message.id)); + const cache = new Map(pageMessages.map(message => [message.id, message])); + let discovered: Api.Message[] = []; + let batchesRead = 0; + let idsRead = 0; + const maximumId = 2_147_483_647; + const groups = new Map; + minimum: number; + maximum: number; + lower: number; + upper: number; + lowerLimit: number; + upperLimit: number; + lowerClosed: boolean; + upperClosed: boolean; + expanded: boolean; + uncertain: boolean; + }>(); for (const message of messages) { - pageIds.add(message.id); - if (hasMedia(message)) byId.set(message.id, message); + cache.set(message.id, message); + if (hasMedia(message) && !byId.has(message.id)) { byId.set(message.id, message); discovered.push(message); } const groupId = messageGroupId(message); if (!groupId || groups.has(groupId)) continue; - groups.add(groupId); - for (let id = Math.max(1, message.id - 20); id <= message.id + 20; id++) { - nearbyIds.add(id); - } + groups.set(groupId, { + members: new Set(), minimum: message.id, maximum: message.id, + lower: message.id, upper: message.id, lowerLimit: message.id, upperLimit: message.id, + lowerClosed: false, upperClosed: false, expanded: false, uncertain: false, + }); } const addGroupMember = (message: Api.Message | undefined) => { - if (!message || !hasMedia(message)) return; + if (!message) return; const groupId = messageGroupId(message); - if (groupId && groups.has(groupId)) byId.set(message.id, message); + const group = groupId ? groups.get(groupId) : undefined; + if (!group) return; + if (message.id < group.minimum) group.lowerClosed = false; + if (message.id > group.maximum) group.upperClosed = false; + group.minimum = Math.min(group.minimum, message.id); + group.maximum = Math.max(group.maximum, message.id); + if (!hasMedia(message)) return; + group.members.add(message.id); + if (!byId.has(message.id)) discovered.push(message); + byId.set(message.id, message); }; - for (const message of pageMessages) addGroupMember(message); - const ids = Array.from(nearbyIds).filter(id => !pageIds.has(id)).sort((a, b) => a - b); - // GramJS paginates IDs internally above 100; bound every account-wrapped request. - for (let offset = 0; offset < ids.length; offset += 100) { - const nearby = await getMessages(ids.slice(offset, offset + 100)); - for (const message of nearby) addGroupMember(message); + for (const message of cache.values()) addGroupMember(message); + for (const group of groups.values()) { + // All caption seeds and cached siblings contribute to the fixed bounds. + group.lowerLimit = Math.max(1, group.minimum - 20); + group.upperLimit = Math.min(maximumId, group.maximum + 20); + group.lower = Math.max(1, group.minimum - 2); + group.upper = Math.min(maximumId, group.maximum + 2); + } + const visited = new Set(cache.keys()); + // Ten distinct media members prove completion. Foreign guards only provide + // heuristic boundaries: unseen siblings beyond them can be skipped under + // arbitrary interleaving; ambiguous groups get fixed, not exhaustive, bounds. + const albumLimit = 10; + const refreshGroups = () => { + for (const [groupId, group] of groups) { + if (group.members.size >= albumLimit) continue; + const expand = () => { + group.expanded = true; + group.lower = group.lowerLimit; + group.upper = group.upperLimit; + group.lowerClosed = false; + group.upperClosed = false; + }; + if (!group.expanded) { + // Reuse evidence from other groups' requests, including after a + // heuristic stop. A known foreign message between siblings is + // interleaving even when those siblings have no readable media. + for (const message of cache.values()) { + if (message.id > group.minimum && message.id < group.maximum + && messageGroupId(message) !== groupId) { expand(); break; } + } + if (group.minimum < group.lower) { + group.lower = Math.max(group.lowerLimit, group.minimum - 2); + group.lowerClosed = false; + } + if (group.maximum > group.upper) { + group.upper = Math.min(group.upperLimit, group.maximum + 2); + group.upperClosed = false; + } + } + while (true) { + let ready = true; + for (let id = group.lower; id <= group.upper; id++) { + if (!visited.has(id)) { ready = false; break; } + } + if (!ready) break; + if (group.expanded) { + group.uncertain = true; + group.lowerClosed = true; + group.upperClosed = true; + break; + } + let missing = 0; + for (let id = group.minimum; id <= group.maximum; id++) { + missing = cache.has(id) ? 0 : missing + 1; + if (missing > 2) { expand(); break; } + } + if (group.expanded) continue; + const advance = (direction: -1 | 1) => { + if (direction === -1 ? group.lowerClosed : group.upperClosed) return; + const edge = direction === -1 ? group.lower : group.upper; + const member = direction === -1 ? group.minimum : group.maximum; + const limit = direction === -1 ? group.lowerLimit : group.upperLimit; + let foreign = false; + for (let id = member + direction; direction === -1 ? id >= edge : id <= edge; id += direction) { + if (cache.has(id)) { foreign = true; break; } + } + if (foreign || edge === (direction === -1 ? 1 : maximumId)) { + if (direction === -1) group.lowerClosed = true; + else group.upperClosed = true; + } else if (Math.abs(edge - member) > 2) { + expand(); + } else if (edge === limit) { + // Same-group evidence at the fixed boundary cannot prove + // that no additional sibling exists beyond it. + expand(); + } else if (direction === -1) { + group.lower--; + } else { + group.upper++; + } + }; + const previousLower = group.lower; + const previousUpper = group.upper; + advance(-1); + if (!group.expanded) advance(1); + if (group.expanded) continue; + if (previousLower === group.lower && previousUpper === group.upper) break; + } + } + }; + const publish = async () => { + let boundaryGroups = 0; + let uncertainGroups = 0; + let expandedGroups = 0; + for (const group of groups.values()) { + if (group.expanded) expandedGroups++; + if (group.members.size >= albumLimit) continue; + if (group.uncertain) uncertainGroups++; + else if (group.lowerClosed && group.upperClosed) boundaryGroups++; + } + await options.onMedia?.(discovered, { + batchesRead, idsRead, mediaFound: byId.size, boundaryGroups, uncertainGroups, expandedGroups, + }); + discovered = []; + }; + refreshGroups(); + await publish(); + while (true) { + const pending = new Set(); + for (const group of groups.values()) { + if (group.members.size >= albumLimit || (group.lowerClosed && group.upperClosed)) continue; + for (let id = group.lower; id <= group.upper; id++) { + if (!visited.has(id)) pending.add(id); + } + } + if (!pending.size) break; + // Rebuild after every successful batch so new edges join the remaining + // frontier instead of forcing a separate short request next round. + const batch = Array.from(pending).slice(0, 100); + const nearby = await getMessages(batch); + // The caller owns retries. Commit IDs, including deleted ones, only on success. + for (const id of batch) visited.add(id); + for (const message of nearby) { + if (!message) continue; + cache.set(message.id, message); + visited.add(message.id); + addGroupMember(message); + } + batchesRead++; + idsRead += batch.length; + refreshGroups(); + await publish(); } return Array.from(byId.values()).sort((a, b) => a.id - b.id); } diff --git a/backend/src/services/telegramCommands.ts b/backend/src/services/telegramCommands.ts index 14d41a2..8634426 100644 --- a/backend/src/services/telegramCommands.ts +++ b/backend/src/services/telegramCommands.ts @@ -15,7 +15,8 @@ import { getProviderDisplayName, } from '../utils/telegramMessages.js'; import { authenticatedUsers, passwordInputState, isAuthenticatedAsync } from './telegramState.js'; -import { forceStopDownloadTasksForScope, getDownloadQueueStats, getTaskStatus, getDownloadTaskScopeStatus, pauseDownloadTasks, resumeDownloadTasks, retryFailedDownloadTasks, getFileDownloadConcurrency, setFileDownloadConcurrency, listDownloadTaskGroups, getDownloadTaskGroup, prioritizeDownloadTaskGroup, pauseDownloadTaskGroup, resumeDownloadTaskGroup, cancelDownloadTaskGroup, getChannelExecutionGroup, prioritizeChannelExecutionGroup, refreshSilentProgress, refreshDownloadProgress } from './telegramUpload.js'; +import { forceStopDownloadTasksForScope, getDownloadQueueStats, getTaskStatus, getDownloadTaskScopeStatus, pauseDownloadTasks, resumeDownloadTasks, retryFailedDownloadTasks, listDownloadTaskGroups, getDownloadTaskGroup, prioritizeDownloadTaskGroup, pauseDownloadTaskGroup, resumeDownloadTaskGroup, cancelDownloadTaskGroup, getChannelExecutionGroup, prioritizeChannelExecutionGroup, refreshSilentProgress, refreshDownloadProgress } from './telegramUpload.js'; +import { assertFileConcurrencyCapacity, FileConcurrencyLimitError, getFileConcurrencyCapacity, loadFileDownloadConcurrencySetting, setFileDownloadConcurrency, type FileConcurrencySettings } from './telegramFileConcurrency.js'; import { storageManager } from './storage.js'; import { cancelTelegramBackgroundJob, listTelegramActiveTaskQueues, pauseTelegramBackgroundJob, resumeTelegramBackgroundJob, retryTelegramBackgroundJob } from './telegramChannelJobs.js'; import { getSetting, setSetting } from '../utils/settings.js'; @@ -66,7 +67,6 @@ import { DEFAULT_LOCALE, t, type TelegramLocale } from '../i18n/telegram.js'; // ESM compatibility const checkDiskSpace = (checkDiskSpaceModule as any).default || checkDiskSpaceModule; const DOWNLOAD_WORKER_OPTIONS = [4, 8, 12, 16]; -const FILE_CONCURRENCY_OPTIONS = [1, 2, 3, 4]; const STORAGE_TYPE_ORDER = ['local', 'onedrive', 'google_drive', 'aliyun_oss', 's3', 'webdav']; const ON_VALUES = new Set(['1', 'true', 'yes', 'on']); const UPLOAD_DIR = process.env.UPLOAD_DIR || './data/uploads'; @@ -365,17 +365,8 @@ function buildDownloadWorkersText(current: number, locale: TelegramLocale = DEFA ].join('\n'); } -function normalizeFileConcurrency(value: unknown): number { - const parsed = parseInt(String(value ?? '2'), 10); - return FILE_CONCURRENCY_OPTIONS.includes(parsed) ? parsed : 2; -} -async function getCurrentFileConcurrency(): Promise { - const value = await getSetting('telegram_file_download_concurrency', process.env.TELEGRAM_FILE_DOWNLOAD_CONCURRENCY || String(getFileDownloadConcurrency())); - return normalizeFileConcurrency(value); -} - -function buildFileConcurrencyKeyboard(current: number, confirmValue?: number, locale: TelegramLocale = DEFAULT_LOCALE): Api.ReplyInlineMarkup { +function buildFileConcurrencyKeyboard(current: number, limit: number, confirmValue?: number, locale: TelegramLocale = DEFAULT_LOCALE): Api.ReplyInlineMarkup { if (confirmValue) { return new Api.ReplyInlineMarkup({ rows: [ @@ -390,32 +381,24 @@ function buildFileConcurrencyKeyboard(current: number, confirmValue?: number, lo } return new Api.ReplyInlineMarkup({ - rows: [ - new Api.KeyboardButtonRow({ - buttons: [ - new Api.KeyboardButtonCallback({ text: `${current === 1 ? '✅ ' : ''}1`, data: Buffer.from('fc_set_1') }), - new Api.KeyboardButtonCallback({ text: `${current === 2 ? '✅ ' : ''}2`, data: Buffer.from('fc_set_2') }), - ], - }), - new Api.KeyboardButtonRow({ - buttons: [ - new Api.KeyboardButtonCallback({ text: `${current === 3 ? '✅ ' : ''}3`, data: Buffer.from('fc_set_3') }), - new Api.KeyboardButtonCallback({ text: `${current === 4 ? '✅ ' : ''}4 ⚠️`, data: Buffer.from('fc_set_4') }), - ], - }), - ], + rows: Array.from({ length: 5 }, (_, row) => new Api.KeyboardButtonRow({ + buttons: [row * 2 + 1, row * 2 + 2].map(value => new Api.KeyboardButtonCallback({ + text: `${current === value ? '✅ ' : ''}${value}${value > limit ? ' 🔒' : value >= 4 ? ' ⚠️' : ''}`, + data: Buffer.from(`fc_set_${value}`), + })), + })), }); } -function buildFileConcurrencyText(current: number, locale: TelegramLocale = DEFAULT_LOCALE): string { +function buildFileConcurrencyText(settings: FileConcurrencySettings, locale: TelegramLocale = DEFAULT_LOCALE): string { const stats = getDownloadQueueStats(); return [ t(locale, 'commands.auto005'), '', - t(locale, 'commands.auto006', { value0: current }), + t(locale, 'commands.auto006', { value0: settings.current }), t(locale, 'commands.auto007', { value0: stats.active, value1: stats.pending }), '', - t(locale, 'commands.auto008'), + t(locale, 'commands.fileConcurrencyCapacity', { count: settings.enabledAccounts, limit: settings.maxConcurrency }), '', t(locale, 'commands.auto009'), '', @@ -1283,8 +1266,13 @@ async function loadVisibleChannelTaskRows(chatId: string, userId: number) { } async function loadTaskCenterItems(chatId: string, userId: number): Promise { - const ordinaryItems = listDownloadTaskGroups(chatId, userId) - .map(ordinaryTaskCenterItem) + const locale = await getTelegramUserLocaleOrDefault(userId); + const groups = listDownloadTaskGroups(chatId, userId); + const accountIds = Array.from(new Set(groups.map(group => group.targetAccountId).filter(Boolean))); + const accountRows = accountIds.length ? await query('SELECT id, name FROM storage_accounts WHERE id=ANY($1::uuid[])', [accountIds]) : { rows: [] }; + const accountNames = new Map(accountRows.rows.map(account => [String(account.id), String(account.name)])); + const ordinaryItems = groups + .map(group => ordinaryTaskCenterItem({ ...group, targetAccountName: group.targetAccountId ? accountNames.get(group.targetAccountId) : undefined }, locale)) .filter((item): item is TaskCenterItem => Boolean(item)); const channelRows = await loadVisibleChannelTaskRows(chatId, userId); const channelItems = channelRows @@ -1294,7 +1282,7 @@ async function loadTaskCenterItems(chatId: string, userId: number): Promise { try { const parsed = JSON.parse(paramsSource); return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}; } catch { return {}; } })() : (paramsSource && typeof paramsSource === 'object' && !Array.isArray(paramsSource) ? paramsSource : {}); const folder = row.folder_override || params.folderOverride || null; - return mergeChannelExecutionState(channelTaskCenterItem({ ...row, folder_override: folder }), row); + return mergeChannelExecutionState(channelTaskCenterItem({ ...row, folder_override: folder }, locale), row); }) .filter((item): item is TaskCenterItem => Boolean(item)); return [...ordinaryItems, ...channelItems]; @@ -1308,7 +1296,9 @@ async function findTaskCenterItem( ): Promise { if (sourceType === 'memory') { const group = getDownloadTaskGroup(id, chatId, userId); - return group ? ordinaryTaskCenterItem(group) : null; + if (!group) return null; + const account = group.targetAccountId ? await query('SELECT name FROM storage_accounts WHERE id=$1', [group.targetAccountId]) : { rows: [] }; + return ordinaryTaskCenterItem({ ...group, targetAccountName: account.rows[0]?.name }, await getTelegramUserLocaleOrDefault(userId)); } const rows = await loadVisibleChannelTaskRows(chatId, userId); const matches = rows.filter(job => String(job.id).toLowerCase().startsWith(id.toLowerCase())); @@ -1320,7 +1310,7 @@ async function findTaskCenterItem( ? (() => { try { const parsed = JSON.parse(paramsSource); return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}; } catch { return {}; } })() : (paramsSource && typeof paramsSource === 'object' && !Array.isArray(paramsSource) ? paramsSource : {}); if (!row.folder_override && params.folderOverride) row.folder_override = params.folderOverride; - return mergeChannelExecutionState(channelTaskCenterItem(row), row); + return mergeChannelExecutionState(channelTaskCenterItem(row, await getTelegramUserLocaleOrDefault(userId)), row); } async function editTaskCenterView( @@ -1569,7 +1559,7 @@ export async function handleTaskCenterCallback( async function getBulkTaskImpact(userId: number, chatId: string): Promise { const ordinaryGroups = listDownloadTaskGroups(chatId, userId) - .map(ordinaryTaskCenterItem) + .map(group => ordinaryTaskCenterItem(group)) .filter((item): item is TaskCenterItem => Boolean(item)); const channelRows = await listTelegramActiveTaskQueues(userId, 1000); return { @@ -1627,7 +1617,7 @@ async function cancelTasksForScope(userId: number, chatId: string): Promise ordinaryTaskCenterItem(group)) .filter((item): item is TaskCenterItem => Boolean(item)).length; const ordinary = forceStopDownloadTasksForScope(chatId, userId, '用户确认取消当前聊天全部任务'); return { @@ -1881,12 +1871,11 @@ export async function handleDownloadWorkers(message: Api.Message, locale?: Teleg export async function handleFileConcurrency(message: Api.Message, locale?: TelegramLocale): Promise { try { - const current = await getCurrentFileConcurrency(); + const settings = await loadFileDownloadConcurrencySetting(); const resolvedLocale = locale || await getTelegramUserLocaleOrDefault(message.senderId?.toJSNumber() || 0); - setFileDownloadConcurrency(current); await message.reply({ - message: buildFileConcurrencyText(current, resolvedLocale), - buttons: buildFileConcurrencyKeyboard(current, undefined, resolvedLocale), + message: buildFileConcurrencyText(settings, resolvedLocale), + buttons: buildFileConcurrencyKeyboard(settings.current, settings.maxConcurrency, undefined, resolvedLocale), }); } catch (error) { console.error('🤖 获取文件级并发设置失败:', error); @@ -2151,66 +2140,55 @@ export async function handleFileConcurrencyCallback(client: TelegramClient, upda try { if (data === 'fc_cancel') { - const current = await getCurrentFileConcurrency(); - setFileDownloadConcurrency(current); + const settings = await loadFileDownloadConcurrencySetting(); await client.editMessage(update.peer, { message: Number(update.msgId), - text: buildFileConcurrencyText(current, locale), - buttons: buildFileConcurrencyKeyboard(current, undefined, locale), + text: buildFileConcurrencyText(settings, locale), + buttons: buildFileConcurrencyKeyboard(settings.current, settings.maxConcurrency, undefined, locale), }); await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: t(locale, 'commands.cancelled') })); return; } - const setMatch = data.match(/^fc_set_(1|2|3|4)$/); - if (setMatch) { - const concurrency = Number(setMatch[1]); - if (concurrency === 4) { - await client.editMessage(update.peer, { - message: Number(update.msgId), - text: [ - t(locale, 'commands.auto138'), - '', - t(locale, 'commands.auto139'), - t(locale, 'commands.auto140'), - t(locale, 'commands.auto141'), - t(locale, 'commands.auto142'), - '', - t(locale, 'commands.auto143'), - ].join('\n'), - buttons: buildFileConcurrencyKeyboard(concurrency, concurrency, locale), - }); - await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: t(locale, 'commands.secondConfirm') })); - return; - } - - await setSetting('telegram_file_download_concurrency', String(concurrency)); - const normalized = setFileDownloadConcurrency(concurrency); + const match = data.match(/^fc_(set|confirm)_([1-9]|10)$/); + if (!match) return; + const concurrency = Number(match[2]); + const capacity = await getFileConcurrencyCapacity(); + assertFileConcurrencyCapacity(concurrency, capacity); + if (match[1] === 'set' && concurrency >= 4) { await client.editMessage(update.peer, { message: Number(update.msgId), - text: `${buildFileConcurrencyText(normalized, locale)}\n\n${t(locale, 'commands.auto144', { value0: '', value1: normalized })}`, - buttons: buildFileConcurrencyKeyboard(normalized, undefined, locale), + text: [ + t(locale, 'commands.auto138', { value0: concurrency }), + '', + t(locale, 'commands.auto139'), + t(locale, 'commands.auto140'), + t(locale, 'commands.auto141'), + t(locale, 'commands.auto142'), + '', + t(locale, 'commands.auto143'), + ].join('\n'), + buttons: buildFileConcurrencyKeyboard(concurrency, capacity.maxConcurrency, concurrency, locale), }); - await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: t(locale, 'commands.auto145', { value0: normalized }) })); + await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: t(locale, 'commands.secondConfirm') })); return; } - const confirmMatch = data.match(/^fc_confirm_4$/); - if (confirmMatch) { - await setSetting('telegram_file_download_concurrency', '4'); - const normalized = setFileDownloadConcurrency(4); - await client.editMessage(update.peer, { - message: Number(update.msgId), - text: `${buildFileConcurrencyText(normalized, locale)}\n\n${t(locale, 'commands.auto146', { value0: '', value1: normalized })}`, - buttons: buildFileConcurrencyKeyboard(normalized, undefined, locale), - }); - await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: t(locale, 'commands.auto147'), alert: true })); - } + await setFileDownloadConcurrency(concurrency); + const settings = await loadFileDownloadConcurrencySetting(); + await client.editMessage(update.peer, { + message: Number(update.msgId), + text: `${buildFileConcurrencyText(settings, locale)}\n\n${t(locale, match[1] === 'confirm' ? 'commands.auto146' : 'commands.auto144', { value0: '', value1: settings.current })}`, + buttons: buildFileConcurrencyKeyboard(settings.current, settings.maxConcurrency, undefined, locale), + }); + await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: t(locale, 'commands.auto145', { value0: settings.current }) })); } catch (error) { console.error('🤖 设置文件级并发失败:', error); await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, - message: t(locale, 'commands.settingFailedRetry'), + message: error instanceof FileConcurrencyLimitError + ? t(locale, 'commands.fileConcurrencyLimit', { count: error.enabledAccounts, limit: error.maxConcurrency }) + : t(locale, 'commands.settingFailedRetry'), alert: true, })); } diff --git a/backend/src/services/telegramDownloadHistoryPolicy.ts b/backend/src/services/telegramDownloadHistoryPolicy.ts index 66d0396..3d02b95 100644 --- a/backend/src/services/telegramDownloadHistoryPolicy.ts +++ b/backend/src/services/telegramDownloadHistoryPolicy.ts @@ -10,8 +10,8 @@ export function normalizeTelegramDownloadHistoryPolicy(value: unknown): Telegram return value === 'all' ? 'all' : DEFAULT_TELEGRAM_DOWNLOAD_HISTORY_POLICY; } -export function terminalStatusesToDelete(policy: TelegramDownloadHistoryPolicy): Array<'success' | 'skipped'> { - return policy === 'errors_only' ? ['success', 'skipped'] : []; +export function terminalStatusesToDelete(policy: TelegramDownloadHistoryPolicy): Array<'success' | 'skipped' | 'excluded'> { + return policy === 'errors_only' ? ['success', 'skipped', 'excluded'] : []; } export async function getTelegramDownloadHistoryPolicy(): Promise { @@ -25,19 +25,42 @@ export async function compactTelegramDownloadHistory(jobId?: string): Promise 'excluded') AS item_count, + COUNT(*) FILTER (WHERE status = 'success') AS success_count, + COUNT(*) FILTER (WHERE status = 'skipped') AS skipped_count, + COALESCE(SUM(total_size) FILTER (WHERE status <> 'excluded'), 0) AS total_bytes, + COUNT(*) FILTER (WHERE status <> 'excluded' AND COALESCE(total_size, 0) <= 0) AS unknown_sizes, + COALESCE(SUM(total_size) FILTER (WHERE status = 'success'), 0) AS completed_bytes, + COUNT(*) FILTER (WHERE status = 'success' AND COALESCE(total_size, 0) <= 0) AS saved_unknown_sizes + FROM removed GROUP BY job_id + ), updated AS ( + UPDATE telegram_background_jobs j + SET params = j.params || jsonb_build_object( + 'compactedItemCount', COALESCE((j.params->>'compactedItemCount')::bigint, 0) + s.item_count, + 'compactedSuccessCount', COALESCE((j.params->>'compactedSuccessCount')::bigint, 0) + s.success_count, + 'compactedSkippedCount', COALESCE((j.params->>'compactedSkippedCount')::bigint, 0) + s.skipped_count, + 'compactedTotalBytes', COALESCE((j.params->>'compactedTotalBytes')::bigint, 0) + s.total_bytes, + 'compactedUnknownSizeCount', COALESCE((j.params->>'compactedUnknownSizeCount')::bigint, 0) + s.unknown_sizes, + 'completedBytes', COALESCE((j.params->>'completedBytes')::bigint, 0) + s.completed_bytes, + 'savedUnknownCount', COALESCE((j.params->>'savedUnknownCount')::bigint, 0) + s.saved_unknown_sizes) + FROM summaries s WHERE j.id = s.job_id + RETURNING j.id + ) + SELECT COUNT(*)::int AS deleted_count FROM removed`, [statuses, jobId || null], ); - return result.rowCount || 0; + return Number(result.rows[0]?.deleted_count || 0); } diff --git a/backend/src/services/telegramDownloadRefStore.ts b/backend/src/services/telegramDownloadRefStore.ts new file mode 100644 index 0000000..1e13e89 --- /dev/null +++ b/backend/src/services/telegramDownloadRefStore.ts @@ -0,0 +1,59 @@ +import { query } from '../db/index.js'; +import type { TelegramDownloadMessageRef } from './telegramUpload.js'; +import { getTelegramBatchFileType } from '../utils/telegramBatchMediaFilter.js'; + +export const TELEGRAM_REF_WRITE_BATCH_SIZE = 100; +export type TelegramRefQuery = (sql: string, values: unknown[]) => Promise; + +export async function persistTelegramDownloadRefs( + jobId: string, + source: string, + refs: TelegramDownloadMessageRef[], + folderOverride?: string | null, + execute: TelegramRefQuery = query, +): Promise { + for (let offset = 0; offset < refs.length; offset += TELEGRAM_REF_WRITE_BATCH_SIZE) { + const rows = new Map(); + for (const ref of refs.slice(offset, offset + TELEGRAM_REF_WRITE_BATCH_SIZE)) { + const peer = ref.source == null ? source : String(ref.source); + const key = `${peer}:${ref.id}`; + const row = [jobId, source, peer, ref.origin || 'channel', ref.id, ref.groupedId || null, + ref.channelPostId || null, ref.fileInfo?.fileName || null, ref.fileInfo?.mimeType || null, + ref.fileInfo?.generatedName || false, ref.totalSize || 0, folderOverride || null, + ref.sharedCaption || null, ref.groupIndex || null, ref.groupSize || null, + ref.fileInfo ? getTelegramBatchFileType(ref.fileInfo) : null]; + const previous = rows.get(key); + if (previous) { + // Match sequential upserts: null metadata keeps the last known value; + // identity/origin remain those of the first discovered row. + for (const index of [5, 7, 8, 9, 10, 12, 13, 14, 15]) { + if (row[index] !== null) previous[index] = row[index]; + } + previous[11] = row[11]; + } else rows.set(key, row); + } + const values: unknown[] = []; + const tuples: string[] = []; + for (const row of rows.values()) { + const base = values.length; + values.push(...row); + tuples.push(`(${row.map((_, index) => `$${base + index + 1}`).join(',')},'pending',NULL,NULL,NULL,NULL)`); + } + await execute(`INSERT INTO telegram_download_items ( + job_id, source, source_peer, origin, message_id, grouped_id, channel_post_id, + file_name, mime_type, generated_name, total_size, folder_override, shared_caption, group_index, group_size, + file_category, status, error, last_error, locked_at, completed_at + ) VALUES ${tuples.join(',')} + ON CONFLICT (job_id, source_peer, message_id) DO UPDATE SET + file_name = COALESCE(EXCLUDED.file_name, telegram_download_items.file_name), + mime_type = COALESCE(EXCLUDED.mime_type, telegram_download_items.mime_type), + file_category = COALESCE(EXCLUDED.file_category, telegram_download_items.file_category), + generated_name = COALESCE(EXCLUDED.generated_name, telegram_download_items.generated_name), + total_size = COALESCE(EXCLUDED.total_size, telegram_download_items.total_size), + grouped_id = COALESCE(EXCLUDED.grouped_id, telegram_download_items.grouped_id), + shared_caption = COALESCE(EXCLUDED.shared_caption, telegram_download_items.shared_caption), + group_index = COALESCE(EXCLUDED.group_index, telegram_download_items.group_index), + group_size = COALESCE(EXCLUDED.group_size, telegram_download_items.group_size), + folder_override = EXCLUDED.folder_override, updated_at = NOW()`, values); + } +} diff --git a/backend/src/services/telegramDownloadStream.ts b/backend/src/services/telegramDownloadStream.ts index 87a7627..beeccfa 100644 --- a/backend/src/services/telegramDownloadStream.ts +++ b/backend/src/services/telegramDownloadStream.ts @@ -1,37 +1,11 @@ import { setTimeout as delay } from 'node:timers/promises'; import { classifyTelegramDownloadAccountError, telegramFloodWaitSeconds } from './telegramUserClientPool.js'; - -export class TelegramDownloadGate { - private cooldownUntil = 0; - private serialUntil = 0; - private active = 0; - - async read(operation: () => Promise, signal?: AbortSignal): Promise { - while (true) { - signal?.throwIfAborted(); - const now = Date.now(); - if (now >= this.cooldownUntil && (now >= this.serialUntil || this.active === 0)) break; - await delay(Math.max(25, Math.min(100, this.cooldownUntil - now)), undefined, { signal }); - } - this.active++; - try { - return await operation(); - } catch (error) { - if (classifyTelegramDownloadAccountError(error) === 'flood_wait') { - this.cooldownUntil = Math.max(this.cooldownUntil, Date.now() + telegramFloodWaitSeconds(error) * 1000); - this.serialUntil = this.cooldownUntil + 60_000; - } - throw error; - } finally { - this.active--; - } - } -} +import { withTelegramOperationSignal } from './telegramOperationContext.js'; /** Keep each stripe's committed offset across FLOOD_WAIT; never restart the file. */ export async function* telegramDownloadStream( open: (offset: number) => AsyncIterable, - options: { offset: number; stride?: number; end?: number; signal?: AbortSignal; beforeRead?: () => Promise; gate?: TelegramDownloadGate }, + options: { offset: number; stride?: number; end?: number; signal?: AbortSignal; beforeRead?: () => Promise }, ): AsyncGenerator { let offset = options.offset; while (options.end === undefined || offset < options.end) { @@ -45,9 +19,9 @@ export async function* telegramDownloadStream( const next = async () => { await options.beforeRead?.(); options.signal?.throwIfAborted(); - return iterator.next(); + return withTelegramOperationSignal(options.signal, () => iterator.next()); }; - const result = options.gate ? await options.gate.read(next, options.signal) : await next(); + const result = await next(); if (result.done) return; options.signal?.throwIfAborted(); await options.beforeRead?.(); @@ -64,3 +38,21 @@ export async function* telegramDownloadStream( } } } + +/** Abort sibling stripes on the first error; settle all writes before closing the file. */ +export async function runTelegramDownloadStripes(workers: number, operation: (index: number, signal: AbortSignal) => Promise, signal?: AbortSignal): Promise { + const controller = new AbortController(); + const combined = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal; + let failure: unknown; + const results = await Promise.allSettled(Array.from({ length: workers }, (_, index) => + withTelegramOperationSignal(combined, () => operation(index, combined)).catch((error: unknown) => { + if (!controller.signal.aborted) { failure = error; controller.abort(error); } + throw error; + }))); + if (controller.signal.aborted) throw failure; + combined.throwIfAborted(); + return results.map(result => { + if (result.status === 'rejected') throw result.reason; + return result.value; + }); +} diff --git a/backend/src/services/telegramFileConcurrency.ts b/backend/src/services/telegramFileConcurrency.ts new file mode 100644 index 0000000..afdf40c --- /dev/null +++ b/backend/src/services/telegramFileConcurrency.ts @@ -0,0 +1,70 @@ +import { query } from '../db/index.js'; +import { getSetting, setSetting } from '../utils/settings.js'; + +const SETTING = 'telegram_file_download_concurrency'; +let applyRuntime: ((value: number) => void) | undefined; +let updates: Promise = Promise.resolve(); + +export interface FileConcurrencyCapacity { + enabledAccounts: number; + maxConcurrency: number; +} + +export interface FileConcurrencySettings extends FileConcurrencyCapacity { + current: number; +} + +function updateFileConcurrency(operation: () => Promise): Promise { + const result = updates.then(operation); + updates = result.catch(() => undefined); + return result; +} + +export function registerFileConcurrencyRuntime(apply: (value: number) => void): void { + applyRuntime = apply; +} + +export function normalizeFileDownloadConcurrency(value: unknown): number { + const parsed = Number(value ?? 2); + return Number.isInteger(parsed) && parsed >= 1 && parsed <= 10 ? parsed : 2; +} + +export async function getFileConcurrencyCapacity(): Promise { + const result = await query('SELECT COUNT(*)::int AS count FROM telegram_user_accounts WHERE enabled = TRUE AND deleted_at IS NULL'); + const enabledAccounts = Number(result.rows[0].count); + return { enabledAccounts, maxConcurrency: Math.min(10, Math.max(2, enabledAccounts * 2)) }; +} + +export class FileConcurrencyLimitError extends Error { + readonly code = 'FILE_CONCURRENCY_LIMIT'; + constructor(readonly enabledAccounts: number, readonly maxConcurrency: number) { + super(`已启用 ${enabledAccounts} 个 Telegram 用户账号,最多允许同时下载 ${maxConcurrency} 个文件;无账号时上限为 2,每个启用账号提供 2 个名额,最高 10。`); + } +} + +export function assertFileConcurrencyCapacity(value: number, capacity: FileConcurrencyCapacity): void { + if (!Number.isInteger(value) || value < 1 || value > 10) throw new Error('同时下载文件数必须是 1–10 的整数'); + if (value > capacity.maxConcurrency) throw new FileConcurrencyLimitError(capacity.enabledAccounts, capacity.maxConcurrency); +} + +export function setFileDownloadConcurrency(value: number): Promise { + return updateFileConcurrency(async () => { + const capacity = await getFileConcurrencyCapacity(); + assertFileConcurrencyCapacity(value, capacity); + await setSetting(SETTING, String(value)); + applyRuntime?.(value); + return value; + }); +} + +/** Shrinking capacity stops new starts; existing transfers keep their slots until settlement. */ +export function loadFileDownloadConcurrencySetting(): Promise { + return updateFileConcurrency(async () => { + const capacity = await getFileConcurrencyCapacity(); + const stored = await getSetting(SETTING, process.env.TELEGRAM_FILE_DOWNLOAD_CONCURRENCY || '2'); + const current = Math.min(normalizeFileDownloadConcurrency(stored), capacity.maxConcurrency); + if (String(current) !== stored) await setSetting(SETTING, String(current)); + applyRuntime?.(current); + return { current, ...capacity }; + }); +} diff --git a/backend/src/services/telegramHashtag.ts b/backend/src/services/telegramHashtag.ts new file mode 100644 index 0000000..96ae220 --- /dev/null +++ b/backend/src/services/telegramHashtag.ts @@ -0,0 +1,32 @@ +import type { Api } from 'telegram'; +import { DEFAULT_LOCALE, t, type TelegramLocale } from '../i18n/telegram.js'; +export type TelegramHashtagMatcher = (message: Pick) => boolean; + +export function normalizeHashtag(tagInput: string, locale: TelegramLocale = DEFAULT_LOCALE): string { + const trimmed = tagInput.trim().normalize('NFC'); + if (!trimmed) throw new Error(t(locale, 'channels.errors.hashtagRequired')); + const tag = trimmed.replace(/^[##]/u, ''); + if (!/^[\p{L}\p{N}_][\p{L}\p{M}\p{N}_]*$/u.test(tag)) { + throw new Error(t(locale, 'channels.errors.hashtagInvalid')); + } + return `#${tag}`; +} + +/** Entity offsets are UTF-16, so slice the original text before normalization. */ +export function createTelegramHashtagMatcher(normalizedTag: string): TelegramHashtagMatcher { + const escaped = normalizedTag.normalize('NFC').replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const exact = new RegExp(`^${escaped}$`, 'iu'); + const fallback = new RegExp(`(^|[^\\p{L}\\p{M}\\p{N}_])${escaped}(?![\\p{L}\\p{M}\\p{N}_])`, 'iu'); + return message => { + const body = message.message || ''; + let hasHashtagEntity = false; + for (const entity of message.entities || []) { + if (entity.className !== 'MessageEntityHashtag' || !Number.isInteger(entity.offset) + || !Number.isInteger(entity.length) || entity.offset < 0 || entity.length <= 1 + || entity.offset + entity.length > body.length || body[entity.offset] !== '#') continue; + hasHashtagEntity = true; + if (exact.test(body.slice(entity.offset, entity.offset + entity.length).normalize('NFC'))) return true; + } + return !hasHashtagEntity && fallback.test(body.normalize('NFC')); + }; +} diff --git a/backend/src/services/telegramMultiAccountLogin.ts b/backend/src/services/telegramMultiAccountLogin.ts index 8ba0739..c1d4cab 100644 --- a/backend/src/services/telegramMultiAccountLogin.ts +++ b/backend/src/services/telegramMultiAccountLogin.ts @@ -2,29 +2,23 @@ import { Api, TelegramClient } from 'telegram'; import { StringSession } from 'telegram/sessions/index.js'; import { Raw } from 'telegram/events/index.js'; import { getEffectiveTelegramBotConfig } from './telegramBotConfig.js'; +import { ownUserTransport } from './telegramUserTransport.js'; +import { telegramAccountRepository } from './telegramAccountRepository.js'; import { TelegramMultiAccountLoginFlows, type TelegramLoginCredentials, type TelegramMultiAccountLoginClient, type TelegramQrExportResult, - type TelegramUserLoginAccount, + type TelegramAuthorizedLogin, } from './telegramMultiAccountLoginFlows.js'; export interface TelegramAuthorizedAccountAdapter { - upsertByTelegramUserId(input: { - session: string; - credentials: TelegramLoginCredentials; - account: TelegramUserLoginAccount; - }): Promise; + upsertByTelegramUserId(input: TelegramAuthorizedLogin): Promise; } let adapter: TelegramAuthorizedAccountAdapter | null = null; -/** - * The future multi-account repository/pool registers one thin adapter here. - * Keeping this module independent prevents the login routes from importing a - * pool API that may not exist yet. - */ +/** The runtime registers persistence and account activation after startup. */ export function registerTelegramMultiAccountAuthorizedAdapter(next: TelegramAuthorizedAccountAdapter | null): void { adapter = next; } @@ -40,15 +34,15 @@ async function getCredentials(): Promise { } function makeClient(credentials: TelegramLoginCredentials): TelegramClient { - return new TelegramClient(new StringSession(''), credentials.apiId, credentials.apiHash, { - connectionRetries: 15, - retryDelay: 2000, + return ownUserTransport(new TelegramClient(new StringSession(''), credentials.apiId, credentials.apiHash, { + connectionRetries: 1, + retryDelay: 0, useWSS: false, deviceModel: 'TG Vault Multi-Account Login', systemVersion: '1.0.0', appVersion: '1.0.0', - floodSleepThreshold: 120, - }); + floodSleepThreshold: 0, + }), { accountId: 'multi-account-login' }); } export class GramJsMultiAccountLoginClient implements TelegramMultiAccountLoginClient { @@ -146,7 +140,7 @@ export class GramJsMultiAccountLoginClient implements TelegramMultiAccountLoginC } } -async function onAuthorized(input: { session: string; credentials: TelegramLoginCredentials; account: TelegramUserLoginAccount }): Promise { +async function onAuthorized(input: TelegramAuthorizedLogin): Promise { if (!adapter) { throw new Error('Telegram 多账号仓库尚未注册,无法保存登录账号'); } @@ -157,4 +151,8 @@ export const telegramMultiAccountLoginFlows = new TelegramMultiAccountLoginFlows credentials: getCredentials, createClient: credentials => new GramJsMultiAccountLoginClient(makeClient(credentials), credentials), onAuthorized, + getTargetAccount: async accountId => { + const account = await telegramAccountRepository.getAccount(accountId); + return account ? { accountId: account.id, telegramUserId: account.telegramUserId } : null; + }, }); diff --git a/backend/src/services/telegramMultiAccountLoginAdapter.ts b/backend/src/services/telegramMultiAccountLoginAdapter.ts index af5a488..3b56ffd 100644 --- a/backend/src/services/telegramMultiAccountLoginAdapter.ts +++ b/backend/src/services/telegramMultiAccountLoginAdapter.ts @@ -1,4 +1,5 @@ import type { TelegramAuthorizedAccountAdapter } from './telegramMultiAccountLogin.js'; +import { TelegramUserLoginFlowError } from './telegramMultiAccountLoginFlows.js'; export interface TelegramLoginAccountRepository { upsertAccount(input: { @@ -7,7 +8,14 @@ export interface TelegramLoginAccountRepository { displayName?: string | null; session: string; enabled?: boolean; - }): Promise; + }): Promise<{ id: string; enabled: boolean }>; + reauthenticateAccount(input: { + accountId: string; + telegramUserId: string; + username: string | null; + displayName: string | null; + session: string; + }): Promise<{ id: string; enabled: boolean } | null>; } export interface TelegramLoginAccountPool { @@ -15,7 +23,7 @@ export interface TelegramLoginAccountPool { } export interface TelegramLoginAccountAccessSweep { - trigger(input: { accountIds: string[]; reason: 'account_created' }): Promise; + trigger(input: { accountIds: string[]; reason: 'account_created' | 'account_reauthenticated' }): Promise; } /** @@ -29,18 +37,30 @@ export function createTelegramMultiAccountAuthorizedAdapter(deps: { accessSweep?: TelegramLoginAccountAccessSweep; }): TelegramAuthorizedAccountAdapter { return { - async upsertByTelegramUserId({ session, credentials, account }): Promise { - const persisted = await deps.repository.upsertAccount({ - telegramUserId: account.userId, - username: account.username, - displayName: account.displayName, - session, - enabled: true, - }); - const accountId = String((persisted as { id?: unknown } | null)?.id || ''); + async upsertByTelegramUserId({ session, credentials, account, target }): Promise { + if (target && target.telegramUserId !== account.userId) { + throw new TelegramUserLoginFlowError('ACCOUNT_MISMATCH', '当前登录账号与待恢复账号不一致,请使用原账号重新登录'); + } + const persisted = target + ? await deps.repository.reauthenticateAccount({ + accountId: target.accountId, + telegramUserId: target.telegramUserId, + username: account.username, + displayName: account.displayName, + session, + }) + : await deps.repository.upsertAccount({ + telegramUserId: account.userId, + username: account.username, + displayName: account.displayName, + session, + enabled: true, + }); + if (target && !persisted) throw new TelegramUserLoginFlowError('ACCOUNT_NOT_FOUND', '待重新登录的账号不存在或已删除'); + const accountId = persisted?.id; if (accountId) await deps.pool.activateAccount(accountId, 'login_complete', credentials); - if (accountId && deps.accessSweep) { - await deps.accessSweep.trigger({ accountIds: [accountId], reason: 'account_created' }); + if (accountId && deps.accessSweep && persisted?.enabled) { + await deps.accessSweep.trigger({ accountIds: [accountId], reason: target ? 'account_reauthenticated' : 'account_created' }); } }, }; diff --git a/backend/src/services/telegramMultiAccountLoginFlows.ts b/backend/src/services/telegramMultiAccountLoginFlows.ts index 7097bbb..0d04893 100644 --- a/backend/src/services/telegramMultiAccountLoginFlows.ts +++ b/backend/src/services/telegramMultiAccountLoginFlows.ts @@ -10,6 +10,18 @@ export interface TelegramLoginCredentials { apiId: number; apiHash: string; } +export interface TelegramUserLoginTarget { + accountId: string; + telegramUserId: string; +} + +export interface TelegramAuthorizedLogin { + session: string; + credentials: TelegramLoginCredentials; + account: TelegramUserLoginAccount; + target: TelegramUserLoginTarget | null; +} + export type TelegramQrExportResult = | { kind: 'token'; token: Buffer; expiresAt: number } @@ -37,6 +49,8 @@ export type TelegramUserLoginFlowErrorCode = | 'FLOW_EXPIRED' | 'TOO_MANY_ERRORS' | 'API_NOT_CONFIGURED' + | 'ACCOUNT_NOT_FOUND' + | 'ACCOUNT_MISMATCH' | 'TELEGRAM_ERROR'; export class TelegramUserLoginFlowError extends Error { @@ -50,6 +64,7 @@ interface BaseFlow { id: string; owner: string; credentials: TelegramLoginCredentials; + target: TelegramUserLoginTarget | null; expiresAt: number; errors: number; client: C | null; @@ -90,11 +105,7 @@ interface QrFlow extends BaseFlow { type LoginFlow = PhoneFlow | QrFlow; -type AuthorizedCallback = (input: { - session: string; - credentials: TelegramLoginCredentials; - account: TelegramUserLoginAccount; -}) => Promise; +type AuthorizedCallback = (input: TelegramAuthorizedLogin) => Promise; function telegramErrorName(error: unknown): string { if (!error || typeof error !== 'object') return ''; @@ -113,12 +124,13 @@ function normalizeAccount(me: { id: unknown; username?: string; firstName?: stri /** * Ephemeral, admin-session-bound Telegram login coordinator. * - * Login tokens, phone-code hashes and StringSessions live only in memory. The - * injected callback is the integration seam for a multi-account repository and - * client pool; it should upsert by account.userId and activate/reload that user. + * Login tokens, phone-code hashes and StringSessions live only in memory. + * Reauthentication pins the original identity before contacting Telegram; only + * a matching authorized identity may replace the persistent session. */ export class TelegramMultiAccountLoginFlows { private readonly flows = new Map>(); + private readonly unclosedClients = new Set(); private readonly ttlMs: number; private readonly maxErrors: number; private readonly now: () => number; @@ -127,6 +139,7 @@ export class TelegramMultiAccountLoginFlows; createClient(credentials: TelegramLoginCredentials): C; onAuthorized: AuthorizedCallback; + getTargetAccount?(accountId: string): Promise; now?: () => number; ttlMs?: number; maxErrors?: number; @@ -141,11 +154,12 @@ export class TelegramMultiAccountLoginFlows { + async startPhone(owner: string, rawPhone: string, accountId?: unknown): Promise<{ flowId: string; delivery: 'app' | 'sms'; expiresAt: string }> { const phone = String(rawPhone || '').replace(/[\s()-]/g, ''); if (!/^\+[1-9]\d{6,14}$/.test(phone)) { throw new TelegramUserLoginFlowError('INVALID_PHONE', '请输入含国家区号的有效手机号'); } + const target = await this.resolveTarget(accountId); await this.removeOwnerFlows(owner); const credentials = await this.requireCredentials(); const client = this.deps.createClient(credentials); @@ -158,6 +172,7 @@ export class TelegramMultiAccountLoginFlows { + async startQr(owner: string, accountId?: unknown): Promise { + const target = await this.resolveTarget(accountId); await this.removeOwnerFlows(owner); const credentials = await this.requireCredentials(); const client = this.deps.createClient(credentials); @@ -189,6 +205,7 @@ export class TelegramMultiAccountLoginFlows): Promise { const client = this.requireClient(flow); const account = normalizeAccount(await client.getMe()); + await this.requireFlow(flow.owner, flow.id); if (!account.userId) throw new TelegramUserLoginFlowError('TELEGRAM_ERROR', 'Telegram 登录未返回用户身份'); - await this.deps.onAuthorized({ session: client.saveSession(), credentials: flow.credentials, account }); + if (flow.target && flow.target.telegramUserId !== account.userId) { + throw new TelegramUserLoginFlowError('ACCOUNT_MISMATCH', '当前登录账号与待恢复账号不一致,请使用原账号重新登录。添加其他账号请使用“添加账号”。'); + } + await this.deps.onAuthorized({ session: client.saveSession(), credentials: flow.credentials, account, target: flow.target }); return account; } @@ -416,6 +438,7 @@ export class TelegramMultiAccountLoginFlows { + if (this.unclosedClients.size) throw new Error('TELEGRAM_LOGIN_CLEANUP_UNCONFIRMED'); for (const flow of [...this.flows.values()]) { if (flow.owner === owner) await this.deleteAndClose(flow); } @@ -449,6 +472,16 @@ export class TelegramMultiAccountLoginFlows { + if (accountId === undefined) return null; + if (typeof accountId !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(accountId)) { + throw new TelegramUserLoginFlowError('ACCOUNT_NOT_FOUND', '待重新登录的账号不存在'); + } + const target = await this.deps.getTargetAccount?.(accountId); + if (!target) throw new TelegramUserLoginFlowError('ACCOUNT_NOT_FOUND', '待重新登录的账号不存在或已删除'); + return target; + } + private async requireCredentials(): Promise { const credentials = await this.deps.credentials(); if (!credentials?.apiId || !credentials.apiHash) { @@ -477,7 +510,8 @@ export class TelegramMultiAccountLoginFlows { - try { await client.disconnect(); } catch { /* best effort */ } - try { await client.destroy(); } catch { /* best effort */ } + this.unclosedClients.add(client); + await client.destroy(); + this.unclosedClients.delete(client); } } diff --git a/backend/src/services/telegramMultiAccountRuntime.ts b/backend/src/services/telegramMultiAccountRuntime.ts index b2df32b..f307feb 100644 --- a/backend/src/services/telegramMultiAccountRuntime.ts +++ b/backend/src/services/telegramMultiAccountRuntime.ts @@ -82,7 +82,7 @@ export async function withTelegramAccount( sourceKey: string, scope: 'download' | 'scan' | 'metadata', operation: (client: TelegramClient, accountId: string) => Promise, - options: { signal?: AbortSignal } = {}, + options: Pick = {}, ): Promise { await waitForInitialTelegramRecovery(options.signal); return telegramUserClientPool.withAccount(sourceKey, scope, operation, options); diff --git a/backend/src/services/telegramOperationContext.ts b/backend/src/services/telegramOperationContext.ts new file mode 100644 index 0000000..c14567e --- /dev/null +++ b/backend/src/services/telegramOperationContext.ts @@ -0,0 +1,7 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; + +const operations = new AsyncLocalStorage(); +export function telegramOperationSignal() { return operations.getStore(); } +export function withTelegramOperationSignal(signal: AbortSignal | undefined, operation: () => Promise): Promise { + return signal ? operations.run(signal, operation) : operation(); +} diff --git a/backend/src/services/telegramScanAccount.ts b/backend/src/services/telegramScanAccount.ts new file mode 100644 index 0000000..5beb8cc --- /dev/null +++ b/backend/src/services/telegramScanAccount.ts @@ -0,0 +1,118 @@ +import { Api, type TelegramClient } from 'telegram'; + +export class TelegramHistoryUnverifiedError extends Error { + constructor(readonly reason = 'EMPTY_HISTORY') { + super(`TELEGRAM_HISTORY_UNVERIFIED: ${reason}`); + this.name = 'TelegramHistoryUnverifiedError'; + } +} + +function isChannelReadDenied(error: unknown): boolean { + const text = error instanceof Error ? error.message : String(error); + return /CHANNEL_PRIVATE|USER_NOT_PARTICIPANT/.test(text); +} +// Callers must retain this only for the same account and source. +export interface TelegramScanVisibility { + latestMessageId: number; + joined: boolean; + checkedAt: number; +} + +const VISIBILITY_MAX_AGE_MS = 30_000; + +// Proxy views of one account share its session; separate accounts never share joins. +const publicChannelJoins = new WeakMap; expiresAt: number }>>(); +/** Each account reads first; joining only repairs an unreadable, unjoined public source. */ +export async function verifyTelegramScanVisibility( + client: Pick, + source: Api.TypeEntityLike, + checkpointId = 0, +): Promise { + let latest: Api.Message | undefined; + try { [latest] = await client.getMessages(source, { limit: 1 }); } + catch (error) { if (!isChannelReadDenied(error)) throw error; } + let joined = false; + if (!latest?.id) { + const publicSource = typeof source === 'string' && (/^@?[a-z][a-z\d_]*$/i.test(source) + || /^(?:https?:\/\/)?(?:www\.)?t\.me\/(?:s\/)?(?!joinchat(?:\/|$))[a-z][a-z\d_]*(?:\/\d+)?\/?(?:[?#].*)?$/i.test(source)); + if (publicSource) { + const entity = await client.getEntity(source); + const restrictions = entity instanceof Api.Channel + ? entity.restrictionReason?.map(reason => `${reason.platform}:${reason.reason}`).join(',') : undefined; + // Platform-specific labels (e.g. ios) do not restrict this server client. + // A server-wide content block cannot be repaired by joining. + if (entity instanceof Api.Channel && entity.restrictionReason?.some(reason => reason.platform.split('-').includes('all'))) { + throw new TelegramHistoryUnverifiedError(`RESTRICTED ${restrictions}`); + } + if (entity instanceof Api.Channel && entity.username && entity.left) { + if (entity.joinRequest) throw new TelegramHistoryUnverifiedError('JOIN_APPROVAL_REQUIRED'); + if (entity.bannedRights?.viewMessages) throw new TelegramHistoryUnverifiedError('CHANNEL_READ_BANNED'); + let joins = publicChannelJoins.get(client.session); + if (!joins) { joins = new Map(); publicChannelJoins.set(client.session, joins); } + const now = Date.now(); + for (const [key, entry] of joins) if (entry.expiresAt <= now) joins.delete(key); + const channelId = entity.id.toString(); + let entry = joins.get(channelId); + if (!entry) { + const flight = (async () => { + try { + await client.invoke(new Api.channels.JoinChannel({ channel: entity })); + } catch (error) { + const code = error && typeof error === 'object' && 'errorMessage' in error + ? String(error.errorMessage) : error instanceof Error ? error.message : String(error); + if (/USER_ALREADY_PARTICIPANT/.test(code)) return; + if (/FLOOD|AUTH_KEY|SESSION_|USER_DEACTIVATED/.test(code)) throw error; + throw new TelegramHistoryUnverifiedError(code); + } + })(); + entry = { flight, expiresAt: Infinity }; + joins.set(channelId, entry); + } + try { + await entry.flight; + entry.expiresAt = Date.now() + VISIBILITY_MAX_AGE_MS; + joined = true; + } catch (error) { + // Do not repeat approval/limit/permission failures for every file. + // Flood/session/cancellation errors stay owned by the account pool. + if (error instanceof TelegramHistoryUnverifiedError) entry.expiresAt = Date.now() + 15 * 60_000; + else if (joins.get(channelId) === entry) joins.delete(channelId); + throw error; + } + [latest] = await client.getMessages(source, { limit: 1 }); + console.info(JSON.stringify({ event: 'telegram_public_channel_join', source, joined, + readable: Boolean(latest?.id), restrictions })); + } + if (!latest?.id && restrictions) throw new TelegramHistoryUnverifiedError(`RESTRICTED ${restrictions}`); + } + } + if (!latest?.id) throw new TelegramHistoryUnverifiedError(); + if (checkpointId > 0) { + const checkpoint = await client.getMessages(source, { ids: [checkpointId] }); + if (!checkpoint.some(message => message?.id === checkpointId)) { + throw new TelegramHistoryUnverifiedError('CHECKPOINT_NOT_VISIBLE'); + } + } + return { latestMessageId: latest.id, joined, checkedAt: Date.now() }; +} + +/** A missing search/file response is not evidence that the source is readable. */ +export async function getTelegramMessagesWithAccess( + client: Pick, + source: Api.TypeEntityLike, + request: Parameters[1], + visibility?: TelegramScanVisibility, +) { + let emptyResult: Api.Message[] | undefined; + try { + const messages = await client.getMessages(source, request); + if (messages.some(message => message?.id)) return messages; + emptyResult = messages; + if (visibility && Date.now() - visibility.checkedAt < VISIBILITY_MAX_AGE_MS) return messages; + } catch (error) { + if (!isChannelReadDenied(error)) throw error; + } + const verified = await verifyTelegramScanVisibility(client, source); + if (visibility) Object.assign(visibility, verified); + return emptyResult && !verified.joined ? emptyResult : client.getMessages(source, request); +} diff --git a/backend/src/services/telegramScanReader.ts b/backend/src/services/telegramScanReader.ts new file mode 100644 index 0000000..566d733 --- /dev/null +++ b/backend/src/services/telegramScanReader.ts @@ -0,0 +1,66 @@ +import type { Api, TelegramClient } from 'telegram'; +import { withTelegramAccount } from './telegramMultiAccountRuntime.js'; +import { verifyTelegramScanVisibility, getTelegramMessagesWithAccess, type TelegramScanVisibility } from './telegramScanAccount.js'; +import type { TelegramAccountFloodWait } from './telegramUserClientPool.js'; + +export interface TelegramScanReaderOptions { + source: string; + signal?: AbortSignal; + preferredAccountId?: string; + onAccount?: (accountId: string, visibility: TelegramScanVisibility) => Promise; + onFloodWait?: (event: TelegramAccountFloodWait) => Promise | void; + withAccount?: typeof withTelegramAccount; +} + +export interface TelegramScanReaderStats { + rpcRequests: number; + rpcByMethod: Record; + readAttempts: number; + floodWaits: number; + floodWaitSeconds: number; + readableAccounts: number; + singleAccountRequests: number; +} + +export interface TelegramScanReader { + accountId?: string; + stats: TelegramScanReaderStats; + read(request: Parameters[1], checkpointId?: number): Promise; +} + +/** Retry only the failed read. Search results and album state stay in the caller. */ +export function createTelegramScanReader(options: TelegramScanReaderOptions): TelegramScanReader { + const withAccount = options.withAccount || withTelegramAccount; + let visibility: TelegramScanVisibility | undefined; + const stats: TelegramScanReaderStats = { rpcRequests: 0, rpcByMethod: {}, readAttempts: 0, + floodWaits: 0, floodWaitSeconds: 0, readableAccounts: 0, singleAccountRequests: 0 }; + const reader: TelegramScanReader = { + accountId: options.preferredAccountId, + stats, + async read(request, checkpointId = 0) { + return withAccount(options.source, 'scan', async (client, accountId) => { + stats.readAttempts++; + if (!visibility || reader.accountId !== accountId) { + const verified = await verifyTelegramScanVisibility(client, options.source, checkpointId); + await options.onAccount?.(accountId, verified); + visibility = verified; + reader.accountId = accountId; + } + return getTelegramMessagesWithAccess(client, options.source, request, visibility); + }, { signal: options.signal, preferredAccountId: reader.accountId, + onScanRequest: ({ method, readableAccounts }) => { + stats.rpcRequests++; + stats.rpcByMethod[method] = (stats.rpcByMethod[method] || 0) + 1; + stats.readableAccounts = readableAccounts; + if (readableAccounts <= 1) stats.singleAccountRequests++; + }, + onFloodWait: async event => { + stats.floodWaits++; + stats.floodWaitSeconds += event.seconds; + await options.onFloodWait?.(event); + }, + }); + }, + }; + return reader; +} diff --git a/backend/src/services/telegramTaskCenter.ts b/backend/src/services/telegramTaskCenter.ts index 7883abc..085d7f6 100644 --- a/backend/src/services/telegramTaskCenter.ts +++ b/backend/src/services/telegramTaskCenter.ts @@ -2,10 +2,11 @@ import type { DownloadTaskGroupSnapshot } from './downloadTaskQueue.js'; import type { TransferTaskRecord } from './transferTasks.js'; import { telegramChannelJobTaskState, telegramChannelJobCounts } from './unifiedTaskMapper.js'; import { DEFAULT_LOCALE, TELEGRAM_LOCALES, formatDate, t, type TelegramLocale } from '../i18n/telegram.js'; +import { getProviderDisplayName } from '../utils/providerMetadata.js'; export type TaskCenterSourceType = 'memory' | 'channel'; export type TaskCenterKind = 'single' | 'album' | 'channel'; -export type TaskCenterState = 'running' | 'waiting' | 'pausing' | 'paused' | 'cooling' | 'failed'; +export type TaskCenterState = 'running' | 'waiting' | 'awaiting_selection' | 'pausing' | 'paused' | 'cooling' | 'failed'; export type TaskCenterProtection = { kind: 'disk_pressure' | 'storage_cooldown' | 'telegram_flood_wait'; @@ -33,6 +34,7 @@ export interface TaskCenterItem { userId?: number; source?: string; targetFolder?: string | null; + storageLabel?: string; reason?: string; protection?: TaskCenterProtection; scanProgressText?: string; @@ -106,6 +108,7 @@ function stateMeta(state: TaskCenterState, locale: TelegramLocale = DEFAULT_LOCA switch (state) { case 'running': return { icon: '🟢', label: t(locale, 'taskCenter.state.running'), bucket: 'running' }; case 'waiting': return { icon: '⏳', label: t(locale, 'taskCenter.state.waiting'), bucket: 'waiting' }; + case 'awaiting_selection': return { icon: '☑', label: t(locale, 'taskCenter.state.awaitingSelection'), bucket: 'waiting' }; case 'pausing': return { icon: '⏸', label: t(locale, 'taskCenter.state.pausing'), bucket: 'paused' }; case 'paused': return { icon: '⏸', label: t(locale, 'taskCenter.state.paused'), bucket: 'paused' }; case 'cooling': return { icon: '🧊', label: t(locale, 'taskCenter.state.cooling'), bucket: 'cooling' }; @@ -114,7 +117,7 @@ function stateMeta(state: TaskCenterState, locale: TelegramLocale = DEFAULT_LOCA } function stateOrder(state: TaskCenterState): number { - return ({ running: 0, waiting: 1, pausing: 2, paused: 3, cooling: 4, failed: 5 } as const)[state]; + return ({ running: 0, awaiting_selection: 1, waiting: 2, pausing: 3, paused: 4, cooling: 5, failed: 6 } as const)[state]; } function sourceCode(sourceType: TaskCenterSourceType): string { @@ -158,7 +161,7 @@ function progressLine(item: TaskCenterItem, locale: TelegramLocale): string { export function sortTaskCenterItems(items: TaskCenterItem[]): TaskCenterItem[] { return items - .filter(item => ['running', 'waiting', 'pausing', 'paused', 'cooling', 'failed'].includes(item.state)) + .filter(item => ['running', 'waiting', 'awaiting_selection', 'pausing', 'paused', 'cooling', 'failed'].includes(item.state)) .sort((a, b) => stateOrder(a.state) - stateOrder(b.state) || b.updatedAt - a.updatedAt || a.id.localeCompare(b.id)); } @@ -222,6 +225,7 @@ export function buildTaskCenterDetail( const source = item.source?.replace(/[\u0000-\u001F\u007F]/g, ' ').trim(); const currentFileName = item.currentFileName?.replace(/[\u0000-\u001F\u007F]/g, ' ').trim(); const targetFolder = item.targetFolder?.replace(/[\u0000-\u001F\u007F]/g, ' ').trim(); + const storageLabel = item.storageLabel?.replace(/[\u0000-\u001F\u007F]/g, ' ').trim(); const reason = item.reason?.replace(/[\u0000-\u001F\u007F]/g, ' ').trim(); const lines = [ `${meta.icon} **${meta.label}**`, @@ -232,6 +236,7 @@ export function buildTaskCenterDetail( t(locale, 'taskCenter.detail.progress', { value: markdownText(progressLine(item, locale)) }), ...(item.scanProgressText ? [item.scanProgressText] : []), ...(currentFileName ? [t(locale, 'taskCenter.detail.currentFile', { value: markdownText(currentFileName) })] : []), + ...(storageLabel ? [t(locale, 'taskCenter.detail.storage', { value: markdownText(storageLabel) })] : []), ...(targetFolder ? [t(locale, 'taskCenter.detail.targetFolder', { value: markdownText(targetFolder) })] : []), ...(reason ? [t(locale, 'taskCenter.detail.reason', { value: markdownText(reason) })] : []), t(locale, 'taskCenter.detail.created', { value: formatAge(item.createdAt, now, locale) }), @@ -253,6 +258,7 @@ export function buildTaskCenterDetail( if (item.state === 'pausing') lines.push('', t(locale, 'taskCenter.note.pausing')); if (item.state === 'failed') lines.push('', t(locale, 'taskCenter.note.failed')); if (item.state === 'waiting') lines.push('', t(locale, 'taskCenter.note.start')); + if (item.state === 'awaiting_selection') lines.push('', t(locale, 'taskCenter.note.awaitingSelection')); if (item.state === 'running') lines.push('', t(locale, 'taskCenter.note.pause')); const actionRow: TaskCenterButton[] = []; @@ -318,7 +324,7 @@ export function parseTaskCenterCallback(data: string): ParsedTaskCenterCallback return { view: 'action', action, sourceType, id: match[3], page: Number(match[4]) }; } -export function ordinaryTaskCenterItem(group: DownloadTaskGroupSnapshot): TaskCenterItem | null { +export function ordinaryTaskCenterItem(group: DownloadTaskGroupSnapshot & { targetAccountName?: string | null }, locale: TelegramLocale = DEFAULT_LOCALE): TaskCenterItem | null { if (group.hidden || group.kind === 'channel' || group.state === 'completed' || group.state === 'cancelled' || group.state === 'cancelling') return null; const state: TaskCenterState = group.systemPause ? 'cooling' @@ -340,7 +346,10 @@ export function ordinaryTaskCenterItem(group: DownloadTaskGroupSnapshot): TaskCe chatId: group.chatId, userId: group.userId, source: group.source, - targetFolder: group.targetFolder, + targetFolder: group.targetFolder === '' ? '/' : group.targetFolder ?? (group.targetProvider ? t(locale, 'bot.wizard.folder.defaultValue') : undefined), + storageLabel: group.targetProvider === 'local' ? t(locale, 'commands.localStorage') : group.targetProvider + ? `${getProviderDisplayName(group.targetProvider)} / ${group.targetAccountName + || (group.targetAccountId ? group.targetAccountId.slice(0, 8) : t(locale, 'commands.unnamedAccount'))}` : undefined, reason: group.reason, protection: group.systemPause, createdAt: group.createdAt, @@ -381,21 +390,23 @@ export function channelTaskCenterItem(row: any, locale: TelegramLocale = DEFAULT } : undefined; const channelState = telegramChannelJobTaskState(row); - const state: TaskCenterState = row.status === 'paused' - ? (active > 0 ? 'pausing' : 'paused') - : inCooldown - ? 'cooling' - : channelState.status === 'cancelled' - ? 'failed' - : channelState.status === 'completed' - ? 'waiting' - : channelState.status === 'failed' - ? 'failed' - : channelState.status === 'pending' - ? 'waiting' - : active > 0 || channelState.stage === 'scanning' || channelState.stage === 'downloading' || row.is_actively_running - ? 'running' - : 'waiting'; + const state: TaskCenterState = row.status === 'awaiting_selection' + ? 'awaiting_selection' + : row.status === 'paused' + ? (active > 0 ? 'pausing' : 'paused') + : inCooldown + ? 'cooling' + : channelState.status === 'cancelled' + ? 'failed' + : channelState.status === 'completed' + ? 'waiting' + : channelState.status === 'failed' + ? 'failed' + : channelState.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') { @@ -418,9 +429,11 @@ export function channelTaskCenterItem(row: any, locale: TelegramLocale = DEFAULT const scan = checkpoint.progress || checkpoint; const scanProgressText = ['date_range', 'tag_download'].includes(String(row.kind)) ? [ t(locale, 'bot.legacy.channelScan', { scanned: safeNumber(scan.channelMessagesScanned), found: safeNumber(scan.channelMediaFound) }), + safeNumber(scan.waitUntil) > Date.now() ? t(locale, 'channelCard.scanWait', { time: formatDate(new Date(Number(scan.waitUntil)), locale) }) : '', safeNumber(scan.commentMessagesScanned) > 0 || safeNumber(scan.commentMediaFound) > 0 ? t(locale, 'bot.legacy.commentScan', { scanned: safeNumber(scan.commentMessagesScanned), found: safeNumber(scan.commentMediaFound) }) : '', scan.lastMessageDate ? t(locale, 'channelCard.scanDate', { time: formatDate(scan.lastMessageDate, locale) }) : '', + safeNumber(options.excludedCount) > 0 ? t(locale, 'channelCard.excluded', { count: safeNumber(options.excludedCount) }) : '', ].filter(Boolean).join('\n') : undefined; return { sourceType: 'channel', @@ -438,7 +451,12 @@ export function channelTaskCenterItem(row: any, locale: TelegramLocale = DEFAULT chatId, userId, source, - targetFolder: row.folder_override || options.folderOverride || null, + targetFolder: row.folder_override || options.folderOverride || t(locale, 'bot.wizard.folder.defaultValue'), + storageLabel: options.storageProvider === 'local' ? t(locale, 'commands.localStorage') : [ + options.storageProvider ? getProviderDisplayName(options.storageProvider) : t(locale, 'fileBrowser.unknown'), + options.storageAccountName || row.storage_account_name || (options.storageAccountId + ? String(options.storageAccountId).slice(0, 8) : t(locale, 'commands.unnamedAccount')), + ].join(' / '), reason: protection?.reason ?? (row.status === 'paused' ? t(locale, 'taskCenter.reason.userPaused') : row.error || undefined), protection, scanProgressText, diff --git a/backend/src/services/telegramTaskFailures.ts b/backend/src/services/telegramTaskFailures.ts new file mode 100644 index 0000000..1785d21 --- /dev/null +++ b/backend/src/services/telegramTaskFailures.ts @@ -0,0 +1,121 @@ +export interface TaskFailurePage { + offset: number; + limit: number; +} + +export interface TelegramTaskFailure { + id: string; + fileName: string | null; + error: string | null; + messageId: number | null; + messageUrl: string | null; + attempts: number; + updatedAt: string | null; +} + +interface FailureRow { + id: string; + file_name: string | null; + error: string | null; + source: string; + source_peer: string | null; + origin: string | null; + message_id: number | null; + attempts: number | null; + updated_at: string | null; +} + +type FailureQuery = (sql: string, values: unknown[]) => Promise<{ + rows: Array<{ total: string | number; items: FailureRow[] }>; +}>; + +export function parseTaskFailurePage(offset: unknown, limit: unknown): TaskFailurePage | null { + function integer(value: unknown, fallback: number): number | null { + if (value === undefined) return fallback; + if (typeof value !== 'string' || !/^\d+$/.test(value)) return null; + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null; + } + const parsedOffset = integer(offset, 0); + const parsedLimit = integer(limit, 10); + if (parsedOffset === null || parsedLimit === null) return null; + return { offset: parsedOffset, limit: Math.min(parsedLimit, 20) }; +} + +function messagePeerPath(value: unknown): string | null { + if (typeof value !== 'string' && typeof value !== 'number') return null; + const source = String(value).trim(); + const signedChannel = /^-100([1-9]\d*)$/.exec(source); + if (signedChannel) return `c/${signedChannel[1]}`; + if (/^@?[a-z][a-z0-9_]{0,31}$/i.test(source)) { + const username = source.replace(/^@/, ''); + return /^(?:joinchat|c|s|share|addstickers|addemoji|proxy|socks|login|iv)$/i.test(username) ? null : username; + } + // Match the original path, not a URL-normalized path that could hide traversal. + const link = /^(?:https:\/\/)?t\.me\/([^?#]*)(?:[?#][^\s]*)?$/i.exec(source); + if (!link) return null; + const privateChannel = /^c\/([1-9]\d*)(?:\/[1-9]\d*)?\/?$/.exec(link[1]); + if (privateChannel) return `c/${privateChannel[1]}`; + const publicChannel = /^(?:s\/)?([a-z][a-z0-9_]{0,31})(?:\/[1-9]\d*)?\/?$/i.exec(link[1]); + return publicChannel ? messagePeerPath(publicChannel[1]) : null; +} + +export function buildTelegramTaskMessageUrl(item: { + source: unknown; + sourcePeer?: unknown; + origin?: unknown; + messageId: unknown; +}): string | null { + if (typeof item.messageId !== 'number' || !Number.isSafeInteger(item.messageId) || item.messageId <= 0) return null; + const hasPeer = item.sourcePeer != null && String(item.sourcePeer).trim() !== ''; + const peer = messagePeerPath(hasPeer ? item.sourcePeer : item.source); + if (!peer) return null; + if (item.origin === 'comment') { + // A comment ID belongs to the discussion group, never to the channel. + // Older rows may have copied the channel source into source_peer. + const channel = messagePeerPath(item.source); + if (!hasPeer || peer.toLowerCase() === channel?.toLowerCase()) return null; + } + return `https://t.me/${peer}/${item.messageId}`; +} + +export async function getTelegramTaskFailures(jobId: string, page: TaskFailurePage, execute: FailureQuery): Promise<{ + items: TelegramTaskFailure[]; + total: number; + offset: number; + limit: number; +} | null> { + // One statement keeps existence, the current failed count and the page on one snapshot. + const result = await execute( + `SELECT (SELECT COUNT(*) FROM telegram_download_items WHERE job_id = j.id AND status = 'failed') AS total, + COALESCE((SELECT json_agg(failure_page) FROM ( + SELECT id, LEFT(NULLIF(file_name, ''), 512) AS file_name, + LEFT(COALESCE(NULLIF(last_error, ''), NULLIF(error, '')), 4096) AS error, + source, source_peer, origin, message_id, attempts, updated_at + FROM telegram_download_items + WHERE job_id = j.id AND status = 'failed' + ORDER BY updated_at DESC, id DESC + LIMIT $2 OFFSET $3 + ) failure_page), '[]'::json) AS items + FROM telegram_background_jobs j WHERE j.id = $1::uuid`, + [jobId, page.limit, page.offset], + ); + const row = result.rows[0]; + if (!row) return null; + return { + items: row.items.map(item => ({ + id: String(item.id), + fileName: item.file_name, + error: item.error, + messageId: item.message_id, + messageUrl: buildTelegramTaskMessageUrl({ + source: item.source, sourcePeer: item.source_peer, origin: item.origin, messageId: item.message_id, + }), + attempts: item.attempts ?? 0, + updatedAt: item.updated_at, + })), + total: Number(row.total), + offset: page.offset, + limit: page.limit, + }; +} diff --git a/backend/src/services/telegramUpload.ts b/backend/src/services/telegramUpload.ts index 6adf92a..a740932 100644 --- a/backend/src/services/telegramUpload.ts +++ b/backend/src/services/telegramUpload.ts @@ -1,5 +1,6 @@ import { Api, TelegramClient } from 'telegram'; import { withTelegramAccount, startTelegramDownloadAttempt, finishTelegramDownloadAttempt } from './telegramMultiAccountRuntime.js'; +import { getTelegramMessagesWithAccess } from './telegramScanAccount.js'; import { NewMessageEvent } from 'telegram/events/index.js'; import fs from 'fs'; import path from 'path'; @@ -45,6 +46,7 @@ import { resolveTelegramStorageFolderPersistent, resolveTelegramTaskStorageFolde import { consumeOrGetTelegramTargetState } from '../utils/telegramTargetStateStore.js'; import { findDuplicateFile, getDuplicateMode } from '../utils/duplicatePolicy.js'; import { DownloadTaskQueue, type DownloadTaskGroupInput, type DownloadTaskGroupSnapshot } from './downloadTaskQueue.js'; +import { runRollingDownloadWindow } from './rollingDownloadWindow.js'; import { persistOrdinaryTransferTask } from './transferTasks.js'; import { saveAndIndexWithCompensation, compensateIndexedWriteAfterCancel } from './storageWrite.js'; import { acquireStorageAccountOperationLease, withStorageAccountOperationLease } from './storageAccountOperation.js'; @@ -60,10 +62,11 @@ import { DEFAULT_LOCALE, formatBytes as formatLocalizedBytes, t, type TelegramLo import { buildTaskCenterDetail, ordinaryTaskCenterItem, type TaskCenterItem } from './telegramTaskCenter.js'; import { buildProgressControlButtons, registerProgressCard, isInteractiveProgressCard, taskCenterCardOwners, taskCenterCardKey } from './telegramProgressControls.js'; import { downloadProgressView } from './downloadProgress.js'; -import { telegramDownloadStream, TelegramDownloadGate } from './telegramDownloadStream.js'; +import { telegramDownloadStream, runTelegramDownloadStripes } from './telegramDownloadStream.js'; import { setTimeout as waitForDownload } from 'node:timers/promises'; import { getProviderDisplayName } from '../utils/providerMetadata.js'; -const telegramDownloadGates = new WeakMap(); +import { protectTemporaryUploadFile, withTemporaryUploadFiles } from '../utils/temporaryUploadFiles.js'; +import { normalizeFileDownloadConcurrency, registerFileConcurrencyRuntime } from './telegramFileConcurrency.js'; const UPLOAD_DIR = process.env.UPLOAD_DIR || './data/uploads'; const DEFAULT_TELEGRAM_DOWNLOAD_WORKERS = Math.max(1, Math.min(16, parseInt(process.env.TELEGRAM_DOWNLOAD_WORKERS || '4', 10) || 4)); @@ -78,10 +81,6 @@ const TG_MEDIA_GROUP_ENQUEUE_BATCH_SIZE = Math.max(1, parseInt(process.env.TG_ME const MEDIA_GROUP_DEBOUNCE_MS = 200; const MEDIA_GROUP_MAX_WAIT_MS = 1500; -function normalizeFileDownloadConcurrency(value: unknown): number { - const parsed = parseInt(String(value ?? process.env.TELEGRAM_FILE_DOWNLOAD_CONCURRENCY ?? '2'), 10); - return [1, 2, 3, 4].includes(parsed) ? parsed : 2; -} const TG_DEBUG_LOG_PATH = process.env.TG_STATUS_DEBUG_LOG || path.join(process.cwd(), 'data', 'logs', 'tg_silent_debug.log'); const TG_DEBUG_LOG_MAX_BYTES = Math.max(1024 * 1024, parseInt(process.env.TG_DEBUG_LOG_MAX_MB || '5', 10) * 1024 * 1024); @@ -189,7 +188,7 @@ async function downloadIncomingTelegramFile( const messageId = forwarded?.savedFromMsgId || forwarded?.channelPost; if (source && messageId) { return withTelegramAccount(sourceKeyForDownloadRef(source), 'download', async client => { - const [visible] = await client.getMessages(source, { ids: [messageId] }); + const [visible] = await getTelegramMessagesWithAccess(client, source, { ids: [messageId] }); if (!visible?.media) throw new Error('消息不存在或不包含可下载媒体'); return downloadAndSaveFile(client, visible, fileName, targetDir, onProgress, signal, groupId); }, { signal }); @@ -345,24 +344,6 @@ async function safeEditMessage(client: TelegramClient, chatId: Api.TypeEntityLik } } -const telegramStorageWriteLocks = new Map>(); - -async function withTelegramStorageWriteLock(key: string, fn: () => Promise): Promise { - const previous = telegramStorageWriteLocks.get(key) || Promise.resolve(); - let release!: () => void; - const current = new Promise(resolve => { release = resolve; }); - telegramStorageWriteLocks.set(key, previous.then(() => current, () => current)); - await previous.catch(() => undefined); - try { - return await fn(); - } finally { - release(); - if (telegramStorageWriteLocks.get(key) === current) { - telegramStorageWriteLocks.delete(key); - } - } -} - const silentNoticePromiseMap = new Map>(); const silentProgressEdits = new Map>(); @@ -526,6 +507,10 @@ const downloadQueue = new DownloadTaskQueue({ failed: group.failed, cancelled: group.cancelled, total: group.total }); }, }); +registerFileConcurrencyRuntime(value => { + downloadQueue.setMaxConcurrent(value); + process.env.TELEGRAM_FILE_DOWNLOAD_CONCURRENCY = String(value); +}); const channelTaskAbortRegistry = new TaskAbortRegistry(); // 状态操作序列化器 @@ -1165,9 +1150,9 @@ async function refreshConsolidatedMessage(client: TelegramClient, chatId: Api.Ty const existingMsgId = lastStatusMessageIdMap.get(chatIdStr); const ownerId = replyTo?.senderId?.toJSNumber() ?? (existingMsgId ? taskCenterCardOwners.get(taskCenterCardKey(chatIdStr, existingMsgId))?.userId : undefined); - const items = ownerId === undefined ? [] : listDownloadTaskGroups(chatIdStr, ownerId) - .map(ordinaryTaskCenterItem).filter((item): item is TaskCenterItem => Boolean(item)); const locale = ownerId === undefined ? DEFAULT_LOCALE : await getTelegramUserLocaleOrDefault(ownerId); + const items = ownerId === undefined ? [] : listDownloadTaskGroups(chatIdStr, ownerId) + .map(group => ordinaryTaskCenterItem(group, locale)).filter((item): item is TaskCenterItem => Boolean(item)); const buttons = buildProgressControlButtons(items, locale); // 新任务触发(有 replyTo):强制删除旧追踪器,并发送一条新的追踪器消息 @@ -1192,20 +1177,7 @@ export function getDownloadQueueStats() { return downloadQueue.getStats(); } -export function getFileDownloadConcurrency(): number { - return downloadQueue.getMaxConcurrent(); -} -export function setFileDownloadConcurrency(value: number): number { - const normalized = downloadQueue.setMaxConcurrent(value); - process.env.TELEGRAM_FILE_DOWNLOAD_CONCURRENCY = String(normalized); - return normalized; -} - -export async function loadFileDownloadConcurrencySetting(): Promise { - const value = await getSetting('telegram_file_download_concurrency', process.env.TELEGRAM_FILE_DOWNLOAD_CONCURRENCY || '2'); - return setFileDownloadConcurrency(normalizeFileDownloadConcurrency(value)); -} export function getTaskStatus() { return downloadQueue.getDetailedStatus(); @@ -1509,16 +1481,15 @@ async function downloadAndSaveFile( } const filePath = path.join(saveDir, tempStoredName); + protectTemporaryUploadFile(filePath); const totalSize = getEstimatedFileSize(message); - let gate = telegramDownloadGates.get(client); - if (!gate) { gate = new TelegramDownloadGate(); telegramDownloadGates.set(client, gate); } let downloadedSize = 0; - const beforeRead = async () => { + const beforeRead = async (readSignal = signal) => { while (groupId) { - signal?.throwIfAborted(); + readSignal?.throwIfAborted(); const group = downloadQueue.getGroup(groupId, {}, true); if (!group || !['paused', 'pausing'].includes(group.state)) return; - await waitForDownload(100, undefined, { signal }); + await waitForDownload(100, undefined, { signal: readSignal }); } }; @@ -1554,7 +1525,7 @@ async function downloadAndSaveFile( try { await fileHandle.truncate(totalSize); - const transfers = await Promise.allSettled(Array.from({ length: workers }, async (_, workerIndex) => { + await runTelegramDownloadStripes(workers, async (workerIndex, stripeSignal) => { let writeOffset = workerIndex * TELEGRAM_DOWNLOAD_PART_SIZE; for await (const chunk of telegramDownloadStream(offset => client.iterDownload({ file: media, @@ -1563,8 +1534,8 @@ async function downloadAndSaveFile( chunkSize: TELEGRAM_DOWNLOAD_PART_SIZE, requestSize: TELEGRAM_DOWNLOAD_PART_SIZE, fileSize: bigInt(totalSize), - }), { offset: writeOffset, stride: TELEGRAM_DOWNLOAD_PART_SIZE * workers, end: totalSize, signal, beforeRead, gate })) { - if (signal?.aborted) throw new Error('下载任务已停止'); + }), { offset: writeOffset, stride: TELEGRAM_DOWNLOAD_PART_SIZE * workers, end: totalSize, signal: stripeSignal, beforeRead: () => beforeRead(stripeSignal) })) { + stripeSignal.throwIfAborted(); if (writeOffset >= totalSize) break; const bytesToWrite = Math.min(chunk.length, totalSize - writeOffset); if (bytesToWrite > 0) { @@ -1576,16 +1547,14 @@ async function downloadAndSaveFile( } writeOffset += TELEGRAM_DOWNLOAD_PART_SIZE * workers; } - })); - const failure = transfers.find(result => result.status === 'rejected'); - if (failure?.status === 'rejected') throw failure.reason; + }, signal); } finally { await fileHandle.close(); } } else { const fileHandle = await fs.promises.open(filePath, 'w'); try { - for await (const chunk of telegramDownloadStream(offset => client.iterDownload({ file: media, offset: bigInt(offset), requestSize: TELEGRAM_DOWNLOAD_PART_SIZE }), { offset: 0, end: totalSize || undefined, signal, beforeRead, gate })) { + for await (const chunk of telegramDownloadStream(offset => client.iterDownload({ file: media, offset: bigInt(offset), requestSize: TELEGRAM_DOWNLOAD_PART_SIZE }), { offset: 0, end: totalSize || undefined, signal, beforeRead })) { if (signal?.aborted) throw new Error('下载任务已停止'); await fileHandle.writeFile(chunk); downloadedSize += chunk.length; @@ -1695,8 +1664,9 @@ async function processFileUpload( getExecutionControlState?: () => Promise<'run' | 'paused' | 'cooldown' | 'cancelled'>, ): Promise { file.status = 'queued'; + let retryDownload = true; - const attemptUpload = async (signal?: AbortSignal, reportProgress?: (downloaded: number, total: number) => void): Promise => { + const attemptUpload = (signal?: AbortSignal, reportProgress?: (downloaded: number, total: number) => void): Promise => withTemporaryUploadFiles(async () => { let localFilePath: string | undefined; let storedName: string | undefined; // This will hold the unique name for final storage @@ -1732,7 +1702,7 @@ async function processFileUpload( const ref = file.persistentRef!; const attemptId = await startTelegramDownloadAttempt({ accountId, sourceKey: sourceKeyForDownloadRef(ref.source || ''), jobId: ref.jobId || null, itemId: ref.itemId || null, leaseToken: ref.leaseToken || null }); try { - const [message] = await downloadClient.getMessages(ref.source || '', { ids: [ref.id] }); + const [message] = await getTelegramMessagesWithAccess(downloadClient, ref.source || '', { ids: [ref.id] }); if (!message) throw new Error('消息不存在或无法重新读取'); const downloaded = await downloadAndSaveFile(downloadClient, message, file.fileName, file.targetDir, reportProgress, signal, groupId); await finishTelegramDownloadAttempt(attemptId, 'succeeded'); @@ -1771,150 +1741,160 @@ async function processFileUpload( } if (signal?.aborted) throw new Error('下载任务已停止'); - const storageLockKey = `${provider.name}:${activeAccountId || 'local'}:${storageFolder || ''}`; - return await withTelegramStorageWriteLock(storageLockKey, async () => { - if (signal?.aborted) throw new Error('下载任务已停止'); - storedName = await getUniqueStoredName(file.fileName, storageFolder, activeAccountId); - - // 生成缩略图和获取尺寸。方案A:只在本地存储生成;第三方存储不生成本地缩略图。 - let thumbnailPath: string | null = null; - let dimensions: { width?: number; height?: number } = {}; - if (provider.name === 'local' && (file.mimeType.startsWith('image/') || file.mimeType.startsWith('video/'))) { - try { - thumbnailPath = await generateThumbnail(localFilePath!, storedName, file.mimeType); - dimensions = await getImageDimensions(localFilePath!, file.mimeType); - } catch (thumbErr) { - console.warn('🤖 生成缩略图/获取尺寸失败,继续上传:', thumbErr); - } + storedName = await getUniqueStoredName(file.fileName, storageFolder, activeAccountId); + + // 生成缩略图和获取尺寸。方案A:只在本地存储生成;第三方存储不生成本地缩略图。 + let thumbnailPath: string | null = null; + let dimensions: { width?: number; height?: number } = {}; + if (provider.name === 'local' && (file.mimeType.startsWith('image/') || file.mimeType.startsWith('video/'))) { + try { + thumbnailPath = await generateThumbnail(localFilePath!, storedName, file.mimeType); + dimensions = await getImageDimensions(localFilePath!, file.mimeType); + } catch (thumbErr) { + console.warn('🤖 生成缩略图/获取尺寸失败,继续上传:', thumbErr); } - - let finalPath = localFilePath!; - let indexedFileId: string | null = null; - let sourceRef = provider.name; - - const permissionBeforeStore = await waitForChannelExecutionPermission( - getExecutionControlState, - signal || new AbortController().signal, - { allowUserPauseForActiveWorker: true }, - ); - if (permissionBeforeStore === 'cancelled' || signal?.aborted) throw new Error('下载任务已停止'); - const save = async (): Promise => { - const persistentRef = file.persistentRef; - let operationId: string | undefined; - let savedPath = ''; - let indexState: 'unknown' | 'present' | 'deleted' = 'unknown'; - if (persistentRef?.jobId && persistentRef.itemId && persistentRef.leaseToken) { - operationId = await beginTelegramWriteReconciliation(pool, { - jobId: persistentRef.jobId, - itemId: persistentRef.itemId, - childLeaseToken: persistentRef.leaseToken, - provider: provider.name, - accountId: activeAccountId, - }); - persistentRef.writeOperationId = operationId; + } + + let finalPath = localFilePath!; + let indexedFileId: string | null = null; + let sourceRef = provider.name; + + const permissionBeforeStore = await waitForChannelExecutionPermission( + getExecutionControlState, + signal || new AbortController().signal, + { allowUserPauseForActiveWorker: true }, + ); + if (permissionBeforeStore === 'cancelled' || signal?.aborted) throw new Error('下载任务已停止'); + // Storage failures must not restart Telegram or create another unresolved write. + retryDownload = false; + const save = async (): Promise => { + const persistentRef = file.persistentRef; + signal?.throwIfAborted(); + const preparedWrite = await provider.prepareSaveFile?.(localFilePath!, storedName!, file.mimeType, storageFolder); + signal?.throwIfAborted(); + let operationId: string | undefined; + let savedPath = ''; + let indexState: 'unknown' | 'present' | 'deleted' = 'unknown'; + if (persistentRef?.jobId && persistentRef.itemId && persistentRef.leaseToken) { + operationId = await beginTelegramWriteReconciliation(pool, { + jobId: persistentRef.jobId, + itemId: persistentRef.itemId, + childLeaseToken: persistentRef.leaseToken, + provider: provider.name, + accountId: activeAccountId, + storedPath: preparedWrite?.storedPath, + }); + persistentRef.writeOperationId = operationId; + } + try { + signal?.throwIfAborted(); + savedPath = preparedWrite + ? await preparedWrite.save(signal) + : await provider.saveFile(localFilePath!, storedName!, file.mimeType, storageFolder, { signal }); + finalPath = savedPath; + if (operationId) await markTelegramWriteObjectPresent(pool, operationId, savedPath); + const inserted = await query(` + INSERT INTO files (name, stored_name, type, mime_type, size, path, thumbnail_path, width, height, source, folder, storage_account_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + RETURNING id + `, [file.fileName, storedName, fileType, file.mimeType, actualSize, savedPath, thumbnailPath, dimensions.width, dimensions.height, sourceRef, storageFolder, activeAccountId]); + indexedFileId = String(inserted.rows[0].id); + indexState = 'present'; + if (operationId) await markTelegramWriteIndexPresent(pool, operationId, indexedFileId); + return { savedPath: finalPath, fileId: indexedFileId }; + } catch (error) { + if (!operationId) throw error; + let objectState: 'unknown' | 'deleted' = 'unknown'; + let compensatedIndexState: 'unknown' | 'deleted' = indexState === 'present' ? 'unknown' : 'deleted'; + const errors: string[] = []; + if (savedPath) { + try { await provider.deleteFile(savedPath); objectState = 'deleted'; } + catch (cleanupError) { errors.push(`object: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`); } } + if (indexedFileId) { + try { + if ((await query('DELETE FROM files WHERE id = $1', [indexedFileId])).rowCount !== 1) throw new Error('索引补偿影响 0 行'); + compensatedIndexState = 'deleted'; + } catch (cleanupError) { errors.push(`index: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`); } + } + const originalMessage = error instanceof Error ? error.message : String(error); try { - savedPath = await provider.saveFile(localFilePath!, storedName!, file.mimeType, storageFolder); - finalPath = savedPath; - if (operationId) await markTelegramWriteObjectPresent(pool, operationId, savedPath); - const inserted = await query(` - INSERT INTO files (name, stored_name, type, mime_type, size, path, thumbnail_path, width, height, source, folder, storage_account_id) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) - RETURNING id - `, [file.fileName, storedName, fileType, file.mimeType, actualSize, savedPath, thumbnailPath, dimensions.width, dimensions.height, sourceRef, storageFolder, activeAccountId]); - indexedFileId = String(inserted.rows[0].id); - indexState = 'present'; - if (operationId) await markTelegramWriteIndexPresent(pool, operationId, indexedFileId); - return { savedPath: finalPath, fileId: indexedFileId }; - } catch (error) { - if (!operationId) throw error; - let objectState: 'unknown' | 'deleted' = savedPath ? 'unknown' : 'unknown'; - let compensatedIndexState: 'unknown' | 'deleted' = indexState === 'present' ? 'unknown' : 'deleted'; - const errors: string[] = []; - if (savedPath) { - try { await provider.deleteFile(savedPath); objectState = 'deleted'; } - catch (cleanupError) { errors.push(`object: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`); } - } - if (indexedFileId) { - try { - if ((await query('DELETE FROM files WHERE id = $1', [indexedFileId])).rowCount !== 1) throw new Error('索引补偿影响 0 行'); - compensatedIndexState = 'deleted'; - } catch (cleanupError) { errors.push(`index: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`); } - } await updateTelegramWriteAfterCompensation(pool, operationId, { objectState, indexState: compensatedIndexState, - reason: errors.join('; ') || `保存失败且补偿已确认: ${error instanceof Error ? error.message : String(error)}`, + reason: `保存失败: ${originalMessage}${errors.length ? `; 补偿失败: ${errors.join('; ')}` : ''}`, }); - throw error; + } catch (journalError) { + console.error('Telegram write journal 保存失败证据更新失败:', journalError); } - }; - const leasedSave = () => withStorageAccountOperationLease(pool, activeAccountId, 'telegram_upload', save); - if (file.withLease) { - const persisted = await runLeaseProtectedTelegramSave( - file.withLease, - leasedSave, - async saved => { - const compensation = await compensateIndexedWriteAfterCancel({ - fileId: saved.fileId, - savedPath: saved.savedPath, - deleteIndex: async fileId => (await query('DELETE FROM files WHERE id = $1', [fileId])).rowCount === 1, - deleteObject: savedPath => provider.deleteFile(savedPath), - }); - if (file.persistentRef?.writeOperationId) { - await updateTelegramWriteAfterCompensation(pool, file.persistentRef.writeOperationId, { - objectState: compensation.status === 'compensated' ? 'deleted' : 'unknown', - indexState: compensation.status === 'compensated' ? 'deleted' : 'unknown', - reason: compensation.status === 'compensated' ? 'child settlement 失败后的补偿已确认' : (compensation.error || '补偿结果不确定'), - }); - } - return compensation; - }, - async () => { - if (signal?.aborted) throw new Error('下载任务已停止'); - const state = await waitForChannelExecutionPermission( - getExecutionControlState, - signal || new AbortController().signal, - { allowUserPauseForActiveWorker: true }, - ); - if (state === 'cancelled') throw new Error('下载任务已停止'); - }, - ); - finalPath = persisted.savedPath; - indexedFileId = persisted.fileId; - file.leaseSettled = true; - } else { - await leasedSave(); + throw error; } - if (fs.existsSync(localFilePath!)) fs.unlinkSync(localFilePath!); - localFilePath = undefined; - - // Durable child-lease saves validate cancellation before success settlement. Once - // settled, late AbortSignal delivery must not delete a file whose child is success. - if (signal?.aborted && !file.leaseSettled) { - const compensation = indexedFileId - ? await compensateIndexedWriteAfterCancel({ - fileId: indexedFileId, - savedPath: finalPath, + }; + const leasedSave = () => withStorageAccountOperationLease(pool, activeAccountId, 'telegram_upload', save); + if (file.withLease) { + const persisted = await runLeaseProtectedTelegramSave( + file.withLease, + leasedSave, + async saved => { + const compensation = await compensateIndexedWriteAfterCancel({ + fileId: saved.fileId, + savedPath: saved.savedPath, deleteIndex: async fileId => (await query('DELETE FROM files WHERE id = $1', [fileId])).rowCount === 1, deleteObject: savedPath => provider.deleteFile(savedPath), - }) - : { status: 'reconciliation-required' as const, error: '取消补偿缺少文件索引 ID' }; - if (compensation.status !== 'compensated') { - throw new Error(`下载任务取消后需要人工对账: ${compensation.error}`); - } - throw new Error('下载任务已停止'); + }); + if (file.persistentRef?.writeOperationId) { + await updateTelegramWriteAfterCompensation(pool, file.persistentRef.writeOperationId, { + objectState: compensation.status === 'compensated' ? 'deleted' : 'unknown', + indexState: compensation.status === 'compensated' ? 'deleted' : 'unknown', + reason: compensation.status === 'compensated' ? 'child settlement 失败后的补偿已确认' : (compensation.error || '补偿结果不确定'), + }); + } + return compensation; + }, + async () => { + if (signal?.aborted) throw new Error('下载任务已停止'); + const state = await waitForChannelExecutionPermission( + getExecutionControlState, + signal || new AbortController().signal, + { allowUserPauseForActiveWorker: true }, + ); + if (state === 'cancelled') throw new Error('下载任务已停止'); + }, + ); + finalPath = persisted.savedPath; + indexedFileId = persisted.fileId; + file.leaseSettled = true; + } else { + await leasedSave(); + } + if (fs.existsSync(localFilePath!)) fs.unlinkSync(localFilePath!); + localFilePath = undefined; + + // Durable child-lease saves validate cancellation before success settlement. Once + // settled, late AbortSignal delivery must not delete a file whose child is success. + if (signal?.aborted && !file.leaseSettled) { + const compensation = indexedFileId + ? await compensateIndexedWriteAfterCancel({ + fileId: indexedFileId, + savedPath: finalPath, + deleteIndex: async fileId => (await query('DELETE FROM files WHERE id = $1', [fileId])).rowCount === 1, + deleteObject: savedPath => provider.deleteFile(savedPath), + }) + : { status: 'reconciliation-required' as const, error: '取消补偿缺少文件索引 ID' }; + if (compensation.status !== 'compensated') { + throw new Error(`下载任务取消后需要人工对账: ${compensation.error}`); } - file.status = 'success'; - file.size = actualSize; - file.fileType = fileType; - if (queue?.chatId) { - const chatIdStr = queue.chatId.toString(); - const batchId = (file.message as any).groupedId?.toString(); - if (batchId) updateBatch(chatIdStr, batchId, { folderPath: storageFolder || '/', providerName: provider.name }); - rememberTransferDestination(chatIdStr, storageFolder || '/', await describeStorageTarget(storageTarget)); - } - return true; - }); + throw new Error('下载任务已停止'); + } + file.status = 'success'; + file.size = actualSize; + file.fileType = fileType; + if (queue?.chatId) { + const chatIdStr = queue.chatId.toString(); + const batchId = (file.message as any).groupedId?.toString(); + if (batchId) updateBatch(chatIdStr, batchId, { folderPath: storageFolder || '/', providerName: provider.name }); + rememberTransferDestination(chatIdStr, storageFolder || '/', await describeStorageTarget(storageTarget)); + } + return true; } catch (error) { if ((error as Error)?.name === 'TelegramDownloadLeaseLostError') throw error; @@ -1941,7 +1921,7 @@ async function processFileUpload( } return false; } - }; + }); const queueTask = async (signal: AbortSignal, taskId?: string) => { try { @@ -1962,13 +1942,14 @@ async function processFileUpload( const firstAttemptSuccess = await attemptUpload(signal, reportProgress); if (taskId) downloadQueue.stopProgress(taskId); - if (!firstAttemptSuccess && !signal.aborted && !file.retried && !file.storageCooldownUntil) { + if (!firstAttemptSuccess && retryDownload && !signal.aborted && !file.retried && !file.storageCooldownUntil) { file.retried = true; file.status = 'uploading'; // 保持 uploading 状态供外部显示 - file.error = undefined; + const originalError = file.error; if (taskId) downloadQueue.resetProgress(taskId); const secondAttemptSuccess = await attemptUpload(signal, reportProgress); if (taskId) downloadQueue.stopProgress(taskId); + file.error = secondAttemptSuccess ? undefined : originalError || file.error; if (!secondAttemptSuccess) { file.status = 'failed'; } @@ -2327,6 +2308,7 @@ export async function downloadTelegramChannelRange( ownerUserId?: number, storageTarget: StorageTargetSnapshot = storageManager.getActiveTarget(), withItemLease?: (ref: TelegramDownloadMessageRef, operation: () => Promise) => Promise, + loadMoreRefs?: (limit: number) => Promise, ): Promise<{ requested: number; found: number; skipped: number; failed: number; successful: number; successfulMessageIds: number[]; failedMessageIds: number[]; skippedMessageIds: number[]; firstId: number; lastId: number }> { const userClient = botClient; const safeLimit = Math.max(1, Math.floor(limit || TG_BATCH_DEFAULT_LIMIT)); @@ -2359,8 +2341,8 @@ export async function downloadTelegramChannelRange( const failedMessageIds: number[] = []; const skippedMessageIds: number[] = []; - if (normalizedExplicitRefs) { - for (const ref of normalizedExplicitRefs) { + const appendExplicitRefs = async (refs: TelegramDownloadMessageRef[]) => { + for (const ref of refs) { const refSource = ref.source || sourceEntity; const fileInfo = ref.fileInfo; if (!fileInfo) { @@ -2385,10 +2367,13 @@ export async function downloadTelegramChannelRange( groupSize: ref.groupSize, }); } + }; + if (normalizedExplicitRefs) { + await appendExplicitRefs(normalizedExplicitRefs); } else { for (let offset = 0; offset < ids.length; offset += TG_LARGE_TASK_SEGMENT_SIZE) { const scanIds = ids.slice(offset, offset + TG_LARGE_TASK_SEGMENT_SIZE); - const scanMessages = await withTelegramAccount(String(source), 'metadata', client => client.getMessages(sourceEntity, { ids: scanIds }), { signal: taskSignal }); + const scanMessages = await withTelegramAccount(String(source), 'metadata', client => getTelegramMessagesWithAccess(client, sourceEntity, { ids: scanIds }), { signal: taskSignal }); const returnedIds = new Set(); for (const sourceMessage of scanMessages) { if (!sourceMessage) continue; @@ -2511,11 +2496,21 @@ export async function downloadTelegramChannelRange( } }; - for (let offset = 0; offset < downloadableRefs.length; offset += TG_LARGE_TASK_SEGMENT_SIZE) { - const segment = downloadableRefs.slice(offset, offset + TG_LARGE_TASK_SEGMENT_SIZE); + const windowSize = Math.max(1, Math.min(downloadableRefs.length, TG_LARGE_TASK_SEGMENT_SIZE)); + let nextRef = 0; + await runRollingDownloadWindow(windowSize, async capacity => { + taskSignal?.throwIfAborted(); + const controlState = await getExecutionControlState?.(); + if (controlState && controlState !== 'run') return []; + if (nextRef >= downloadableRefs.length && loadMoreRefs) { + const additional = normalizeTelegramDownloadRefs(await loadMoreRefs(capacity), sourceEntity) || []; + ids.push(...additional.map(ref => ref.id)); + await appendExplicitRefs(additional); + } + const segment = downloadableRefs.slice(nextRef, nextRef + capacity); const segmentBytes = segment.reduce((sum, item) => sum + (item.totalSize || 0), 0); + if (segment.length === 0) return []; await waitForDiskWatermark(segmentBytes, taskSignal); - const segmentMessagesBySource = new Map>(); const refsBySource = new Map(); for (const item of segment) { const items = refsBySource.get(item.sourceKey) || []; @@ -2534,15 +2529,17 @@ export async function downloadTelegramChannelRange( } if (missingSourceItems.length > 0) { const segmentIds = missingSourceItems.map(item => item.id); - const segmentMessages = await withTelegramAccount(sourceKey, 'metadata', client => client.getMessages(sourceItems[0].sourceEntity, { ids: segmentIds }), { signal: taskSignal }); + const segmentMessages = await withTelegramAccount(sourceKey, 'metadata', client => getTelegramMessagesWithAccess(client, sourceItems[0].sourceEntity, { ids: segmentIds }), { signal: taskSignal }); for (const segmentMessage of segmentMessages) { if (segmentMessage) preloadedMessageById.set(segmentMessage.id, segmentMessage as Api.Message); } } - segmentMessagesBySource.set(sourceKey, preloadedMessageById); + for (const item of sourceItems) item.message = preloadedMessageById.get(item.id); } - const outcomes = await Promise.allSettled(segment.map(async (item) => { + nextRef += segment.length; + return segment; + }, async item => { if (getExecutionControlState) { const controlState = await getExecutionControlState(); if (controlState !== 'run') { @@ -2556,7 +2553,7 @@ export async function downloadTelegramChannelRange( } } const { fileName, mimeType } = item.fileInfo; - const message = segmentMessagesBySource.get(item.sourceKey)?.get(item.id); + const message = item.message; if (!message) { skipped += 1; failed += 1; @@ -2628,13 +2625,11 @@ export async function downloadTelegramChannelRange( completed += 1; found += 1; } + item.message = undefined; await refreshSegmentStatus(false, fileName); } - })); - const failure = outcomes.find(result => result.status === 'rejected'); - if (failure?.status === 'rejected') throw failure.reason; - await refreshSegmentStatus(true, segment[segment.length - 1]?.fileInfo.fileName); - } + }); + await refreshSegmentStatus(true); if (downloadableRefs.length > 0 && !executionGroupKey) { updateBatch(chatIdStr, batchId, { completed, successful, failed, queuePending: 0, currentFileName: undefined }); @@ -2823,7 +2818,7 @@ export async function handleFileUpload(client: TelegramClient, event: NewMessage message: buildDownloadProgress(finalFileName, 0, totalSize, typeEmoji, undefined, locale) + destinationText, buttons: buildProgressControlButtons(listDownloadTaskGroups(chatIdStr, senderId) .filter(group => group.id === singleGroupId) - .map(ordinaryTaskCenterItem).filter((item): item is TaskCenterItem => Boolean(item)), locale), + .map(group => ordinaryTaskCenterItem(group, locale)).filter((item): item is TaskCenterItem => Boolean(item)), locale), }) as Api.Message; if (statusMsg) { updateLastStatusMessageId(chatId, statusMsg.id, false); @@ -2838,10 +2833,10 @@ export async function handleFileUpload(client: TelegramClient, event: NewMessage await runStatusAction(chatId, async () => { await safeEditMessage(client, chatId, { message: statusMsg!.id, - text: buildQueuedMessage(finalFileName, stats.pending, locale), + text: buildQueuedMessage(finalFileName, stats.pending, locale) + destinationText, buttons: buildProgressControlButtons(listDownloadTaskGroups(chatIdStr, senderId) .filter(group => group.id === singleGroupId) - .map(ordinaryTaskCenterItem).filter((item): item is TaskCenterItem => Boolean(item)), locale), + .map(group => ordinaryTaskCenterItem(group, locale)).filter((item): item is TaskCenterItem => Boolean(item)), locale), }); }); } @@ -2859,11 +2854,13 @@ export async function handleFileUpload(client: TelegramClient, event: NewMessage if (!statusMsg) return; const group = downloadQueue.getGroup(singleGroupId, { chatId: chatIdStr, userId: senderId }); if (!group || !['running', 'waiting', 'paused', 'pausing'].includes(group.state)) return; - const item = ordinaryTaskCenterItem(group); + const item = ordinaryTaskCenterItem(group, locale); if (!item) return; + item.storageLabel = storageLabel; + item.targetFolder = previewFolder || '/'; const paused = group.state === 'paused' || group.state === 'pausing'; const text = paused - ? buildTaskCenterDetail(item, 0, { locale }).text + destinationText + ? buildTaskCenterDetail(item, 0, { locale }).text : buildDownloadProgress(finalFileName, group.completedBytes || 0, group.totalBytes || totalSize, typeEmoji, undefined, locale, group.speedBytesPerSecond || 0) + destinationText; await safeEditMessage(client, chatId, { message: statusMsg.id, text, buttons: buildProgressControlButtons([item], locale), @@ -2906,7 +2903,7 @@ export async function handleFileUpload(client: TelegramClient, event: NewMessage let lastError: string | undefined; let storageCooldownUntil: Date | undefined; - const attemptSingleUpload = async (signal?: AbortSignal, reportProgress: (downloaded: number, total: number) => void = (downloaded, total) => { void onProgress(downloaded, total); }): Promise => { + const attemptSingleUpload = (signal?: AbortSignal, reportProgress: (downloaded: number, total: number) => void = (downloaded, total) => { void onProgress(downloaded, total); }): Promise => withTemporaryUploadFiles(async () => { let localFilePath: string | undefined; const storageTarget = singleStorageTarget; const { provider, accountId: activeAccountId } = storageTarget; @@ -2958,7 +2955,7 @@ export async function handleFileUpload(client: TelegramClient, event: NewMessage await runStatusAction(chatId, async () => { await safeEditMessage(client, chatId, { message: statusMsg!.id, - text: buildDuplicateSkipped(finalFileName, storageFolder, duplicate.id, locale), + text: buildDuplicateSkipped(finalFileName, storageFolder, duplicate.id, locale) + `\n📍 ${storageLabel}`, }); }); } @@ -3064,7 +3061,7 @@ export async function handleFileUpload(client: TelegramClient, event: NewMessage lastLocalPath = undefined; return false; } - }; + }); const singleUploadTask = async (signal: AbortSignal, taskId?: string) => { const reportQueueProgress = (downloaded: number, total: number) => { @@ -3094,10 +3091,10 @@ export async function handleFileUpload(client: TelegramClient, event: NewMessage await runStatusAction(chatId, async () => { await safeEditMessage(client, chatId, { message: statusMsg!.id, - text: buildRetryMessage(finalFileName, typeEmoji, locale), + text: buildRetryMessage(finalFileName, typeEmoji, locale) + destinationText, buttons: buildProgressControlButtons(listDownloadTaskGroups(chatIdStr, senderId) .filter(group => group.id === singleGroupId) - .map(ordinaryTaskCenterItem).filter((item): item is TaskCenterItem => Boolean(item)), locale), + .map(group => ordinaryTaskCenterItem(group, locale)).filter((item): item is TaskCenterItem => Boolean(item)), locale), }); }); } @@ -3133,7 +3130,7 @@ export async function handleFileUpload(client: TelegramClient, event: NewMessage await runStatusAction(chatId, async () => { await safeEditMessage(client, chatId, { message: statusMsg!.id, - text: buildUploadFail(finalFileName, lastError!, locale) + text: buildUploadFail(finalFileName, lastError!, locale) + destinationText }).catch(() => { }); }); } @@ -3154,12 +3151,12 @@ export async function handleFileUpload(client: TelegramClient, event: NewMessage await runStatusAction(chatId, async () => { await safeEditMessage(client, chatId, { message: statusMsg!.id, - text: buildUploadFail(finalFileName, lastError || t(locale, 'upload.error.unknown'), locale) + text: buildUploadFail(finalFileName, lastError || t(locale, 'upload.error.unknown'), locale) + destinationText }).catch(() => { }); }); } else { await safeReply(message, { - message: buildUploadFail(finalFileName, lastError || t(locale, 'upload.error.unknown'), locale) + message: buildUploadFail(finalFileName, lastError || t(locale, 'upload.error.unknown'), locale) + destinationText }); } } else { @@ -3193,7 +3190,7 @@ export async function handleFileUpload(client: TelegramClient, event: NewMessage } else if (statusMsg) { await safeEditMessage(client, chatId, { message: statusMsg.id, - text: buildUploadFail(finalFileName, error, locale), + text: buildUploadFail(finalFileName, error, locale) + destinationText, }); } setTimeout(() => removeUpload(chatIdStr, uploadId), 8000); diff --git a/backend/src/services/telegramUserClient.ts b/backend/src/services/telegramUserClient.ts index aaf5bca..de51855 100644 --- a/backend/src/services/telegramUserClient.ts +++ b/backend/src/services/telegramUserClient.ts @@ -14,6 +14,8 @@ import { upsertTelegramUserAccountWithoutRuntimeRefresh, } from './telegramUserClientPool.js'; import { initializeTelegramMultiAccountRuntime } from './telegramMultiAccountRuntime.js'; +import { ownUserTransport } from './telegramUserTransport.js'; +import { TelegramUserWorkerClient } from './telegramUserWorkerClient.js'; export const TELEGRAM_USER_SESSION_SETTING = 'telegram_user_session'; const TELEGRAM_USER_ENABLED_SETTING = 'telegram_user_download_enabled'; @@ -37,11 +39,8 @@ function getSessionFilePath(): string { async function stopLegacyClient(): Promise { const current = userClient; + if (current) await current.destroy(); userClient = null; - if (current) { - try { await current.disconnect(); } catch { /* best effort */ } - try { await current.destroy(); } catch { /* best effort */ } - } } export async function migrateLegacyTelegramUserSession(): Promise { @@ -56,15 +55,16 @@ export async function migrateLegacyTelegramUserSession(): Promise { } function makeClient(session: string, credentials: { apiId: number; apiHash: string }): TelegramClient { - return new TelegramClient(new StringSession(session), credentials.apiId, credentials.apiHash, { - connectionRetries: 15, - retryDelay: 2000, + if (session) return new TelegramUserWorkerClient(session, credentials, 'legacy'); + return ownUserTransport(new TelegramClient(new StringSession(session), credentials.apiId, credentials.apiHash, { + connectionRetries: 1, + retryDelay: 0, useWSS: false, deviceModel: 'TG Vault User Downloader', systemVersion: '1.0.0', appVersion: '1.0.0', - floodSleepThreshold: 120, - }); + floodSleepThreshold: 0, + }), { accountId: 'web-login' }); } export async function initTelegramUserClient(credentials?: { apiId: number; apiHash: string }): Promise { @@ -108,8 +108,7 @@ export async function initTelegramUserClient(credentials?: { apiId: number; apiH const legacyPath = getSessionFilePath(); if (fs.existsSync(legacyPath)) fs.rmSync(legacyPath, { force: true }); } catch (error) { - try { await client.disconnect(); } catch { /* best effort */ } - try { await client.destroy(); } catch { /* best effort */ } + await client.destroy(); recordTelegramUserClientFailure(String((error as Error).message).includes('EXPIRED') ? 'expired' : 'error', 'Telegram 用户账号连接失败'); } } diff --git a/backend/src/services/telegramUserClientPool.ts b/backend/src/services/telegramUserClientPool.ts index 1831d71..ec50a5a 100644 --- a/backend/src/services/telegramUserClientPool.ts +++ b/backend/src/services/telegramUserClientPool.ts @@ -7,7 +7,9 @@ import type { import { telegramAccountRepository } from './telegramAccountRepository.js'; import { TelegramClient, utils } from 'telegram'; import type { Api } from 'telegram'; -import { StringSession } from 'telegram/sessions/index.js'; +import { TelegramUserWorkerClient } from './telegramUserWorkerClient.js'; +import { TELEGRAM_EXTREME_FLOOD_WAIT_SECONDS, type TelegramTransportRuntimeEvent } from './telegramWorkerProtocol.js'; +import { withTelegramOperationSignal } from './telegramOperationContext.js'; import { decryptCredential } from '../utils/credentialCrypto.js'; import { selectWeightedLeastConnectedTelegramAccount, @@ -24,6 +26,8 @@ export interface TelegramPoolCredentials { export interface TelegramPooledClient { connected?: boolean; + runtimeProcessId?: number; + setRuntimeEventHandler?(handler: ((event: TelegramTransportRuntimeEvent) => void) | undefined): void; connect(): Promise; checkAuthorization(): Promise; getMe(): Promise; @@ -47,26 +51,49 @@ interface PoolRepository extends Pick { account: TelegramUserAccountRecord; client: C; + closing?: boolean; active: Record; lastSelected: Record; concurrencyLimit: number; - stableSince: number; scanIntervalMs: number; lastScanStartedAt?: number; scanSuccessSince?: number; + scanSuccessfulRequests: number; lastScanSucceededAt?: number; + floodEpisode?: number; + reducedFloodEpisode?: number; + fragmentLimit?: number; + successfulReads: number; + recoveryReads: number; + successSince?: number; + lastSucceededAt?: number; } export interface SelectedTelegramDownloadAccount { accountId: string; client: C; release(): void; - generation: number; +} + +export interface TelegramAccountFloodWait { + accountId: string; + retryAt: number; + seconds: number; +} + +export interface TelegramScanRequestEvent { + accountId: string; + method: string; + readableAccounts: number; + pacingMs: number; } export interface TelegramAccountSelectionOptions extends TelegramAccountSchedulingOptions { scope?: TelegramAccountSourceScope; signal?: AbortSignal; + preferredAccountId?: string; + onFloodWait?: (event: TelegramAccountFloodWait) => Promise | void; + onScanRequest?: (event: TelegramScanRequestEvent) => void; } export type PublicTelegramUserAccount = Omit & { @@ -88,8 +115,11 @@ function throwIfAborted(signal?: AbortSignal): void { const ACCOUNT_RECOVERY_INTERVAL_MS = 60_000; const ACCOUNT_WAIT_RECHECK_MS = 1_000; const SCAN_INTERVAL_MS = 1_000; -const SCAN_MAX_INTERVAL_MS = 60_000; -const SCAN_RECOVERY_INTERVAL_MS = 10 * 60_000; +// Adaptive spacing is separate from Telegram's mandatory cooldown: add one +// second per flood, cap at eight, and recover one second after active success. +const SCAN_MAX_INTERVAL_MS = 8_000; +const SCAN_RECOVERY_INTERVAL_MS = 60_000; +const SCAN_RECOVERY_SUCCESSES = 8; function errorName(error: unknown): string { const value = error as { errorMessage?: unknown; message?: unknown } | null; @@ -125,6 +155,10 @@ export class TelegramUserClientPool void>(); private readonly pendingAccessDenials = new Map(); + private readonly quarantined = new Map(); + private readonly reconnectAfter = new Map(); + private readonly runtimeClients = new Map(); + private readonly cooldownWrites = new Map }>(); constructor(private readonly deps: { repository: PoolRepository; @@ -142,14 +176,24 @@ export class TelegramUserClientPool account.healthState !== 'session_expired').map(account => account.id)); - await Promise.all([...this.entries.keys()].filter(id => !enabledIds.has(id)).map(id => this.expireEntry(id))); + await Promise.all([...this.entries.keys()].filter(id => !enabledIds.has(id) || this.entries.get(id)?.closing).map(id => this.expireEntry(id))); for (const account of accounts) { const entry = this.entries.get(account.id); + if (entry?.client.connected === false && !entry.closing && Object.values(entry.active).every(count => count === 0)) { + await this.expireEntry(account.id); + continue; + } if (!entry) continue; - entry.account = account; + const cooldownUntil = Math.max(entry.account.cooldownUntil?.getTime() || 0, account.cooldownUntil?.getTime() || 0); + entry.account = { ...account, cooldownUntil: cooldownUntil ? new Date(cooldownUntil) : null, + healthState: cooldownUntil > this.now() ? 'degraded' : account.healthState, + lastError: cooldownUntil > this.now() ? entry.account.lastError || account.lastError : account.lastError }; entry.concurrencyLimit = Math.min(entry.concurrencyLimit, Math.max(1, account.maxConnections)); } - await Promise.allSettled(accounts.map(account => this.connectAccount(account))); + const results = await Promise.allSettled(accounts.map(account => this.connectAccount(account))); + for (let index = 0; index < results.length; index++) { + if (results[index].status === 'rejected') console.error(JSON.stringify({ event: 'telegram_account_cleanup_quarantined', accountId: accounts[index].id })); + } this.wakeWaiters(); }); this.initializationTail = run.catch(() => undefined); @@ -180,44 +224,128 @@ export class TelegramUserClientPool { - if (this.entries.has(account.id) || !this.credentials || !account.enabled || account.healthState === 'session_expired') return; + if (this.entries.has(account.id) || this.quarantined.has(account.id) || this.now() < (this.reconnectAfter.get(account.id) || 0) || !this.credentials || !account.enabled || account.healthState === 'session_expired') return; let client: C | null = null; try { const session = this.deps.decryptSession(account.session); client = this.deps.createClient(session, this.credentials, account.id); + const entry: PoolEntry = { + account: { ...account }, client, + active: { download: 0, scan: 0, metadata: 0 }, + lastSelected: { download: 0, scan: 0, metadata: 0 }, + concurrencyLimit: Math.max(1, account.maxConnections), + successfulReads: 0, recoveryReads: 0, + scanSuccessfulRequests: 0, + scanIntervalMs: account.cooldownUntil && account.cooldownUntil.getTime() > this.now() ? SCAN_INTERVAL_MS * 2 : SCAN_INTERVAL_MS, + }; + this.runtimeClients.set(account.id, client); + client.setRuntimeEventHandler?.(event => { + if (this.runtimeClients.get(account.id) !== entry.client || entry.closing) return; + this.handleRuntimeEvent(entry, event); + }); await client.connect(); if (!(await client.checkAuthorization())) throw new Error('SESSION_EXPIRED'); await client.getMe(); const saved = this.deps.saveSession?.(client) || session; if (saved && saved !== session) await this.deps.repository.updateSession(account.id, saved); - await this.deps.repository.recordHealthy(account.id); - this.entries.set(account.id, { - account: { ...account, healthState: 'healthy', lastError: null }, - client, - active: { download: 0, scan: 0, metadata: 0 }, - lastSelected: { download: 0, scan: 0, metadata: 0 }, - concurrencyLimit: Math.max(1, account.maxConnections), - stableSince: this.now(), - scanIntervalMs: account.cooldownUntil && account.cooldownUntil.getTime() > this.now() ? SCAN_INTERVAL_MS * 2 : SCAN_INTERVAL_MS, - }); + if ((entry.account.cooldownUntil?.getTime() || 0) > this.now()) { + entry.account = { ...entry.account, healthState: 'degraded' }; + await this.persistCooldown(account.id, entry.account.cooldownUntil!, entry.account.lastError); + } else { + await this.deps.repository.recordHealthy(account.id); + if ((entry.account.cooldownUntil?.getTime() || 0) > this.now()) { + await this.persistCooldown(account.id, entry.account.cooldownUntil!, entry.account.lastError); + } else entry.account = { ...entry.account, healthState: 'healthy', lastError: null }; + } + this.entries.set(account.id, entry); this.wakeWaiters(); } catch (error) { - if (client) await this.closeClient(client); + const observedCooldown = this.cooldownWrites.get(account.id); + this.reconnectAfter.set(account.id, classifyTelegramDownloadAccountError(error) === 'flood_wait' + ? observedCooldown?.until.getTime() ?? this.now() + telegramFloodWaitSeconds(error) * 1000 + : this.now() + 60_000); + if (client) { + try { await this.closeClient(client); } + catch (closeError) { this.quarantined.set(account.id, client); throw closeError; } + } const message = errorName(error); if (isTelegramSessionExpiredError(error)) await this.deps.repository.markSessionExpired(account.id, message); else if (classifyTelegramDownloadAccountError(error) === 'flood_wait') { - await this.deps.repository.markCooldown(account.id, telegramFloodWaitSeconds(error), message); + const seconds = telegramFloodWaitSeconds(error); + await this.persistCooldown(account.id, observedCooldown?.until || new Date(this.now() + seconds * 1000), observedCooldown?.error || message); } else await this.deps.repository.recordFailure(account.id, message); } } private now(): number { return this.deps.now?.() ?? Date.now(); } + private handleRuntimeEvent(entry: PoolEntry, event: TelegramTransportRuntimeEvent): void { + if (event.episode < (entry.floodEpisode || 0)) return; + entry.fragmentLimit = event.fragmentLimit; + if (event.type === 'cooldown') { + const distinct = event.episode > (entry.floodEpisode || 0); + entry.floodEpisode = event.episode; + const reason = `${event.reason}_${event.seconds}`; + const reduceDownloads = event.file && event.seconds >= TELEGRAM_EXTREME_FLOOD_WAIT_SECONDS + && entry.reducedFloodEpisode !== event.episode; + if (reduceDownloads) entry.reducedFloodEpisode = event.episode; + this.applyCooldown(entry, new Date(event.until), reason, event.file ? 'download' : 'scan', distinct, reduceDownloads); + void this.persistCooldown(entry.account.id, entry.account.cooldownUntil!, reason).catch((error: unknown) => { + console.error(JSON.stringify({ event: 'telegram_account_cooldown_persist_failed', accountId: entry.account.id, + error: errorName(error) })); + }); + return; + } + if (event.episode !== (entry.floodEpisode || 0) || event.successfulReads <= entry.successfulReads || + event.at < (entry.account.cooldownUntil?.getTime() || 0)) return; + const reads = event.successfulReads - entry.successfulReads; + entry.successfulReads = event.successfulReads; + if (entry.lastSucceededAt === undefined || event.at - entry.lastSucceededAt > ACCOUNT_RECOVERY_INTERVAL_MS) { + entry.successSince = event.at; + entry.recoveryReads = 0; + } + entry.lastSucceededAt = event.at; + entry.successSince ??= event.at; + entry.recoveryReads += reads; + if (event.at - entry.successSince >= ACCOUNT_RECOVERY_INTERVAL_MS && entry.recoveryReads >= 8) { + entry.concurrencyLimit = Math.min(Math.max(1, entry.account.maxConnections), entry.concurrencyLimit + 1); + entry.successSince = event.at; + entry.recoveryReads = 0; + this.wakeWaiters(); + } + } + + private persistCooldown(accountId: string, until: Date, error: string | null): Promise { + let pending = this.cooldownWrites.get(accountId); + if (!pending) { + pending = { until, error, version: 1, persistedVersion: 0 }; + this.cooldownWrites.set(accountId, pending); + } else if (until.getTime() > pending.until.getTime() || error !== pending.error) { + pending.until = new Date(Math.max(until.getTime(), pending.until.getTime())); + pending.error = error; + pending.version++; + } + if (!pending.flight && pending.persistedVersion !== pending.version) { + const write = pending; + write.flight = Promise.resolve().then(async () => { + while (write.persistedVersion !== write.version) { + const version = write.version; + await this.deps.repository.markCooldown(accountId, Math.max(0, (write.until.getTime() - this.now()) / 1000), write.error, write.until); + write.persistedVersion = version; + } + }).finally(() => { + write.flight = undefined; + }); + } + return pending.flight || Promise.resolve(); + } + private wakeWaiters(): void { for (const wake of this.waiters) wake(); } @@ -238,7 +366,7 @@ export class TelegramUserClientPool, signal?: AbortSignal): Promise { + private async beginScanRequest(entry: PoolEntry, signal?: AbortSignal, minimumIntervalMs = SCAN_INTERVAL_MS): Promise { while (true) { throwIfAborted(signal); if (this.entries.get(entry.account.id) !== entry || !entry.account.enabled || entry.client.connected === false) { @@ -246,7 +374,7 @@ export class TelegramUserClientPool= readyAt) { entry.lastScanStartedAt = now; return; @@ -259,19 +387,22 @@ export class TelegramUserClientPool Math.max(60_000, entry.scanIntervalMs * 2)) { + if (entry.lastScanSucceededAt === undefined || now - entry.lastScanSucceededAt > SCAN_RECOVERY_INTERVAL_MS) { entry.scanSuccessSince = now; + entry.scanSuccessfulRequests = 0; } entry.lastScanSucceededAt = now; entry.scanSuccessSince ??= now; - if (now - entry.scanSuccessSince >= SCAN_RECOVERY_INTERVAL_MS) { - entry.scanIntervalMs = Math.max(SCAN_INTERVAL_MS, entry.scanIntervalMs / 2); + entry.scanSuccessfulRequests++; + if (now - entry.scanSuccessSince >= SCAN_RECOVERY_INTERVAL_MS && entry.scanSuccessfulRequests >= SCAN_RECOVERY_SUCCESSES) { + entry.scanIntervalMs = Math.max(SCAN_INTERVAL_MS, entry.scanIntervalMs - SCAN_INTERVAL_MS); entry.scanSuccessSince = now; + entry.scanSuccessfulRequests = 0; this.wakeWaiters(); } } - private scanClient(entry: PoolEntry, signal?: AbortSignal): C { + private scanClient(entry: PoolEntry, signal: AbortSignal | undefined, readableAccounts: number, onScanRequest?: TelegramAccountSelectionOptions['onScanRequest']): C { let invoke: C['invoke']; // A per-operation view keeps downloads untouched and also catches RPCs // made inside getMessages (entity resolution and internal pagination). @@ -286,13 +417,20 @@ export class TelegramUserClientPool(access.map(row => [row.accountId, row])); const key = normalizeTelegramAccountSourceKey(sourceKey); const excluded = new Set(options.excludeAccountIds || []); + for (const access of accessByAccount.values()) { + if (access.lastError?.startsWith('TELEGRAM_HISTORY_UNVERIFIED') && access.checkedAt + && this.now() - new Date(access.checkedAt).getTime() < 15 * 60_000) excluded.add(access.accountId); + } const candidates = [...this.entries.values()].map(entry => ({ accountId: entry.account.id, - enabled: entry.account.enabled && entry.client.connected !== false, + enabled: !entry.closing && entry.account.enabled && entry.client.connected !== false, healthState: entry.account.healthState, cooldownUntil: entry.account.cooldownUntil, weight: entry.account.weight, @@ -328,18 +470,25 @@ export class TelegramUserClientPool candidate.accountId === options.preferredAccountId), selectionOptions) + : null; + const selected = preferred || selectWeightedLeastConnectedTelegramAccount(candidates, selectionOptions); const viable = candidates.some(candidate => candidate.enabled && candidate.healthState !== 'session_expired' && candidate.sourceAccessState !== 'denied' && !excluded.has(candidate.accountId)); const entry = selected && this.entries.get(selected.accountId); if (!entry) return { lease: null, viable }; entry.active[scope] += 1; entry.lastSelected[scope] = ++this.selectionSequence; + const readableAccounts = scope === 'scan' ? Math.max(1, candidates.filter(candidate => candidate.enabled + && candidate.healthState !== 'session_expired' && !excluded.has(candidate.accountId) + && candidate.sourceAccessState === 'allowed' + && (!candidate.cooldownUntil || new Date(candidate.cooldownUntil).getTime() <= this.now())).length) : 0; let released = false; return { viable, lease: { accountId: entry.account.id, - client: scope === 'scan' ? this.scanClient(entry, options.signal) : entry.client, - generation: entry.stableSince, + client: scope === 'scan' ? this.scanClient(entry, options.signal, readableAccounts, options.onScanRequest) : entry.client, release: () => { if (released) return; released = true; @@ -363,27 +512,53 @@ export class TelegramUserClientPool Promise, - options: { signal?: AbortSignal } = {}, + options: Pick = {}, ): Promise { const key = normalizeTelegramAccountSourceKey(sourceKey); + const excluded = new Set(options.excludeAccountIds || []); while (true) { - const lease = await this.acquire(key, { scope, signal: options.signal }); + const lease = await this.acquire(key, { ...options, scope, excludeAccountIds: excluded }); try { throwIfAborted(options.signal); - const result = await operation(lease.client, lease.accountId); + const result = await withTelegramOperationSignal(options.signal, () => operation(lease.client, lease.accountId)); throwIfAborted(options.signal); - if (scope !== 'scan') this.recordSuccessfulOperation(lease.accountId, lease.generation); return result; } catch (error) { throwIfAborted(options.signal); const classification = classifyTelegramDownloadAccountError(error); const message = errorName(error); - if (classification === 'retryable') throw error; + if (/^TELEGRAM_HISTORY_UNVERIFIED/.test(message)) { + excluded.add(lease.accountId); + lease.release(); + await this.deps.repository.markSourceAccess(lease.accountId, key, scope, 'unknown', message); + console.warn(JSON.stringify({ event: 'telegram_source_unverified', accountId: lease.accountId, source: key, scope, reason: message })); + continue; + } else if (classification === 'retryable') { + if (/EADDRNOTAVAIL|EMFILE|ENFILE|ENOBUFS|TELEGRAM_WORKER_(EXITED|UNAVAILABLE|REQUEST_TIMEOUT|CANCEL_TIMEOUT)|USER_RESOURCE_CIRCUIT/i.test(message)) { + this.updateCooldown(lease.accountId, new Date(this.now() + 60_000), message, scope); + await this.persistCooldown(lease.accountId, this.entries.get(lease.accountId)?.account.cooldownUntil || new Date(this.now() + 60_000), message); + } + throw error; + } if (classification === 'flood_wait') { const seconds = telegramFloodWaitSeconds(error); - this.updateCooldown(lease.accountId, new Date(this.now() + seconds * 1000), message, scope); + const entry = this.entries.get(lease.accountId); + const observedRuntimeFlood = entry?.floodEpisode !== undefined && (entry.account.cooldownUntil?.getTime() || 0) >= this.now(); + if (!observedRuntimeFlood) this.updateCooldown(lease.accountId, new Date(this.now() + seconds * 1000), message, scope); lease.release(); - await this.deps.repository.markCooldown(lease.accountId, seconds, message); + await this.persistCooldown(lease.accountId, entry?.account.cooldownUntil || new Date(this.now() + seconds * 1000), observedRuntimeFlood ? entry!.account.lastError : message); + if (options.onFloodWait) { + const retryAt = Math.max(entry?.account.cooldownUntil?.getTime() || 0, + this.cooldownWrites.get(lease.accountId)?.until.getTime() || 0); + try { + await options.onFloodWait({ accountId: lease.accountId, retryAt, + seconds: Math.max(0, Math.ceil((retryAt - this.now()) / 1000)) }); + } catch (notificationError) { + // An observer failure must not undo cooldown persistence or account failover. + console.error(JSON.stringify({ event: 'telegram_account_flood_wait_notify_failed', + accountId: lease.accountId, error: errorName(notificationError) })); + } + } } else if (classification === 'permission_denied') { const denialKey = JSON.stringify([lease.accountId, key, scope]); this.pendingAccessDenials.set(denialKey, (this.pendingAccessDenials.get(denialKey) || 0) + 1); @@ -408,22 +583,16 @@ export class TelegramUserClientPool !entry.closing && entry.client.connected !== false) .sort((left, right) => right.account.priority - left.account.priority || left.account.id.localeCompare(right.account.id))[0]?.client || null; } getAccountClient(accountId: string): C | null { - return this.entries.get(accountId)?.client || null; + const entry = this.entries.get(accountId); + return entry && !entry.closing && entry.client.connected !== false ? entry.client : null; } getActiveConnections(accountId: string): number { @@ -431,34 +600,51 @@ export class TelegramUserClientPool !entry.closing && entry.client.connected !== false).map(entry => entry.account.id); } - getRuntimeState(): Array<{ accountId: string; connected: boolean; activeConnections: number; activeScanConnections: number; activeMetadataConnections: number; concurrencyLimit: number }> { + getRuntimeState(): Array<{ accountId: string; connected: boolean; processId?: number; closing: boolean; activeConnections: number; activeScanConnections: number; activeMetadataConnections: number; concurrencyLimit: number; fragmentLimit?: number; cooldownUntil: Date | null }> { return [...this.entries.values()].map(entry => ({ accountId: entry.account.id, connected: Boolean(entry.client.connected), + processId: entry.client.runtimeProcessId, + closing: Boolean(entry.closing), activeConnections: entry.active.download, activeScanConnections: entry.active.scan, activeMetadataConnections: entry.active.metadata, concurrencyLimit: entry.concurrencyLimit, + fragmentLimit: entry.fragmentLimit, + cooldownUntil: entry.account.cooldownUntil, })); } updateCooldown(accountId: string, cooldownUntil: Date, error: string | null, scope: TelegramAccountSourceScope = 'download'): void { const entry = this.entries.get(accountId); if (entry) { - const until = new Date(Math.max(entry.account.cooldownUntil?.getTime() || 0, cooldownUntil.getTime())); - entry.account = { ...entry.account, cooldownUntil: until, healthState: 'degraded', lastError: error }; - if (scope === 'scan') entry.scanIntervalMs = Math.min(SCAN_MAX_INTERVAL_MS, entry.scanIntervalMs * 2); - else entry.concurrencyLimit = Math.max(1, Math.floor(entry.concurrencyLimit / 2)); - entry.scanSuccessSince = undefined; - entry.lastScanSucceededAt = undefined; - entry.stableSince = Math.max(this.now(), until.getTime()); + const now = this.now(); + const distinct = now >= (entry.account.cooldownUntil?.getTime() || 0); + const seconds = error && classifyTelegramDownloadAccountError(error) === 'flood_wait' + ? telegramFloodWaitSeconds(error) : (cooldownUntil.getTime() - now) / 1000; + this.applyCooldown(entry, cooldownUntil, error, scope, distinct, distinct && seconds >= TELEGRAM_EXTREME_FLOOD_WAIT_SECONDS); } this.wakeWaiters(); } + private applyCooldown(entry: PoolEntry, cooldownUntil: Date, error: string | null, scope: TelegramAccountSourceScope, distinct: boolean, reduceDownloads: boolean): void { + const until = new Date(Math.max(entry.account.cooldownUntil?.getTime() || 0, cooldownUntil.getTime())); + entry.account = { ...entry.account, cooldownUntil: until, healthState: 'degraded', lastError: error }; + if (scope === 'scan' && distinct) entry.scanIntervalMs = Math.min(SCAN_MAX_INTERVAL_MS, entry.scanIntervalMs + SCAN_INTERVAL_MS); + else if (scope !== 'scan' && reduceDownloads) entry.concurrencyLimit = Math.max(1, Math.floor(entry.concurrencyLimit / 2)); + if (distinct) entry.successfulReads = 0; + entry.scanSuccessSince = undefined; + entry.lastScanSucceededAt = undefined; + entry.scanSuccessfulRequests = 0; + entry.successSince = undefined; + entry.lastSucceededAt = undefined; + entry.recoveryReads = 0; + this.wakeWaiters(); + } + updateSourceAccess(_accountId: string, _sourceKey: string, _scope: TelegramAccountSourceScope, _state: TelegramSourceAccessState): void { // Source access is read through the repository for each selection, so no stale in-memory cache needs invalidation. this.wakeWaiters(); @@ -470,9 +656,17 @@ export class TelegramUserClientPool { const entry = this.entries.get(accountId); - this.entries.delete(accountId); + const quarantined = this.quarantined.get(accountId); + if (entry) entry.closing = true; this.wakeWaiters(); if (entry) await this.closeClient(entry.client); + if (quarantined) await this.closeClient(quarantined); + this.entries.delete(accountId); + this.quarantined.delete(accountId); + this.wakeWaiters(); + const pending = this.cooldownWrites.get(accountId); + if (pending?.flight) await pending.flight.catch(() => undefined); + this.cooldownWrites.delete(accountId); } async shutdown(): Promise { @@ -480,10 +674,7 @@ export class TelegramUserClientPool { - const entries = [...this.entries.values()]; - this.entries.clear(); - this.wakeWaiters(); - await Promise.all(entries.map(entry => this.closeClient(entry.client))); + await Promise.all([...new Set([...this.entries.keys(), ...this.quarantined.keys()])].map(id => this.expireEntry(id))); } private async runLifecycleOperation(operation: () => Promise): Promise { @@ -493,31 +684,41 @@ export class TelegramUserClientPool { - try { await client.disconnect(); } catch { /* best effort */ } - try { await client.destroy(); } catch { /* best effort */ } + // destroy is the terminal ownership boundary; errors retain the entry in quarantine. + client.setRuntimeEventHandler?.(undefined); + for (const [accountId, owner] of this.runtimeClients) if (owner === client) this.runtimeClients.delete(accountId); + await client.destroy(); } } export const telegramUserClientPool = new TelegramUserClientPool({ repository: telegramAccountRepository, decryptSession: decryptCredential, - createClient: (session, credentials) => new TelegramClient( - new StringSession(session), credentials.apiId, credentials.apiHash, { - connectionRetries: 15, retryDelay: 2000, useWSS: false, - deviceModel: 'TG Vault User Downloader', systemVersion: '1.0.0', appVersion: '1.0.0', floodSleepThreshold: 0, - }, - ), + createClient: (session, credentials, accountId) => new TelegramUserWorkerClient(session, credentials, accountId), saveSession: client => client.session.save() as unknown as string, }); let currentCredentials: TelegramPoolCredentials | null = null; +let recoveryTimer: NodeJS.Timeout | undefined; +let recoveryPending = false; export async function initializeTelegramUserClientPool(credentials: TelegramPoolCredentials): Promise { currentCredentials = credentials; await telegramUserClientPool.initialize(credentials); + recoveryTimer ??= setInterval(() => { + if (recoveryPending) return; + recoveryPending = true; + void telegramUserClientPool.refresh().catch((error: unknown) => console.error('Telegram account recovery failed:', error)) + .finally(() => { recoveryPending = false; }); + }, 60_000); + recoveryTimer.unref(); } -export async function shutdownTelegramUserClientPool(): Promise { await telegramUserClientPool.shutdown(); } +export async function shutdownTelegramUserClientPool(): Promise { + clearInterval(recoveryTimer); + recoveryTimer = undefined; + await telegramUserClientPool.shutdown(); +} export function getTelegramUserClientPoolState() { return telegramUserClientPool.getRuntimeState(); } export async function reloadTelegramUserClientPool(): Promise { if (currentCredentials) await telegramUserClientPool.initialize(currentCredentials); diff --git a/backend/src/services/telegramUserSocket.ts b/backend/src/services/telegramUserSocket.ts new file mode 100644 index 0000000..7be854b --- /dev/null +++ b/backend/src/services/telegramUserSocket.ts @@ -0,0 +1,180 @@ +import { Socket } from 'node:net'; +import { createRequire } from 'node:module'; +import type { SocksClient as SocksClientClass } from 'socks'; +import type { ProxyInterface, SocksProxyType } from 'telegram/network/connection/TCPMTProxy.js'; + +const require = createRequire(import.meta.url); +const { SocksClient } = require('telegram/extensions/socks.js') as { SocksClient: typeof SocksClientClass }; + +export interface UserSocketOwner { + assertOpen(): void; + beforeConnect?(): void; + socketError(error: unknown): void; +} +const socketClosed = () => Object.assign(new Error('USER_SOCKET_CLOSED'), { code: 'USER_SOCKET_CLOSED' }); + +/** Net socket ownership starts before TCP/SOCKS opening, not after it succeeds. */ +export class UserNetSocket { + private client?: Socket; + private generation = 0; + private closed = true; + private chunks: Buffer[] = []; + private headOffset = 0; + private available = 0; + private readers = new Set<() => void>(); + private openingReject?: (error: unknown) => void; + private closing: Promise = Promise.resolve(); + private readonly proxy?: SocksProxyType; + constructor(proxy?: ProxyInterface, private readonly owner?: UserSocketOwner) { + if (proxy && !('MTProxy' in proxy)) { + if (!proxy.ip || !proxy.port || ![4, 5].includes(proxy.socksType)) { + throw new Error('USER_INVALID_SOCKS_PROXY'); + } + this.proxy = proxy; + } + } + + private wake() { + for (const wake of this.readers) wake(); + this.readers.clear(); + } + + private teardown(error: unknown): Promise { + this.closed = true; + this.openingReject?.(error); + this.openingReject = undefined; + this.wake(); + this.chunks = []; + this.headOffset = this.available = 0; + const socket = this.client; + this.client = undefined; + if (!socket) return this.closing; + const done = Promise.withResolvers(); + if (socket.closed) done.resolve(); + else socket.once('close', () => done.resolve()); + socket.destroy(); + socket.unref(); + this.closing = Promise.all([this.closing, done.promise]).then(() => undefined); + return this.closing; + } + + async connect(port: number, ip: string): Promise { + const generation = ++this.generation; + await this.teardown(socketClosed()); + if (generation !== this.generation) throw socketClosed(); + this.owner?.assertOpen(); + this.owner?.beforeConnect?.(); + const socket = new Socket(); + this.client = socket; + this.closed = false; + socket.setNoDelay(true); + let receiving = false; + const current = () => generation === this.generation && this.client === socket && !this.closed; + const fail = (error: unknown) => { + if (!current()) return; + this.owner?.socketError(error); + void this.teardown(error); + }; + socket.on('error', fail); + socket.on('end', () => fail(socketClosed())); + socket.on('close', () => fail(socketClosed())); + socket.on('data', (chunk: Buffer) => { + if (!receiving || !current()) return; + this.chunks.push(chunk); + this.available += chunk.length; + this.wake(); + }); + const timeout = setTimeout(() => fail(Object.assign(new Error('USER_SOCKET_CONNECT_TIMEOUT'), + { code: 'USER_SOCKET_CONNECT_TIMEOUT' })), 8000); + try { + const opening = Promise.withResolvers(); + this.openingReject = opening.reject; + const ready = () => { + if (!current()) { opening.reject(socketClosed()); return; } + this.openingReject = undefined; + receiving = true; + opening.resolve(); + }; + socket.once('connect', () => { + if (!current()) return; + if (!this.proxy) { ready(); return; } + // Existing socket avoids the SOCKS library's unowned opening socket. + void SocksClient.createConnection({ + existing_socket: socket, + command: 'connect', + timeout: Math.min((this.proxy.timeout || 5) * 1000, 8000), + proxy: { host: this.proxy.ip, port: this.proxy.port, type: this.proxy.socksType, + userId: this.proxy.username, password: this.proxy.password }, + destination: { host: ip, port }, + }).then(ready, fail); + }); + socket.connect(this.proxy ? this.proxy.port : port, this.proxy ? this.proxy.ip : ip); + await opening.promise; + this.owner?.assertOpen(); + if (!current()) throw socketClosed(); + } catch (error) { + if (generation === this.generation) await this.teardown(error); + throw error; + } finally { + clearTimeout(timeout); + } + } + + async read(number: number): Promise { + const generation = this.generation; + while (!this.available) { + if (this.closed || generation !== this.generation) throw socketClosed(); + const pending = Promise.withResolvers(); + this.readers.add(pending.resolve); + await pending.promise; + } + if (this.closed || generation !== this.generation) throw socketClosed(); + const count = Math.min(number, this.available); + if (count <= 0) return Buffer.alloc(0); + const first = this.chunks[0]; + let result: Buffer; + if (first.length - this.headOffset >= count) { + result = first.subarray(this.headOffset, this.headOffset + count); + this.headOffset += count; + if (this.headOffset === first.length) { this.chunks.shift(); this.headOffset = 0; } + } else { + result = Buffer.allocUnsafe(count); + let offset = 0; + while (offset < count) { + const chunk = this.chunks[0]; + const n = Math.min(count - offset, chunk.length - this.headOffset); + chunk.copy(result, offset, this.headOffset, this.headOffset + n); + offset += n; + this.headOffset += n; + if (this.headOffset === chunk.length) { this.chunks.shift(); this.headOffset = 0; } + } + } + this.available -= count; + return result; + } + + async readExactly(number: number): Promise { + if (number <= 0) return Buffer.alloc(0); + const first = await this.read(number); + if (first.length === number) return first; + const result = Buffer.allocUnsafe(number); + first.copy(result); + let offset = first.length; + while (offset < number) { + const chunk = await this.read(number - offset); + chunk.copy(result, offset); + offset += chunk.length; + } + return result; + } + + readAll(): Promise { return this.read(this.available || Number.MAX_SAFE_INTEGER); } + write(data: Buffer): void { + this.owner?.assertOpen(); + if (this.closed || !this.client) throw socketClosed(); + this.client.write(data); + } + close(): Promise { ++this.generation; return this.teardown(socketClosed()); } + async receive(): Promise { /* Data listeners are installed before opening. */ } + toString(): string { return 'OwnedUserNetSocket'; } +} diff --git a/backend/src/services/telegramUserTransport.ts b/backend/src/services/telegramUserTransport.ts new file mode 100644 index 0000000..23634d3 --- /dev/null +++ b/backend/src/services/telegramUserTransport.ts @@ -0,0 +1,816 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import { createHash } from 'node:crypto'; +import { createRequire } from 'node:module'; +import { Api, errors, utils } from 'telegram'; +import type { TelegramClient } from 'telegram'; +import { RequestState } from 'telegram/network/RequestState.js'; +import { Logger } from 'telegram/extensions/Logger.js'; +import { LAYER } from 'telegram/tl/AllTLObjects.js'; +import type { AuthKey } from 'telegram/crypto/AuthKey.js'; +import type { Connection } from 'telegram/network/connection/Connection.js'; +import type { MTProtoSender } from 'telegram/network/MTProtoSender.js'; +import type { MTProtoState } from 'telegram/network/MTProtoState.js'; +import type { ProxyInterface } from 'telegram/network/connection/TCPMTProxy.js'; +import { UserNetSocket } from './telegramUserSocket.js'; +import { TELEGRAM_EXTREME_FLOOD_WAIT_SECONDS, type TelegramTransportRuntimeEvent } from './telegramWorkerProtocol.js'; +import type { BigInteger } from 'big-integer'; +import { doAuthentication } from 'telegram/network/Authenticator.js'; +import { MTProtoPlainSender } from 'telegram/network/MTProtoPlainSender.js'; +import { UpdateConnectionState } from 'telegram/network/index.js'; + +const require = createRequire(import.meta.url); +const signals = new AsyncLocalStorage<{ signal?: AbortSignal; onWait?: (waiting: boolean) => void }>(); +const VERSION = '2.26.22'; +const EXHAUSTED: Record = { EADDRNOTAVAIL: true, EMFILE: true, ENFILE: true, ENOBUFS: true }; + +export async function withUserTransportSignal(signal: AbortSignal | undefined, operation: () => Promise, onWait?: (waiting: boolean) => void): Promise { + signal?.throwIfAborted(); + return signals.run({ signal, onWait }, operation); +} + +interface OwnedState extends Omit { + request: Api.AnyRequest | Api.MsgsAck | Api.MsgsStateInfo; + promise: Promise; + after?: OwnedState; + resolve(value?: unknown): void; + reject(error?: unknown): void; + isReady(): boolean | Promise; +} +interface Packer { + _queue: (OwnedState | undefined)[]; + _pendingStates: OwnedState[]; + append(state?: OwnedState, ready?: boolean, atStart?: boolean): void; + clear(): void; + wait(): Promise; + get(): Promise<{ batch: OwnedState[]; data: Buffer } | null | undefined>; +} +interface OwnedConnection extends Omit { + socket: UserNetSocket; + _sendTask?: Promise; + _recvTask?: Promise; + recv(): Promise; + _recv(): Promise; + _sendArray: TransportQueue; + _recvArray: TransportQueue; +} +interface OwnedSender { + dcId: number; + authKey: AuthKey; + _connection?: OwnedConnection; + _state: MTProtoState; + _sendQueue: Packer; + _pendingState: { + set(id: BigInteger, state: OwnedState): void; + delete(id: BigInteger): void; + values(): OwnedState[]; + clear(): void; + }; + _pendingAck: Set; + _lastAcks: OwnedState[]; + _userConnected: boolean; + _authenticated: boolean; + _finishedConnecting: boolean; + _disconnected: boolean; + _reconnecting: boolean; + userDisconnected: boolean; + isConnecting: boolean; + isReconnecting: boolean; + _sendLoopHandle?: Promise; + _updateCallback?: (client: TelegramClient, update: UpdateConnectionState) => void; + _recvLoopHandle?: Promise; + _authKeyCallback?: (key: AuthKey, dc: number) => Promise; + _autoReconnectCallback?: () => Promise; + _handleBadAuthKey(skip?: boolean): void; + _connect(): Promise; + _disconnect(): Promise; + _sendLoop(): Promise; + _recvLoop(): Promise; + connect(connection: OwnedConnection, force?: boolean): Promise; + disconnect(): Promise; + isConnected(): boolean; + reconnect(): Promise; + _reconnect(): Promise; + send(request: Api.AnyRequest): Promise; + addStateToQueue(state: OwnedState): void; +} +interface OwnedClient extends Omit { + _sender?: OwnedSender; + _connection: new (options: { ip: string; port: number; dcId: number; loggers: Logger; proxy?: ProxyInterface; + testServers: boolean; socket: new (proxy?: ProxyInterface) => UserNetSocket }) => OwnedConnection; + networkSocket: new (proxy?: ProxyInterface) => UserNetSocket; + _log: Logger; + _proxy?: ProxyInterface; + _initRequest: Api.InitConnection; + _entityCache: { add(value: unknown): void }; + _lastRequest: number; + _eventBuilders: unknown[]; + _exportedSenderReleaseTimeouts: Map; + _exportedSenderPromises: Map>; + _borrowedSenderPromises: Record>; + _createExportedSender(dc: number): OwnedSender; + _connectSender(sender: OwnedSender, dc: number): Promise; + _borrowExportedSender(dc: number, reconnect?: boolean, existing?: OwnedSender): Promise; +} +interface SenderOwner { + id: number; + generation: number; + failed?: unknown; + retryAt?: number; + flight?: Promise; + closed: boolean; + authorized: boolean; + states: Set; + connections: Set; +} + +class TransportQueue { + private values: T[] = []; + private waiters: ((value: T) => void)[] = []; + async push(value: T): Promise { + const waiter = this.waiters.shift(); + if (waiter) waiter(value); + else this.values.push(value); + } + pop(): Promise { + if (this.values.length) return Promise.resolve(this.values.shift()!); + const { promise, resolve } = Promise.withResolvers(); + this.waiters.push(resolve); + return promise; + } +} + +function transportError(code: string): Error & { code: string } { + return Object.assign(new Error(code), { code }); +} +function errorCode(error: unknown): string { + if (typeof error === 'object' && error !== null && 'code' in error && typeof error.code === 'string') { + return /^[A-Z0-9_]{1,64}$/.test(error.code) ? error.code : 'USER_TRANSPORT_ERROR'; + } + return 'USER_TRANSPORT_ERROR'; +} + +/** Instance-only ownership boundary for the pinned GramJS release. */ +export function ownUserTransport(client: T, options: { accountId?: string; onRuntimeEvent?: (event: TelegramTransportRuntimeEvent) => void } = {}): T { + // SDK private fields have no usable public type; keep their fixed-version layout here. + const c = client as unknown as OwnedClient; + const packageInfo: unknown = require('telegram/package.json'); + if (!packageInfo || typeof packageInfo !== 'object' || !('version' in packageInfo) || packageInfo.version !== VERSION || + c._sender !== undefined || !(c._exportedSenderPromises instanceof Map) || + !['connect', 'destroy', '_createExportedSender', '_connectSender', '_borrowExportedSender', '_switchDC', 'invoke'].every( + key => typeof Reflect.get(c, key) === 'function')) throw transportError('USER_SDK_OWNERSHIP_UNSUPPORTED'); + + const account = options.accountId ? createHash('sha256').update(options.accountId).digest('hex').slice(0, 16) : 'login'; + const owners = new Map(); + const sockets = new Set(); + const work = new Set>(); + const terminal = new AbortController(); + let main: OwnedSender | undefined; + let closing: Promise | undefined; + let clientFlight: Promise | undefined; + let initialized = false; + let migrationFlight: Promise | undefined; + let circuitUntil = 0; + let accountFailure: unknown; + let floodUntil = 0; + let fragmentLimit = 12; + let floodEpisode = 0; + let reducedFloodEpisode = 0; + let successfulReads = 0; + let recoverySince: number | undefined; + let recoveryReads = 0; + let lastFileSuccess = 0; + let lastSuccessEvent = 0; + let fileActive = 0; + const dcFailures = new Map(); + const dcClosures = new Map>(); + const exportedSenders = new Map(); + const gateWake = new Set<() => void>(); + let nextSender = 0; + let lastDiagnostic = 0; + let suppressed = 0; + const log = new Logger(); + log.canSend = () => false; + c._log = log; + const assertOpen = () => { + if (terminal.signal.aborted) throw transportError('USER_TRANSPORT_CLOSED'); + if (accountFailure) throw accountFailure; + }; + const diagnostic = (event: string, sender?: OwnedSender, error?: unknown, cooldown?: { seconds: number; until: number }) => { + const now = Date.now(); + if (now - lastDiagnostic < 5000) { suppressed++; return; } + lastDiagnostic = now; + const owner = sender ? owners.get(sender) : undefined; + console.warn(JSON.stringify({ event, account, dc: sender?.dcId, sender: owner?.id, + generation: owner?.generation, code: error === undefined ? undefined : errorCode(error), suppressed, + seconds: cooldown?.seconds, until: cooldown?.until, fragmentLimit, activeFragments: fileActive })); + suppressed = 0; + }; + const onSocketError = (error: unknown) => { + const code = errorCode(error); + if (EXHAUSTED[code]) { circuitUntil = Math.max(circuitUntil, Date.now() + 60_000); diagnostic('user_socket_circuit', undefined, error); } + }; + const track = (promise: Promise): Promise => { + work.add(promise); + void promise.then(() => work.delete(promise), () => work.delete(promise)); + return promise; + }; + const signalForCall = () => { + const signal = signals.getStore()?.signal; + return signal ? AbortSignal.any([signal, terminal.signal]) : terminal.signal; + }; + const waitFor = (promise: Promise, signal: AbortSignal): Promise => { + signal.throwIfAborted(); + const pending = Promise.withResolvers(); + const abort = () => { signal.removeEventListener('abort', abort); pending.reject(signal.reason); }; + signal.addEventListener('abort', abort, { once: true }); + void promise.then(pending.resolve, pending.reject).finally(() => signal.removeEventListener('abort', abort)); + return pending.promise; + }; + const delay = (ms: number, signal: AbortSignal): Promise => { + const pending = Promise.withResolvers(); + if (signal.aborted) { pending.reject(signal.reason); return pending.promise; } + const abort = () => { clearTimeout(timer); signal.removeEventListener('abort', abort); pending.reject(signal.reason); }; + const timer = setTimeout(() => { signal.removeEventListener('abort', abort); pending.resolve(); }, Math.max(0, ms)); + signal.addEventListener('abort', abort, { once: true }); + return pending.promise; + }; + const waiting = async (operation: () => Promise): Promise => { + const notify = signals.getStore()?.onWait; + notify?.(true); + try { return await operation(); } finally { notify?.(false); } + }; + const wakeGate = () => { for (const wake of gateWake) wake(); gateWake.clear(); }; + const acquire = async (file: boolean, signal: AbortSignal, liveness: boolean): Promise<() => void> => { + for (;;) { + signal.throwIfAborted(); + // Heartbeats keep the socket alive during server-mandated business cooldowns. + if (!liveness && floodUntil > Date.now()) { await waiting(() => delay(floodUntil - Date.now(), signal)); continue; } + if (!file || fileActive < fragmentLimit) { + if (file) fileActive++; + return () => { if (file) fileActive--; wakeGate(); }; + } + const pending = Promise.withResolvers(); + const abort = () => { gateWake.delete(wake); signal.removeEventListener('abort', abort); pending.reject(signal.reason); }; + const wake = () => { signal.removeEventListener('abort', abort); pending.resolve(); }; + gateWake.add(wake); + signal.addEventListener('abort', abort, { once: true }); + await waiting(() => pending.promise); + } + }; + class AccountSocket extends UserNetSocket { + constructor(proxy?: ProxyInterface) { + super(proxy, { assertOpen, beforeConnect: () => { + if (Date.now() < circuitUntil) throw transportError('USER_RESOURCE_CIRCUIT_OPEN'); + }, socketError: onSocketError }); + sockets.add(this); + } + async connect(port: number, ip: string): Promise { + sockets.add(this); + try { await super.connect(port, ip); } + catch (error) { sockets.delete(this); throw error; } + } + async close(): Promise { + await super.close(); + sockets.delete(this); + } + } + c.networkSocket = AccountSocket; + + const ownConnection = (connection: OwnedConnection, owner: SenderOwner): OwnedConnection => { + if (owner.connections.has(connection)) return connection; + owner.connections.add(connection); + connection._sendArray = new TransportQueue(); + connection._recvArray = new TransportQueue(); + const open = connection._connect.bind(connection); + connection._connect = async () => { + assertOpen(); + if (owner.closed) throw transportError('USER_SENDER_CLOSED'); + const generation = owner.generation; + try { + await open(); + assertOpen(); + if (owner.closed || owner.generation !== generation) throw transportError('USER_CONNECTION_SUPERSEDED'); + } catch (error) { await connection.disconnect(); throw error; } + }; + connection.disconnect = async () => { + connection._connected = false; + // Wake both SDK queues even when opening never set _connected. + void connection._recvArray.push(undefined); + void connection._sendArray.push(undefined); + await connection.socket.close(); + await Promise.allSettled([connection._sendTask, connection._recvTask]); + connection._sendTask = undefined; + connection._recvTask = undefined; + }; + const connected = connection.isConnected.bind(connection); + connection.isConnected = () => !owner.closed && !terminal.signal.aborted && connected(); + return connection; + }; + const removeState = (sender: OwnedSender, state: OwnedState) => { + sender._sendQueue._queue = sender._sendQueue._queue.filter(s => s !== state); + sender._sendQueue._pendingStates = sender._sendQueue._pendingStates.filter(s => s !== state); + if (state.msgId) sender._pendingState.delete(state.msgId); + for (const other of sender._sendQueue._pendingStates) if (other.after === state) other.after = undefined; + }; + const rejectStates = (sender: OwnedSender, owner: SenderOwner, error: unknown) => { + for (const state of owner.states) { removeState(sender, state); state.reject(error); state.finished.resolve(); } + owner.states.clear(); + for (const state of sender._pendingState.values()) state.reject(error); + for (const state of sender._sendQueue._pendingStates) { + state.reject(error); + state.finished.resolve(); + } + sender._sendQueue._pendingStates = []; + sender._pendingState.clear(); + sender._sendQueue.clear(); + sender._pendingAck.clear(); + sender._lastAcks.length = 0; + }; + const stop = async (sender: OwnedSender, owner: SenderOwner, error: unknown) => { + sender._userConnected = false; + sender.isReconnecting = true; + rejectStates(sender, owner, error); + const connections = [...owner.connections]; + const closedConnections = await Promise.allSettled(connections.map(connection => connection.disconnect())); + closedConnections.forEach((result, index) => { + if (result.status === 'fulfilled') owner.connections.delete(connections[index]); + }); + const closedLoops = await Promise.allSettled([sender._sendLoopHandle, sender._recvLoopHandle]); + sender._sendLoopHandle = undefined; + sender._recvLoopHandle = undefined; + sender._disconnected = true; + const failures = [...closedConnections, ...closedLoops].filter(result => result.status === 'rejected'); + if (failures.length) throw new AggregateError(failures.map(result => result.reason), 'USER_CONNECTION_CLOSE_FAILED'); + }; + const enqueue = async (sender: OwnedSender, owner: SenderOwner, state: OwnedState, signal: AbortSignal): Promise => { + let query: unknown = state.request; + while (query && typeof query === 'object' && 'query' in query) query = query.query; + const file = query instanceof Api.upload.GetFile; + const liveness = query instanceof Api.Ping || query instanceof Api.PingDelayDisconnect; + const release = await acquire(file, signal, liveness); + const requestEpisode = floodEpisode; + let timer: NodeJS.Timeout | undefined; + const abort = () => { removeState(sender, state); owner.states.delete(state); state.reject(signal.reason); state.finished.resolve(); }; + try { + assertOpen(); signal.throwIfAborted(); + if (owner.closed) throw transportError('USER_SENDER_CLOSED'); + if (!sender.isConnected()) await waiting(() => waitFor(sender._reconnect(), signal)); + signal.throwIfAborted(); + owner.states.add(state); + signal.addEventListener('abort', abort, { once: true }); + timer = setTimeout(() => { + removeState(sender, state); owner.states.delete(state); + state.reject(transportError('USER_REQUEST_TIMEOUT')); state.finished.resolve(); + }, 30_000); + sender._sendQueue.append(state); + const result = await state.promise; + if (file && result instanceof Api.upload.File && result.bytes.length > 0 && requestEpisode === floodEpisode && floodUntil <= Date.now()) { + const now = Date.now(); + successfulReads++; + if (!lastFileSuccess || now - lastFileSuccess > 60_000) { recoverySince = now; recoveryReads = 0; } + lastFileSuccess = now; + recoverySince ??= now; + recoveryReads++; + let recovered = false; + if (fragmentLimit < 12 && now - recoverySince >= 60_000 && recoveryReads >= fragmentLimit * 4) { + fragmentLimit++; + recoverySince = now; + recoveryReads = 0; + recovered = true; + wakeGate(); + } + if (now - lastSuccessEvent >= 1_000 || recovered) { + lastSuccessEvent = now; + options.onRuntimeEvent?.({ type: 'file_success', at: now, episode: floodEpisode, successfulReads, + fragmentLimit, activeFragments: fileActive }); + } + } + return result; + } catch (error) { + if (error instanceof errors.FloodWaitError || error instanceof errors.FloodTestPhoneWaitError) { + const seconds = error.seconds; + if (Number.isSafeInteger(seconds) && seconds >= 0) { + floodUntil = Math.max(floodUntil, Date.now() + seconds * 1000); + // Requests admitted before this flood are siblings, not new episodes. + if (requestEpisode === floodEpisode) { + floodEpisode++; + successfulReads = 0; + } + if (seconds >= TELEGRAM_EXTREME_FLOOD_WAIT_SECONDS && reducedFloodEpisode !== floodEpisode) { + fragmentLimit = Math.max(1, Math.floor(fragmentLimit / 2)); + reducedFloodEpisode = floodEpisode; + } + recoverySince = undefined; + recoveryReads = 0; + lastFileSuccess = 0; + options.onRuntimeEvent?.({ type: 'cooldown', seconds, until: floodUntil, + reason: error instanceof errors.FloodTestPhoneWaitError ? 'FLOOD_TEST_PHONE_WAIT' : 'FLOOD_WAIT', + file, episode: floodEpisode, fragmentLimit, activeFragments: fileActive }); + diagnostic('user_flood_cooldown', sender, undefined, { seconds, until: floodUntil }); + wakeGate(); + } + } + throw error; + } finally { + clearTimeout(timer); + signal.removeEventListener('abort', abort); + owner.states.delete(state); + removeState(sender, state); + state.finished.resolve(); + release(); + } + }; + const protect = (sender: OwnedSender): OwnedSender => { + assertOpen(); + if (owners.has(sender)) return sender; + if (!sender._sendQueue || !sender._pendingState || typeof sender._connect !== 'function') throw transportError('USER_SDK_OWNERSHIP_UNSUPPORTED'); + const owner: SenderOwner = { id: ++nextSender, generation: 0, closed: false, + authorized: !!sender.authKey.getKey(), states: new Set(), connections: new Set() }; + owners.set(sender, owner); + const connectOnce = async () => { + const generation = owner.generation; + const connection = sender._connection!; + const assertGeneration = () => { + assertOpen(); + if (owner.closed || owner.generation !== generation || !connection.isConnected()) { + throw transportError('USER_CONNECTION_SUPERSEDED'); + } + }; + await connection.connect(); + assertGeneration(); + if (!sender.authKey.getKey()) { + const plain = new MTProtoPlainSender(connection, log); + const authenticated = await doAuthentication(plain, log); + assertGeneration(); + await sender.authKey.setKey(authenticated.authKey); + sender._state.timeOffset = authenticated.timeOffset; + await sender._authKeyCallback?.(sender.authKey, sender.dcId); + assertGeneration(); + } + sender._userConnected = true; + sender.isReconnecting = false; + sender._sendLoopHandle = sender._sendLoop(); + sender._recvLoopHandle = sender._recvLoop(); + for (const loop of [sender._sendLoopHandle, sender._recvLoopHandle]) { + void loop.catch(error => { + if (!owner.closed && !terminal.signal.aborted && owner.generation === generation) { + diagnostic('user_sender_loop_error', sender, error); + void sender.reconnect().catch(() => undefined); + } + }); + } + sender._updateCallback?.(client, new UpdateConnectionState(UpdateConnectionState.connected)); + }; + sender._connect = connectOnce; + const saveKey = sender._authKeyCallback; + sender._authKeyCallback = async (key, dc) => { + assertOpen(); + if (owner.closed) throw transportError('USER_SENDER_CLOSED'); + await saveKey?.(key, dc); + assertOpen(); + if (owner.closed) throw transportError('USER_SENDER_CLOSED'); + }; + sender.isConnected = () => !accountFailure && !terminal.signal.aborted && !owner.closed && !owner.failed && + !sender.isConnecting && !sender.isReconnecting && sender._userConnected && !!sender._connection?.isConnected(); + // Own the send loop so states canceled while packing/encrypting cannot be + // reinserted into pending state or sent as part of a stale batch. + sender._sendLoop = async () => { + const generation = owner.generation; + while (sender._userConnected && !sender.isReconnecting && owner.generation === generation) { + if (sender._pendingAck.size) { + const ack = new RequestState(new Api.MsgsAck({ msgIds: Array.from(sender._pendingAck) })); + // GramJS RequestState declarations lose the request/result types. + const ownedAck = ack as unknown as OwnedState; + sender._sendQueue.append(ownedAck); + sender._pendingAck.clear(); + } + await sender._sendQueue.wait(); + if (!sender._userConnected || sender.isReconnecting || owner.generation !== generation) return; + const packed = await sender._sendQueue.get(); + if (!packed) continue; + const active = (state: OwnedState) => state.request.classType !== 'request' || owner.states.has(state); + const stale = () => !sender._userConnected || sender.isReconnecting || owner.generation !== generation; + if (stale()) return; + if (packed.batch.some(state => !active(state))) { + for (const state of packed.batch) if (active(state)) sender._sendQueue.append(state); + continue; + } + const data = await sender._state.encryptMessageData(packed.data); + if (stale()) return; + if (packed.batch.some(state => !active(state))) { + for (const state of packed.batch) if (active(state)) sender._sendQueue.append(state); + continue; + } + for (const state of packed.batch) if (state.request.classType === 'request' && state.msgId) { + sender._pendingState.set(state.msgId, state); + } + try { await sender._connection!.send(data); } + catch { void sender.reconnect().catch(() => undefined); return; } + } + }; + sender.send = (request: Api.AnyRequest) => { + const state = new RequestState(request); + // Fixed-version RequestState has a promise immediately after construction. + const ownedState = state as unknown as OwnedState; + void ownedState.promise.catch(() => undefined); + return track(enqueue(sender, owner, ownedState, signalForCall()).catch(error => { + ownedState.reject(error); + ownedState.finished.resolve(); + throw error; + })); + }; + sender.addStateToQueue = state => { + assertOpen(); + // SDK salt/sequence repair may only requeue a still-owned request. + if (!owner.closed && owner.states.has(state)) sender._sendQueue.append(state); + }; + const newConnection = (old: OwnedConnection): OwnedConnection => new c._connection({ + ip: old._ip, port: old._port, dcId: old._dcId, loggers: c._log, + proxy: old._proxy, testServers: old._testServers, socket: c.networkSocket, + }); + const connect = (connection?: OwnedConnection, force = false): Promise => { + try { + assertOpen(); + if (owner.closed) throw transportError('USER_SENDER_CLOSED'); + if (owner.failed && Date.now() >= (owner.retryAt || Infinity)) owner.failed = undefined; + if (owner.failed) throw owner.failed; + if (owner.flight) { + if (connection && connection !== sender._connection) void connection.socket.close(); + return owner.flight; + } + if (sender.isConnected() && !force) { + if (connection && connection !== sender._connection) void connection.socket.close(); + return Promise.resolve(true); + } + } catch (error) { return Promise.reject(error); } + const flight = track((async () => { + const start = Date.now(); + ++owner.generation; + sender.isConnecting = true; + sender.isReconnecting = true; + sender.userDisconnected = false; + sender._finishedConnecting = false; + await stop(sender, owner, transportError('USER_CONNECTION_LOST')); + let candidate = connection || (sender._connection && newConnection(sender._connection)); + if (!candidate) throw transportError('USER_NO_CONNECTION'); + let lastError: unknown = transportError('USER_CONNECT_FAILED'); + for (let attempt = 0; attempt < 5; attempt++) { + assertOpen(); + if (owner.closed) throw transportError('USER_SENDER_CLOSED'); + if (Date.now() < circuitUntil) throw transportError('USER_RESOURCE_CIRCUIT_OPEN'); + if (attempt) { + const pause = Math.min(8000, 500 * 2 ** (attempt - 1)) + Math.floor(Math.random() * 250); + await delay(pause, terminal.signal); + candidate = newConnection(candidate); + } + sender._connection = ownConnection(candidate, owner); + sender._state.reset(); + const remaining = 58_000 - (Date.now() - start); + if (remaining <= 0) break; + const timeout = setTimeout(() => { + lastError = transportError('USER_CONNECT_TIMEOUT'); + void candidate!.disconnect(); + }, Math.min(8000, remaining)); + try { + await connectOnce(); + assertOpen(); + if (owner.closed || !candidate.isConnected()) throw transportError('USER_CONNECTION_LOST'); + sender._finishedConnecting = true; + sender._disconnected = false; + sender.isConnecting = false; + sender.isReconnecting = false; + diagnostic('user_sender_connected', sender); + return true; + } catch (error) { + lastError = error; + onSocketError(error); + await stop(sender, owner, error); + diagnostic('user_sender_connect_error', sender, error); + if (Date.now() < circuitUntil || terminal.signal.aborted || owner.closed) throw error; + } finally { clearTimeout(timeout); } + } + throw lastError; + })()); + owner.flight = flight; + void flight.then(() => { owner.flight = undefined; }, error => { + owner.flight = undefined; + if (!owner.closed && !terminal.signal.aborted) { + owner.failed = error; + owner.retryAt = Date.now() + 10_000; + if (sender === main || Date.now() < circuitUntil) accountFailure = error; + } + sender.isConnecting = sender.isReconnecting = false; + sender._userConnected = false; + rejectStates(sender, owner, error); + }); + return flight; + }; + sender.connect = connect; + sender.reconnect = sender._reconnect = () => { + const result = connect(undefined, true).then(() => undefined); + // SDK invokes reconnect without awaiting its promise. + void result.catch(() => undefined); + return result; + }; + sender._autoReconnectCallback = undefined; + sender._handleBadAuthKey = skip => { + if (skip && sender === main) return; + owner.failed = transportError('USER_AUTH_KEY_INVALID'); + if (sender === main) accountFailure = owner.failed; + diagnostic('user_auth_key_invalid', sender, owner.failed); + void sender.disconnect().catch(() => undefined); + }; + let disconnectFlight: Promise | undefined; + sender.disconnect = sender._disconnect = () => { + if (disconnectFlight) return disconnectFlight; + owner.closed = true; + ++owner.generation; + sender.userDisconnected = true; + disconnectFlight = track((async () => { + await stop(sender, owner, transportError('USER_SENDER_CLOSED')); + await Promise.allSettled([owner.flight]); + await stop(sender, owner, transportError('USER_SENDER_CLOSED')); + sender.isConnecting = sender.isReconnecting = false; + })()); + return disconnectFlight; + }; + return sender; + }; + Object.defineProperty(c, '_sender', { configurable: false, get: () => main, + set: (sender: OwnedSender | undefined) => { main = sender === undefined ? undefined : protect(sender); } }); + const create = c._createExportedSender.bind(c); + c._createExportedSender = dc => protect(create(dc)); + c._connectSender = (sender, dcId) => track(signals.run({}, async () => { + assertOpen(); protect(sender); + const dc = await c.getDC(dcId, !!sender.authKey.getKey()); + assertOpen(); + await sender.connect(new c._connection({ ip: dc.ipAddress, port: dc.port, dcId, + loggers: c._log, proxy: c._proxy, testServers: c.testServers, socket: c.networkSocket })); + if (c.session.dcId !== dcId && !owners.get(sender)!.authorized) { + const auth = await c.invoke(new Api.auth.ExportAuthorization({ dcId })); + const init = new Api.InitConnection({ ...c._initRequest, + query: new Api.auth.ImportAuthorization({ id: auth.id, bytes: auth.bytes }) }); + await sender.send(new Api.InvokeWithLayer({ layer: LAYER, query: init })); + sender._authenticated = true; + owners.get(sender)!.authorized = true; + } + assertOpen(); sender.dcId = dcId; + return sender; + }).catch(async error => { + if (!terminal.signal.aborted && (sender === main || Date.now() < circuitUntil)) accountFailure = error; + await sender.disconnect(); + throw error; + })); + c._borrowExportedSender = async (dc, reconnect = false, existing) => { + assertOpen(); + const signal = signalForCall(); + const closure = dcClosures.get(dc); + if (closure) await waiting(() => waitFor(closure, signal)); + const failure = dcFailures.get(dc); + if (failure && Date.now() < failure.retryAt) throw failure.error; + dcFailures.delete(dc); + let promise = c._exportedSenderPromises.get(dc); + if (!promise) { + const sender = existing || c._createExportedSender(dc); + exportedSenders.set(dc, sender); + promise = c._connectSender(sender, dc); + c._exportedSenderPromises.set(dc, promise); + void promise.catch(() => undefined); + } + try { + const sender = await waiting(() => waitFor(promise!, signal)); + assertOpen(); + if (reconnect || !sender.isConnected()) await waiting(() => waitFor(sender._reconnect(), signal)); + return sender; + } catch (error) { + signal.throwIfAborted(); + assertOpen(); + const unavailable = transportError('USER_DC_UNAVAILABLE'); + const failedSender = exportedSenders.get(dc); + if (failedSender && c._exportedSenderPromises.get(dc) === promise && !dcClosures.has(dc)) { + dcFailures.set(dc, { retryAt: Date.now() + 10_000, error: unavailable }); + const retired = track((async () => { + try { + await failedSender.disconnect(); + if (c._exportedSenderPromises.get(dc) === promise) { + c._exportedSenderPromises.delete(dc); + exportedSenders.delete(dc); + } + owners.delete(failedSender); + } catch (closeError) { + dcFailures.set(dc, { retryAt: Infinity, error: transportError('USER_DC_CLOSE_FAILED') }); + throw closeError; + } + })()); + dcClosures.set(dc, retired); + void retired.then(() => dcClosures.delete(dc), () => dcClosures.delete(dc)); + } + const retiring = dcClosures.get(dc); + if (retiring) await waiting(() => waitFor(retiring, signal)); + diagnostic('user_dc_unavailable', failedSender, error); + throw unavailable; + } + }; + c._cleanupExportedSender = async dc => { + const sender = await c._exportedSenderPromises.get(dc); + if (sender) await sender.disconnect(); + }; + const nativeConnect = c.connect.bind(c); + c.connect = () => { + try { assertOpen(); } catch (error) { return Promise.reject(error); } + if (clientFlight) return clientFlight; + if (initialized && main?.isConnected() && !c._isSwitchingDc) return Promise.resolve(true); + const flight = track(nativeConnect()); + clientFlight = flight; + void flight.then(result => { initialized = result; clientFlight = undefined; }, () => { clientFlight = undefined; }); + return flight; + }; + const nativeSwitch = c._switchDC.bind(c); + c._switchDC = dc => { + assertOpen(); + if (migrationFlight) return migrationFlight; + const flight = track(nativeSwitch(dc)); + migrationFlight = flight; + void flight.then(() => { migrationFlight = undefined; }, () => { migrationFlight = undefined; }); + return flight; + }; + // Replay only idempotent GetFile reads; mutations keep their original failure. + const invoke = async (request: R, dc?: number, supplied?: OwnedSender): Promise => { + const signal = signalForCall(); + assertOpen(); signal.throwIfAborted(); + if (request.classType !== 'request') throw new Error('You can only invoke MTProtoRequests'); + await waitFor(c._connectedDeferred.promise, signal); + await waitFor(request.resolve(client, utils), signal); + c._lastRequest = Date.now(); + for (let attempt = 0; attempt < 5; attempt++) { + const sender = supplied || (dc ? await waitFor(c._borrowExportedSender(dc), signal) : main); + if (!sender) throw transportError('USER_DISCONNECTED'); + try { + let result: unknown; + for (let retry = 0; ; retry++) { + try { result = await sender.send(request); break; } + catch (error) { + const message = error && typeof error === 'object' && 'errorMessage' in error && typeof error.errorMessage === 'string' + ? error.errorMessage : error instanceof Error ? error.message : ''; + const transient = error instanceof errors.ServerError || /^(USER_CONNECTION_LOST|USER_REQUEST_TIMEOUT|ECONNRESET|ETIMEDOUT|RPC_CALL_FAIL|RPC_MCGET_FAIL|TIMEOUT|Timeout)$/.test(message); + if (!(request instanceof Api.upload.GetFile) || retry >= 2 || !transient) throw error; + signal.throwIfAborted(); + await waiting(() => delay(300 * 2 ** retry + Math.floor(Math.random() * 200), signal)); + } + } + c.session.processEntities(result); + c._entityCache.add(result); + // The response is decoded by this TL request's readResult in GramJS. + const response = result as unknown as R['__response']; + return response; + } catch (error) { + if (error instanceof errors.PhoneMigrateError || error instanceof errors.NetworkMigrateError || error instanceof errors.UserMigrateError) { + if (!(error instanceof errors.UserMigrateError) && await c.isUserAuthorized()) throw error; + if (supplied || attempt === 4) throw error; + await waitFor(c._switchDC(error.newDc), signal); + } else throw error; + } + } + throw transportError('USER_MIGRATION_LIMIT'); + }; + c.invoke = (request, dc) => track(invoke(request, dc)); + c.invokeWithSender = (request, sender?: MTProtoSender) => { + if (!sender) return track(invoke(request)); + // Consumer receives actual GramJS senders; their private layout is pinned above. + const ownedSender = sender as unknown as OwnedSender; + if (!owners.has(ownedSender)) return Promise.reject(transportError('USER_UNOWNED_SENDER')); + return track(invoke(request, undefined, ownedSender)); + }; + const close = (): Promise => { + if (closing) return closing; + terminal.abort(transportError('USER_TRANSPORT_CLOSED')); + c._destroyed = true; + wakeGate(); + for (const timer of c._exportedSenderReleaseTimeouts.values()) clearTimeout(timer); + c._exportedSenderReleaseTimeouts.clear(); + const promise = (async () => { + const failures: unknown[] = []; + const collect = (results: PromiseSettledResult[]) => { + for (const result of results) if (result.status === 'rejected') failures.push(result.reason); + }; + collect(await Promise.allSettled([...sockets].map(socket => socket.close()))); + collect(await Promise.allSettled([...owners.keys()].map(sender => sender.disconnect()))); + while (work.size) await Promise.allSettled([...work]); + await Promise.allSettled([...c._exportedSenderPromises.values(), ...Object.values(c._borrowedSenderPromises)]); + collect(await Promise.allSettled([...owners.keys()].map(sender => sender.disconnect()))); + collect(await Promise.allSettled([...sockets].map(socket => socket.close()))); + c._exportedSenderPromises.clear(); + c._borrowedSenderPromises = {}; + c._eventBuilders = []; + sockets.clear(); + dcFailures.clear(); + dcClosures.clear(); + exportedSenders.clear(); + if (failures.length) throw new AggregateError(failures, 'USER_TRANSPORT_CLOSE_FAILED'); + })(); + closing = promise; + return promise; + }; + c.disconnect = c.destroy = close; + return client; +} diff --git a/backend/src/services/telegramUserWebLogin.ts b/backend/src/services/telegramUserWebLogin.ts index 2a8cf99..9732dcf 100644 --- a/backend/src/services/telegramUserWebLogin.ts +++ b/backend/src/services/telegramUserWebLogin.ts @@ -58,6 +58,7 @@ function normalizeAccount(me: { id: unknown; username?: string; firstName?: stri export class TelegramUserWebLoginFlows { private readonly flows = new Map>(); + private readonly unclosedClients = new Set(); private readonly ttlMs: number; private readonly maxErrors: number; private readonly now: () => number; @@ -131,6 +132,7 @@ export class TelegramUserWebLoginFlows { } private async removeOwnerFlows(owner: string): Promise { + if (this.unclosedClients.size) throw new Error('TELEGRAM_LOGIN_CLEANUP_UNCONFIRMED'); const owned = [...this.flows.values()].filter(flow => flow.owner === owner); for (const flow of owned) { this.flows.delete(flow.id); @@ -180,7 +182,8 @@ export class TelegramUserWebLoginFlows { } private async closeClient(client: C): Promise { - try { await client.disconnect(); } catch { /* best effort */ } - try { await client.destroy(); } catch { /* best effort */ } + this.unclosedClients.add(client); + await client.destroy(); + this.unclosedClients.delete(client); } } diff --git a/backend/src/services/telegramUserWorker.ts b/backend/src/services/telegramUserWorker.ts new file mode 100644 index 0000000..c5d328b --- /dev/null +++ b/backend/src/services/telegramUserWorker.ts @@ -0,0 +1,132 @@ +import { Api, TelegramClient } from 'telegram'; +import { StringSession } from 'telegram/sessions/index.js'; +import bigInt from 'big-integer'; +import { open } from 'node:fs/promises'; +import type { IterDownloadFunction, DownloadMediaInterface } from 'telegram/client/downloads.js'; +import { ownUserTransport, withUserTransportSignal } from './telegramUserTransport.js'; +import { decodeTelegramValue, encodeTelegramValue, parseWorkerRequest, telegramWorkerError, type WorkerResponse } from './telegramWorkerProtocol.js'; + +import { isRecord } from '../utils/typeGuards.js'; +function requestArgument(value: unknown): value is Api.AnyRequest { + return isRecord(value) && value.classType === 'request' && typeof value.getBytes === 'function' && typeof value.resolve === 'function'; +} +function downloadArgument(value: unknown): IterDownloadFunction { + if (!isRecord(value) || !Number.isSafeInteger(value.requestSize) || Number(value.requestSize) <= 0) throw new Error('TELEGRAM_WORKER_INVALID_DOWNLOAD'); + for (const field of ['offset', 'fileSize']) if (value[field] !== undefined && !bigInt.isInstance(value[field])) throw new Error('TELEGRAM_WORKER_INVALID_OFFSET'); + for (const field of ['stride', 'limit', 'chunkSize', 'dcId']) if (value[field] !== undefined && (!Number.isSafeInteger(value[field]) || Number(value[field]) <= 0)) throw new Error('TELEGRAM_WORKER_INVALID_DOWNLOAD_LIMIT'); + if (!(value.file instanceof Api.MessageMediaDocument || value.file instanceof Api.MessageMediaPhoto || value.file instanceof Api.InputDocumentFileLocation || value.file instanceof Api.InputPhotoFileLocation)) throw new Error('TELEGRAM_WORKER_INVALID_MEDIA'); + // Schema above establishes the SDK-supported numeric and TL location fields. + const params = value as unknown as IterDownloadFunction; + return params; +} + +let client: TelegramClient | undefined; +let closing = false; +let iteratorSequence = 0; +const iterators = new Map>(); +const operations = new Map(); +const progressWaiters = new Map void>(); +let lastSession = ''; +const send = (message: WorkerResponse) => { if (process.connected) process.send?.(message); }; +const heartbeat = setInterval(() => send({ id: 0, heartbeat: true, connected: Boolean(client?.connected) }), 1_000); +heartbeat.unref(); + +async function close() { + if (closing) return; + closing = true; + clearInterval(heartbeat); + for (const controller of operations.values()) controller.abort(new Error('TELEGRAM_WORKER_CLOSED')); + for (const resolve of progressWaiters.values()) resolve(); + if (client) await client.destroy(); + iterators.clear(); +} +process.once('disconnect', () => { void close().then(() => process.exit(0), () => process.exit(1)); }); +process.once('SIGTERM', () => { void close().then(() => process.exit(0), () => process.exit(1)); }); + +process.on('message', (raw: unknown) => { + const request = parseWorkerRequest(raw); + if (request.method === 'cancel') { operations.get(request.id)?.abort(new DOMException('Cancelled', 'AbortError')); progressWaiters.get(request.id)?.(); return; } + if (request.method === 'progressAck') { progressWaiters.get(request.id)?.(); return; } + const controller = new AbortController(); + operations.set(request.id, controller); + let waitDepth = 0; + void withUserTransportSignal(controller.signal, async () => { + const args = request.args.map(decodeTelegramValue); + let value: unknown; + if (request.method === 'initialize') { + const [session, apiId, apiHash, accountId] = args; + if (client || typeof session !== 'string' || typeof apiId !== 'number' || typeof apiHash !== 'string' || typeof accountId !== 'string') throw new Error('TELEGRAM_WORKER_INVALID_INITIALIZATION'); + client = ownUserTransport(new TelegramClient(new StringSession(session), apiId, apiHash, { + connectionRetries: 1, reconnectRetries: 0, requestRetries: 2, downloadRetries: 1, + retryDelay: 0, useWSS: false, floodSleepThreshold: 0, + deviceModel: 'TG Vault User Downloader', systemVersion: '1.0.0', appVersion: '1.0.0', + }), { accountId, onRuntimeEvent: runtime => send({ id: 0, runtime }) }); + value = await client.connect(); + } else { + if (!client || closing) throw new Error('TELEGRAM_WORKER_CLOSED'); + // These arguments originate from the typed SDK-facing parent API and TL decoder. + switch (request.method) { + case 'invoke': { + const [request, dcId] = args; + if (!requestArgument(request) || (dcId !== undefined && !Number.isSafeInteger(dcId))) throw new Error('TELEGRAM_WORKER_INVALID_RPC'); + value = await client.invoke(request, typeof dcId === 'number' ? dcId : undefined); + break; + } + case 'open': { + const iterator = client.iterDownload(downloadArgument(args[0]))[Symbol.asyncIterator](); + const id = ++iteratorSequence; + iterators.set(id, iterator); + value = id; + break; + } + case 'next': { + const iterator = iterators.get(Number(args[0])); + if (!iterator) throw new Error('TELEGRAM_WORKER_ITERATOR_CLOSED'); + const result = await iterator.next(); + if (result.done) iterators.delete(Number(args[0])); + value = result; + break; + } + case 'return': { + const id = Number(args[0]); + const iterator = iterators.get(id); + iterators.delete(id); + await iterator?.return?.(); + value = undefined; + break; + } + case 'downloadMedia': { + const [message, options] = args; + if (!(message instanceof Api.Message) || !isRecord(options) || typeof options.outputFile !== 'string') throw new Error('TELEGRAM_WORKER_INVALID_PHOTO_DOWNLOAD'); + const file = await open(options.outputFile, 'w'); + try { + const params: DownloadMediaInterface = { outputFile: { write: (chunk: Buffer) => file.writeFile(chunk) } }; + await client.downloadMedia(message, { ...params, + progressCallback: async (downloaded, total) => { + controller.signal.throwIfAborted(); + await new Promise(resolve => { + progressWaiters.set(request.id, resolve); + send({ id: request.id, progress: [Number(downloaded), Number(total)] }); + }); + progressWaiters.delete(request.id); + controller.signal.throwIfAborted(); + }, + }); + value = options.outputFile; + } finally { await file.close(); } + break; + } + case 'destroy': await close(); value = undefined; break; + default: throw new Error('TELEGRAM_WORKER_UNKNOWN_METHOD'); + } + } + const session = client?.session.save(); + send({ id: request.id, value: encodeTelegramValue(value), connected: Boolean(client?.connected), session: typeof session === 'string' && session !== lastSession ? session : undefined }); + if (typeof session === 'string') lastSession = session; + }, waiting => { + const previous = waitDepth; + waitDepth += waiting ? 1 : -1; + if ((previous === 0) !== (waitDepth === 0)) send({ id: request.id, waiting: waitDepth > 0 }); + }).catch((error: unknown) => send({ id: request.id, error: telegramWorkerError(error), connected: Boolean(client?.connected) })) + .finally(() => { operations.delete(request.id); progressWaiters.delete(request.id); }); +}); diff --git a/backend/src/services/telegramUserWorkerClient.ts b/backend/src/services/telegramUserWorkerClient.ts new file mode 100644 index 0000000..120189f --- /dev/null +++ b/backend/src/services/telegramUserWorkerClient.ts @@ -0,0 +1,221 @@ +import { fork, type ChildProcess } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { TelegramClient, Api, utils } from 'telegram'; +import { StringSession } from 'telegram/sessions/index.js'; +import bigInt from 'big-integer'; +import { LogLevel } from 'telegram/extensions/Logger.js'; +import { DirectDownloadIter, type IterDownloadFunction, type DownloadMediaInterface } from 'telegram/client/downloads.js'; +import { encodeTelegramValue, decodeTelegramValue, parseWorkerResponse, type TelegramTransportRuntimeEvent } from './telegramWorkerProtocol.js'; +import { telegramOperationSignal } from './telegramOperationContext.js'; + +interface PendingCall { + resolve(value: unknown): void; + reject(error: Error): void; + cleanup(): void; + setWaiting(waiting: boolean): void; + cancelTimer?: NodeJS.Timeout; + cancellation?: Error; + progress?: (downloaded: number, total: number) => void | Promise; +} + +class RemoteDownloadIterator extends DirectDownloadIter { + constructor(client: TelegramUserWorkerClient, private readonly owner: TelegramUserWorkerClient, private readonly params: IterDownloadFunction) { super(client); } + override [Symbol.asyncIterator](): AsyncIterator { + let handle: number | undefined; + let finished = false; + return { + next: async () => { + if (finished) return { done: true, value: undefined }; + handle ??= await this.owner.call('open', [this.params]); + const result = await this.owner.call>('next', [handle]); + if (result.done) finished = true; + return result; + }, + return: async () => { + finished = true; + if (handle !== undefined && this.owner.connected) await this.owner.call('return', [handle], undefined, null); + return { done: true, value: undefined }; + }, + }; + } +} + +/** SDK-facing facade: no MTProto sockets or sender loops run in the HTTP process. */ +export class TelegramUserWorkerClient extends TelegramClient { + private child?: ChildProcess; + private live = false; + private closing = false; + private sequence = 0; + private heartbeatAt = 0; + private watchdog?: NodeJS.Timeout; + private readonly pending = new Map(); + private connectFlight?: Promise; + private closeFlight?: Promise; + private exitFlight?: Promise; + private runtimeEventHandler?: (event: TelegramTransportRuntimeEvent) => void; + + constructor(session: string, private readonly credentials: { apiId: number; apiHash: string }, private readonly accountId: string) { + super(new StringSession(session), credentials.apiId, credentials.apiHash, { connectionRetries: 1, useWSS: false }); + this.setLogLevel(LogLevel.NONE); + } + override get connected() { return this.live && !this.closing; } + get runtimeProcessId() { return this.child?.pid; } + setRuntimeEventHandler(handler: ((event: TelegramTransportRuntimeEvent) => void) | undefined): void { this.runtimeEventHandler = handler; } + + override connect(): Promise { + if (this.closing) return Promise.reject(new Error('TELEGRAM_WORKER_CLOSED')); + if (this.connectFlight) return this.connectFlight; + this.connectFlight = this.spawnWorker(); + return this.connectFlight; + } + private async spawnWorker() { + // StringSession.save() is empty until load installs its serialized auth key. + await this.session.load(); + if (this.closing) throw new Error('TELEGRAM_WORKER_CLOSED'); + const sourceMode = import.meta.url.endsWith('.ts'); + const worker = fileURLToPath(new URL(sourceMode ? './telegramUserWorker.ts' : './telegramUserWorker.js', import.meta.url)); + const child = fork(worker, [], { serialization: 'advanced', stdio: ['ignore', 'inherit', 'inherit', 'ipc'], + execArgv: sourceMode ? ['--import', 'tsx'] : [], env: { ...process.env, TELEGRAM_WORKER_ACCOUNT_ID: this.accountId } }); + this.child = child; + this.heartbeatAt = Date.now(); + this.exitFlight = new Promise(resolve => child.once('close', () => { + this.live = false; + clearInterval(this.watchdog); + this.failPending(new Error('TELEGRAM_WORKER_EXITED')); + resolve(); + })); + child.on('error', (error: Error) => this.failPending(error)); + child.on('message', async (raw: unknown) => { + try { + const message = parseWorkerResponse(raw); + if (message.runtime) { + if (!this.closing && this.child === child) { + try { this.runtimeEventHandler?.(message.runtime); } + catch (error) { + console.error(JSON.stringify({ event: 'telegram_worker_runtime_callback_failed', accountId: this.accountId, + error: error instanceof Error ? error.message : String(error) })); + } + } + return; + } + if (message.connected !== undefined) this.live = message.connected; + if (message.heartbeat) { this.heartbeatAt = Date.now(); return; } + if (message.session) { + const session = new StringSession(message.session); + await session.load(); + this.session = session; + } + const call = this.pending.get(message.id); + if (!call) return; + if (message.waiting !== undefined) { call.setWaiting(message.waiting); return; } + if (message.progress) { + void Promise.resolve(call.progress?.(...message.progress)).then(() => { + if (child.connected) child.send({ id: message.id, method: 'progressAck', args: [] }); + }).catch((error: unknown) => this.cancel(message.id, error)); + return; + } + this.pending.delete(message.id); + clearTimeout(call.cancelTimer); + call.cleanup(); + if (call.cancellation) call.reject(call.cancellation); + else if (message.error) call.reject(Object.assign(new Error(message.error.message), message.error)); + else call.resolve(decodeTelegramValue(message.value)); + } catch { this.failPending(new Error('TELEGRAM_WORKER_INVALID_RESPONSE')); child.kill('SIGKILL'); } + }); + this.watchdog = setInterval(() => { + if (!this.closing && Date.now() - this.heartbeatAt > 10_000) { + console.error(JSON.stringify({ event: 'telegram_worker_unresponsive', accountId: this.accountId })); + this.live = false; + child.kill('SIGKILL'); + } + }, 1_000); + this.watchdog.unref(); + try { return await this.call('initialize', [this.session.save(), this.credentials.apiId, this.credentials.apiHash, this.accountId], undefined, undefined, 90_000); } + catch (error) { await this.destroy(); throw error; } + } + private cancel(id: number, reason: unknown) { + const child = this.child; + if (!child?.connected || !this.pending.has(id)) return; + const call = this.pending.get(id); + if (!call || call.cancelTimer) return; + call.cancellation = reason instanceof Error ? reason : new DOMException('Cancelled', 'AbortError'); + child.send({ id, method: 'cancel', args: [] }); + call.cancelTimer = setTimeout(() => { + if (!this.pending.has(id)) return; + this.live = false; + this.failPending(reason instanceof Error ? reason : new Error('TELEGRAM_WORKER_CANCEL_TIMEOUT')); + child.kill('SIGKILL'); + }, 5_000); + call.cancelTimer.unref(); + } + private failPending(error: Error) { + for (const call of this.pending.values()) { clearTimeout(call.cancelTimer); call.cleanup(); call.reject(error); } + this.pending.clear(); + } + async call(method: string, args: unknown[], progress?: PendingCall['progress'], signal: AbortSignal | null | undefined = telegramOperationSignal(), timeoutMs = 120_000): Promise { + signal?.throwIfAborted(); + const child = this.child; + if (!child?.connected) throw new Error('TELEGRAM_WORKER_UNAVAILABLE'); + const id = ++this.sequence; + const result = new Promise((resolve, reject) => { + const abort = () => this.cancel(id, signal?.reason); + let remaining = timeoutMs; + let activeSince = Date.now(); + let waiting = false; + const expired = () => this.cancel(id, new Error('TELEGRAM_WORKER_REQUEST_TIMEOUT')); + let timer = setTimeout(expired, remaining); + timer.unref(); + const setWaiting = (next: boolean) => { + if (next === waiting) return; + waiting = next; + clearTimeout(timer); + if (waiting) remaining = Math.max(1, remaining - (Date.now() - activeSince)); + else { activeSince = Date.now(); timer = setTimeout(expired, remaining); timer.unref(); } + }; + this.pending.set(id, { resolve, reject, progress, setWaiting, cleanup: () => { clearTimeout(timer); signal?.removeEventListener('abort', abort); } }); + signal?.addEventListener('abort', abort, { once: true }); + child.send({ id, method, args: args.map(encodeTelegramValue) }, (error: Error | null) => { if (error) this.failPending(error); }); + }); + // The private pipe parses the protocol and TL bytes; caller supplies the SDK return type. + return await result as T; + } + override async invoke(request: R, dcId?: number): Promise { + await request.resolve(this, utils); + const result = await this.call('invoke', [request, dcId]); + this.session.processEntities(result); + this._entityCache.add(result); + return result; + } + override async checkAuthorization() { + try { await this.invoke(new Api.updates.GetState()); return true; } + catch (error) { + const message = error instanceof Error ? error.message : ''; + if (/AUTH_KEY_(UNREGISTERED|INVALID|DUPLICATED)|SESSION_(REVOKED|EXPIRED)|USER_DEACTIVATED/.test(message)) return false; + throw error; + } + } + override iterDownload(params: IterDownloadFunction) { return new RemoteDownloadIterator(this, this, params); } + override downloadMedia(message: Api.TypeMessage | Api.TypeMessageMedia, params: DownloadMediaInterface = {}) { + const { progressCallback, ...options } = params; + return this.call('downloadMedia', [message, options], progressCallback ? (downloaded, total) => progressCallback(bigInt(downloaded), bigInt(total)) : undefined, telegramOperationSignal(), 24 * 60 * 60_000); + } + override disconnect() { return this.destroy(); } + override destroy(): Promise { + if (this.closeFlight) return this.closeFlight; + this.closing = true; + this.live = false; + this.runtimeEventHandler = undefined; + this.closeFlight = (async () => { + const child = this.child; + if (!child || !this.exitFlight) return; + clearInterval(this.watchdog); + const timer = setTimeout(() => child.kill('SIGKILL'), 5_000); + timer.unref(); + child.kill('SIGTERM'); + await this.exitFlight; + clearTimeout(timer); + this.child = undefined; + })(); + return this.closeFlight; + } +} diff --git a/backend/src/services/telegramWorkerProtocol.ts b/backend/src/services/telegramWorkerProtocol.ts new file mode 100644 index 0000000..80ef1ff --- /dev/null +++ b/backend/src/services/telegramWorkerProtocol.ts @@ -0,0 +1,99 @@ +import { BinaryReader } from 'telegram/extensions/BinaryReader.js'; +import bigInt from 'big-integer'; + +import { isRecord as record } from '../utils/typeGuards.js'; + +// Only trusted parent/child pipes use this codec; TL bytes preserve GramJS classes. +export function encodeTelegramValue(value: unknown): unknown { + if (Array.isArray(value)) return { $telegram: 'array', value: value.map(encodeTelegramValue), total: 'total' in value ? value.total : undefined }; + if (!record(value) || Buffer.isBuffer(value)) return value; + if (value instanceof Date) return { $telegram: 'date', value: value.getTime() }; + if (bigInt.isInstance(value)) return { $telegram: 'long', value: value.toString() }; + if (typeof value.getBytes === 'function' && typeof value.className === 'string') return { $telegram: 'tl', value: value.getBytes() }; + return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, encodeTelegramValue(entry)])); +} + +export function decodeTelegramValue(value: unknown): unknown { + if (!record(value) || Buffer.isBuffer(value)) return value; + switch (value.$telegram) { + case 'date': + if (typeof value.value === 'number') return new Date(value.value); + break; + case 'long': + if (typeof value.value === 'string' && /^-?\d+$/.test(value.value)) return bigInt(value.value); + break; + case 'tl': + if (Buffer.isBuffer(value.value)) return new BinaryReader(value.value).tgReadObject(); + break; + case 'array': { + if (!Array.isArray(value.value)) break; + const result: unknown[] & { total?: number } = value.value.map(decodeTelegramValue); + if (value.total !== undefined) { + if (typeof value.total !== 'number') break; + result.total = value.total; + } + return result; + } + case undefined: return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, decodeTelegramValue(entry)])); + } + throw new Error('TELEGRAM_WORKER_INVALID_VALUE'); +} + +export interface TelegramWorkerError { name: string; message: string; code?: string; errorMessage?: string; seconds?: number } + +export function telegramWorkerError(error: unknown) { + const value = record(error) ? error : {}; + return { name: typeof value.name === 'string' ? value.name : 'Error', + message: typeof value.message === 'string' ? value.message : 'TELEGRAM_WORKER_FAILED', + code: typeof value.code === 'string' ? value.code : undefined, + errorMessage: typeof value.errorMessage === 'string' ? value.errorMessage : undefined, + seconds: typeof value.seconds === 'number' ? value.seconds : undefined }; +} + +export interface WorkerRequest { id: number; method: string; args: unknown[] } +export function parseWorkerRequest(value: unknown): WorkerRequest { + if (!record(value) || !Number.isSafeInteger(value.id) || typeof value.method !== 'string' || !Array.isArray(value.args)) throw new Error('TELEGRAM_WORKER_INVALID_REQUEST'); + return { id: Number(value.id), method: value.method, args: value.args }; +} + +// Short server cooldowns must not reduce configured download concurrency. +export const TELEGRAM_EXTREME_FLOOD_WAIT_SECONDS = 60 * 60; + +export type TelegramTransportRuntimeEvent = + | { type: 'cooldown'; seconds: number; until: number; reason: 'FLOOD_WAIT' | 'FLOOD_TEST_PHONE_WAIT'; file: boolean; episode: number; fragmentLimit: number; activeFragments: number } + | { type: 'file_success'; at: number; episode: number; successfulReads: number; fragmentLimit: number; activeFragments: number }; + +function parseRuntimeEvent(value: unknown): TelegramTransportRuntimeEvent { + if (!record(value) || !Number.isSafeInteger(value.episode) || Number(value.episode) < 0 || + !Number.isSafeInteger(value.fragmentLimit) || Number(value.fragmentLimit) < 1 || Number(value.fragmentLimit) > 12 || + !Number.isSafeInteger(value.activeFragments) || Number(value.activeFragments) < 0 || Number(value.activeFragments) > 12) throw new Error('TELEGRAM_WORKER_INVALID_RUNTIME'); + if (value.type === 'cooldown' && Number.isSafeInteger(value.seconds) && Number(value.seconds) >= 0 && + Number.isSafeInteger(value.until) && Number(value.until) >= 0 && Number(value.until) <= 8_640_000_000_000_000 && + (value.reason === 'FLOOD_WAIT' || value.reason === 'FLOOD_TEST_PHONE_WAIT') && typeof value.file === 'boolean') { + return { type: value.type, seconds: Number(value.seconds), until: Number(value.until), reason: value.reason, file: value.file, + episode: Number(value.episode), fragmentLimit: Number(value.fragmentLimit), activeFragments: Number(value.activeFragments) }; + } + if (value.type === 'file_success' && Number.isSafeInteger(value.at) && Number(value.at) >= 0 && Number(value.at) <= 8_640_000_000_000_000 && + Number.isSafeInteger(value.successfulReads) && Number(value.successfulReads) > 0) { + return { type: value.type, at: Number(value.at), episode: Number(value.episode), successfulReads: Number(value.successfulReads), + fragmentLimit: Number(value.fragmentLimit), activeFragments: Number(value.activeFragments) }; + } + throw new Error('TELEGRAM_WORKER_INVALID_RUNTIME'); +} + +export interface WorkerResponse { id: number; value?: unknown; error?: TelegramWorkerError; progress?: [number, number]; waiting?: boolean; connected?: boolean; session?: string; heartbeat?: boolean; runtime?: TelegramTransportRuntimeEvent } +export function parseWorkerResponse(value: unknown): WorkerResponse { + if (!record(value) || !Number.isSafeInteger(value.id)) throw new Error('TELEGRAM_WORKER_INVALID_RESPONSE'); + if (value.error !== undefined && (!record(value.error) || typeof value.error.message !== 'string' || typeof value.error.name !== 'string')) throw new Error('TELEGRAM_WORKER_INVALID_ERROR'); + if (value.progress !== undefined && (!Array.isArray(value.progress) || value.progress.length !== 2 || !value.progress.every(entry => typeof entry === 'number'))) throw new Error('TELEGRAM_WORKER_INVALID_PROGRESS'); + if (value.waiting !== undefined && typeof value.waiting !== 'boolean') throw new Error('TELEGRAM_WORKER_INVALID_WAIT'); + // Parsed discriminator/fields above; remaining optional runtime fields checked here. + return { id: Number(value.id), value: value.value, + error: value.error === undefined ? undefined : telegramWorkerError(value.error), + progress: Array.isArray(value.progress) ? [Number(value.progress[0]), Number(value.progress[1])] : undefined, + waiting: typeof value.waiting === 'boolean' ? value.waiting : undefined, + connected: typeof value.connected === 'boolean' ? value.connected : undefined, + session: typeof value.session === 'string' ? value.session : undefined, + runtime: value.runtime === undefined ? undefined : parseRuntimeEvent(value.runtime), + heartbeat: value.heartbeat === true }; +} diff --git a/backend/src/services/telegramWriteReconciliation.ts b/backend/src/services/telegramWriteReconciliation.ts index 006f8ef..180d452 100644 --- a/backend/src/services/telegramWriteReconciliation.ts +++ b/backend/src/services/telegramWriteReconciliation.ts @@ -23,6 +23,7 @@ export interface TelegramWriteReconciliationInput { childLeaseToken: string; provider: string; accountId: string | null; + storedPath?: string; } export interface ClaimedTelegramWriteReconciliation { @@ -43,15 +44,15 @@ export async function beginTelegramWriteReconciliation(db: Queryable, input: Tel const operationId = crypto.randomUUID(); const result = await db.query( `INSERT INTO telegram_write_reconciliations - (operation_id, job_id, item_id, child_lease_token, provider, account_id, + (operation_id, job_id, item_id, child_lease_token, provider, account_id, stored_path, object_state, index_state, reason, status, created_at, updated_at) - SELECT $1,$2,$3,$4,$5,$6,'unknown','unknown','Telegram 外部写进行中','pending',NOW(),NOW() + SELECT $1,$2,$3,$4,$5,$6,$7,'unknown','unknown','Telegram 外部写进行中','pending',NOW(),NOW() WHERE EXISTS ( SELECT 1 FROM telegram_download_items i WHERE i.id = $3::uuid AND i.job_id = $2::uuid AND i.status = 'downloading' AND i.lease_token = $4::uuid ) RETURNING operation_id`, - [operationId, input.jobId, input.itemId, input.childLeaseToken, input.provider, input.accountId], + [operationId, input.jobId, input.itemId, input.childLeaseToken, input.provider, input.accountId, input.storedPath || null], ) as { rowCount?: number | null }; if (result.rowCount !== 1) throw new Error('Telegram write journal 创建失败或 child lease 已丢失'); return operationId; @@ -115,6 +116,12 @@ export async function claimTelegramWriteReconciliations(db: Queryable, leaseToke WHERE r.status = 'pending' AND r.resolution IS DISTINCT FROM 'operator_required' AND (r.lease_expires_at IS NULL OR r.lease_expires_at <= NOW()) + AND NOT EXISTS ( + SELECT 1 FROM telegram_download_items active + WHERE active.id = r.item_id AND active.status = 'downloading' + AND active.lease_token = r.child_lease_token + AND active.lease_expires_at > NOW() + ) ORDER BY r.created_at FOR UPDATE SKIP LOCKED LIMIT $2 diff --git a/backend/src/services/unifiedTaskMapper.ts b/backend/src/services/unifiedTaskMapper.ts index dc67fff..fde675d 100644 --- a/backend/src/services/unifiedTaskMapper.ts +++ b/backend/src/services/unifiedTaskMapper.ts @@ -64,12 +64,13 @@ function channelStatus(status: string): string { /** Both clients count settled items, including failures and skips, as processed. */ export function telegramChannelJobCounts(row: Record) { - const total = Math.max(safeNumber(row?.total_count), safeNumber(row?.item_count)); + const params = parseTaskOptions(row?.params); + const total = Math.max(safeNumber(row?.total_count), safeNumber(row?.item_count) + safeNumber(params.compactedItemCount)); const failed = safeNumber(row?.failed_items ?? row?.failed_count); - const skipped = safeNumber(row?.skipped_count_items ?? row?.skipped_count); + const skipped = Math.max(safeNumber(row?.skipped_count_items) + safeNumber(params.compactedSkippedCount), safeNumber(row?.skipped_count)); const pending = safeNumber(row?.pending_count); const active = safeNumber(row?.downloading_count ?? row?.active_items); - const completed = safeNumber(row?.completed_items ?? row?.success_count) + const completed = (safeNumber(row?.completed_items ?? row?.success_count) + safeNumber(params.compactedSuccessCount)) || (['completed', 'completed_with_errors'].includes(String(row?.status)) ? Math.max(0, total - failed - skipped - active - pending) : 0); return { total, completed, failed, skipped, pending, active, processed: completed + failed + skipped }; @@ -79,6 +80,7 @@ export function telegramChannelJobTaskState(row: Record): Teleg let status = String(row.runtime_status || channelStatus(String(row?.status || 'pending'))); const scanStatus = String(row?.scan_status || 'pending'); const counts = telegramChannelJobCounts(row); + if (status === 'awaiting_selection') return { status, stage: 'awaiting_selection' }; if (status === 'running' && scanStatus === 'done' && counts.active === 0 && counts.pending > 0) status = 'pending'; const stage = status === 'completed' ? 'completed' @@ -121,22 +123,25 @@ export function mapTelegramChannelJob(row: any, accountNames: ReadonlyMap= total && safeNumber(row?.unknown_size_count) === 0, + savedUnknownCount: safeNumber(row?.completed_unknown_size_count) + safeNumber(params.savedUnknownCount), + bytesKnown: safeNumber(row?.item_count) + safeNumber(params.compactedItemCount) >= total + && safeNumber(row?.unknown_size_count) + safeNumber(params.compactedUnknownSizeCount) === 0, savedBytesKnown: safeNumber(row?.completed_unknown_size_count) === 0 && safeNumber(params.savedUnknownCount) === 0 }, speedBytesPerSecond: safeNumber(row?.speed_bytes_per_second), 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)), + cancellable: ['queued', 'running', 'paused', 'cooling', 'awaiting_selection'].includes(String(row?.status)), createdAt: row?.created_at, updatedAt: row?.progress_updated_at || row?.updated_at, finishedAt: row?.finished_at || null, diff --git a/backend/src/utils/advancedSettings.ts b/backend/src/utils/advancedSettings.ts index 62999ec..ab34388 100644 --- a/backend/src/utils/advancedSettings.ts +++ b/backend/src/utils/advancedSettings.ts @@ -4,6 +4,8 @@ export type TelegramDownloadHistoryPolicySetting = 'errors_only' | 'all'; export interface AdvancedSettings { telegramDownloadWorkers: number; telegramFileConcurrency: number; + telegramFileConcurrencyLimit: number; + telegramEnabledAccounts: number; duplicateMode: DuplicateModeSetting; autoCleanupOrphans: boolean; skipTelegramPhotosInBatch: boolean; @@ -12,7 +14,6 @@ export interface AdvancedSettings { } const WORKERS = new Set([4, 8, 12, 16]); -const FILE_CONCURRENCY = new Set([1, 2, 3, 4]); function booleanValue(value: unknown, fallback: boolean): boolean { if (value === undefined || value === null || value === '') return fallback; @@ -22,6 +23,8 @@ function booleanValue(value: unknown, fallback: boolean): boolean { export function buildAdvancedSettings(input: { telegramDownloadWorkers: unknown; telegramFileConcurrency: unknown; + telegramFileConcurrencyLimit: number; + telegramEnabledAccounts: number; duplicateMode: unknown; autoCleanupOrphans: unknown; skipTelegramPhotosInBatch: unknown; @@ -33,7 +36,9 @@ export function buildAdvancedSettings(input: { const telegramDownloadHistoryPolicy: TelegramDownloadHistoryPolicySetting = input.telegramDownloadHistoryPolicy === 'all' ? 'all' : 'errors_only'; return { telegramDownloadWorkers: WORKERS.has(workers) ? workers : 4, - telegramFileConcurrency: FILE_CONCURRENCY.has(fileConcurrency) ? fileConcurrency : 2, + telegramFileConcurrency: Number.isInteger(fileConcurrency) && fileConcurrency >= 1 && fileConcurrency <= 10 ? fileConcurrency : 2, + telegramFileConcurrencyLimit: input.telegramFileConcurrencyLimit, + telegramEnabledAccounts: input.telegramEnabledAccounts, duplicateMode, autoCleanupOrphans: booleanValue(input.autoCleanupOrphans, true), skipTelegramPhotosInBatch: booleanValue(input.skipTelegramPhotosInBatch, false), @@ -53,7 +58,7 @@ export function normalizeAdvancedSettingsPatch(input: Record): } if (key === 'telegramFileConcurrency') { const parsed = Number(value); - if (!FILE_CONCURRENCY.has(parsed)) throw new Error('telegramFileConcurrency 必须是 1/2/3/4'); + if (!Number.isInteger(parsed) || parsed < 1 || parsed > 10) throw new Error('telegramFileConcurrency 必须是 1–10 的整数'); return { telegramFileConcurrency: parsed, highRisk: parsed >= 4 }; } if (key === 'duplicateMode') { diff --git a/backend/src/utils/runtimeConfig.ts b/backend/src/utils/runtimeConfig.ts index 2691e26..7cfc425 100644 --- a/backend/src/utils/runtimeConfig.ts +++ b/backend/src/utils/runtimeConfig.ts @@ -19,7 +19,7 @@ const NUMBER_SPECS: NumberSpec[] = [ { name: 'ORPHAN_CLEANUP_MIN_AGE_MS', fallback: 600_000, min: 60_000, max: 2_592_000_000 }, { name: 'SQL_LOG_SLOW_MS', fallback: 500, min: 0, max: 3_600_000 }, { name: 'TELEGRAM_DOWNLOAD_WORKERS', fallback: 4, min: 1, max: 16 }, - { name: 'TELEGRAM_FILE_DOWNLOAD_CONCURRENCY', fallback: 2, min: 1, max: 4 }, + { name: 'TELEGRAM_FILE_DOWNLOAD_CONCURRENCY', fallback: 2, min: 1, max: 10 }, { name: 'TELEGRAM_RATE_WINDOW_MS', fallback: 60_000, min: 10_000, max: 86_400_000 }, { name: 'TELEGRAM_RATE_MAX', fallback: 30, min: 5, max: 10_000 }, { name: 'TELEGRAM_HEAVY_RATE_WINDOW_MS', fallback: 600_000, min: 60_000, max: 86_400_000 }, diff --git a/backend/src/utils/telegramBatchMediaFilter.ts b/backend/src/utils/telegramBatchMediaFilter.ts index a32d69e..14f6938 100644 --- a/backend/src/utils/telegramBatchMediaFilter.ts +++ b/backend/src/utils/telegramBatchMediaFilter.ts @@ -1,5 +1,21 @@ import type { Api } from 'telegram'; import { isTelegramPhotoMedia } from './telegramMedia.js'; +import type { TelegramFileInfo } from './telegramMedia.js'; +import { getDetailedTypeFolder } from './storagePath.js'; + +export const TELEGRAM_BATCH_FILE_TYPES = [ + 'images', 'raw-images', 'videos', 'audio', 'archives', 'apps', + 'text', 'word-docs', 'pdfs', 'spreadsheets', 'presentations', 'ebooks', + 'code', 'fonts', 'design', 'torrents', 'documents', 'other', +] as const; + +export type TelegramBatchFileType = typeof TELEGRAM_BATCH_FILE_TYPES[number]; + +export function getTelegramBatchFileType( + fileInfo: Pick, +): TelegramBatchFileType { + return (getDetailedTypeFolder(fileInfo.mimeType, fileInfo.fileName) || 'other') as TelegramBatchFileType; +} export function filterTelegramBatchMessages(messages: Api.Message[], skipTelegramPhotos: boolean): Api.Message[] { if (!skipTelegramPhotos) return messages; diff --git a/backend/src/utils/telegramCommandRegistry.ts b/backend/src/utils/telegramCommandRegistry.ts index dbd327c..cb062cc 100644 --- a/backend/src/utils/telegramCommandRegistry.ts +++ b/backend/src/utils/telegramCommandRegistry.ts @@ -40,7 +40,7 @@ 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: '设置同时下载的文件数:1 / 2 / 3 / 4', category: 'settings', aliases: ['file_workers', 'download_files'], menu: false, help: true }, + { command: 'file_concurrency', description: '文件级下载并发', helpDescription: '设置同时下载的文件数:1–10,上限为启用账号数的两倍,无账号时最多 2', 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', menu: true, help: true }, { command: 'duplicate_mode', description: '重复文件处理', helpDescription: '设置重复文件处理策略', category: 'settings', aliases: ['duplicate', 'dup'], help: true }, diff --git a/backend/src/utils/telegramMessages.ts b/backend/src/utils/telegramMessages.ts index 4ba4b7e..a2bfdf3 100644 --- a/backend/src/utils/telegramMessages.ts +++ b/backend/src/utils/telegramMessages.ts @@ -72,8 +72,8 @@ function collectCompletedFolders( ): string[] { const folders = new Set(); singleFiles - .filter(file => file.phase === 'success' && file.folder) - .forEach(file => folders.add(file.folder!)); + .filter(file => (file.phase === 'success' || file.phase === 'failed') && file.folder != null) + .forEach(file => folders.add(file.folder || '/')); batches .filter(batch => batch.completed === batch.totalFiles) .forEach(batch => { @@ -526,7 +526,7 @@ export function buildSilentAllTasksComplete( ): string { const successCount = Math.max(0, totalCount - failedCount); const providers = new Set(); - singleFiles.filter(f => f.phase === 'success' && (f.storageLabel || f.providerName)).forEach(f => providers.add(f.storageLabel || f.providerName!)); + singleFiles.filter(f => f.storageLabel || f.providerName).forEach(f => providers.add(f.storageLabel || f.providerName!)); batches.filter(b => b.storageLabel || b.providerName).forEach(b => providers.add(b.storageLabel || b.providerName!)); const folders = collectCompletedFolders(singleFiles, batches); const detailLines = [ @@ -651,7 +651,7 @@ export async function buildConsolidatedStatus( // 显示存储提供商 const providers = new Set(); - singleFiles.filter(f => f.phase === 'success' && (f.storageLabel || f.providerName)).forEach(f => providers.add(f.storageLabel || f.providerName!)); + singleFiles.filter(f => f.storageLabel || f.providerName).forEach(f => providers.add(f.storageLabel || f.providerName!)); batches.filter(b => b.storageLabel || b.providerName).forEach(b => providers.add(b.storageLabel || b.providerName!)); if (providers.size > 0) { lines.push(`📍 存储: ${Array.from(providers).map(p => getProviderDisplayName(p)).join(', ')}`); @@ -773,6 +773,8 @@ export async function buildConsolidatedStatus( queuedSingles.forEach(file => { lines.push(`🕒 ${file.typeEmoji} ${file.fileName}`); lines.push(` └ 排队`); + if (file.storageLabel || file.providerName) lines.push(` 📍 存储:${file.storageLabel || getProviderDisplayName(file.providerName!)}`); + if (file.folder != null) lines.push(` 📁 保存目录:${file.folder || '/'}`); }); } @@ -788,7 +790,7 @@ export async function buildConsolidatedStatus( icon = '✅'; const parts: string[] = []; if (file.size) parts.push(formatBytes(file.size)); - if (file.folder) parts.push(`📁 ${file.folder}`); + if (file.folder != null) parts.push(`📁 ${file.folder || '/'}`); detail = parts.join(' · ') || '完成'; break; case 'failed': @@ -800,6 +802,8 @@ export async function buildConsolidatedStatus( lines.push(`${icon} ${file.typeEmoji} ${file.fileName}`); lines.push(` └ ${detail}`); + if (file.storageLabel || file.providerName) lines.push(` 📍 存储:${file.storageLabel || getProviderDisplayName(file.providerName!)}`); + if (file.phase === 'failed' && file.folder != null) lines.push(` 📁 保存目录:${file.folder || '/'}`); }); } diff --git a/backend/src/utils/temporaryUploadFiles.ts b/backend/src/utils/temporaryUploadFiles.ts new file mode 100644 index 0000000..601e47f --- /dev/null +++ b/backend/src/utils/temporaryUploadFiles.ts @@ -0,0 +1,29 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import path from 'node:path'; + +const scopes = new AsyncLocalStorage>(); +const activePaths = new Set(); + +export function isTemporaryUploadFileActive(filePath: string): boolean { + return activePaths.has(path.resolve(filePath)); +} + +export function protectTemporaryUploadFile(filePath: string): void { + const scope = scopes.getStore(); + if (!scope) throw new Error('Temporary upload file requires an owning transfer scope'); + const resolvedPath = path.resolve(filePath); + scope.add(resolvedPath); + activePaths.add(resolvedPath); +} + +/** UUID paths belong to one attempt, including pauses, cloud writes and settlement. */ +export function withTemporaryUploadFiles(operation: () => Promise): Promise { + const paths = new Set(); + return scopes.run(paths, async () => { + try { + return await operation(); + } finally { + for (const filePath of paths) activePaths.delete(filePath); + } + }); +} diff --git a/backend/src/utils/typeGuards.ts b/backend/src/utils/typeGuards.ts new file mode 100644 index 0000000..363810c --- /dev/null +++ b/backend/src/utils/typeGuards.ts @@ -0,0 +1,4 @@ +/** Object discriminator only; boundary parsers must validate every consumed field. */ +export function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} diff --git a/deploy/DEPLOY.md b/deploy/DEPLOY.md index 85052c2..28e332b 100644 --- a/deploy/DEPLOY.md +++ b/deploy/DEPLOY.md @@ -1,136 +1,121 @@ -> 本地保存位置与 bind/卷备份:[完整说明](../docs/local-storage.md)。新安装询问保存目录;升级不迁移,`.env` 单独存在也不套用新目录默认值。 - # TG Vault 服务器部署指南 -TG Vault 使用 **Docker Compose + 宿主机反向代理**。Compose 包含 `postgres`、`backend`、`frontend`;Nginx 和证书由宿主机或面板管理。 +[首页](../README.md) · [配置参考](../docs/configuration.md) · [本地存储](../docs/local-storage.md) + +标准部署使用 **Docker Compose + 宿主机 HTTPS 反向代理**。Compose 包含 `postgres`、`backend`、`frontend`,不包含证书管理服务。默认 Web/API 端口仅绑定宿主机 `127.0.0.1`。 ## 1. 前置条件 -- Linux 服务器,Bash 4+、Python 3.6+、Git、Docker Engine、Compose 插件及 Buildx,当前用户能够访问 Docker daemon 和 builder。 -- 默认生成 SBOM(软件物料清单)和 provenance(构建来源证明),需要 Compose 2.39+ 及支持证明的 builder/镜像存储。 -- 已把 Web 域名和 API 域名解析到服务器。 -- 宿主机 Nginx、宝塔或其他反向代理负责 HTTPS 证书。 -- 从项目目录执行下列命令;项目目录即包含 `docker-compose.yml` 的目录。 +- Linux、Bash 4+、Python 3.7+、Git、Docker Engine、Compose 插件及 Buildx;当前用户能访问 Docker daemon 和 builder。 +- 标准模式需要 Compose 2.39+ 及支持 SBOM/provenance 的 builder/镜像存储。较旧环境可主动使用 `./deploy/install.sh --compat`,最低检查基线为 Compose 2.18+;本次不生成构建证明,不改原 Compose 文件。不支持 Python `docker-compose` v1,兼容模式不支持额外 Compose 覆盖文件。 +- Web 与 API 两个域名解析到服务器,例如 `cloud.example.com`、`api.example.com`。使用同一主域名下的 HTTPS 子域名,避免 `SameSite=Lax` Cookie 的跨站限制。 +- 宿主机 Nginx、管理面板或其他反向代理与证书;独立存储磁盘应提前挂载,并留足下载暂存和备份空间。 -环境检查只读。缺少组件时,输入 `1` 才授权从 apt(Debian/Ubuntu)或 dnf/yum 的现有仓库安装;可能更新包索引并请求 sudo 密码。按 Enter 或 `q` 退出。脚本不添加仓库、关闭签名校验、放宽 Docker socket 权限或执行远程安装脚本;包管理器自身可能启动安装的软件服务。 +环境检查默认只读。缺少组件时,输入 `1` 才授权从 apt(Debian/Ubuntu)或 dnf/yum 的现有仓库安装,可能请求 sudo 密码;按 Enter 或 `q` 退出。脚本不添加软件仓库、关闭签名校验或放宽 Docker socket 权限。 -较旧环境可主动使用 `./deploy/install.sh --compat`,验证基线为 Compose 2.18+。兼容模式去掉本次构建的 SBOM/provenance 字段,不改原 Compose 文件、应用配置或数据库配置;因此本次构建不生成这些证明。它不支持额外的 Compose 覆盖文件。标准模式和兼容模式都检查实际能力,不只比较版本号;均不支持 Python `docker-compose` v1。 - -## 2. 创建环境变量 - -首次部署直接运行安装器,无需先复制 `.env.example`: +## 2. 首次安装 ```bash +git clone https://github.com/hicocos/tg-vault.git +cd tg-vault ./deploy/install.sh ``` -首次运行只需要填写以下 2 项: +**不要先复制 `.env.example`**:安装器会生成 `.env`,而已有 `.env` 会被视为旧部署,不套用新的存储目录默认值。按提示填写: ```dotenv VITE_API_URL=https://api.example.com CORS_ORIGIN=https://cloud.example.com ``` -填写实际 HTTPS origin(协议、域名及可选端口),不带路径、查询参数或末尾 `/`。域名后缀不限,例如 `.cc`、`.cn` 或多级子域名均可;裸域名无效。HTTP 仅适用于本地调试,默认安全 Cookie 需要 HTTPS。 +使用实际完整 origin(协议、域名及可选端口),不要路径、查询、片段或末尾 `/`。已有值按 Enter 保留,包括误填的示例地址,必须主动替换。安装器不替换 Nginx 示例中的域名。 -没有已有地址时,按 Enter 不会使用示例。如果已复制 `.env.example`,或在环境变量中设置了示例地址,必须主动替换,否则 Enter 会保留占位值。安装器不替换 Nginx 配置中的域名。确认页面按 Enter 保存并部署,输入 `e` 重填,输入 `q` 取消。 +新安装还会询问服务器文件目录,默认为 `<项目目录>/data`。应选择尚不存在的专用子目录,上级目录必须已存在;安装器以 `0700` 创建并设置 UID/GID `1000:1000`,不递归改其他目录权限。路径支持 ASCII 字母、数字、空格、`/`、`-`、`_`、`.`,不接受符号链接或 `..`。详见[本地存储](../docs/local-storage.md)。 -`.env` 支持单行引号、`export KEY=...`、赋值空格、行尾注释和 CRLF;不执行配置内容。不支持依赖环境的 `${VAR}` 插值或多行值,遇到时在写入前停止。数据库密码和应用密钥以文件中的值为准,不受同名进程环境变量覆盖。密码包含 URL 保留字符时,只对连接串编码,不改变数据库原密码。 +确认页按 Enter 保存并部署,输入 `e` 重填,`q` 取消。脚本自动生成数据库密码、`SESSION_SECRET` 与 `STORAGE_CREDENTIALS_SECRET`,将 `.env` 权限设为 `0600`。保留这些值;已有数据库不能换成新密码。 -**Telegram 不属于首次部署的 `.env` 配置。** 完成 HTTPS 和 Web 管理员初始化后,在「设置 → Telegram」配置 Bot、允许用户和账号登录。`TELEGRAM_*` 等环境变量仅用于旧版本兼容和高级部署;新安装无需填写或手动生成 session。 +Telegram 凭证不需要写入 `.env`。完成 HTTPS 和管理员初始化后,在 Web 设置中配置 Bot、允许用户和个人账号。 -新安装时,脚本会自动生成并保留: - -```dotenv -DB_PASSWORD=随机生成的64位十六进制密码 -SESSION_SECRET=随机生成的64位十六进制密钥 -STORAGE_CREDENTIALS_SECRET=随机生成的64位十六进制密钥 -``` - -升级时,若 `.env` 未设置 `SESSION_SECRET` 或 `STORAGE_CREDENTIALS_SECRET`,继续使用 `/data/secrets` 中已有的持久密钥,不生成替代值,以免 2FA 和存储凭证失效。 - -OAuth 默认使用 `VITE_API_URL` 作为回调来源,并使用 `CORS_ORIGIN` 的第一个地址作为前端通知来源。只有多入口或特殊反代部署才需显式设置 `OAUTH_CALLBACK_BASE_URL`、`OAUTH_FRONTEND_ORIGIN`。 - -直接运行 Compose 时,需自行生成新部署的 `DB_PASSWORD`,已有数据库必须使用原密码;镜像名称会使用 `source`,源码修订号和版本元数据回退为 `unknown` / `worktree`。 - -## 3. 构建并启动 - -安装器从 `backend/package.json` 和当前 Git 提交读取版本信息,只传给本次 Compose 调用,不写入 `.env`。以下是手动构建时的版本标记示例(需要宿主机 Node.js): +非交互安装不安装缺失软件,也不读取终端输入。全新部署可使用: ```bash -revision=$(git rev-parse HEAD) -version="v$(node -p "require('./backend/package.json').version")" -IMAGE_VERSION="$version" SOURCE_REVISION="$revision" SOURCE_VERSION="$version" docker compose up -d --build +CORS_ORIGIN=https://cloud.example.com \ +VITE_API_URL=https://api.example.com \ +INSTALL_STORAGE_PATH=/mnt/archive/tg-vault-data \ +./deploy/install.sh --non-interactive ``` +`INSTALL_STORAGE_PATH` 仅用于全新非交互安装;已有部署不会因此迁移文件。完整环境变量见[配置参考](../docs/configuration.md)。 + +## 3. 构建与启动 + +安装器更新当前分支源码后,依次: + +1. 校验部署配置与原有 `/data` 挂载。 +2. 分别构建后端和前端,降低同时构建的内存峰值。 +3. 启动并等待 PostgreSQL 健康,已有数据库容器使用 `--no-recreate` 保留。 +4. 替换前后端并等待健康检查;后端启动自动初始化/迁移数据库。 + +默认等待 180 秒,可用 `INSTALL_HEALTH_TIMEOUT=600 ./deploy/install.sh` 调整(30–1800 秒)。失败时保留容器和数据,不自动清库重试。安装器从 `backend/package.json` 与 Git 读取版本/修订号,临时传给构建,不写入 `.env`。 + +手动运行 Compose 需要自行准备 `.env` 和数据库密码;密码包含 URL 保留字符时还要为连接串提供 URL 编码后的 `DB_PASSWORD_URI`,数据库原密码不变。直接 Compose 使用历史 named volume 默认值,不会执行安装器的目录选择/密钥保护逻辑。已确认配置后可运行: + ```bash docker compose config --quiet -docker compose up -d --build -docker compose ps +docker compose build backend +docker compose build frontend +docker compose up -d --wait ``` -安装器按以下顺序执行,避免小内存服务器同时构建前后端: +不传构建元数据时,镜像标签为 `source`、修订为 `unknown`、版本为 `worktree`。需要版本标记时使用安装器。首次安装不能只启动 `--no-deps backend frontend`,必须先有 PostgreSQL。 -1. 分别构建 `backend` 和 `frontend`。 -2. 启动并等待 `postgres` 健康。已有容器使用 `--no-recreate` 保留,停止的数据库会恢复启动。 -3. 替换前后端并等待健康检查通过。后端启动时自动检查/迁移数据库 schema。 - -首次安装不能只启动 `--no-deps backend frontend`,否则缺少数据库服务。安装器保留持久化卷,仅在所有健康检查通过后报告完成;公网访问还需检查 HTTPS 反向代理。 - -默认健康等待时间为 180 秒;慢速服务器可使用 `INSTALL_HEALTH_TIMEOUT=600 ./deploy/install.sh`(允许 30–1800 秒)。失败时会显示具体阶段和排障命令,保留容器与数据,不自动清库重试。若已有数据库容器/数据卷但 `.env` 中缺失数据库密码,脚本会停止并要求恢复原密码,避免随机生成的新密码破坏数据库连接。 +容器构建/后端运行使用 Node.js 24,前端运行由 Nginx 提供。后端通过镜像自带的进程外监督器启动;不要覆盖为 `node dist/index.js`,否则绕过活性保护。监督器针对 `/livez` 连续失败重启进程组,单纯 `/readyz` 降级不会触发此重启。 ## 4. 配置宿主机反向代理 -按实际域名配置宿主机 Nginx 或其他反向代理: +| 入口 | 上游 | +| --- | --- | +| `https://cloud.example.com` | `http://127.0.0.1:47832` | +| `https://api.example.com` | `http://127.0.0.1:51947` | -- Web 域名代理至 `http://127.0.0.1:47832` -- API 域名代理至 `http://127.0.0.1:51947` -- API 上传链路的 `client_max_body_size` 必须与应用的请求/分片限制匹配;它限制单次 HTTP 请求,不是文件总大小 -- TLS 证书由宿主机 Nginx/面板/Certbot 管理,不要运行 `docker compose run certbot` +标准部署保留后端 `PORT=51947`。反向代理应转发完整路径、Host、真实客户端 IP、`X-Forwarded-For` 与 `X-Forwarded-Proto`,支持 `Range`/`If-Range`,为上传与媒体响应设置合理超时,不公共缓存鉴权响应。API 上传请求体上限至少容纳默认 32 MiB 分片及 multipart 开销;例如 `client_max_body_size 64m` 可用于默认分片配置,非分片请求仍受此上限限制。 -`deploy/nginx-site.conf` 和 `deploy/nginx-site-init.conf` 是旧的单域名示例,不是 Web/API 双域名的完整配置。使用前替换域名和证书路径,补齐 API 域名站点,并核对 `/uploads`、`/thumbnails` 等旧路由及缓存策略;不要对受保护文件启用公共缓存。初始 HTTP 配置仅用于申请证书,不要通过它登录或传输凭证。加载前执行 `nginx -t`;证书生效后再开放登录。 +TLS 证书由宿主机或面板管理;不要运行 `docker compose run certbot`。[`nginx-site.conf`](nginx-site.conf) 和 [`nginx-site-init.conf`](nginx-site-init.conf) 是旧单域名示例,不是可直接套用的双域名配置。使用前替换域名、证书路径,补齐 API 站点,核对旧 `/uploads`、`/thumbnails` 路由与公共缓存规则。加载前执行 `nginx -t`;初始 HTTP 站点仅用于证书申请,不用于登录或传输凭证。 + +**管理员初始化前,仅允许自己访问 Web 和 API 两个入口**(例如临时代理 IP 白名单)。第一个完成初始化的人会设置管理员密码;没有默认管理员凭证或额外安装口令。HTTPS 生效后创建至少 8 位的强密码,再开放服务。在设置中配置 Telegram 与 TOTP,明确 Bot 用户允许列表后再分享 Bot。 + +默认 `COOKIE_SECURE=true`;全新安装器还写入 `COOKIE_SECURE_FORCE=true`。不要为解决生产登录失败而关闭安全 Cookie:先核对 HTTPS、Web/API 同站域名、`CORS_ORIGIN` 与代理。修改 `VITE_API_URL` 必须重建前端。 ## 5. 更新部署 -升级前备份 `.env`、数据库和文件卷;替换前后端会短暂中断服务。从实际项目目录执行: +先完成下一节的备份,从实际项目根目录运行: ```bash -cd /path/to/tg-vault ./deploy/install.sh ``` -安装器拉取当前分支的最新代码,再显示已有 Web/API 地址。按 Enter 保留,或输入新地址;确认后才构建。数据库容器和持久卷的保留方式见上一节。 +安装器从 `origin` 更新当前分支,只接受快进;本地修改、detached HEAD 或无法快进时停止,不丢弃文件或强行合并。已有地址按 Enter 保留;原数据库和文件挂载保留,不自动迁移数据。已手动更新、固定发布源码或离线部署可使用 `--skip-source-update`。也可通过安装器绝对路径调用,它会定位项目目录。 -有本地修改、处于 detached HEAD 或更新无法快进时,脚本停止,不丢弃修改或强制合并。已手动更新或需要离线部署时,可主动添加 `--skip-source-update`。也可从其他目录通过绝对路径运行安装器,它会定位到仓库根目录。 +替换前后端会短暂中断服务;仅重启旧容器不会升级源码或容器内 Node。数据库迁移由后端自动执行,不需要手工应用某个历史迁移。需要回退时,不能假设旧镜像兼容新 schema:先在隔离环境验证,必要时恢复升级前同一批数据库、文件与密钥。 -项目容器基线为 Node.js 24 LTS(当前固定官方 Node 24.21.0 Alpine 镜像摘要):后端构建、后端运行及前端构建使用同一 Node 镜像,前端运行仍由 Nginx 提供服务。标准 Compose 部署获取此版本源码后,运行上述安装器即可重新构建并升级容器内 Node,无需修改宿主机 Node。数据库容器、原有配置和文件卷继续保留;仅重启旧容器不会升级 Node。手工创建或迁移的容器应先核对 Compose 项目归属、网络、环境变量和挂载,再按原部署方式重建。升级后可执行 `docker exec tg-vault-backend node --version` 确认版本。 - -检查服务状态、源码标签和前端资源是否对应本次部署(读取版本的命令需要 Node.js): +部署后检查: ```bash 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="v$(node -p "require('./backend/package.json').version")" 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"。 - -# 比较镜像内 index.html 与 HTTP 返回的 assets/ 入口。 -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 ``` -两个容器的 revision/version 应与预期一致,镜像内外的 `assets/` 入口也应相同。另用实际 Web/API HTTPS 地址检查公网访问。 - -不要使用 `down -v` 排查安装故障。出现 `ENOTFOUND postgres` 或 `EAI_AGAIN` 时,先用 `docker compose ps --all` 检查数据库服务是否存在并已启动,不要先改 DNS 或删数据。 +两端镜像标签应对应预期源码提交/版本;另用浏览器检查实际 Web/API HTTPS 地址、登录、文件访问与 Telegram 状态。不要仅凭容器运行就认定公网部署完成。 ## 6. 常用运维命令 +从包含 `docker-compose.yml` 的项目根目录运行: + ```bash -docker compose ps +docker compose ps --all docker compose logs -f backend docker compose logs -f frontend docker compose logs -f postgres @@ -138,49 +123,72 @@ docker compose restart docker compose down ``` -`docker compose down` 不会删除 named volumes;不要添加 `-v`,除非明确要永久删除数据库和文件数据。 +`down` 停止并删除容器,但不删除 named volume。**不要添加 `-v`**,除非确定要永久删除数据库及文件卷。bind 目录也不能作为临时构建文件删除。备份脚本需要原 backend 容器(可以已停止)来确认实际挂载,运行备份前不要先执行 `down`。 ## 7. 备份与恢复 -备份必须包含同一维护窗口内的: - -1. PostgreSQL custom-format dump -2. `file-storage` 卷中的完整 `/data`(包括 secrets、缩略图和未完成上传状态) -3. 版本、时间和 SHA-256 manifest - -仓库脚本: +### 协调备份 ```bash -chmod +x deploy/backup.sh deploy/restore-verify.sh -BACKUP_DIR=./backups ./deploy/backup.sh +BACKUP_DIR=/path/to/secure-backups ./deploy/backup.sh +./deploy/restore-verify.sh /path/to/secure-backups/ ``` -脚本先检查目标空间,要求至少满足“文件卷未压缩大小 + PostgreSQL 数据库大小 + 512 MiB”。空间不足时,不停止 backend。 +备份依赖运行中的 PostgreSQL、现有 backend 容器及 Docker 拉取辅助镜像的能力。脚本识别 backend 的实际 `/data` 挂载,支持 bind 与 named volume,不猜卷名。输出目录包含: -备份期间停止 backend,使数据库 dump 与 `/data` 归档之间没有应用写入;完成或失败退出时恢复原先运行的 backend。API 在此期间不可用,请安排维护时段。manifest 记录 `consistency=backend-stopped`,校验和使用归档文件名。 +- `postgres.dump`:PostgreSQL custom-format dump。 +- `file-storage.tar.gz`:完整 `/data`,包含本地文件、缩略图/预览、临时状态、Telegram session 与持久密钥。 +- `manifest.txt`:时间、源码修订、一致性标记及 SHA-256。 -**另行备份 `.env` 和自定义反向代理配置;脚本不包含它们。** `.env` 中可能保存数据库密码和加密密钥,仅恢复数据卷不够。 +脚本先检查目标至少可容纳“未压缩 `/data` + 数据库大小 + 512 MiB”。空间不足不会停止 backend。归档期间停止 backend,结束或失败退出时恢复其原运行状态,API/上传/后台任务暂不可用;不要同时运行其他写入工具。 -备份目录可能包含敏感凭证材料,应加密后异地保存并限制访问。恢复前在隔离环境执行: +**另行安全备份 `.env` 和自定义反向代理配置,脚本不包含它们。** 远端存储对象不在本地归档中。备份应存到另一块磁盘,加密并异地保存,严格限制访问;不要提交到 GitHub。丢失应用密钥可能导致 TOTP、存储凭证不可读。 -```bash -./deploy/restore-verify.sh ./backups/ -``` +`restore-verify.sh` 只读检查清单、校验和及归档格式,不恢复数据。通过不代表应用可恢复;定期进行隔离恢复演练。 -验证脚本只检查清单、校验和与归档格式,不还原数据。还需定期在隔离 Compose 项目和数据卷中恢复,验证 schema、行数、密钥可读性及 `/readyz`,再安排生产恢复。 +### 隔离恢复演练与正式恢复 + +以下示例**只在独立测试主机或独立 Docker daemon 上执行**,使用空数据库与空存储。仓库 Compose 固定了项目名、容器名和宿主机端口,仅换目录或加 `-p` 不足以避免与生产冲突。不要直接复用生产 bind 目录。 + +1. 获取备份 manifest 对应的源码版本,安全复制原 `.env`,改为测试 HTTPS 地址与代理。原应用加密密钥必须保留。测试可将 `LOCAL_STORAGE_SOURCE=file-storage`、`LOCAL_STORAGE_MOUNT_TYPE=volume`,清空其余 `LOCAL_STORAGE_*` 展示元数据,使用测试 daemon 中的空 named volume。不要运行会拉取最新版的安装器。 +2. 核对所有挂载均为隔离目标,构建镜像并只启动数据库,创建但不启动 backend: + + ```bash + docker compose config --quiet + docker compose build backend frontend + docker compose up -d --wait postgres + docker compose create --no-deps backend + ``` + +3. 将备份目录记为 `BACKUP`(必须是安全归档的绝对路径),恢复数据库与完整 `/data`: + + ```bash + BACKUP=/absolute/path/to/backups/ + docker compose exec -T postgres pg_restore -U tgvault -d tgvault --clean --if-exists --no-owner < "$BACKUP/postgres.dump" + docker run --rm --volumes-from tg-vault-backend \ + --mount "type=bind,source=$BACKUP,target=/backup,readonly" \ + alpine:3.20 sh -c 'tar -C /data -xzf /backup/file-storage.tar.gz' + docker compose up -d --wait backend frontend + ``` + + 归档解压到 `/data` 根,不额外套一层 uploads;保留归档权限。该命令会覆盖目标内容,因此目标必须为空且已确认隔离。 + +4. 检查 schema、行数、`/readyz`、管理员登录/TOTP、密钥解密、本地文件与远端存储连接。隔离副本可能持有有效 Telegram 会话与订阅,启动前限制其外网访问,避免与生产同时使用同一会话或执行后台同步。 + +正式恢复安排维护窗口,停止应用写入后按已演练流程还原同一批数据库、完整 `/data` 与原密钥;验证成功后才切换访问。保留原数据库/卷供回退,不向运行中的数据目录解压覆盖。 ## 8. 故障排查 ### backend 不健康 ```bash -docker compose ps +docker compose ps --all docker compose logs --tail=200 backend curl -i http://127.0.0.1:51947/livez curl -i http://127.0.0.1:51947/readyz ``` -`/livez=200` 但 `/readyz=503` 表示进程存活,但数据库、存储或安全密钥尚不可用。 +`/livez=200`、`/readyz=503` 表示进程存活但依赖未就绪,检查数据库、存储、密钥与按需启用的 Telegram 必需状态。不要先清库或替换密钥。 ### 数据库连接失败 @@ -189,6 +197,8 @@ docker compose exec postgres pg_isready -U tgvault -d tgvault docker compose logs --tail=200 postgres ``` -### HTTPS/502 +`ENOTFOUND postgres` / `EAI_AGAIN` 先检查数据库容器是否存在并启动;认证失败核对原 `DB_PASSWORD`,不要随机生成新密码。密码含特殊字符时核对连接串 URL 编码。 -检查宿主机 Nginx 配置、证书、Web/API upstream 端口和请求体限制。Compose 内不存在 `nginx` 或 `certbot` 服务。 +### HTTPS、502 或登录失败 + +检查宿主机代理、证书、Web/API upstream、请求体与超时限制,核对构建时的 API 地址和运行时 CORS。同站 HTTPS 子域名与安全 Cookie 应一致。Compose 内没有 `nginx` 或 `certbot` 服务;只调整实际负责入口的代理。提交日志前脱敏,不公开 `.env`、session 或数据库归档。 diff --git a/deploy/install.sh b/deploy/install.sh index 568dc84..f0e902e 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -319,7 +319,9 @@ fi echo "Web:$CORS_ORIGIN_VALUE" echo "API:$VITE_API_URL_VALUE" echo +API_PORT="${PORT:-$(read_env PORT)}" +API_PORT="${API_PORT:-51947}" echo "容器健康检查已通过。公网访问还需宿主机 Nginx/面板的 HTTPS 反向代理:" echo " Web -> http://127.0.0.1:47832" -echo " API -> http://127.0.0.1:51947" -echo "本机就绪检查:curl -fsS http://127.0.0.1:51947/readyz;另请验证 Web/API 的 HTTPS 地址。" +echo " API -> http://127.0.0.1:$API_PORT" +echo "本机就绪检查:curl -fsS http://127.0.0.1:$API_PORT/readyz;另请验证 Web/API 的 HTTPS 地址。" diff --git a/deploy/nginx-site-init.conf b/deploy/nginx-site-init.conf index d3858d3..d7ed97b 100644 --- a/deploy/nginx-site-init.conf +++ b/deploy/nginx-site-init.conf @@ -34,7 +34,7 @@ server { # 后端 API location /api { - proxy_pass http://127.0.0.1:51947; + proxy_pass http://127.0.0.1:47832; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; @@ -55,18 +55,18 @@ server { # 旧文件路径:使用前核对当前路由、鉴权和缓存要求 location /uploads { - proxy_pass http://127.0.0.1:51947/uploads; + proxy_pass http://127.0.0.1:47832/uploads; proxy_set_header Host $host; - expires 1d; + expires off; } location /thumbnails { - proxy_pass http://127.0.0.1:51947/thumbnails; + proxy_pass http://127.0.0.1:47832/thumbnails; proxy_set_header Host $host; - expires 7d; + expires off; } location /health { - proxy_pass http://127.0.0.1:51947/health; + proxy_pass http://127.0.0.1:47832/health; } } diff --git a/deploy/nginx-site.conf b/deploy/nginx-site.conf index 3c6671d..f88881b 100644 --- a/deploy/nginx-site.conf +++ b/deploy/nginx-site.conf @@ -65,7 +65,7 @@ server { # 后端 API location /api { - proxy_pass http://127.0.0.1:51947; + proxy_pass http://127.0.0.1:47832; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; @@ -85,27 +85,25 @@ server { proxy_read_timeout 300s; } - # 旧上传路径:先核对当前路由与鉴权;受保护文件不能使用下方公共缓存 + # 文件请求经过前端代理,后端端口由 Compose 的 PORT 统一控制。 location /uploads { - proxy_pass http://127.0.0.1:51947/uploads; + proxy_pass http://127.0.0.1:47832/uploads; proxy_http_version 1.1; proxy_set_header Host $host; - expires 1d; - add_header Cache-Control "public"; + expires off; } - # 旧缩略图路径:先核对当前路由与鉴权;受保护文件不能使用下方公共缓存 + # 受保护文件保留上游缓存策略,不覆盖为公共缓存。 location /thumbnails { - proxy_pass http://127.0.0.1:51947/thumbnails; + proxy_pass http://127.0.0.1:47832/thumbnails; proxy_http_version 1.1; proxy_set_header Host $host; - expires 7d; - add_header Cache-Control "public"; + expires off; } # 旧健康检查路径;部署就绪检查使用 API 的 /readyz location /health { - proxy_pass http://127.0.0.1:51947/health; + proxy_pass http://127.0.0.1:47832/health; proxy_http_version 1.1; proxy_set_header Host $host; } diff --git a/docker-compose.yml b/docker-compose.yml index c973b98..4febb26 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,8 @@ services: - SOURCE_REVISION=${SOURCE_REVISION:-unknown} - SOURCE_VERSION=${SOURCE_VERSION:-worktree} container_name: tg-vault-frontend + environment: + - PORT=${PORT:-51947} ports: - "127.0.0.1:47832:80" networks: @@ -86,6 +88,8 @@ services: - SQL_LOG_SLOW_MS=${SQL_LOG_SLOW_MS:-500} - SQL_LOG_ALL=${SQL_LOG_ALL:-false} - LOG_LEVEL=${LOG_LEVEL:-info} + - UPDATE_CHECK_ENABLED=${UPDATE_CHECK_ENABLED:-true} + - UPDATE_CHECK_REPOSITORY=${UPDATE_CHECK_REPOSITORY:-hicocos/tg-vault} - TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN:-} - TELEGRAM_REQUIRED=${TELEGRAM_REQUIRED:-false} - TELEGRAM_API_ID=${TELEGRAM_API_ID:-} diff --git a/docs/configuration.md b/docs/configuration.md index 38b7ce4..2d7962d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,193 +1,151 @@ # 配置与使用指南 -[返回首页](../README.md) · [部署指南](../deploy/DEPLOY.md) +[返回首页](../README.md) · [部署指南](../deploy/DEPLOY.md) · [本地存储位置](local-storage.md) -## 🛠️ 环境变量配置 +## 环境变量 -常用变量如下,完整模板见 [`.env.example`](../.env.example)。 +完整模板见 [`.env.example`](../.env.example),容器实际接收的变量和默认值见 [`docker-compose.yml`](../docker-compose.yml)。安装器会生成 `.env`;新安装不要预先复制模板,否则会按已有部署处理存储位置。`.env` 是部署配置,不是应提交的源码。 -
-新手只需填写(2 项) +### 地址、数据库与密钥 -- **`VITE_API_URL`** — 前端访问后端的公网地址;示例:`https://api.example.com` -- **`CORS_ORIGIN`** — 允许跨域的前端来源;示例:`https://cloud.example.com` +| 变量 | 默认值或来源 | 用途 | +| --- | --- | --- | +| `VITE_API_URL` | 安装时填写 | 浏览器访问的 API origin;构建时写入前端,修改后必须重建前端 | +| `CORS_ORIGIN` | 安装时填写 | 允许的 Web origin;后端支持英文逗号分隔多个来源,安装器交互仅填写一个 | +| `PORT` | `51947` | 后端监听及宿主机 API 端口;标准部署建议保留默认值 | +| `DB_PASSWORD` | 安装器生成 64 位十六进制值 | PostgreSQL 密码;已有数据库必须保留原值 | +| `SESSION_SECRET` | 新安装由安装器生成 | 文件链接签名及 TOTP 加密密钥 | +| `STORAGE_CREDENTIALS_SECRET` | 新安装由安装器生成 | 存储及敏感配置加密密钥 | +| `TG_VAULT_SECRET_DIR` | `/data/secrets` | 未通过环境变量指定的内部持久密钥目录 | +| `OAUTH_CALLBACK_BASE_URL` | 继承 `VITE_API_URL` | OneDrive/Google Drive 回调 origin | +| `OAUTH_FRONTEND_ORIGIN` | 继承 `CORS_ORIGIN` | OAuth 完成后通知的前端 origin;多来源时显式指定一个 | -两项填写完整 origin(协议、域名及可选端口),不带路径、查询参数或末尾 `/`;生产环境使用 HTTPS。安装器会询问并校验地址,无需预先复制模板。若已复制 `.env.example`,必须将示例地址换成实际域名;按 Enter 会保留已有值。 +地址应是完整的 `http(s)://主机[:端口]`,不带路径、查询、片段或末尾 `/`。生产环境使用 HTTPS,Web/API 建议为同一主域名的子域名,以符合登录 Cookie 的 `SameSite=Lax` 策略;仅设置 CORS 不会使跨站 Cookie 可用。不要把 `CORS_ORIGIN` 设置为 `*`。 -
+已有部署缺少显式应用密钥时,安装器保留 `/data/secrets` 中的密钥,不生成替代值。迁移或备份必须同时保留 `.env`、数据库和完整 `/data`。替换密钥可能使 TOTP 或存储凭证无法解密。不要在 `.env` 固定 `IMAGE_VERSION`、`SOURCE_REVISION`、`SOURCE_VERSION`;安装器从 package 与 Git 临时注入构建元数据。 + +### 代理与登录安全 + +| 变量 | Compose 默认值 | 说明 | +| --- | --- | --- | +| `TRUST_PROXY` | `loopback` | Express 信任代理范围;自定义网络部署仅信任实际代理地址,不要盲目设为 `true` | +| `COOKIE_SECURE` | `true` | 仅通过 HTTPS 发送登录 Cookie | +| `COOKIE_SECURE_FORCE` | 空;全新安装器写入 `true` | `true`/`false` 优先于 `COOKIE_SECURE`;只在本地 HTTP 调试中关闭 | +| `JSON_BODY_LIMIT` | `2mb` | JSON 请求体上限,不是文件总大小 | -
-安装脚本自动生成 +首次启动没有默认管理员密码。限制 Web 和 API 入口的访问后,通过 HTTPS 创建至少 8 位的强密码,再开放给其他用户。管理员密码以加盐 `scrypt` 哈希保存在数据库;浏览器使用 HttpOnly Cookie 会话。修改类请求校验 `Origin`,因此 Web 地址必须与 `CORS_ORIGIN` 一致。 -- **`DB_PASSWORD`** — 自动生成 64 位十六进制 PostgreSQL 密码 -- **`SESSION_SECRET`** / **`STORAGE_CREDENTIALS_SECRET`** — 新安装时自动生成并保留;升级旧部署时不会覆盖 `/data/secrets` 中已有的持久密钥 +可在 Web 安全设置或 Bot `/setup_2fa` 启用 TOTP;启用后 Web 与 Bot 都需要二次验证。不要共享二维码或 TOTP 密钥。通常不需要填写 `TOTP_SECRET`,该高级环境值会覆盖数据库中的 TOTP 密钥。 + +### 数据目录与上传限制 -版本号不保存在 `.env`。安装时会直接从 `backend/package.json` 读取应用版本,并从当前 Git 提交读取源码修订号,只把它们临时传给 Docker 构建。 +以下路径是**容器内路径**,应保持在持久挂载 `/data` 内。`LOCAL_STORAGE_*` 由安装器维护,不是修改保存位置的快捷方式,详见[存储说明](local-storage.md)。 -
+| 变量 | Compose 默认值 | 说明 | +| --- | --- | --- | +| `UPLOAD_DIR` | `/data/uploads` | 本地文件与下载暂存目录 | +| `THUMBNAIL_DIR` | `/data/thumbnails` | 缩略图 | +| `PREVIEW_DIR` | `/data/previews` | 预览衍生文件 | +| `CHUNK_DIR` | `/data/chunks` | 分片上传暂存 | +| `MAX_UPLOAD_CHUNK_MB` | `32` | 单个上传分片上限(MiB) | +| `MAX_CHUNK_UPLOAD_GB` | `20` | 单次分片上传会话总大小上限(GiB) | +| `CHUNK_GLOBAL_BUDGET_GB` | `40` | 全局分片预算(GiB),不能小于单会话上限 | +| `CHUNK_DISK_RESERVE_GB` | `8` | 分片上传的磁盘保留空间(GiB) | +| `MAX_TOTAL_CHUNKS` | `50000` | 分片数量保护上限 | +| `CHUNK_SESSION_TTL_MS` | `86400000` | 上传会话有效期,默认 24 小时 | +| `CHUNK_COMPLETION_LEASE_MS` | `1800000` | 分片合并完成租约,默认 30 分钟 | +| `DUPLICATE_FILE_MODE` | `copy` | `copy` 生成副本;`skip` 跳过同目录、同名、同大小文件 | +| `AUTO_CLEANUP_ORPHANS` | `true` | 清理未登记到数据库的本地孤儿文件;也可在 Web/Bot 维护设置中管理 | +| `ORPHAN_CLEANUP_MIN_AGE_MS` | `600000` | 新文件保护期,默认 10 分钟 | -
-高级部署覆盖 +反向代理的请求体上限必须容纳分片与 multipart 开销;代理限制的是单次请求,不是文件总大小。远端存储也需要本地暂存空间,不要把下载并发当作无磁盘的直传模式。不要手工向受管理的 uploads 目录导入未索引文件,自动清理可能删除它们。 -- **`OAUTH_CALLBACK_BASE_URL`** — 默认继承 `VITE_API_URL`;仅特殊 API 入口时填写 -- **`OAUTH_FRONTEND_ORIGIN`** — 默认继承 `CORS_ORIGIN` 的第一个地址;仅多前端入口时填写 +### 存储连接与日志 -
+| 变量 | Compose 默认值 | 说明 | +| --- | --- | --- | +| `ALLOW_INSECURE_STORAGE_ENDPOINTS` | `false` | 保持默认,避免开放不安全存储地址 | +| `STORAGE_PROBE_TIMEOUT_MS` | `15000` | 存储探测超时 | +| `WEBDAV_INACTIVITY_TIMEOUT_MS` | `300000` | WebDAV 无活动超时 | +| `WEBDAV_UPLOAD_TIMEOUT_MS` | `21600000` | WebDAV 上传总超时,默认 6 小时 | +| `LOG_LEVEL` | `info` | 应用日志级别 | +| `SQL_LOG_SLOW_MS` | `500` | 慢查询日志阈值 | +| `SQL_LOG_ALL` | `false` | 全量 SQL 日志,仅排障时按需启用 | +| `UPDATE_CHECK_ENABLED` | `true` | 是否向 GitHub 查询新版本;设为 `false` 关闭自动检测 | +| `UPDATE_CHECK_REPOSITORY` | `hicocos/tg-vault` | 更新检测使用的公开 GitHub 仓库,格式为 `owner/repo` | -
-Telegram 运行配置 +不要公开原始日志、请求头、数据库导出或调试归档。提供问题报告前,移除凭证、session、用户/聊天标识、文件名和私有服务器路径。 -Telegram 凭证、允许用户、账号登录、来源白名单和下载并发均属于运行配置,统一在 Web **设置 → Telegram** 中管理。新部署不需要在 `.env` 中填写 Telegram 变量,也不需要手动生成 session 文件。 +## Telegram 配置与能力 -旧版本 `.env` 变量仍会被兼容读取,但只用于升级和迁移;不要在新部署中同时维护两套配置。Web 中的设置优先于环境变量。 +### 连接 Bot 与个人账号 -
+本项目通过 GramJS/MTProto 连接 Telegram;Bot 配置也需要 API ID 与 API Hash,而不只是 Bot Token。 -
-常用可选项 +1. 向 [@BotFather](https://t.me/BotFather) 发送 `/newbot` 创建 Bot,取得 Token。 +2. 在 [my.telegram.org](https://my.telegram.org) 的 `API development tools` 创建应用,取得 `api_id` 与 `api_hash`。 +3. 在 Web **设置 → Telegram → Telegram Bot 连接** 中填写三项凭证,测试连接、设置 4 位数字 Bot PIN,再保存并启用。 +4. 在 **Telegram Bot 用户权限** 中预先填写允许使用 Bot 的数字 user ID,多个 ID 用英文逗号分隔,然后向 Bot 发送 `/start` 完成认证。 +5. 需要频道/群组批量转存或订阅时,在同页通过手机号、验证码和可选的两步验证密码登录个人 Telegram 账号;无需手工生成 session 文件。 -- **`PORT`** `51947` — 后端监听端口 -- **`UPLOAD_DIR`** `/data/uploads` · **`THUMBNAIL_DIR`** `/data/thumbnails` · **`CHUNK_DIR`** `/data/chunks` -- **`DUPLICATE_FILE_MODE`** `copy` — `copy` 生成副本;`skip` 跳过同名、同目录且同大小的文件 -- **`AUTO_CLEANUP_ORPHANS`** `true` — 自动清理未登记到数据库的本地孤儿文件 +PIN 不是用户允许列表的替代品。如果允许列表为空且没有已认证的 Telegram 用户,第一个正确输入 PIN 的用户可自动加入。不要在允许列表未设置时公开 Bot/PIN。环境变量 `TELEGRAM_ALLOWED_USER_IDS` 非空时优先于 Web 列表,并禁止在 Web 修改;新部署应统一使用 Web。Bot 凭证的 Web 设置优先于环境凭证,不要同时维护两套凭证。 -
+Token、API Hash、手机号验证码、两步验证密码和登录 session 都是秘密。session 可用于访问 Telegram 账号;只通过自己的 HTTPS 管理页面登录,不要提交到仓库、粘贴到 issue 或共享备份。丢失或泄露后,应在 Telegram 中撤销相关会话,并按需撤销/更换 Bot 凭证。 -
-限流与安全项 +### 权限与下载限制 -- **普通消息限流** — `TELEGRAM_RATE_WINDOW_MS=60000`,`TELEGRAM_RATE_MAX=30` -- **重型命令限流** — `TELEGRAM_HEAVY_RATE_WINDOW_MS=600000`,`TELEGRAM_HEAVY_RATE_MAX=5` -- **`TRUST_PROXY`** `loopback` · **`COOKIE_SECURE`** `true` · **`JSON_BODY_LIMIT`** `2mb` -- **分片限制** — `MAX_UPLOAD_CHUNK_MB=32`,`MAX_CHUNK_UPLOAD_GB=20`,`CHUNK_GLOBAL_BUDGET_GB=40` -- **磁盘与数量保护** — `CHUNK_DISK_RESERVE_GB=8`,`MAX_TOTAL_CHUNKS=50000` -- **`ORPHAN_CLEANUP_MIN_AGE_MS`** `600000` — 10 分钟内不清理本地孤儿文件 +| 场景 | 要求与边界 | +| --- | --- | +| 私聊发送文件给 Bot | 启用 Bot 并通过 PIN/允许列表认证;不要求登录个人账号 | +| 频道/群组日期、标签下载与订阅 | 启用有来源访问权限的个人账号;私有来源通常需要先加入,且能读取目标历史消息 | +| 转发媒体 | 有可重读原始来源时可以使用具备访问权限的个人账号;没有可读来源时仍走 Bot 下载 | +| 大文件 | MTProto 与 HTTP Bot API `getFile` 不同,不能直接套用其大小上限;仍受 Telegram 账号类型、文件可用性、来源权限和服务端限制影响,不保证任意大小可下载 | -
+登录个人账号不会把 Bot 对话消息变成个人账号可读取的消息,也不会绕过受保护内容或不存在/已删除的来源。只归档有权访问和保存的内容,遵守 Telegram 与存储服务条款。遇到 FloodWait 应等待,不要反复重试或增加账号/并发规避限制。 -## 🤖 Telegram 配置与能力 +### 下载、订阅与任务 -### Bot 与账号级下载器的区别 +- `/tg_download` 打开日期或标签下载向导,可选择来源、范围、目录及评论区选项,确认前核对目标;`/tg_sub` 管理自动订阅,`/tg_subs` 查看列表。 +- Web 与 Bot 可暂停、继续、取消和重试。暂停阻止后续下载读取,在途请求或存储写入仍可能完成;服务器冷却不能通过“继续”解除。 +- 任务显示处理数量、失败/跳过、保存去向与下载速度。100% 表示已发现条目处理完成,不等于全部成功;扫描总量未知时不显示扫描百分比。排队不表示文件正在传输。 +- 多账号按来源权限、冷却与占用调度,一个文件不会跨账号分片。已开始的文件在原账号上遵守限流并保留续传偏移;增加账号不会自动提高全局并发、带宽或存储上传额度。 +- 下载并发在 **设置 → 维护 → 高级任务设置** 调整,也可用 `/download_workers` 和 `/file_concurrency`。分片默认 4,范围 1–16;同时文件默认 2,允许 1–10,实际最高为 `min(10, max(2, 已启用账号数 × 2))`。保持默认值,按磁盘、网络与账号承受能力调整。 +- 账号共享冷却,普通 FloodWait 只等待;单次服务端等待达到 1 小时才触发减半降载,后续成功读取逐步恢复。扫描按账号串行并限速;用户命令限流与扫描限速不是同一设置。 -**只启用 Bot 即可使用:** +新部署的 Telegram 运行设置在 Web 管理。高级/兼容环境参数默认值见 Compose;常用项如下(单位 `_MS` 为毫秒): -- ✅ 私聊发送文件给 Bot 转存 -- ✅ 任务管理、存储统计和删除文件 +| 变量 | 默认值 | 用途 | +| --- | --- | --- | +| `TELEGRAM_REQUIRED` | `false` | 是否将 Bot 可用性纳入服务就绪条件 | +| `TELEGRAM_RATE_WINDOW_MS` / `TELEGRAM_RATE_MAX` | `60000` / `30` | 普通命令限流 | +| `TELEGRAM_HEAVY_RATE_WINDOW_MS` / `TELEGRAM_HEAVY_RATE_MAX` | `600000` / `5` | 重型命令限流 | +| `TELEGRAM_PIN_FAIL_WINDOW_MS` / `TELEGRAM_PIN_FAIL_MAX` / `TELEGRAM_PIN_LOCK_MS` | `900000` / `5` / `900000` | PIN 错误尝试保护 | +| `TELEGRAM_SUBSCRIPTION_INTERVAL_MS` / `TELEGRAM_SUBSCRIPTION_SCAN_LIMIT` | `300000` / `100` | 订阅扫描间隔与条数 | +| `TELEGRAM_COMMENTS_MAX_PER_POST` | `200` | 每条主帖评论扫描上限 | +| `TG_JOB_SCAN_SEGMENT_SIZE` | `100` | 频道任务扫描分页大小 | +| `TG_JOB_MAX_ATTEMPTS` | `3` | 后台条目尝试上限 | +| `TG_MIN_FREE_DISK_GB` | `8` | Telegram 下载磁盘保留空间(GiB) | +| `TG_DISK_WATERMARK_RECHECK_MS` / `TG_DISK_WATERMARK_MAX_WAIT_MS` | `30000` / `0` | 空间不足时复查间隔与最长等待;`0` 不设置等待超时 | +| `TG_STATUS_DEBUG` | `0` | 本地状态调试日志,日常保持关闭 | -**额外启用账号级下载器后增加:** +## 添加存储 -- ✅ 频道/群组按日期或标签批量抓取 -- ✅ 频道订阅自动同步 -- ✅ 下载受 Bot 限制影响的大文件(仍受 Telegram 账号权限和限流约束) +在 **设置 → 存储** 添加账户并选择默认目标;任务提交前确认保存账户与目录。切换默认存储不等于迁移已有文件。 -### 创建和管理频道下载 +- **本地磁盘**:路径由部署挂载决定,Web 只读展示;参见[本地存储位置](local-storage.md)。 +- **OneDrive**:创建 Microsoft 应用,填写 Client ID、租户与按需使用的 Client Secret,再完成 OAuth 授权。 +- **Google Drive**:启用 Drive API,配置 OAuth 应用、Client ID/Secret;使用共享云端硬盘时按界面填写其 ID,并确保账号有写入权限。 +- **OSS / S3**:准备 bucket、region、访问密钥;S3 还需 endpoint,按服务商要求选择 path-style。使用仅覆盖所需 bucket 的最小权限凭证。 +- **WebDAV**:填写 endpoint、用户名和密码。默认拒绝不安全或私有地址;仅在明确理解 SSRF/内网访问风险时调整安全设置,不要为了连接失败盲目放开限制。 + +OAuth 提供方登记的 redirect URI 必须与 Web 设置页显示的一致:OneDrive 默认 `https://api.example.com/api/storage/onedrive/callback`,Google Drive 默认 `https://api.example.com/api/storage/google-drive/callback`。改域名时同步修改提供方登记与部署配置。远端存储凭证保存在数据库中并依赖应用密钥解密;本地备份不包含远端对象,远端文件需按提供方另行备份。 + +## 本地开发 + +使用 Node.js 24、npm 和可访问的 PostgreSQL;ffmpeg 用于视频缩略图/转码。两个子项目均有独立锁文件,先执行首页的 `npm --prefix ... ci`。后端从其工作目录读取 `.env`,容器根目录 `.env` 不会自动成为本地后端配置。 + +1. 创建独立开发数据库,在 `backend/.env` 设置真实的开发 `DATABASE_URL`,不要连接生产库;后端启动会初始化 schema 并应用迁移。 +2. 设置 `CORS_ORIGIN=http://localhost:5173`、`OAUTH_CALLBACK_BASE_URL=http://localhost:51947`、`OAUTH_FRONTEND_ORIGIN=http://localhost:5173`。本地 HTTP 使用 `COOKIE_SECURE=false`,确保 `COOKIE_SECURE_FORCE` 未开启;数据目录可用默认的 `backend/data`。 +3. 在一个终端运行 `npm --prefix backend run dev`;另一个终端运行 `VITE_API_URL=http://localhost:51947 npm --prefix frontend run dev -- --host 127.0.0.1`。若 Vite 使用了其他端口,同步修改前端 origin。不要将开发服务器暴露给公网。 -`/tg_download` 按“类型 → 频道 → 日期或标签 → 目录 → 确认”创建任务。目录可点击“使用默认目录”;确认卡可修改频道、范围、目录或切换评论区选项。点击确认开始,也可回复当前确认卡发送 `确认`;旧卡不能提交新的向导。输入错误会保留草稿,创建失败可重试。 - -确认卡在创建成功后变为回执,下载进度使用独立任务卡;一次性存储目标仅在任务创建成功后消费,取消不改变目标。Bot 和 Web 使用相同的暂停、继续、取消和失败重试逻辑。暂停会等待当前文件处理完毕;继续可以解除手动暂停,若存在服务限额仍自动等待。 - -“处理中”只统计实际工作线程,领取但尚未执行的文件归入排队。速度为当前任务下载采样的合计,保存大小不是下载速度。部分文件大小未知时,`+` 表示已知下限。任务卡随持久化任务恢复,重启后沿用已有卡片;已结束任务移除无效控制按钮,失败项可单独重试。Web 账号面板在可见时每 5 秒刷新,短时请求限流不单独报警。 - -日期/标签任务在 Bot 任务卡、`/tasks` 详情和 Web 任务中心显示已检查的频道消息数、发现的文件数、评论扫描计数和当前扫描到的消息时间。频道消息数包含检查后不符合日期/标签或无可下载文件的消息,不把相册补查出的文件重复算作已检查消息。进度在每页扫描后更新,评论分页和文件发现期间最多每 5 秒持久化一次;Bot 卡每 5 秒检查更新。扫描总量事先未知,不额外请求 Telegram 统计总量,也不显示扫描百分比;现有百分比仍表示已发现文件的处理进度。暂停、失败后保留扫描信息,部分页进度不会提前推进恢复游标。 - -扫描默认每页 `TG_JOB_SCAN_SEGMENT_SIZE=100` 条,单任务串行分页。多个任务共享账号池,每个账号同一时间只执行一个扫描;同账号的扫描 RPC(包括来源解析、内部翻页、相册补查和评论扫描)共用至少 1 秒的请求启动间隔,不占用文件下载名额。扫描遇到 FloodWait 时,遵守 Telegram 返回的冷却时间,并将该账号的扫描间隔加倍,最长 60 秒;持续成功扫描满 10 分钟后逐步减半,最低恢复至 1 秒,长时间空闲不计入恢复。`TELEGRAM_RATE_MAX` 和 `TELEGRAM_HEAVY_RATE_MAX` 限制的是用户命令,不是扫描 RPC。 - -### 获取 Bot Token - -1. 在 Telegram 中搜索 [@BotFather](https://t.me/BotFather) 并开始对话。 -2. 发送 `/newbot`,按提示创建机器人。 -3. 复制 BotFather 返回的 `HTTP API TOKEN`。 -4. 在 TG Vault Web 的“设置 → Telegram”中填写 Token。 - -### 获取 API ID 和 API Hash - -1. 访问 [my.telegram.org](https://my.telegram.org) 并登录 Telegram 账号。 -2. 进入 `API development tools`。 -3. 创建应用后复制 `api_id` 和 `api_hash`。 -4. 在 **设置 → Telegram → Telegram Bot 连接** 中与 Bot Token 一起填写,先测试连接,再保存并启用。连接卡片保留连接状态与 Bot 名称;“更换凭证”“修改 Bot PIN”“刷新状态”位于第一行,“删除配置”单独位于第二行。 -5. 需要账号级下载器时,在同页使用手机号、验证码和可选两步验证密码登录;无需手工生成或编辑 session 文件。 - -### Telegram Bot 允许用户 - -TG Vault 会限制能通过 Bot PIN 登录的 Telegram 用户。推荐进入 **设置 → Telegram → Telegram Bot 用户权限**,填写一个或多个数字 user id;多个 ID 用英文逗号分隔。 - -获取 user id:让用户在 Telegram 私聊 `@userinfobot` 查看 `Id`。如果允许列表留空,并且后台还没有任何 Telegram 用户认证成功,第一个正确输入 Bot PIN 的用户可自动加入允许列表。之后应在 Web 中明确维护列表。 - -### 账号级下载器什么时候需要? - -账号级下载器使用登录的 Telegram 用户账号读取媒体,适用于: - -- 频道/群组转存:用户账号需要加入对应频道/群组,并确保能看到历史媒体。 -- 按日期/标签批量抓取:`/tg_download date`、`/tg_download tag` 依赖用户账号访问来源消息。 -- 频道订阅同步:`/tg_sub` 后台扫描依赖用户账号读取频道/群组新消息。 -- 大文件下载:Bot 无法直接下载的文件,可尝试通过有访问权限的用户账号下载。 - -仅登录或明确启用账号时才应连接个人账号;更改界面语言或查看设置不应激活账号。只转存有权访问和保存的内容,遵守 Telegram 限流;遇到冷却或账号限制时,不要反复重试或提高并发。 - -### Telegram 下载设置 - -下载并发在 Web **设置 → 维护 → 高级任务设置** 中调整。单文件分片并发与 Bot `/download_workers` 共用,同时下载文件数与 `/file_concurrency` 共用;页面不再常驻显示二次确认阈值,但选择高并发时仍需确认。保留默认值,确有需要时再修改;不要通过提高并发绕过账号冷却。 - -下载设置中的“下载中”仅统计账号正在执行的文件下载,不含排队、扫描和存储上传;“权限检测”为来源权限最近检查时间。“来源权限汇总”按账号累计,同一来源可重复计入。 - -### 多账号调度与自动恢复 - -- 频道任务仍按批次领取持久化条目,但每个文件进入执行阶段后独立选择账号。排队和后续存储上传不占用账号下载名额;同一任务的不同文件可以分配给不同账号。 -- 调度跳过禁用、断线、登录失效、冷却和已知无来源权限的账号。已确认来源权限优先,再按权重与当前占用选择;同负载时优先使用更久未被选择的账号,避免空闲时始终使用同一账号。 -- 扫描频道、评论和补全元数据也经过调度。扫描、元数据和文件下载分别计数,共享账号冷却;扫描采用独立的串行与请求间隔策略,下载和元数据使用自适应并发上限。 -- FloodWait 只冷却返回限制的账号,遵守 Telegram 指定的等待时间;扫描请求会增大扫描间隔,文件下载会降低账号并发上限。当前文件可由另一个有权限的账号重新读取并下载。未完成文件重新下载,不支持跨账号续传分片;已完成文件不重新入队。 -- 所有合适账号都忙或冷却时自动等待,释放名额会唤醒等待者,最长每秒重新检查一次;等待可取消。全部账号禁用、断线、登录失效或无来源权限时返回明确错误,不回退到默认账号。账号等待仍占用一个全局文件执行槽,因此全局并发保持原有上限。 -- 下载或元数据请求遇到 FloodWait 时,将该账号的运行时并发上限减半,最低为 1。冷却结束后稳定成功运行满 60 秒,每次恢复 1,最多恢复至账号配置上限。自适应上限在进程重启后重置;持久化冷却时间继续生效。 -- 全局文件并发仍默认 2、可设置为 1~4;添加账号不会自动提高它,也不会提高服务器带宽或存储服务上传额度。私密频道需要接力账号各自具备访问权限。 -- 转发且带可读取来源的媒体使用同一账号调度;直接发给 Bot、没有可重读来源的消息仍通过 Bot 下载,因为 Bot 对话消息 ID 不属于用户账号对话。 - -## 🔐 安全与访问控制 - -TG Vault 默认采用“首次初始化”模式保护 Web 和 API: - -1. 服务启动后,首次访问 Web 页面需创建至少 8 位的管理员密码,使用 `scrypt` 加盐哈希保存到数据库。请在开放给其他用户前完成初始化。 -2. 如果此时已经通过环境变量配置 Telegram Bot,初始化页还会要求创建 Bot 4 位 PIN;新安装也可以先完成网页初始化,再到 **设置 → Telegram** 配置 Bot 和 PIN。 -3. 登录成功后,浏览器会获得 HttpOnly Cookie 会话,前端不再把访问 token 写入 `localStorage`。 -4. 修改类请求会校验 `Origin`,请确保 `.env` 中的 `CORS_ORIGIN` 与前端公网地址一致。 - -> [!IMPORTANT] -> 生产环境请使用 HTTPS。默认 `COOKIE_SECURE=true` 时,浏览器只会在 HTTPS 下发送登录 Cookie;如果你只在本地 HTTP 调试,可临时设置 `COOKIE_SECURE=false`。 -> `deploy/install.sh` 生成的正式部署会额外写入 `COOKIE_SECURE_FORCE=true`,防止遗留环境值意外关闭 HTTPS Cookie;本地 HTTP 调试请不要沿用该强制值,或同时设为 `false`。 - -### 自动密钥说明 - -安装器为新部署生成 `.env` 密钥;未显式配置的内部密钥由应用保存到数据卷的 `/data/secrets/`。迁移时同时备份 `.env`、数据库和完整数据卷。丢失密钥可能导致登录会话失效,TOTP 和第三方存储凭证无法解密。 - -完整的宿主机 Nginx 部署、健康检查、协调备份与隔离恢复校验流程见 [`deploy/DEPLOY.md`](../deploy/DEPLOY.md)。仓库提供 `deploy/backup.sh` 和只读归档检查脚本 `deploy/restore-verify.sh`;备份包含密钥材料,必须加密并异地保存。 - -### 双重验证 (TOTP) - -TG Vault 内置支持 TOTP 双重验证(如 Google Authenticator): - -- Web 端:在个人设置中扫码激活 -- Telegram Bot:发送 `/setup_2fa` 获取设置二维码,并在对话框输入验证码激活 -- 启用后,网页登录和使用 Bot 均需二次验证 - ---- - -## 🌐 反向代理建议 - -如果你使用 Nginx、Nginx Proxy Manager 或 Caddy 部署,请参考以下映射: - -- **前端 / 网页入口** - - 示例域名:`https://cloud.example.com` - - 转发地址:`127.0.0.1:47832` -- **后端 / API 接口** - - 示例域名:`https://api.example.com` - - 转发地址:`127.0.0.1:51947` - -如果前后端使用不同域名,请在后端环境变量中设置: - -```env -VITE_API_URL=https://api.example.com -CORS_ORIGIN=https://cloud.example.com -COOKIE_SECURE=true -``` - -> [!CAUTION] -> 开启 HTTPS 后,`.env` 中的 `VITE_API_URL` 和 `CORS_ORIGIN` 都应使用 `https://`,否则浏览器可能拦截请求。修改 `VITE_API_URL` 后必须重新构建前端镜像,因为它会被打包进静态文件。 +本地检查入口:`npm --prefix backend run typecheck`、`npm --prefix backend run schema:check`、两端 `run build`、`npm --prefix frontend run lint`。后端 `run build` 使用 esbuild,不替代 TypeScript 类型检查。schema 变更后使用 `npm --prefix backend run schema:generate-init` 更新根目录快照,不要手工改写生成文件或已发布迁移。执行哪些检查取决于变更范围;提交时只报告实际执行的结果。 diff --git a/docs/local-storage.md b/docs/local-storage.md index c200e50..5dc1562 100644 --- a/docs/local-storage.md +++ b/docs/local-storage.md @@ -1,34 +1,57 @@ -# 本地文件保存位置 / Local file location +# 本地文件保存位置 -新安装运行 `deploy/install.sh` 时只需填写服务器文件保存位置,默认是安装目录下的 `data`。可填写另一块已挂载磁盘下的专用新子目录,例如 `/mnt/archive/tg-vault-data`。非交互安装可使用 `INSTALL_STORAGE_PATH`。路径允许空格;明确不支持冒号、美元符号、引号、非 ASCII 字符及控制字符。 +[首页](../README.md) · [部署指南](../deploy/DEPLOY.md) · [配置参考](configuration.md) -安装器只创建尚不存在的专用目录,权限为 `0700`,所有者为容器运行用户 UID/GID `1000:1000`。不会递归改动其他目录权限。上级目录须已存在,不能使用符号链接或系统/磁盘根目录。遇到权限错误请使用 sudo 重试。文件目录固定映射到容器 `/data`,原文件仍存于内部 `uploads` 子目录。 +## 全新安装 -**升级不迁移文件。** `.env` 单独存在也按旧部署处理,不应用新默认位置。默认 Compose 仍保留历史 `file-storage` 卷。安装器核对现有 backend 的实际 `/data` 挂载与解析后的 Compose;不一致即停止,不覆盖旧文件。自定义旧 Compose 必须保留原挂载,不能先换成新默认再部署。丢失旧配置且没有容器可供检查时,需管理员恢复原配置再升级;系统不能从文件目录猜测历史挂载。 +`deploy/install.sh` 询问服务器保存目录,默认 `<项目目录>/data`。文件保存在服务器而非浏览器所在电脑;可选另一块已挂载磁盘下的新专用目录,如 `/mnt/archive/tg-vault-data`。非交互安装通过 `INSTALL_STORAGE_PATH` 指定。 -设置 → 存储的本地卡片显示一个可复制的**服务器文件位置**,文件不是保存在浏览器所在电脑。剩余容量来自实际上传目录所在文件系统,和其他应用共享,不代表 TG Vault 文件占用量。容器目录、挂载类型和校验说明在折叠详情中;网页不提供路径修改或迁移。 +- 目录必须尚不存在,上级目录已存在;安装器创建权限 `0700`、所有者 UID/GID `1000:1000`,不递归修改其他目录。 +- 仅支持绝对路径中的 ASCII 字母、数字、空格、`/`、`-`、`_`、`.`,拒绝 `..`、符号链接、系统/磁盘根目录及特殊字符。权限不足时按安装器提示使用 sudo。 +- 目录映射到容器 `/data`;原文件在 `uploads` 子目录,缩略图、预览、分片、session 和密钥也在该持久目录下。不要只备份 uploads。 +- **不要预先复制 `.env.example`**,已有 `.env` 会被当作旧部署,不应用新的目录默认值。 -## 旧部署路径展示元数据 +## 已有部署 -应用不访问 Docker socket。管理员在宿主机核对 `docker inspect ` 的 `/data` Mounts(Type、Source、Destination、RW)后,可运行只读命令: +升级保留原挂载,**不自动迁移文件**。Compose 未配置 `LOCAL_STORAGE_SOURCE` 时仍使用历史 `file-storage` named volume。安装器核对现有 backend 的实际 `/data` 挂载与 Compose;不一致即停止,不用新默认目录遮蔽旧数据。 + +保留原 `.env` 与自定义挂载。只有 `.env`、没有旧容器时,也需要原目录或卷存在;丢失部署配置不能从文件名推断原挂载。不要通过修改 `UPLOAD_DIR` 或 `LOCAL_STORAGE_SOURCE` 尝试移动数据。 + +确需换盘或搬迁时,先做协调备份,在维护窗口停止写入,将**完整 `/data`** 连同数据库、原密钥恢复到已确认的新目标,保留 UID/GID 与权限;同步挂载和展示元数据,在隔离环境验证后切换。不要向运行中的目录复制覆盖,也不要提前删除原卷。具体恢复流程见[部署指南](../deploy/DEPLOY.md#7-备份与恢复)。 + +## Web 路径展示 + +**设置 → 存储** 的本地卡片展示可复制的服务器文件路径、可写状态与实际文件系统剩余空间。剩余容量与其他应用共享,不表示 TG Vault 的专用配额。Web 不提供路径修改或迁移。 + +应用不访问 Docker socket。管理员可在宿主机读取实际挂载: ```bash -python3 deploy/install-storage.py metadata /实际宿主根目录 volume -# bind 挂载时最后一个参数改为 bind +docker inspect --format '{{json .Mounts}}' tg-vault-backend ``` -将输出的 `LOCAL_STORAGE_HOST_ROOT`、`LOCAL_STORAGE_CONTAINER_ROOT`、`LOCAL_STORAGE_DEVICE`、`LOCAL_STORAGE_INODE`、`LOCAL_STORAGE_MOUNT_TYPE` 保存到原 `.env`,保留其他配置和秘密不变。不要更改 `LOCAL_STORAGE_SOURCE`,除非已经明确核对原 Compose 的 source。路径显示会校验容器目录的设备号/inode、真实路径及子目录关系。元数据缺失、失效、符号链接或不同文件系统时显示“未确认”;设备号/inode 在 rootless/远程 Docker 或重挂载后可能不同,此时需重新核对而不是伪造标识。此校验用于发现配置过期,不是对恶意宿主机的信任证明。 +找到 `Destination=/data`,核对 `Type`、`Source` 与 `RW`。需要为已有挂载补充展示信息时,使用真实 Source 路径运行只读命令: -## 备份与恢复 +```bash +python3 deploy/install-storage.py metadata /actual/host/data-root volume +# bind 挂载将最后一个参数改为 bind +``` -`deploy/backup.sh` 从现有(可停止的)backend 读取挂载,使用只读 `--volumes-from` 归档 `/data`,兼容 named volume 和 bind,不再猜测卷名。容器不存在时停止而非创建空卷备份。备份过程中按原运行状态暂停/恢复 backend。备份位置应在另一块磁盘;`.env` 不在归档内,需单独安全备份。 +命令输出 **JSON 对象**。将各字段的值分别保存为原 `.env` 中的 `LOCAL_STORAGE_HOST_ROOT`、`LOCAL_STORAGE_CONTAINER_ROOT`、`LOCAL_STORAGE_DEVICE`、`LOCAL_STORAGE_INODE`、`LOCAL_STORAGE_MOUNT_TYPE`,不要把整段 JSON 直接粘入 `.env`。保留其他配置与秘密,不要因此修改 `LOCAL_STORAGE_SOURCE`。 -`deploy/restore-verify.sh` 仅校验归档。恢复先在隔离项目中建好目标目录/卷,将 `file-storage.tar.gz` 解压到目标 `/data` 根(不要额外套一层 uploads),保留归档权限,并恢复数据库与原密钥。验证文件和 `/readyz` 后再安排生产切换。不要对运行中的数据目录解压覆盖,不要删除原卷。 +路径显示校验设备号/inode、真实路径和子目录关系。元数据缺失、过期、符号链接或文件系统不匹配时显示“未确认”。rootless/远程 Docker 或重挂载后标识可能不同,应重新核对实际挂载,不能伪造标识让检查通过;该检查不是对恶意宿主机的信任证明。 -## English +## 备份边界 -Fresh installs ask for a server folder (default: `/data`); use a new dedicated folder on an already-mounted disk. Existing installations keep their original mount, even when only `.env` remains. No automatic migration. Spaces work; special characters such as `:`, `$`, quotes and non-ASCII characters are rejected explicitly. The web card is read-only and shows the verified server uploads path, write status and shared filesystem free space. Unknown host paths stay unconfirmed. Backup uses the backend's actual mounts, supporting both bind directories and named volumes. Back up `.env` separately. +[`deploy/backup.sh`](../deploy/backup.sh) 使用已有 backend 容器的实际挂载,以只读 `--volumes-from` 归档 `/data`,同时备份数据库,兼容 bind 与 named volume。原容器可以停止,但不能不存在;先运行 `docker compose down` 会失去该检查依据。 -## Русский +归档期间 backend 停止,完成或失败后恢复原运行状态。备份位置宜在另一块磁盘,**`.env` 和反向代理配置另行安全备份**。归档含密钥与 Telegram session,必须限制访问、加密并异地保存。远端存储文件需另行备份。 -При новой установке укажите новый отдельный каталог на сервере (по умолчанию `<каталог установки>/data`), при необходимости на другом подключённом диске. Пробелы поддерживаются; двоеточия, `$`, кавычки и символы вне ASCII не поддерживаются. При обновлении исходное хранилище сохраняется, даже если остался только `.env`. Автоматического переноса нет. Веб-интерфейс показывает подтверждённый путь к файлам на сервере, доступность записи и общее свободное место файловой системы. Изменить путь через веб нельзя. Резервное копирование поддерживает каталоги bind и именованные тома; `.env` сохраняйте отдельно. +[`deploy/restore-verify.sh`](../deploy/restore-verify.sh) 仅验证清单、校验和及格式,不恢复数据。恢复时将 `file-storage.tar.gz` 解压到目标 `/data` 根,不额外嵌套 uploads;与同一批数据库和原密钥配套恢复,检查文件、权限及 `/readyz` 后再正式切换。 + +## English summary + +Fresh installs ask for a new, dedicated server directory (default: `/data`). The parent must already exist. The installer creates the directory with mode `0700` and UID/GID `1000:1000`; supported path characters are ASCII letters, digits, spaces, `/`, `-`, `_` and `.`. Symlinks and `..` are rejected. Do not copy `.env.example` before a fresh installer run: an existing `.env` selects the existing-deployment path. + +The directory mounts at `/data`; original files live in `/data/uploads`, but backup must include the entire `/data`, the database and `.env`. Existing installations keep their original bind mount or named volume, with no automatic migration. The web card is read-only and displays verified server paths and shared filesystem free space; it does not refer to your browser's computer. Missing or stale path metadata remains unconfirmed. + +Backup reads the backend's actual mounts, including a stopped backend; do not remove that container before backup. `.env`, proxy configuration and remote storage objects are not included. Archives contain secrets and sessions: encrypt them and store them off-site. Archive verification is not a restore drill. See the [deployment guide](../deploy/DEPLOY.md#7-备份与恢复) for coordinated backup and isolated recovery. diff --git a/frontend/.dockerignore b/frontend/.dockerignore index e43a852..24e56a6 100644 --- a/frontend/.dockerignore +++ b/frontend/.dockerignore @@ -7,6 +7,7 @@ yarn-error.log* # Build output dist/ build/ +*.tsbuildinfo # Environment files .env @@ -28,11 +29,33 @@ Thumbs.db # Tests coverage/ .nyc_output/ +**/__tests__/ +**/*.test.* +**/*.spec.* # Logs logs/ *.log +# Local credentials and backup artifacts +data/ +secrets/ +.hermes/ +backups/ +*.session +*.session-journal +*.pem +*.key +*.p12 +*.pfx +*.dump +*.backup +*.bak +*.tar +*.tar.gz +*.tgz +*.zip + # Git .git/ .gitignore diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 6ac911b..1c0ec43 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -17,6 +17,9 @@ ENV VITE_API_URL=${VITE_API_URL} RUN NODE_OPTIONS=--max-old-space-size=768 npm run build +# Bind CSP to the same API origin embedded in the frontend; render PORT at startup. +RUN node scripts/build-nginx-config.mjs + # Production stage FROM nginx@sha256:4a73073bd557c65b759505da037898b61f1be6cbcc3c2c3aeac22d2a470c1752 @@ -30,8 +33,9 @@ LABEL org.opencontainers.image.revision=$SOURCE_REVISION \ # 复制构建产物 COPY --from=builder /app/dist /usr/share/nginx/html -# 复制 nginx 配置 -COPY nginx.conf /etc/nginx/conf.d/default.conf +# The official entrypoint substitutes only PORT, preserving Nginx request variables. +ENV PORT=51947 NGINX_ENVSUBST_FILTER=^PORT$ +COPY --from=builder /tmp/default.conf.template /etc/nginx/templates/default.conf.template EXPOSE 80 diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 3eebf5e..a7328e2 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -1,3 +1,7 @@ +upstream tg_vault_backend { + server backend:${PORT}; +} + server { listen 80; server_name localhost; @@ -13,18 +17,19 @@ server { gzip_comp_level 6; # 安全头 + add_header_inherit merge; add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; - add_header Content-Security-Policy "default-src 'self'; img-src 'self' blob: data:; media-src 'self' blob:; connect-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; font-src 'self' data:; frame-ancestors 'self'; base-uri 'self'; form-action 'self'" always; + add_header Content-Security-Policy "default-src 'self'; img-src 'self' blob: data: __API_ORIGIN__; media-src 'self' blob: __API_ORIGIN__; connect-src 'self' __API_ORIGIN__; style-src 'self' 'unsafe-inline'; script-src 'self'; font-src 'self' data:; frame-ancestors 'self'; base-uri 'self'; form-action 'self'" always; # [重要] 优先处理 API 和 静态资源代理 (放在最前面保证高优先级) # 使用 ^~ 确保如果匹配到这些路径,就不再检查正则表达式路径 # API 代理 location ^~ /api { - proxy_pass http://backend:51947; + proxy_pass http://tg_vault_backend; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header Range $http_range; @@ -37,12 +42,16 @@ server { # 静态资源代理 location ^~ /uploads { - proxy_pass http://backend:51947; + proxy_pass http://tg_vault_backend; client_max_body_size 2000M; } location ^~ /thumbnails { - proxy_pass http://backend:51947; + proxy_pass http://tg_vault_backend; + } + + location = /health { + proxy_pass http://tg_vault_backend; } # 静态资源缓存 (放在代理之后) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 60902a5..d129bf7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "tg-vault", - "version": "2.4.8", + "version": "2.4.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tg-vault", - "version": "2.4.8", + "version": "2.4.9", "dependencies": { "@phosphor-icons/react": "2.1.10", "class-variance-authority": "^0.7.1", @@ -19,11 +19,11 @@ }, "devDependencies": { "@eslint/js": "^9.39.1", + "@tailwindcss/postcss": "^4.3.3", "@types/node": "^24.10.1", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.1", - "autoprefixer": "^10.4.19", "eslint": "^9.39.1", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", @@ -31,18 +31,18 @@ "globals": "^16.5.0", "i18next": "^25.8.18", "lucide-react": "^0.577.0", - "postcss": "^8.4.38", + "postcss": "^8.5.30", "react-i18next": "^16.5.8", - "tailwindcss": "^3.4.17", + "tailwindcss": "^4.3.3", "typescript": "~5.9.3", "typescript-eslint": "^8.46.4", "vite": "^7.2.4" } }, "node_modules/@alloc/quick-lru": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", - "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.3.0.tgz", + "integrity": "sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA==", "dev": true, "license": "MIT", "engines": { @@ -109,14 +109,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", - "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -185,9 +185,9 @@ } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", - "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", "dev": true, "license": "MIT", "engines": { @@ -225,27 +225,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", - "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.10.tgz", + "integrity": "sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==", "dev": true, "license": "MIT", "dependencies": { "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7" + "@babel/types": "^7.29.8" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", - "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.7" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -255,13 +255,13 @@ } }, "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", - "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -271,13 +271,13 @@ } }, "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", - "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -287,9 +287,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", - "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz", + "integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -311,18 +311,18 @@ } }, "node_modules/@babel/traverse": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", - "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.10.tgz", + "integrity": "sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", + "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", - "@babel/parser": "^7.29.7", + "@babel/parser": "^7.29.9", "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7", + "@babel/types": "^7.29.8", "debug": "^4.3.1" }, "engines": { @@ -330,9 +330,9 @@ } }, "node_modules/@babel/types": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", - "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "dev": true, "license": "MIT", "dependencies": { @@ -344,9 +344,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", - "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", "cpu": [ "ppc64" ], @@ -361,9 +361,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", - "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", "cpu": [ "arm" ], @@ -378,9 +378,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", - "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", "cpu": [ "arm64" ], @@ -395,9 +395,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", - "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", "cpu": [ "x64" ], @@ -412,9 +412,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", - "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", "cpu": [ "arm64" ], @@ -429,9 +429,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", - "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", "cpu": [ "x64" ], @@ -446,9 +446,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", - "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", "cpu": [ "arm64" ], @@ -463,9 +463,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", - "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", "cpu": [ "x64" ], @@ -480,9 +480,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", - "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", "cpu": [ "arm" ], @@ -497,9 +497,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", - "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", "cpu": [ "arm64" ], @@ -514,9 +514,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", - "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", "cpu": [ "ia32" ], @@ -531,9 +531,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", - "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", "cpu": [ "loong64" ], @@ -548,9 +548,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", - "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", "cpu": [ "mips64el" ], @@ -565,9 +565,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", - "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", "cpu": [ "ppc64" ], @@ -582,9 +582,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", - "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", "cpu": [ "riscv64" ], @@ -599,9 +599,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", - "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", "cpu": [ "s390x" ], @@ -616,9 +616,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", - "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", "cpu": [ "x64" ], @@ -633,9 +633,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", - "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", "cpu": [ "arm64" ], @@ -650,9 +650,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", - "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", "cpu": [ "x64" ], @@ -667,9 +667,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", - "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", "cpu": [ "arm64" ], @@ -684,9 +684,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", - "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", "cpu": [ "x64" ], @@ -701,9 +701,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", - "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", "cpu": [ "arm64" ], @@ -718,9 +718,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", - "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", "cpu": [ "x64" ], @@ -735,9 +735,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", - "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", "cpu": [ "arm64" ], @@ -752,9 +752,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", - "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", "cpu": [ "ia32" ], @@ -769,9 +769,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", - "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", "cpu": [ "x64" ], @@ -786,9 +786,9 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", - "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", "dev": true, "license": "MIT", "dependencies": { @@ -828,15 +828,15 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", - "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", "dev": true, "license": "Apache-2.0", "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", - "minimatch": "^3.1.2" + "minimatch": "^3.1.5" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -869,20 +869,20 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", - "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.7.tgz", + "integrity": "sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw==", "dev": true, "license": "MIT", "dependencies": { - "ajv": "^6.12.4", + "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", - "js-yaml": "^4.1.1", - "minimatch": "^3.1.2", + "js-yaml": "^4.3.2", + "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" }, "engines": { @@ -906,9 +906,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", - "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", "dev": true, "license": "MIT", "engines": { @@ -943,29 +943,43 @@ } }, "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, "engines": { "node": ">=18.18.0" } }, "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@humanfs/core": "^0.19.1", + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -1027,9 +1041,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", "dev": true, "license": "MIT" }, @@ -1044,42 +1058,24 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" + "node": "^22.20 || ^24.12 || >=25" } }, "node_modules/@phosphor-icons/react": { @@ -1096,16 +1092,16 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.2", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz", - "integrity": "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==", + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", "dev": true, "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", - "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.64.7.tgz", + "integrity": "sha512-GFF2tHk0NqYVObrK0+/ps/LEXsV/v2q6Lyiy7mZr+Ej24fpQScvqK2ziz9NHeEQYnKWKym38Ml7dZbPtC9tuAw==", "cpu": [ "arm" ], @@ -1117,9 +1113,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", - "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.64.7.tgz", + "integrity": "sha512-BOymcXJFYYXqluDidj6sLelNjXruC3u1BqVxLn2OPCXH9xaPSOTH+MS4uychsGI1NU7tJoiv9KvqijDHFe+hhQ==", "cpu": [ "arm64" ], @@ -1131,9 +1127,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", - "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.64.7.tgz", + "integrity": "sha512-jgVpGweIbls2H5jfBB70tnX52abMd5pJr3xmtoDILx6JdmGl+Un+tMb9R+MbHPVB46tTBpY8emtn7M71SnQhEQ==", "cpu": [ "arm64" ], @@ -1145,9 +1141,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", - "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.64.7.tgz", + "integrity": "sha512-AVMpt94mrXH7WjyWKmd9nT4RC5yM0AN3Lt6A89xVIsuLDi3dDX6dxMytLbW36FlppY9JsoqUoS3LUUpUrYxiMQ==", "cpu": [ "x64" ], @@ -1159,9 +1155,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", - "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.64.7.tgz", + "integrity": "sha512-Nje29c/MyajxRNFGU05oyeTDS6SWdMg/wWMSlZSBTBju5BKz2NVc0kFiFSQGzRELNiB+CBk/ZvAVsWsENgjGag==", "cpu": [ "arm64" ], @@ -1173,9 +1169,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", - "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.64.7.tgz", + "integrity": "sha512-xxgN5/kMzCNz/NYJ4t49B0/WS6InNlWv6xvgwUQqNnWM9WJMMwMUGWrAxdNEgYWdHL0sMnuh8I87StGrIMTA+w==", "cpu": [ "x64" ], @@ -1187,13 +1183,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", - "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.64.7.tgz", + "integrity": "sha512-5U/9gVn/X657I8+NHoHO9Wnfyd0lEXwqbslobRoB9TC8SwYzOitcIr6TLE7531Q/NWs3L4mb+YOGzlf3vHIeYw==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1201,13 +1200,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", - "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.64.7.tgz", + "integrity": "sha512-hcDS0a/Y8ggM3dcOfzH7UpSaogCViWTtNDSl0EXpWIj8g8/r86ITqptjil2hayTXS0Umzb+Yt3mjgpQVFVf2+A==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1215,13 +1217,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", - "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.64.7.tgz", + "integrity": "sha512-awbAr4N7Qd/yjZAQBEEPzgcPveYG2Gi5L6gq1/Bp+3SMRjn5F68Wv1zKsntzvvoioBR6yKvhLtWqu11am1TsxA==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1229,13 +1234,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", - "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.64.7.tgz", + "integrity": "sha512-Ut7xQJfeLhzu2yN3rHzVvL9ccyPJvN4aCdP1V7hJ5lGxX/eCzgB9SiMk/Md3J+rWWZgvoYsDW4BUFggJr32pBA==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1243,13 +1251,16 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", - "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.64.7.tgz", + "integrity": "sha512-trqoni/vHkuM5oo5iQhoKPh6fjjD7E80pMgCXWgGxU8vSRPUlwvG8GeYvEEcB6eJAxNa99WH9iVHPHelgtKiHA==", "cpu": [ "loong64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1257,13 +1268,16 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", - "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.64.7.tgz", + "integrity": "sha512-gvlUy0XFIwtUWiQmhc+4GT2Knm3KosinpUVhr7CtTFA78gFDnE5NWwMPzHrJsYpKkh2LlY7yvBIboN1FAV6Xeg==", "cpu": [ "loong64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1271,13 +1285,16 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", - "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.64.7.tgz", + "integrity": "sha512-8yS/kSXTJ/47Z62JW+C2AJ3vd/8zZw016vFjtNpZwmZlwJumLua4s1i78dkYuSSqQ+0aIMxJcBp/4uIwp2ehjA==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1285,13 +1302,16 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", - "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.64.7.tgz", + "integrity": "sha512-mxVSKvvty0hOP1AFo/l4Kpv3i3HK0zx4rYUrirZDToYlWhS3xzmIkq3vUJ7rtrM2cPyXveDyDT6M1FfXZ9hTRA==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1299,13 +1319,16 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", - "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.64.7.tgz", + "integrity": "sha512-lEse1zra1CagM+uHH2bl28GInDsZZ45NJzHei2OMaXQFzZWKBO1hFo07rS0xB6ayV4WU360UrSmPDSyCMpYsUQ==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1313,13 +1336,16 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", - "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.64.7.tgz", + "integrity": "sha512-d/cqCoiG9WMZ5VuJZ5Qdlv9g97QyoGwnmvHccvGhKSacTy7xFrAS+jMcJSBdMNOw1oacSkl6p7K6O/DRYTKL3g==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1327,13 +1353,16 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", - "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.64.7.tgz", + "integrity": "sha512-kVEA//CeDOZZP1XCH2JBgqXb9+ALlwuA/4civaazMVdgzaSQC9WM38w65zG/LLk0lA7F9Od19CSBZW7J9mCbNg==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1341,13 +1370,16 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", - "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.64.7.tgz", + "integrity": "sha512-14oLfDsOSr0TSntT/34jRg/V8B2XW/Jomh9zX9CjIM9gwwNlI1OCymS/GVn0DV32h67eRf7U0GGem0rvfXgNwQ==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1355,13 +1387,16 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", - "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.64.7.tgz", + "integrity": "sha512-dFVtPfjZqAnUPpPY4Bfg+87yo1IBW9HiY8iCFtg49Pn46MfZTebSHZh8PL6SOsgyt9LQP8Nj/sE6jm3aWpySmQ==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1369,9 +1404,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", - "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.64.7.tgz", + "integrity": "sha512-Dm9CZ2SM3u2NgvBj4awW/gGqkOwzH8f6MSYIJE6+bKcKLI+T0+LhpD6vtetTfrIIx+rLXDf5RJqBP6v7V5O+wg==", "cpu": [ "x64" ], @@ -1383,9 +1418,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", - "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.64.7.tgz", + "integrity": "sha512-oaxsvS2mL6+78741CT2HCORBvy1USg3Wqpt5qvZwy3aMXRMl4zxyDo8maeFRWJkF3dp0Wbi71fKH5Gr9YI3vYA==", "cpu": [ "arm64" ], @@ -1397,9 +1432,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", - "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.64.7.tgz", + "integrity": "sha512-UKZz1Y4mxUqkWh60uH7B1aZn6UcAimPk4ipRktpb3wfZq7JrzZYyAXTwiafkb3T+kd5P3Sf+vzcRdsDks4WtjQ==", "cpu": [ "arm64" ], @@ -1411,9 +1446,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", - "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.64.7.tgz", + "integrity": "sha512-WWkSuC1J+mZJnsDh0mRaCAFSWfQdl6y3fDjZeWElI6zHo8CcOSG5lY3yurKJjEq+FrX0WD60sFrVVpbHqzMmrw==", "cpu": [ "ia32" ], @@ -1425,9 +1460,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", - "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.64.7.tgz", + "integrity": "sha512-u9bp2yXCI3/BUjUxY6uWknAY32pfvvDCPysyUTySotx0dj3ydlFHrRQYfn/b/kZgnfjyEwySMtI7Ga054uiysQ==", "cpu": [ "x64" ], @@ -1439,9 +1474,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", - "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.64.7.tgz", + "integrity": "sha512-EhX6N3XPMEnVU5cRfNgzPOKcSucsbu3nNNWuoAaEuKIqmddBKHN5b9d4rPe65T1I+FaOT176JKiZNsUBp3rqbA==", "cpu": [ "x64" ], @@ -1452,6 +1487,355 @@ "win32" ] }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.3.3.tgz", + "integrity": "sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "postcss": "^8.5.16", + "tailwindcss": "4.3.3" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1512,19 +1896,19 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.10.12", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.12.tgz", - "integrity": "sha512-68e+T28EbdmLSTkPgs3+UacC6rzmqrcWFPQs1C8mwJhI/r5Uxr0yEuQotczNRROd1gq30NGxee+fo0rSIxpyAw==", + "version": "24.19.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz", + "integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.16.0" + "undici-types": ">=7.24.0 <7.24.7" } }, "node_modules/@types/react": { - "version": "19.2.14", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", - "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", "dev": true, "license": "MIT", "dependencies": { @@ -1532,30 +1916,30 @@ } }, "node_modules/@types/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", "dev": true, "license": "MIT", "peerDependencies": { - "@types/react": "^19.2.0" + "@types/react": "^19.3.0" } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.55.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.55.0.tgz", - "integrity": "sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.2.tgz", + "integrity": "sha512-K72/pvT7qpm8q3k8G5u93NCSN1RcLxX5h7Gw0bQpbMXviBHeY6Nx1WEwmk9wP+eKTSQ8ecxvcgt/ZX/rB938xQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.55.0", - "@typescript-eslint/type-utils": "8.55.0", - "@typescript-eslint/utils": "8.55.0", - "@typescript-eslint/visitor-keys": "8.55.0", + "@typescript-eslint/scope-manager": "8.71.2", + "@typescript-eslint/type-utils": "8.71.2", + "@typescript-eslint/utils": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1565,15 +1949,15 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.55.0", - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "@typescript-eslint/parser": "^8.71.2", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz", + "integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==", "dev": true, "license": "MIT", "engines": { @@ -1581,16 +1965,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.55.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.55.0.tgz", - "integrity": "sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.2.tgz", + "integrity": "sha512-aTtmZjp/x3gsGHkDs04/1I8kNogBJkpQaeGaN0PfiG43NzDEmW2n+OP5im9wfsvyX0yP1UZWBrAGtDBfH9jEVw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.55.0", - "@typescript-eslint/types": "8.55.0", - "@typescript-eslint/typescript-estree": "8.55.0", - "@typescript-eslint/visitor-keys": "8.55.0", + "@typescript-eslint/scope-manager": "8.71.2", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2", "debug": "^4.4.3" }, "engines": { @@ -1601,19 +1985,19 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.55.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.55.0.tgz", - "integrity": "sha512-zRcVVPFUYWa3kNnjaZGXSu3xkKV1zXy8M4nO/pElzQhFweb7PPtluDLQtKArEOGmjXoRjnUZ29NjOiF0eCDkcQ==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.2.tgz", + "integrity": "sha512-Pd68GsLVh5ZXlcEccFPdhSOvtHZ/2WU0XsA8zmXOS3F158lVlcTLhfQ6qvtL1RzbMie0Kp8xtbVwAg4nejKPHw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.55.0", - "@typescript-eslint/types": "^8.55.0", + "@typescript-eslint/tsconfig-utils": "^8.71.2", + "@typescript-eslint/types": "^8.71.2", "debug": "^4.4.3" }, "engines": { @@ -1624,18 +2008,18 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.55.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.55.0.tgz", - "integrity": "sha512-fVu5Omrd3jeqeQLiB9f1YsuK/iHFOwb04bCtY4BSCLgjNbOD33ZdV6KyEqplHr+IlpgT0QTZ/iJ+wT7hvTx49Q==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.2.tgz", + "integrity": "sha512-YF4wgg5iUX5Obywoac27B56FhozMNFhdJSbh11H5GBAwmBtfyRhmgTQqW9tRNNmHDvsZ2CX6UGzoz+YfYn2Leg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.55.0", - "@typescript-eslint/visitor-keys": "8.55.0" + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1646,9 +2030,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.55.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.55.0.tgz", - "integrity": "sha512-1R9cXqY7RQd7WuqSN47PK9EDpgFUK3VqdmbYrvWJZYDd0cavROGn+74ktWBlmJ13NXUQKlZ/iAEQHI/V0kKe0Q==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.2.tgz", + "integrity": "sha512-jz49TAWokm/7yWD9/o837nYax9DyALxIPOImdImvVzo6canBCZYzin8zJyqNR5k1j9K1DE0bVvr0XYB+99uAdg==", "dev": true, "license": "MIT", "engines": { @@ -1659,21 +2043,21 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.55.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.55.0.tgz", - "integrity": "sha512-x1iH2unH4qAt6I37I2CGlsNs+B9WGxurP2uyZLRz6UJoZWDBx9cJL1xVN/FiOmHEONEg6RIufdvyT0TEYIgC5g==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.2.tgz", + "integrity": "sha512-cC8UaalCViJkb3rH/xLIvidEwoFRtMZMUqDjjJMGEKaTFwYRHYBcoczlEGHuvkHEO8svRDrgZ0yQgOArFmwang==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.55.0", - "@typescript-eslint/typescript-estree": "8.55.0", - "@typescript-eslint/utils": "8.55.0", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2", + "@typescript-eslint/utils": "8.71.2", "debug": "^4.4.3", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1683,14 +2067,14 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.55.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.55.0.tgz", - "integrity": "sha512-ujT0Je8GI5BJWi+/mMoR0wxwVEQaxM+pi30xuMiJETlX80OPovb2p9E8ss87gnSVtYXtJoU9U1Cowcr6w2FE0w==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.2.tgz", + "integrity": "sha512-y5Ukl3oVp7e/VGxqIoEXoeb/Roj71PAJ4WG/N/RGj+eeDvKRLM15kGr9MijLbN0i03S52zxu31Z98E5gWChJKw==", "dev": true, "license": "MIT", "engines": { @@ -1702,21 +2086,21 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.55.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.55.0.tgz", - "integrity": "sha512-EwrH67bSWdx/3aRQhCoxDaHM+CrZjotc2UCCpEDVqfCE+7OjKAGWNY2HsCSTEVvWH2clYQK8pdeLp42EVs+xQw==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.2.tgz", + "integrity": "sha512-DgNOEsl4pdYzwSL+N/Cl33tX1Hlw3FvId73LnKXfq7jnrF2viOEn7X+P7RBkkSWCVm92gnWd28GIC5vJuLREeA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.55.0", - "@typescript-eslint/tsconfig-utils": "8.55.0", - "@typescript-eslint/types": "8.55.0", - "@typescript-eslint/visitor-keys": "8.55.0", + "@typescript-eslint/project-service": "8.71.2", + "@typescript-eslint/tsconfig-utils": "8.71.2", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2", "debug": "^4.4.3", - "minimatch": "^9.0.5", + "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1726,39 +2110,52 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/@typescript-eslint/typescript-estree/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": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.2" + "brace-expansion": "^5.0.8" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -1769,16 +2166,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.55.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.55.0.tgz", - "integrity": "sha512-BqZEsnPGdYpgyEIkDC1BadNY8oMwckftxBT+C8W0g1iKPdeqKZBtTfnvcq0nf60u7MkjFO8RBvpRGZBPw4L2ow==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.2.tgz", + "integrity": "sha512-RyKa1G1M9rKHDhrk+HJtiF1MvLD5Ux2JW3TSkzw+02pRALdpJb+QzG1Y5em+LvCgzGwgg8H7mBRFKljNz7ImPw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.55.0", - "@typescript-eslint/types": "8.55.0", - "@typescript-eslint/typescript-estree": "8.55.0" + "@typescript-eslint/scope-manager": "8.71.2", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1788,19 +2185,19 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.55.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.55.0.tgz", - "integrity": "sha512-AxNRwEie8Nn4eFS1FzDMJWIISMGoXMb037sgCBJ3UR6o0fQTzr2tqN9WT+DkWJPhIdQCfV7T6D387566VtnCJA==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.2.tgz", + "integrity": "sha512-zozlN0UA32bSG2zkKAgQyRYSbEISVoyU6vvkKYo2aSny74D8Jh4e+POkJ6Lk0dlezZi5x7TtD+88Xb06IB8nhg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.55.0", - "eslint-visitor-keys": "^4.2.1" + "@typescript-eslint/types": "8.71.2", + "eslint-visitor-keys": "^5.0.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1810,17 +2207,30 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@vitejs/plugin-react": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.3.tgz", - "integrity": "sha512-NVUnA6gQCl8jfoYqKqQU5Clv0aPw14KkZYCsX6T9Lfu9slI0LOU10OTwFHS/WmptsMMpshNd/1tuWsHQ2Uk+cg==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-rc.2", + "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, @@ -1828,13 +2238,13 @@ "node": "^20.19.0 || >=22.12.0" }, "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", "dev": true, "license": "MIT", "bin": { @@ -1887,34 +2297,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/any-promise": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", - "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", - "dev": true, - "license": "MIT" - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/arg": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", - "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", - "dev": true, - "license": "MIT" - }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -1922,43 +2304,6 @@ "dev": true, "license": "Python-2.0" }, - "node_modules/autoprefixer": { - "version": "10.4.24", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.24.tgz", - "integrity": "sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/autoprefixer" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "browserslist": "^4.28.1", - "caniuse-lite": "^1.0.30001766", - "fraction.js": "^5.3.4", - "picocolors": "^1.1.1", - "postcss-value-parser": "^4.2.0" - }, - "bin": { - "autoprefixer": "bin/autoprefixer" - }, - "engines": { - "node": "^10 || ^12 || >=14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1967,32 +2312,22 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.9.19", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", - "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "version": "2.11.29", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.29.tgz", + "integrity": "sha512-YeesxhUmR4h5y5V8KF8iBCqJb8YFrBJ++XBTeGcWIaQOYXGN19JM0FoiOjAhwuDoln8owMgXcWBmgHtYSkky2w==", "dev": true, "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" - } - }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" + "baseline-browser-mapping": "dist/cli.cjs" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">=6.0.0" } }, "node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -2000,23 +2335,10 @@ "concat-map": "0.0.1" } }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "version": "4.29.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", + "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", "dev": true, "funding": [ { @@ -2034,11 +2356,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "baseline-browser-mapping": "^2.11.26", + "caniuse-lite": "^1.0.30001813", + "electron-to-chromium": "^1.5.439", + "node-releases": "^2.0.57", + "update-browserslist-db": "^1.3.3" }, "bin": { "browserslist": "cli.js" @@ -2057,20 +2379,10 @@ "node": ">=6" } }, - "node_modules/camelcase-css": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", - "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, "node_modules/caniuse-lite": { - "version": "1.0.30001769", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz", - "integrity": "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==", + "version": "1.0.30001816", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001816.tgz", + "integrity": "sha512-+8Yx7BXCoqk6IF8yTicjfUR6D/ldJzdlsodlEOiOSPmhK844WIgk67/zIQaGAOxNkZyyvBlrRFnLvqKIoqbAFw==", "dev": true, "funding": [ { @@ -2105,44 +2417,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/chokidar/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/class-variance-authority": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", @@ -2184,16 +2458,6 @@ "dev": true, "license": "MIT" }, - "node_modules/commander": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2223,19 +2487,6 @@ "node": ">= 8" } }, - "node_modules/cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "dev": true, - "license": "MIT", - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -2268,31 +2519,41 @@ "dev": true, "license": "MIT" }, - "node_modules/didyoumean": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", - "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "dev": true, - "license": "Apache-2.0" - }, - "node_modules/dlv": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", - "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", - "dev": true, - "license": "MIT" + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } }, "node_modules/electron-to-chromium": { - "version": "1.5.286", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", - "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", + "version": "1.5.458", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.458.tgz", + "integrity": "sha512-/hIH2gRSmxrf4/X4Nw/t/Wc8PVS22l8/3HnWjWTWsTOM5dsnMUPZF/+u+yr3LqweNuTBJE1rq6qAQzKkZVwMfA==", "dev": true, "license": "ISC" }, + "node_modules/enhanced-resolve": { + "version": "5.26.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.26.0.tgz", + "integrity": "sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/esbuild": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", - "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -2303,32 +2564,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.1", - "@esbuild/android-arm": "0.28.1", - "@esbuild/android-arm64": "0.28.1", - "@esbuild/android-x64": "0.28.1", - "@esbuild/darwin-arm64": "0.28.1", - "@esbuild/darwin-x64": "0.28.1", - "@esbuild/freebsd-arm64": "0.28.1", - "@esbuild/freebsd-x64": "0.28.1", - "@esbuild/linux-arm": "0.28.1", - "@esbuild/linux-arm64": "0.28.1", - "@esbuild/linux-ia32": "0.28.1", - "@esbuild/linux-loong64": "0.28.1", - "@esbuild/linux-mips64el": "0.28.1", - "@esbuild/linux-ppc64": "0.28.1", - "@esbuild/linux-riscv64": "0.28.1", - "@esbuild/linux-s390x": "0.28.1", - "@esbuild/linux-x64": "0.28.1", - "@esbuild/netbsd-arm64": "0.28.1", - "@esbuild/netbsd-x64": "0.28.1", - "@esbuild/openbsd-arm64": "0.28.1", - "@esbuild/openbsd-x64": "0.28.1", - "@esbuild/openharmony-arm64": "0.28.1", - "@esbuild/sunos-x64": "0.28.1", - "@esbuild/win32-arm64": "0.28.1", - "@esbuild/win32-ia32": "0.28.1", - "@esbuild/win32-x64": "0.28.1" + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" } }, "node_modules/escalade": { @@ -2355,25 +2616,26 @@ } }, "node_modules/eslint": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", - "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.1", + "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.39.2", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", - "ajv": "^6.12.4", + "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", @@ -2392,7 +2654,7 @@ "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", + "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -2415,9 +2677,9 @@ } }, "node_modules/eslint-plugin-react-hooks": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", - "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", "dev": true, "license": "MIT", "dependencies": { @@ -2431,7 +2693,7 @@ "node": ">=18" }, "peerDependencies": { - "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" } }, "node_modules/eslint-plugin-react-refresh": { @@ -2545,36 +2807,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -2589,16 +2821,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -2612,19 +2834,6 @@ "node": ">=16.0.0" } }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -2657,35 +2866,21 @@ } }, "node_modules/flatted": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", - "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", "dev": true, "license": "ISC" }, - "node_modules/fraction.js": { - "version": "5.3.4", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", - "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/rawify" - } - }, "node_modules/framer-motion": { - "version": "12.37.0", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.37.0.tgz", - "integrity": "sha512-j/QUcZS9Nw3NzZWoAbkzr3ETRFHyVeQMlGOUYUmG15U+uiyn9DqIktYruVPDcqY8I35qYR70JaZBvFmS6p+Pdg==", + "version": "12.43.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.43.0.tgz", + "integrity": "sha512-1eaL3RvR/kAlbG7UYcpMptEyzPoENO0c6w7ZnB3/hh2vSAz/6uGAFn6fdoqTBguNstf3MsFhJHsD/0DHiclG+g==", "dev": true, "license": "MIT", "dependencies": { - "motion-dom": "^12.37.0", - "motion-utils": "^12.36.0", + "motion-dom": "^12.43.0", + "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { @@ -2720,16 +2915,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -2766,6 +2951,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -2776,19 +2968,6 @@ "node": ">=8" } }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -2807,9 +2986,9 @@ } }, "node_modules/html-parse-stringify": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", - "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.1.0.tgz", + "integrity": "sha512-E0oAXcELOtsXe+BmpJ2EZyedbldPpriV5vICzEuo6xjC/D1lDukOI7KrpfQGF2Qc4wWEy0nk3bFORS2K5ZAhFQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2817,30 +2996,30 @@ } }, "node_modules/i18next": { - "version": "25.8.18", - "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.8.18.tgz", - "integrity": "sha512-lzY5X83BiL5AP77+9DydbrqkQHFN9hUzWGjqjLpPcp5ZOzuu1aSoKaU3xbBLSjWx9dAzW431y+d+aogxOZaKRA==", + "version": "25.10.10", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.10.10.tgz", + "integrity": "sha512-cqUW2Z3EkRx7NqSyywjkgCLK7KLCL6IFVFcONG7nVYIJ3ekZ1/N5jUsihHV6Bq37NfhgtczxJcxduELtjTwkuQ==", "dev": true, "funding": [ { "type": "individual", - "url": "https://locize.com" - }, - { - "type": "individual", - "url": "https://locize.com/i18next.html" + "url": "https://www.locize.com/i18next" }, { "type": "individual", "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" } ], "license": "MIT", "dependencies": { - "@babel/runtime": "^7.28.6" + "@babel/runtime": "^7.29.2" }, "peerDependencies": { - "typescript": "^5" + "typescript": "^5 || ^6" }, "peerDependenciesMeta": { "typescript": { @@ -2849,9 +3028,9 @@ } }, "node_modules/i18next-browser-languagedetector": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.0.tgz", - "integrity": "sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g==", + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz", + "integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.23.2" @@ -2894,35 +3073,6 @@ "node": ">=0.8.19" } }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -2946,16 +3096,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -2964,13 +3104,13 @@ "license": "ISC" }, "node_modules/jiti": { - "version": "1.21.7", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", - "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", "dev": true, "license": "MIT", "bin": { - "jiti": "bin/jiti.js" + "jiti": "lib/jiti-cli.mjs" } }, "node_modules/js-tokens": { @@ -2981,9 +3121,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, "funding": [ { @@ -3074,25 +3214,278 @@ "node": ">= 0.8.0" } }, - "node_modules/lilconfig": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", - "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", "dev": true, - "license": "MIT", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, "engines": { - "node": ">=14" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/antonk52" + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" } }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT" + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } }, "node_modules/locate-path": { "version": "6.0.0", @@ -3137,28 +3530,14 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, "license": "MIT", "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" + "@jridgewell/sourcemap-codec": "^1.5.5" } }, "node_modules/minimatch": { @@ -3175,19 +3554,19 @@ } }, "node_modules/motion-dom": { - "version": "12.37.0", - "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.37.0.tgz", - "integrity": "sha512-LnppZuwX1jQizRWTl9LBLMN3RbAEmdQkX/2Af0UW70NCqYJI/7GfI83vQP9Ucel/Avc0Tf2ZWy8FHawuc0O6Vg==", + "version": "12.43.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.43.0.tgz", + "integrity": "sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag==", "dev": true, "license": "MIT", "dependencies": { - "motion-utils": "^12.36.0" + "motion-utils": "^12.39.0" } }, "node_modules/motion-utils": { - "version": "12.36.0", - "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz", - "integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==", + "version": "12.39.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz", + "integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==", "dev": true, "license": "MIT" }, @@ -3198,22 +3577,10 @@ "dev": true, "license": "MIT" }, - "node_modules/mz": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", - "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0", - "object-assign": "^4.0.1", - "thenify-all": "^1.0.0" - } - }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", "dev": true, "funding": [ { @@ -3237,40 +3604,13 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "version": "2.0.59", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.59.tgz", + "integrity": "sha512-5qr2okC4ItAI0hG5SaV0HoYMpnM/Y6+Oqr7AmPYy/2uEoGvS+GbSKLeRxSS1uAdu59yz0ouzcuXievSgg///WA==", "dev": true, "license": "MIT", "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-hash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", - "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" + "node": ">=18" } }, "node_modules/optionator": { @@ -3356,13 +3696,6 @@ "node": ">=8" } }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true, - "license": "MIT" - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3370,43 +3703,10 @@ "dev": true, "license": "ISC" }, - "node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/pirates": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", - "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, "node_modules/postcss": { - "version": "8.5.19", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", - "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", + "version": "8.5.30", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.30.tgz", + "integrity": "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==", "dev": true, "funding": [ { @@ -3424,148 +3724,14 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.20", "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "source-map-js": "^1.2.2" }, "engines": { "node": "^10 || ^12 || >=14" } }, - "node_modules/postcss-import": { - "version": "15.1.0", - "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", - "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", - "dev": true, - "license": "MIT", - "dependencies": { - "postcss-value-parser": "^4.0.0", - "read-cache": "^1.0.0", - "resolve": "^1.1.7" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "postcss": "^8.0.0" - } - }, - "node_modules/postcss-js": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", - "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "camelcase-css": "^2.0.1" - }, - "engines": { - "node": "^12 || ^14 || >= 16" - }, - "peerDependencies": { - "postcss": "^8.4.21" - } - }, - "node_modules/postcss-load-config": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", - "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "lilconfig": "^3.1.1" - }, - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "jiti": ">=1.21.0", - "postcss": ">=8.0.9", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - }, - "postcss": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/postcss-nested": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", - "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "postcss-selector-parser": "^6.1.1" - }, - "engines": { - "node": ">=12.0" - }, - "peerDependencies": { - "postcss": "^8.2.14" - } - }, - "node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "dev": true, - "license": "MIT" - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -3595,63 +3761,42 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/react": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", - "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "version": "19.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.1.tgz", + "integrity": "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", - "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "version": "19.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.1.tgz", + "integrity": "sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==", "license": "MIT", "dependencies": { - "scheduler": "^0.27.0" + "scheduler": "^0.28.0" }, "peerDependencies": { - "react": "^19.2.4" + "react": "^19.3.1" } }, "node_modules/react-i18next": { - "version": "16.5.8", - "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.5.8.tgz", - "integrity": "sha512-2ABeHHlakxVY+LSirD+OiERxFL6+zip0PaHo979bgwzeHg27Sqc82xxXWIrSFmfWX0ZkrvXMHwhsi/NGUf5VQg==", + "version": "16.6.6", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.6.6.tgz", + "integrity": "sha512-ZgL2HUoW34UKUkOV7uSQFE1CDnRPD+tCR3ywSuWH7u2iapnz86U8Bi3Vrs620qNDzCf1F47NxglCEkchCTDOHw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/runtime": "^7.28.4", + "@babel/runtime": "^7.29.2", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { - "i18next": ">= 25.6.2", + "i18next": ">= 25.10.9", "react": ">= 16.8.0", - "typescript": "^5" + "typescript": "^5 || ^6" }, "peerDependenciesMeta": { "react-dom": { @@ -3675,50 +3820,6 @@ "node": ">=0.10.0" } }, - "node_modules/read-cache": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", - "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "pify": "^2.3.0" - } - }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/resolve": { - "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-core-module": "^2.16.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -3729,21 +3830,10 @@ "node": ">=4" } }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, "node_modules/rollup": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", - "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.64.7.tgz", + "integrity": "sha512-aCiJONoZp0lNGJ6sYqspLkh6UXaWEey/7Ko2TZyXQ4kgzAhDUxAgbt/oulXKmD+zFHXw1k+RF6ZZWOrjp7DZnQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3757,62 +3847,39 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.62.2", - "@rollup/rollup-android-arm64": "4.62.2", - "@rollup/rollup-darwin-arm64": "4.62.2", - "@rollup/rollup-darwin-x64": "4.62.2", - "@rollup/rollup-freebsd-arm64": "4.62.2", - "@rollup/rollup-freebsd-x64": "4.62.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", - "@rollup/rollup-linux-arm-musleabihf": "4.62.2", - "@rollup/rollup-linux-arm64-gnu": "4.62.2", - "@rollup/rollup-linux-arm64-musl": "4.62.2", - "@rollup/rollup-linux-loong64-gnu": "4.62.2", - "@rollup/rollup-linux-loong64-musl": "4.62.2", - "@rollup/rollup-linux-ppc64-gnu": "4.62.2", - "@rollup/rollup-linux-ppc64-musl": "4.62.2", - "@rollup/rollup-linux-riscv64-gnu": "4.62.2", - "@rollup/rollup-linux-riscv64-musl": "4.62.2", - "@rollup/rollup-linux-s390x-gnu": "4.62.2", - "@rollup/rollup-linux-x64-gnu": "4.62.2", - "@rollup/rollup-linux-x64-musl": "4.62.2", - "@rollup/rollup-openbsd-x64": "4.62.2", - "@rollup/rollup-openharmony-arm64": "4.62.2", - "@rollup/rollup-win32-arm64-msvc": "4.62.2", - "@rollup/rollup-win32-ia32-msvc": "4.62.2", - "@rollup/rollup-win32-x64-gnu": "4.62.2", - "@rollup/rollup-win32-x64-msvc": "4.62.2", + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.64.7", + "@rollup/rollup-android-arm64": "4.64.7", + "@rollup/rollup-darwin-arm64": "4.64.7", + "@rollup/rollup-darwin-x64": "4.64.7", + "@rollup/rollup-freebsd-arm64": "4.64.7", + "@rollup/rollup-freebsd-x64": "4.64.7", + "@rollup/rollup-linux-arm-gnueabihf": "4.64.7", + "@rollup/rollup-linux-arm-musleabihf": "4.64.7", + "@rollup/rollup-linux-arm64-gnu": "4.64.7", + "@rollup/rollup-linux-arm64-musl": "4.64.7", + "@rollup/rollup-linux-loong64-gnu": "4.64.7", + "@rollup/rollup-linux-loong64-musl": "4.64.7", + "@rollup/rollup-linux-ppc64-gnu": "4.64.7", + "@rollup/rollup-linux-ppc64-musl": "4.64.7", + "@rollup/rollup-linux-riscv64-gnu": "4.64.7", + "@rollup/rollup-linux-riscv64-musl": "4.64.7", + "@rollup/rollup-linux-s390x-gnu": "4.64.7", + "@rollup/rollup-linux-x64-gnu": "4.64.7", + "@rollup/rollup-linux-x64-musl": "4.64.7", + "@rollup/rollup-openbsd-x64": "4.64.7", + "@rollup/rollup-openharmony-arm64": "4.64.7", + "@rollup/rollup-win32-arm64-msvc": "4.64.7", + "@rollup/rollup-win32-ia32-msvc": "4.64.7", + "@rollup/rollup-win32-x64-gnu": "4.64.7", + "@rollup/rollup-win32-x64-msvc": "4.64.7", "fsevents": "~2.3.2" } }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", "license": "MIT" }, "node_modules/semver": { @@ -3849,9 +3916,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -3871,29 +3938,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/sucrase": { - "version": "3.35.1", - "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", - "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.2", - "commander": "^4.0.0", - "lines-and-columns": "^1.1.6", - "mz": "^2.7.0", - "pirates": "^4.0.1", - "tinyglobby": "^0.2.11", - "ts-interface-checker": "^0.1.9" - }, - "bin": { - "sucrase": "bin/sucrase", - "sucrase-node": "bin/sucrase-node" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -3907,23 +3951,10 @@ "node": ">=8" } }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/tailwind-merge": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz", - "integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==", + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.7.0.tgz", + "integrity": "sha512-XPPUyAc+cvspz3lHTcR/QgPfW2A0lv/xQNIjX3HGhLR+Nq2lHaLq5MtTesHn8GUr3W3DguT2KT5x3NVgRtYwmA==", "license": "MIT", "funding": { "type": "github", @@ -3931,75 +3962,35 @@ } }, "node_modules/tailwindcss": { - "version": "3.4.19", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", - "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", "dev": true, - "license": "MIT", - "dependencies": { - "@alloc/quick-lru": "^5.2.0", - "arg": "^5.0.2", - "chokidar": "^3.6.0", - "didyoumean": "^1.2.2", - "dlv": "^1.1.3", - "fast-glob": "^3.3.2", - "glob-parent": "^6.0.2", - "is-glob": "^4.0.3", - "jiti": "^1.21.7", - "lilconfig": "^3.1.3", - "micromatch": "^4.0.8", - "normalize-path": "^3.0.0", - "object-hash": "^3.0.0", - "picocolors": "^1.1.1", - "postcss": "^8.4.47", - "postcss-import": "^15.1.0", - "postcss-js": "^4.0.1", - "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", - "postcss-nested": "^6.2.0", - "postcss-selector-parser": "^6.1.2", - "resolve": "^1.22.8", - "sucrase": "^3.35.0" - }, - "bin": { - "tailwind": "lib/cli.js", - "tailwindcss": "lib/cli.js" - }, - "engines": { - "node": ">=14.0.0" - } + "license": "MIT" }, - "node_modules/thenify": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", - "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", "dev": true, "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0" - } - }, - "node_modules/thenify-all": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", - "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "thenify": ">= 3.1.0 < 4" - }, "engines": { - "node": ">=0.8" + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -4027,9 +4018,9 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -4039,23 +4030,10 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/ts-api-utils": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", - "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, "license": "MIT", "engines": { @@ -4065,13 +4043,6 @@ "typescript": ">=4.8.4" } }, - "node_modules/ts-interface-checker": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", - "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", - "dev": true, - "license": "Apache-2.0" - }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -4107,16 +4078,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.55.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.55.0.tgz", - "integrity": "sha512-HE4wj+r5lmDVS9gdaN0/+iqNvPZwGfnJ5lZuz7s5vLlg9ODw0bIiiETaios9LvFI1U94/VBXGm3CB2Y5cNFMpw==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.71.2.tgz", + "integrity": "sha512-Rz4effbMy2g9wnvOJZG6SbVavX313Lap1vNEbk1RUochzickvzP8P4HB+bkNg5e50eVz1hQGjh9K1omI1xB4Bw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.55.0", - "@typescript-eslint/parser": "8.55.0", - "@typescript-eslint/typescript-estree": "8.55.0", - "@typescript-eslint/utils": "8.55.0" + "@typescript-eslint/eslint-plugin": "8.71.2", + "@typescript-eslint/parser": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2", + "@typescript-eslint/utils": "8.71.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4126,21 +4097,21 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "dev": true, "license": "MIT" }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz", + "integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==", "dev": true, "funding": [ { @@ -4179,26 +4150,19 @@ } }, "node_modules/use-sync-external-store": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", - "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz", + "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==", "dev": true, "license": "MIT", "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, - "license": "MIT" - }, "node_modules/vite": { - "version": "7.3.6", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", - "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "version": "7.3.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.7.tgz", + "integrity": "sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA==", "dev": true, "license": "MIT", "dependencies": { @@ -4289,9 +4253,9 @@ } }, "node_modules/vite/node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -4358,9 +4322,9 @@ } }, "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", "dev": true, "license": "MIT", "funding": { diff --git a/frontend/package.json b/frontend/package.json index 2fb9c43..fb5a207 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "tg-vault", "private": true, - "version": "2.4.8", + "version": "2.4.9", "type": "module", "scripts": { "dev": "vite", @@ -21,11 +21,11 @@ }, "devDependencies": { "@eslint/js": "^9.39.1", + "@tailwindcss/postcss": "^4.3.3", "@types/node": "^24.10.1", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.1", - "autoprefixer": "^10.4.19", "eslint": "^9.39.1", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", @@ -33,9 +33,9 @@ "globals": "^16.5.0", "i18next": "^25.8.18", "lucide-react": "^0.577.0", - "postcss": "^8.4.38", + "postcss": "^8.5.30", "react-i18next": "^16.5.8", - "tailwindcss": "^3.4.17", + "tailwindcss": "^4.3.3", "typescript": "~5.9.3", "typescript-eslint": "^8.46.4", "vite": "^7.2.4" diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js index d41ad63..521f73c 100644 --- a/frontend/postcss.config.js +++ b/frontend/postcss.config.js @@ -1,6 +1,5 @@ export default { plugins: { - tailwindcss: {}, - autoprefixer: {}, + "@tailwindcss/postcss": {}, }, } diff --git a/frontend/public/fonts/geist/index.css b/frontend/public/fonts/geist/index.css index 41e2ff2..16e2afc 100644 --- a/frontend/public/fonts/geist/index.css +++ b/frontend/public/fonts/geist/index.css @@ -4,7 +4,7 @@ font-style: normal; font-display: swap; font-weight: 100 900; - src: url(./files/geist-cyrillic-ext-wght-normal.woff2) format('woff2-variations'); + src: url(/fonts/geist/files/geist-cyrillic-ext-wght-normal.woff2) format('woff2-variations'); unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F; } @@ -14,7 +14,7 @@ font-style: normal; font-display: swap; font-weight: 100 900; - src: url(./files/geist-cyrillic-wght-normal.woff2) format('woff2-variations'); + src: url(/fonts/geist/files/geist-cyrillic-wght-normal.woff2) format('woff2-variations'); unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116; } @@ -24,7 +24,7 @@ font-style: normal; font-display: swap; font-weight: 100 900; - src: url(./files/geist-vietnamese-wght-normal.woff2) format('woff2-variations'); + src: url(/fonts/geist/files/geist-vietnamese-wght-normal.woff2) format('woff2-variations'); unicode-range: U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB; } @@ -34,7 +34,7 @@ font-style: normal; font-display: swap; font-weight: 100 900; - src: url(./files/geist-latin-ext-wght-normal.woff2) format('woff2-variations'); + src: url(/fonts/geist/files/geist-latin-ext-wght-normal.woff2) format('woff2-variations'); unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; } @@ -44,6 +44,6 @@ font-style: normal; font-display: swap; font-weight: 100 900; - src: url(./files/geist-latin-wght-normal.woff2) format('woff2-variations'); + src: url(/fonts/geist/files/geist-latin-wght-normal.woff2) format('woff2-variations'); unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; } \ No newline at end of file diff --git a/frontend/scripts/build-nginx-config.mjs b/frontend/scripts/build-nginx-config.mjs new file mode 100644 index 0000000..34decd6 --- /dev/null +++ b/frontend/scripts/build-nginx-config.mjs @@ -0,0 +1,15 @@ +import { readFileSync, writeFileSync } from 'node:fs'; + +const api = process.env.VITE_API_URL?.trim() || ''; +let origin = ''; +// Match the frontend's relative-path fallback for the default development URL. +if (api && api !== 'http://localhost:51947') { + const url = new URL(api); + if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash || !['/', '/api', '/api/'].includes(url.pathname)) { + throw new Error('VITE_API_URL must be an HTTP(S) API origin'); + } + origin = url.origin; +} + +const template = readFileSync(new URL('../nginx.conf', import.meta.url), 'utf8'); +writeFileSync('/tmp/default.conf.template', template.replaceAll('__API_ORIGIN__', origin)); diff --git a/frontend/src/App.css b/frontend/src/App.css deleted file mode 100644 index b9d355d..0000000 --- a/frontend/src/App.css +++ /dev/null @@ -1,42 +0,0 @@ -#root { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; -} - -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; -} -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); -} -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); -} - -@keyframes logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -@media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { - animation: logo-spin infinite 20s linear; - } -} - -.card { - padding: 2em; -} - -.read-the-docs { - color: #888; -} diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg deleted file mode 100644 index 6c87de9..0000000 --- a/frontend/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/src/components/pages/SettingsPage.tsx b/frontend/src/components/pages/SettingsPage.tsx index 9527f54..97e9f05 100644 --- a/frontend/src/components/pages/SettingsPage.tsx +++ b/frontend/src/components/pages/SettingsPage.tsx @@ -287,32 +287,44 @@ export const SettingsPage = ({ storageStats, onSignedOut, onOpenTasksForAccount, const [isCleaningDownloadItems, setIsCleaningDownloadItems] = useState(false); const [advancedTasks, setAdvancedTasks] = useState(null); - const reloadAdvancedTasks = async () => { + const reloadAdvancedTasks = useCallback(async () => { const data = await fileApi.getAdvancedTaskSettings(); setAdvancedTasks(data); return data; - }; + }, []); + + useEffect(() => { + if (activeSection !== 'maintenance') return; + void reloadAdvancedTasks().catch((error: unknown) => { + setActionNotice({ title: t('settings.remaining.copy.139'), message: errorMessage(error), tone: 'error' }); + }); + }, [activeSection, reloadAdvancedTasks, t]); const updateAdvancedTask = async (patch: Partial>) => { - let result: { success: boolean; deletedCount?: number }; try { - result = await fileApi.updateAdvancedTaskSetting(patch); + let result: { success: boolean; deletedCount?: number }; + try { + result = await fileApi.updateAdvancedTaskSetting(patch); + } catch (error: unknown) { + if (errorCode(error) !== 'CONFIRMATION_REQUIRED') throw error; + const enablingPhotoFilter = patch.skipTelegramPhotosInBatch === true; + const confirmationMessage = enablingPhotoFilter + ? t('settings.remaining.copy.129') + : t('settings.remaining.copy.130'); + const confirmationTitle = enablingPhotoFilter ? t('settings.remaining.copy.131') : t('settings.remaining.copy.132'); + if (!(await requestConfirmation(confirmationMessage, confirmationTitle))) return; + result = await fileApi.updateAdvancedTaskSetting(patch, true); + } + await reloadAdvancedTasks(); + if ('telegramDownloadHistoryPolicy' in patch) { + const message = patch.telegramDownloadHistoryPolicy === 'errors_only' + ? t('settings.remaining.copy.133', { value1: result.deletedCount || 0 }) + : t('settings.remaining.copy.134'); + await showNotice(message); + } } catch (error: unknown) { - if (errorCode(error) !== 'CONFIRMATION_REQUIRED') throw error; - const enablingPhotoFilter = patch.skipTelegramPhotosInBatch === true; - const confirmationMessage = enablingPhotoFilter - ? t('settings.remaining.copy.129') - : t('settings.remaining.copy.130'); - const confirmationTitle = enablingPhotoFilter ? t('settings.remaining.copy.131') : t('settings.remaining.copy.132'); - if (!(await requestConfirmation(confirmationMessage, confirmationTitle))) return; - result = await fileApi.updateAdvancedTaskSetting(patch, true); - } - await reloadAdvancedTasks(); - if ('telegramDownloadHistoryPolicy' in patch) { - const message = patch.telegramDownloadHistoryPolicy === 'errors_only' - ? t('settings.remaining.copy.133', { value1: result.deletedCount || 0 }) - : t('settings.remaining.copy.134'); - await showNotice(message); + await showNotice(errorMessage(error) || t('telegramDownloadHistoryPolicy' in patch ? 'settings.cards.maintenance.history.updateFailed' : 'settings.remaining.shared.unknownError'), t('settings.remaining.copy.139'), 'error'); + await reloadAdvancedTasks().catch(() => undefined); } }; @@ -640,7 +652,7 @@ export const SettingsPage = ({ storageStats, onSignedOut, onOpenTasksForAccount, useEffect(() => { const loadConfig = async () => { try { - const [, , versionStatus] = await Promise.all([reloadStorageConfig(), reloadAdvancedTasks(), fileApi.getUpdateStatus()]); + const [, versionStatus] = await Promise.all([reloadStorageConfig(), fileApi.getUpdateStatus()]); setUpdateStatus(versionStatus); } catch (error) { console.error("Failed to load storage config:", error); @@ -1364,9 +1376,9 @@ export const SettingsPage = ({ storageStats, onSignedOut, onOpenTasksForAccount, {[4, 8, 12, 16].map(value => )} } /> - void updateAdvancedTask({ telegramFileConcurrency: Number(event.target.value) })}> - {[1, 2, 3, 4].map(value => )} + {[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(value => )} } /> void updateAdvancedTask({ telegramDownloadHistoryPolicy: event.target.value as AdvancedTaskSettings['telegramDownloadHistoryPolicy'] }).catch((error: any) => showNotice(errorMessage(error) || t('settings.cards.maintenance.history.updateFailed'), t('settings.remaining.copy.139')))} + onChange={(event) => void updateAdvancedTask({ telegramDownloadHistoryPolicy: event.target.value as AdvancedTaskSettings['telegramDownloadHistoryPolicy'] })} className="h-10 w-full px-3 sm:w-auto sm:min-w-48" aria-label={t('settings.cards.maintenance.history.policyLabel')} > diff --git a/frontend/src/components/pages/TaskFailureDetails.tsx b/frontend/src/components/pages/TaskFailureDetails.tsx new file mode 100644 index 0000000..97660d9 --- /dev/null +++ b/frontend/src/components/pages/TaskFailureDetails.tsx @@ -0,0 +1,64 @@ +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { fileApi, type TaskFailurePage, type UnifiedTask } from '../../services/api'; +import { isUnauthorizedError } from '../../services/apiActionError'; +import { errorMessage } from '../../services/unknownError'; +import { localizeTaskReason } from '../../i18n/taskReason'; +import { Button } from '../ui/Button'; +import { IndeterminateSpinner } from '../ui/IndeterminateSpinner'; + +export function TaskFailureDetails({ task, onUnauthorized }: { task: UnifiedTask; onUnauthorized?: () => void }) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const [offset, setOffset] = useState(0); + const [revision, setRevision] = useState(0); + const [page, setPage] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const supported = task.sourceType === 'telegram_channel'; + + useEffect(() => { + if (!open || !supported) return; + const controller = new AbortController(); + setLoading(true); + setError(null); + setPage(null); + void fileApi.getTaskFailures(task.sourceType, task.id, offset, controller.signal) + .then(result => { if (!controller.signal.aborted) setPage(result); }) + .catch(cause => { + if (controller.signal.aborted) return; + if (isUnauthorizedError(cause)) { onUnauthorized?.(); return; } + setError(errorMessage(cause, t('tasks.failures.loadFailed'))); + }) + .finally(() => { if (!controller.signal.aborted) setLoading(false); }); + return () => controller.abort(); + }, [open, supported, task.sourceType, task.id, task.counts.failed, offset, revision, onUnauthorized, t]); + + return
{ + const next = event.currentTarget.open; + setOpen(next); + if (!next) setOffset(0); + }}> + {t(task.counts.failed > 0 ? 'tasks.failures.title' : 'tasks.failures.errorTitle', { count: task.counts.failed })} + {open &&
+ {(!supported || (!loading && !page?.items.length)) && task.error &&
{localizeTaskReason(task.error, t)}
} + {loading && } + {error &&

{error}

} + {!loading && !error && (!supported || page?.items.length === 0) &&

{t('tasks.failures.empty')}

} + {!loading && page && page.items.length > 0 &&
} + {supported &&
+ {page && page.total > 0 && {t('tasks.failures.range', { start: page.items.length ? page.offset + 1 : 0, end: page.items.length ? page.offset + page.items.length : 0, total: page.total })}} + {offset > 0 && } + {page && page.offset + page.items.length < page.total && } + +
} +
} +
; +} diff --git a/frontend/src/components/pages/TasksPage.tsx b/frontend/src/components/pages/TasksPage.tsx index 0157c16..709e5af 100644 --- a/frontend/src/components/pages/TasksPage.tsx +++ b/frontend/src/components/pages/TasksPage.tsx @@ -1,6 +1,5 @@ import './tasks-audit.css'; import { formatDateTime } from '../../i18n/format'; -import { localizeTaskReason } from '../../i18n/taskReason'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { AlertCircle, Ban, CheckCircle2, CheckSquare, Clock3, Copy, @@ -17,6 +16,7 @@ import { dismissibleTaskSnapshot, pruneSelectedTaskKeys, scopeTasks, summarizeTa import { createSerialPoller } from '../../services/serialPoller'; import { Dialog } from '../ui/Dialog'; import { errorMessage } from '../../services/unknownError'; +import { TaskFailureDetails } from './TaskFailureDetails'; interface TasksPageProps { onUnauthorized?: () => void; onOpenUploads?: () => void; onShowAllTasks?: () => void; initialAccountId?: string | null; } interface TaskNotice { message: string; sequence: number; } @@ -31,6 +31,7 @@ const STATUS_OPTIONS = [ { value: '', labelKey: 'tasks.statuses.all' }, { value: 'pending', labelKey: 'tasks.statuses.pending' }, { value: 'running', labelKey: 'tasks.statuses.running' }, { value: 'paused', labelKey: 'tasks.statuses.paused' }, { value: 'waiting', labelKey: 'tasks.statuses.waiting' }, { value: 'failed', labelKey: 'tasks.statuses.failed' }, + { value: 'awaiting_selection', labelKey: 'tasks.statuses.awaitingSelection' }, { value: 'pausing', labelKey: 'tasks.statuses.pausing' }, { value: 'interrupted', labelKey: 'tasks.statuses.interrupted' }, { value: 'retry_required', labelKey: 'tasks.statuses.retryRequired' }, { value: 'completed', labelKey: 'tasks.statuses.completed' }, { value: 'cancelled', labelKey: 'tasks.statuses.cancelled' }, @@ -40,6 +41,7 @@ const SOURCE_LABELS: Record = Object.fromEntries(SOURCE_OPTIONS. const STATUS_LABELS: Record = Object.fromEntries(STATUS_OPTIONS.filter(o => o.value).map(o => [o.value, o.labelKey])); const STAGE_LABELS: Record = { waiting: 'tasks.stages.queued', queued: 'tasks.stages.queued', scanning: 'tasks.stages.scanning', downloading: 'tasks.stages.downloading', + awaiting_selection: 'tasks.statuses.awaitingSelection', uploading: 'tasks.stages.uploading', processing: 'tasks.stages.processing', awaiting_file: 'tasks.stages.awaitingFile', resumable: 'tasks.stages.resumable', waiting_for_next_scan: 'tasks.stages.waitingForNextScan', waiting_for_next_task: 'tasks.stages.waitingForNextTask', completed: 'tasks.stages.completed', failed: 'tasks.stages.failed', cancelled: 'tasks.statuses.cancelled', interrupted: 'tasks.stages.interrupted', @@ -89,6 +91,7 @@ export const TasksPage = ({ onUnauthorized, onOpenUploads, onShowAllTasks, initi folder: task.target.folder || t('tasks.target.root'), }); const [tasks, setTasks] = useState([]); + const [tasksObservedAt, setTasksObservedAt] = useState(0); const tasksRef = useRef([]); const [source, setSource] = useState(''); const [status, setStatus] = useState(''); @@ -123,6 +126,7 @@ export const TasksPage = ({ onUnauthorized, onOpenUploads, onShowAllTasks, initi : result.tasks; tasksRef.current = relevantTasks; setTasks(relevantTasks); setError(null); + setTasksObservedAt(Date.now()); setSelected(previous => pruneSelectedTaskKeys(previous, relevantTasks, scopeFilters, taskKey)); } catch (loadError: unknown) { if (generation !== requestGeneration.current) return; @@ -288,7 +292,11 @@ export const TasksPage = ({ onUnauthorized, onOpenUploads, onShowAllTasks, initi } {visibleTasks.map(task => { const stageLabel = STAGE_LABELS[task.stage] ? t(STAGE_LABELS[task.stage]) : task.stage; - const statusLabel = STATUS_LABELS[task.status] ? t(STATUS_LABELS[task.status]) : task.status; + const noMatches = task.status === 'completed' && task.sourceType === 'telegram_channel' + && task.detail.scan && typeof task.detail.scan === 'object' + && 'outcome' in task.detail.scan && task.detail.scan.outcome === 'no_matches'; + const statusLabel = noMatches ? t('tasks.statuses.noMatches') + : STATUS_LABELS[task.status] ? t(STATUS_LABELS[task.status]) : task.status; const stopped = ['cancelled', 'failed', 'interrupted', 'retry_required', 'disabled'].includes(task.status); const progress = Number.isFinite(task.progress) ? Math.max(0, Math.min(100, task.progress)) : 0; const progressLabel = stopped ? audit.history : task.status === 'paused' ? audit.paused : t('tasks.table.progress'); @@ -309,22 +317,25 @@ export const TasksPage = ({ onUnauthorized, onOpenUploads, onShowAllTasks, initi

{task.title}

{!mobile &&
{audit.details}{task.id}
} - {statusLabel}{showStage && {stageLabel}} + {statusLabel}{showStage && {stageLabel}} {t('tasks.target.label', { target: taskTarget(task) })} {(progress > 0 || stopped || ['running', 'paused'].includes(task.status)) &&
{progressLabel}
{Math.round(progress)}%
} {task.counts.total > 0 && {task.sourceType === 'telegram_channel' ? t('tasks.progress.channelItems', { processed: task.detail.processed ?? task.counts.completed, total: task.counts.total, completed: task.counts.completed, failed: task.counts.failed, skipped: task.detail.skipped ?? 0 }) : <>{t('tasks.progress.items', { completed: task.counts.completed, total: task.counts.total })}{task.counts.failed > 0 ? t('tasks.progress.failed', { count: task.counts.failed }) : ''}}} + {task.sourceType === 'telegram_channel' && Number(task.detail.excluded) > 0 && {t('tasks.progress.excluded', { count: Number(task.detail.excluded) })}} {task.sourceType === 'telegram_channel' && task.status !== 'completed' && !stopped && {t('tasks.progress.channelQueue', { active: task.detail.active ?? 0, pending: task.detail.pending ?? 0 })}} {scan && {t('tasks.progress.channelScan', { messages: Number(scan.channelMessagesScanned ?? 0), files: Number(scan.channelMediaFound ?? 0) })}} + {scan && Number(scan.waitUntil || 0) > tasksObservedAt && {t('tasks.progress.scanWait', { time: formatDateTime(new Date(Number(scan.waitUntil)).toISOString(), locale) })}} {scan && (commentMessagesScanned > 0 || commentMediaFound > 0) && {t('tasks.progress.commentScan', { messages: commentMessagesScanned, files: commentMediaFound })}} {lastMessageDate && {t('tasks.progress.scanDate', { time: formatDateTime(lastMessageDate, locale) })}} {task.sourceType === 'telegram_channel' && task.detail.scanStatus !== 'done' && !stopped && {t('tasks.progress.scanningHint')}} - {transfer.showBytes && {t(task.sourceType === 'telegram_channel' ? 'tasks.progress.savedData' : 'tasks.progress.data', { transferred: transfer.transferred, total: transfer.total })}} + {task.status === 'awaiting_selection' && {t('tasks.progress.selectionHint')}} + {transfer.showBytes && {t(task.sourceType === 'telegram_channel' ? (task.detail.fileSelectionConfirmed ? 'tasks.progress.selectedData' : 'tasks.progress.savedData') : 'tasks.progress.data', { transferred: transfer.transferred, total: transfer.total })}} {Number(task.detail.savedUnknownCount) > 0 && {t('tasks.progress.unknownSizes', { count: Number(task.detail.savedUnknownCount) })}} {(detailSpeed || detailEta) && {detailSpeed ? t('tasks.progress.speed', { speed: detailSpeed }) : ''}{detailSpeed && detailEta ? ' · ' : ''}{detailEta ? t('tasks.progress.eta', { eta: detailEta }) : ''}} - {task.error &&

{localizeTaskReason(task.error, t)}

} + {(task.counts.failed > 0 || task.error) && }
{!selectionMode &&
diff --git a/frontend/src/components/pages/TelegramUserAccountsPanel.tsx b/frontend/src/components/pages/TelegramUserAccountsPanel.tsx index 7477fb1..ab6ca51 100644 --- a/frontend/src/components/pages/TelegramUserAccountsPanel.tsx +++ b/frontend/src/components/pages/TelegramUserAccountsPanel.tsx @@ -75,10 +75,12 @@ function PermissionSummary({ summary, compact = false }: { summary: TelegramPerm function LoginDialog({ open, + target, onClose, onComplete, }: { open: boolean; + target: TelegramUserAccount | null; onClose: () => void; onComplete: () => Promise; }) { @@ -93,6 +95,7 @@ function LoginDialog({ const [failure, setFailure] = useState(null); const closedRef = useRef(false); const pollGenerationRef = useRef(0); + const completedFlowRef = useRef(null); const clearSecrets = useCallback(() => { setPhone(''); @@ -100,19 +103,26 @@ function LoginDialog({ setPassword(''); }, []); + const loginFlowId = login?.flowId; + const targetAccountId = target?.id; + const startQr = useCallback(async () => { setBusy(true); setFailure(null); + pollGenerationRef.current += 1; + setPassword(''); try { - if (login?.flowId) await fileApi.cancelTelegramUserLogin(login.flowId).catch(() => undefined); - const result = await fileApi.startTelegramUserQrLogin(); + if (loginFlowId) await fileApi.cancelTelegramUserLogin(loginFlowId).catch(() => undefined); + setLogin(null); + const result = await fileApi.startTelegramUserQrLogin(targetAccountId); + if (closedRef.current) return; setLogin(result); } catch (error) { setFailure(errorMessage(error) || t('management.telegramAccounts.errors.qrCreate')); } finally { setBusy(false); } - }, [login?.flowId]); + }, [loginFlowId, targetAccountId, t]); useEffect(() => { if (!open) { @@ -120,13 +130,29 @@ function LoginDialog({ return; } closedRef.current = false; + completedFlowRef.current = null; setMethod('choose'); setSelectedMethod(null); setLogin(null); setFailure(null); clearSecrets(); return () => { closedRef.current = true; }; - }, [open, clearSecrets]); + }, [open, target?.id, clearSecrets]); + + useEffect(() => { + if (!open || closedRef.current || !login) return; + if (login.status === 'error' || login.status === 'expired') { + setPassword(''); + setFailure(login.message || t(login.status === 'expired' ? 'management.telegramAccounts.errors.qrExpired' : 'management.telegramAccounts.errors.login')); + } + if (login.status === 'complete' && completedFlowRef.current !== login.flowId) { + completedFlowRef.current = login.flowId; + clearSecrets(); + void onComplete().catch(error => { + if (!closedRef.current) setFailure(errorMessage(error) || t('management.telegramAccounts.errors.login')); + }); + } + }, [open, login, clearSecrets, onComplete, t]); useEffect(() => { const flowId = open && login?.status === 'waiting_for_scan' ? login.flowId : null; @@ -139,16 +165,10 @@ function LoginDialog({ const next = await fileApi.getTelegramUserLoginStatus(flowId); if (closedRef.current || stopped || generation !== pollGenerationRef.current) return; setLogin(next); - if (next.status === 'complete') { - clearSecrets(); - await onComplete(); - return; - } - if (next.status === 'expired') setFailure(t('management.telegramAccounts.errors.qrExpired')); - if (next.status === 'error') setFailure(next.message || t('management.telegramAccounts.errors.login')); + if (next.status === 'error' || next.status === 'expired') setPassword(''); if (!stopped && next.status === 'waiting_for_scan') timer = window.setTimeout(() => { void poll(); }, 1_500); } catch (error) { - if (!closedRef.current && !stopped) { + if (!closedRef.current && !stopped && generation === pollGenerationRef.current) { setFailure(errorMessage(error) || t('management.telegramAccounts.errors.status')); timer = window.setTimeout(() => { void poll(); }, 3_000); } @@ -160,11 +180,12 @@ function LoginDialog({ pollGenerationRef.current += 1; if (timer !== undefined) window.clearTimeout(timer); }; - }, [open, login?.flowId, clearSecrets, onComplete]); + }, [open, login?.flowId, login?.status, t]); const close = () => { const flowId = login?.flowId; closedRef.current = true; + pollGenerationRef.current += 1; clearSecrets(); setLogin(null); setFailure(null); @@ -173,6 +194,8 @@ function LoginDialog({ }; const usePhone = () => { + pollGenerationRef.current += 1; + clearSecrets(); const flowId = login?.flowId; if (flowId) void fileApi.cancelTelegramUserLogin(flowId).catch(() => undefined); setLogin(null); @@ -194,6 +217,8 @@ function LoginDialog({ }; const backToMethodChoice = () => { + pollGenerationRef.current += 1; + clearSecrets(); const flowId = login?.flowId; if (flowId && login?.status !== 'complete') void fileApi.cancelTelegramUserLogin(flowId).catch(() => undefined); setLogin(null); @@ -207,7 +232,7 @@ function LoginDialog({ setBusy(true); setFailure(null); try { - const result = await fileApi.startTelegramUserPhoneLogin(phone.trim()); + const result = await fileApi.startTelegramUserPhoneLogin(phone.trim(), target?.id); setLogin(result); setPhone(''); } catch (error) { @@ -223,10 +248,9 @@ function LoginDialog({ const result = await fileApi.submitTelegramUserLoginCode(login.flowId, code.trim()); setCode(''); setLogin(result); - if (result.status === 'complete') await onComplete(); } catch (error) { setFailure(errorMessage(error) || t('management.telegramAccounts.errors.codeVerify')); - } finally { setBusy(false); } + } finally { setPassword(''); setBusy(false); } }; const submitPassword = async () => { @@ -235,12 +259,10 @@ function LoginDialog({ setFailure(null); try { const result = await fileApi.submitTelegramUserLoginPassword(login.flowId, password); - setPassword(''); setLogin(result); - if (result.status === 'complete') await onComplete(); } catch (error) { setFailure(errorMessage(error) || t('management.telegramAccounts.errors.password')); - } finally { setBusy(false); } + } finally { setPassword(''); setBusy(false); } }; const complete = login?.status === 'complete'; @@ -250,10 +272,10 @@ function LoginDialog({
-

{t('management.telegramAccounts.login.title')}

-

{t('management.telegramAccounts.login.description')}

+

{target ? t('management.telegramAccounts.reauth.title', { name: accountName(target, t) }) : t('management.telegramAccounts.login.title')}

+

{target ? t('management.telegramAccounts.reauth.description', { name: accountName(target, t), id: target.userId }) : t('management.telegramAccounts.login.description')}

- +
{failure &&
{failure}
} @@ -278,14 +300,14 @@ function LoginDialog({
} - {method === 'qr' && !complete &&
+ {method === 'qr' && !complete && login?.status !== 'password_required' && login?.status !== 'code_required' &&

{t('management.telegramAccounts.login.qr')}

{t('management.telegramAccounts.login.qrInstructions')}

-
- {login?.qrCode ? : } -
+ {(busy || login?.status === 'waiting_for_scan') &&
+ {login?.status === 'waiting_for_scan' && login.qrCode ? : } +
}
@@ -301,8 +323,8 @@ function LoginDialog({
- - + +
} @@ -324,9 +346,14 @@ function LoginDialog({
} + {!complete && login && (method === 'phone' || login.status === 'password_required' || login.status === 'code_required') &&
+ + +
} + {complete &&
-

{t('management.telegramAccounts.login.completeTitle')}

{t('management.telegramAccounts.login.completeDescription')}

+

{t(target ? 'management.telegramAccounts.reauth.completeTitle' : 'management.telegramAccounts.login.completeTitle')}

{t(target ? 'management.telegramAccounts.reauth.completeDescription' : 'management.telegramAccounts.login.completeDescription')}

} @@ -351,6 +378,7 @@ export function TelegramUserAccountsPanel({ const [loadError, setLoadError] = useState(null); const [busyAccountId, setBusyAccountId] = useState(null); const [loginOpen, setLoginOpen] = useState(false); + const [loginTarget, setLoginTarget] = useState(null); const reload = useCallback(async (fresh = false) => { if (fresh) invalidateSettingsRequests(); @@ -416,7 +444,7 @@ export function TelegramUserAccountsPanel({

{t('management.telegramAccounts.description')}

- + {!configured &&
{t('management.telegramAccounts.botRequired')}
} @@ -458,9 +486,11 @@ export function TelegramUserAccountsPanel({ {account.cooldownUntil &&

{t('management.telegramAccounts.account.cooldownUntil', { time: formatTime(account.cooldownUntil, locale) || t('management.telegramAccounts.account.pendingUpdate') })}

} } {account.lastError && account.health !== 'disabled' && (!rateLimited || account.health === 'error' || account.health === 'expired') &&

{t('management.telegramAccounts.account.lastError')}: {account.lastError}

} + {account.health === 'expired' &&

{t('management.telegramAccounts.reauth.expiredHint')}

} {!account.enabled &&

{t('management.telegramAccounts.account.disabledHint')}

}
+ {account.enabled && !account.connected && (account.health === 'expired' || account.health === 'error') && } {account.enabled ? : }
@@ -470,7 +500,7 @@ export function TelegramUserAccountsPanel({ } - setLoginOpen(false)} onComplete={async () => { await reload(true); await onNotice(t('management.telegramAccounts.notices.bound')); }} /> + setLoginOpen(false)} onComplete={async () => { await reload(true); await onNotice(t(loginTarget ? 'management.telegramAccounts.reauth.notice' : 'management.telegramAccounts.notices.bound')); }} /> ); } diff --git a/frontend/src/components/pages/tasks-audit.css b/frontend/src/components/pages/tasks-audit.css index ed2d733..d083dce 100644 --- a/frontend/src/components/pages/tasks-audit.css +++ b/frontend/src/components/pages/tasks-audit.css @@ -37,3 +37,12 @@ .op-tasks-page .audit-task-card .op-task-id { font-size: var(--tv-text-meta); } .op-tasks-page .op-more-actions > summary { display: flex; align-items: center; justify-content: flex-end; min-height: 44px; } } +.op-tasks-page .op-task-failures { min-width: 0; max-width: 100%; font-size: var(--tv-text-meta); } +.op-tasks-page .op-task-failures > summary { cursor: pointer; padding: 8px 0; color: hsl(var(--destructive)); overflow-wrap: anywhere; } +.op-tasks-page .op-task-failure-body { max-height: 20rem; overflow: auto; overscroll-behavior: contain; padding: 8px; border: 1px solid hsl(var(--border)); border-radius: var(--tv-radius-panel); } +.op-tasks-page .op-task-failure-list { display: grid; gap: 12px; margin: 0; padding: 0; list-style: none; } +.op-tasks-page .op-task-failure-list li { min-width: 0; padding-bottom: 10px; border-bottom: 1px solid hsl(var(--border)); } +.op-tasks-page .op-task-failure-name { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; overflow-wrap: anywhere; } +.op-tasks-page .op-task-failure-reason { max-height: 8rem; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; font: inherit; color: hsl(var(--destructive)); margin: 6px 0; } +.op-tasks-page .op-task-failure-link { display: block; overflow-wrap: anywhere; color: hsl(var(--primary)); text-decoration: underline; } +.op-tasks-page .op-task-failure-controls { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-top: 8px; } diff --git a/frontend/src/index.css b/frontend/src/index.css index dd32603..12e6684 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,14 +1,35 @@ @import url('/fonts/geist/index.css'); -@tailwind base; -@tailwind components; -@tailwind utilities; +/* Keep the v3 unlayered cascade so component styles and utilities retain priority. */ +@import "tailwindcss/theme.css"; +@import "tailwindcss/preflight.css"; +@config "../tailwind.config.js"; + +@theme { + --font-sans: 'Geist Variable', 'PingFang SC', 'Microsoft YaHei', system-ui, sans-serif; + --default-ring-width: 3px; + --default-ring-color: #3b82f6; +} + +@custom-variant hover { + &:hover { @slot; } +} + +/* These existing utility names retain their v3 semantics. */ +@source not inline("focus-visible:outline-none space-y-{0.5,1,1.5,2,3,4,5} divide-y divide-border divide-border/50"); + +@utility flex-shrink-0 { + flex-shrink: 0; +} + +/* Preserve v3 preflight defaults not already supplied by the application theme. */ +*, ::before, ::after, ::backdrop, ::file-selector-button { border-color: #e5e7eb; } +button, [role='button'] { cursor: pointer; } +dialog { margin: auto; } /* Final reference design tokens, not the reference site's legacy CSS stack. Keep tokens at :root: dialogs/menus rendered into body share this theme. */ -@layer base { :root { color-scheme: light; - --font-sans: 'Geist Variable', 'PingFang SC', 'Microsoft YaHei', system-ui, sans-serif; --background: 225 100% 96%; --foreground: 214.0299 51.1450% 25.6863%; --card: 0 0% 100%; @@ -152,9 +173,7 @@ @media(max-width:760px) { input:not([type='checkbox']):not([type='radio']):not([type='range']):not([type='file']), select, textarea { font-size:16px; } } -} -@layer components { .tv-panel { min-width:0; padding:25px; border:1px solid hsl(var(--border)); border-radius:var(--tv-radius-panel); background:var(--tv-surface); box-shadow:var(--tv-shadow); } .tv-page-header { display:flex; align-items:flex-start; justify-content:space-between; flex-wrap:wrap; gap:16px; margin-bottom:24px; } .tv-page-header :is(h1,h2) { margin:0; font-size:28px; font-weight:600; } @@ -185,9 +204,20 @@ @media(max-width:980px) { .tv-panel { padding:22px; } } @media(max-width:760px) { .tv-page-header :is(h1,h2) { font-size:24px; } .tv-btn-sm { min-height:44px; } } @media(max-width:380px) { .tv-panel { padding:18px; } } -} -@layer utilities { +@tailwind utilities; +.focus-visible\:outline-none:focus-visible { outline: 2px solid transparent; outline-offset: 2px; } +.space-y-0\.5 > :not([hidden]) ~ :not([hidden]) { margin-top: 0.125rem; margin-bottom: 0; } +.space-y-1 > :not([hidden]) ~ :not([hidden]) { margin-top: 0.25rem; margin-bottom: 0; } +.space-y-1\.5 > :not([hidden]) ~ :not([hidden]) { margin-top: 0.375rem; margin-bottom: 0; } +.space-y-2 > :not([hidden]) ~ :not([hidden]) { margin-top: 0.5rem; margin-bottom: 0; } +.space-y-3 > :not([hidden]) ~ :not([hidden]) { margin-top: 0.75rem; margin-bottom: 0; } +.space-y-4 > :not([hidden]) ~ :not([hidden]) { margin-top: 1rem; margin-bottom: 0; } +.space-y-5 > :not([hidden]) ~ :not([hidden]) { margin-top: 1.25rem; margin-bottom: 0; } +.divide-y > :not([hidden]) ~ :not([hidden]) { border-top-width: 1px; border-bottom-width: 0; } +.divide-border > :not([hidden]) ~ :not([hidden]) { border-color: hsl(var(--border)); } +.divide-border\/50 > :not([hidden]) ~ :not([hidden]) { border-color: hsl(var(--border) / 0.5); } + @keyframes indeterminate-spinner-rotate { to { transform:rotate(360deg); } } .indeterminate-spinner { animation:indeterminate-spinner-rotate .8s linear infinite; transform-origin:center; } .indeterminate-spinner__track { opacity:1; } @@ -196,7 +226,6 @@ *, *::before, *::after { scroll-behavior:auto !important; animation-duration:.01ms !important; animation-iteration-count:1 !important; transition-duration:.01ms !important; } .indeterminate-spinner { animation:none; transform:rotate(-90deg); } } -} /* Icon and select share one visible control boundary and one focus indicator. */ :is(.tv-language, .tv-login-theme):focus-within { border-color:hsl(var(--primary)); outline:2px solid hsl(var(--ring)); outline-offset:2px; } diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 81fcf23..f60dc05 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -330,10 +330,12 @@ "paused": "Paused", "pausing": "Pausing", "waiting": "Waiting to resume", + "awaitingSelection": "Waiting for file type selection", "failed": "Failed", "interrupted": "Interrupted", "retryRequired": "Retry required", "completed": "Completed", + "noMatches": "No matches", "cancelled": "Cancelled", "scheduled": "Scheduled", "disabled": "Disabled" @@ -407,17 +409,36 @@ "title": "No matching tasks", "description": "Adjust the source or status filter and try again." }, + "failures": { + "title": "Failed files ({{count}})", + "errorTitle": "Task error details", + "loading": "Loading failure details", + "loadFailed": "Could not load failure details. Try again.", + "empty": "No per-file failure records are available. They may have been cleared or queued for retry; any recorded task-level error is shown above.", + "unknownFile": "File name not recorded", + "unknownReason": "Failure reason not recorded", + "openMessage": "Open original message #{{id}} ↗", + "noLink": "Message #{{id}}: no reliable source for a direct link", + "range": "{{start}}–{{end}} of {{total}} (10 per page)", + "previous": "Previous", + "next": "Next", + "retry": "Reload" + }, "progress": { "speed": "Speed: {{speed}}", "eta": "About {{eta}} remaining", "items": "Items: {{completed}} / {{total}}", "channelItems": "Processed {{processed}}/{{total}} · Succeeded {{completed}} · Failed {{failed}} · Skipped {{skipped}}", "savedData": "Saved {{transferred}} / Discovered {{total}}", + "selectedData": "Saved {{transferred}} / Selected files {{total}}", + "excluded": "Filtered out {{count}} files (not included in download progress)", "channelQueue": "Processing {{active}} · Queued {{pending}}", "channelScan": "Channel: {{messages}} messages checked · {{files}} files found", "commentScan": "Comments: {{messages}} messages checked · {{files}} files found", "scanDate": "Scanned through {{time}}", + "scanWait": "Account rate limit until {{time}}; waiting or switching accounts, discovered files retained", "scanningHint": "Scanning continues; the file total may increase", + "selectionHint": "Scan complete. Select file types and confirm the download on the Telegram task card.", "unknownSizes": "{{count}} saved files have unknown sizes (+ marks a known minimum)", "failed": ", {{count}} failed", "data": "Transferred: {{transferred}} / {{total}}" diff --git a/frontend/src/locales/en/management.ts b/frontend/src/locales/en/management.ts index 03e3a69..13d2079 100644 --- a/frontend/src/locales/en/management.ts +++ b/frontend/src/locales/en/management.ts @@ -11,6 +11,16 @@ export const management = { summary: { enabled: 'Enabled accounts', ready: 'Available accounts', cooldown: 'Temporarily limited', permissions: 'Source access summary' }, scheduling: { title: "Account scheduling", description: 'Per-file selection: confirmed access first, then weight and load; ties rotate. New requests wait automatically if all eligible accounts are busy or rate limited; unavailable accounts are skipped.' }, empty: { title: 'No Telegram user accounts linked', description: "Add an account and sign in with a QR code or phone number." }, + reauth: { + action: 'Sign in again', + expiredHint: 'This session has expired. Sign in again with the same Telegram account to update its credentials.', + title: 'Sign in again: {{name}}', + description: 'Update credentials for {{name}} (Telegram ID: {{id}}). Use this same Telegram account to scan the QR code or sign in by phone. Existing settings and enabled/disabled state will be kept.', + closeAria: 'Close account sign-in', + completeTitle: 'Sign-in credentials updated', + completeDescription: 'The existing account and its settings are unchanged. Check the account list for its actual connection status. Disabled accounts stay disabled until you enable them.', + notice: 'Sign-in credentials updated. Check the account list for the current status; disabled accounts remain disabled.', + }, account: { permissions: 'Source access', activeDownloads: 'Downloading: {{count}}', lastChecked: 'Access checked', weight: 'Scheduling weight: {{value}}', cooldownUntil: 'New requests expected to resume: {{time}}', rateLimitHint: 'Telegram requires a longer wait. This account cannot accept new requests yet; retries are automatic.', pendingUpdate: "Awaiting update", lastError: 'Latest error', disabledHint: "Disabled: no new tasks are assigned; sign-in credentials are kept.", disable: 'Disable', enable: 'Re-enable', delete: 'Remove account' }, login: { title: 'Add Telegram user account', description: 'Sign in with a QR code or phone number. Signing in by phone requires a verification code and, if enabled, your two-step verification password. Credentials are encrypted and stored only on the server.', closeAria: 'Close account setup', chooseTitle: 'Choose a sign-in method', chooseDescription: "Sign in with a QR code or phone number.", qr: 'QR code', qrDescription: 'Scan a QR code in Telegram without entering your phone number.', phone: 'Phone number', phoneDescription: 'Enter your phone number and Telegram verification code, then your two-step verification password if required.', next: 'Next', qrInstructions: 'In Telegram, open Settings → Devices → Link Desktop Device, then scan this code.', qrAria: 'Telegram sign-in QR code', generatingQr: 'Generating QR code', refreshQr: 'Refresh QR code', usePhone: 'Use phone number', back: 'Back to sign-in methods', phoneLabel: 'Phone number', phoneHint: 'Include the country or region code. The number will not be shown in the account list.', phonePlaceholder: 'For example, +1…', sendCode: 'Send code', backQr: 'Back to QR code', codeLabel: 'Verification code', codeSent: 'A verification code was sent to your Telegram app', codePlaceholder: 'Enter verification code', verify: 'Verify', passwordLabel: 'Two-step verification password', passwordHint: 'This Telegram account has two-step verification enabled. Enter its password to finish linking the account.', passwordPlaceholder: 'Enter two-step verification password', signIn: 'Sign in', completeTitle: 'Account linked and enabled', completeDescription: "Downloads are assigned by account permissions, connection status, and load.", done: 'Done' }, errors: { qrCreate: 'Could not generate a QR code. Sign in with your phone number instead.', qrExpired: 'The QR code expired. Refresh it and scan again.', login: 'Sign-in failed. Try again.', status: 'Could not check sign-in status. Try again.', codeSend: 'Could not send the verification code', codeVerify: 'Could not verify the code', password: 'Two-step verification failed', load: 'Could not load accounts', operation: 'Account operation failed', operationTitle: 'Operation failed' }, diff --git a/frontend/src/locales/en/remainingPages.ts b/frontend/src/locales/en/remainingPages.ts index 9a1376d..3c63bca 100644 --- a/frontend/src/locales/en/remainingPages.ts +++ b/frontend/src/locales/en/remainingPages.ts @@ -25,7 +25,7 @@ export const securityCards = { export const maintenanceCards = { chunkConcurrency: { title: 'Concurrent chunks per file', description: 'Also controlled by the Bot command /download_workers.' }, - fileConcurrency: { title: 'Concurrent file downloads', description: 'Also controlled by the Bot command /file_concurrency.' }, + fileConcurrency: { title: 'Concurrent file downloads', description: 'Also controlled by the Bot command /file_concurrency.', limitDescription: 'Enabled Telegram user accounts: {{enabledAccounts}}. Current limit: {{limit}}. The limit is twice the enabled account count, with a fallback of 2 when no accounts are enabled and a maximum of 10.' }, duplicateMode: { title: 'Duplicate-file handling', description: 'Choose how to handle files with the same name and size in the same folder.', copy: 'Create a copy', skip: 'Skip duplicates' }, skipPhotos: { title: 'Skip photo messages', description: "Subscriptions and batch downloads by date or tag skip photo messages, but still download images sent as files and all other files. Use this only if you need original image files. No images will be downloaded from channels that only send photos." }, cleanupOrphans: { title: 'Auto-clean unindexed temporary files', description: "Delete only local temporary files that are past the protection period and are not referenced in the index. File index entries and cloud files are kept." }, diff --git a/frontend/src/locales/ru.json b/frontend/src/locales/ru.json index a3ccd45..9d3517c 100644 --- a/frontend/src/locales/ru.json +++ b/frontend/src/locales/ru.json @@ -386,7 +386,8 @@ }, "fileConcurrency": { "title": "Одновременно скачиваемые файлы", - "description": "Этот же параметр задаётся командой бота /file_concurrency." + "description": "Этот же параметр задаётся командой бота /file_concurrency.", + "limitDescription": "Включённых пользовательских аккаунтов Telegram: {{enabledAccounts}}. Текущий лимит: {{limit}}. Лимит равен удвоенному числу включённых аккаунтов; если их нет, используется 2. Максимум — 10." }, "duplicateMode": { "title": "Обработка дубликатов файлов", @@ -881,10 +882,12 @@ "paused": "Приостановлено", "pausing": "Приостановка", "waiting": "Ожидание возобновления", + "awaitingSelection": "Ожидание выбора типов файлов", "failed": "Ошибка", "interrupted": "Прервано", "retryRequired": "Нужно повторить", "completed": "Завершено", + "noMatches": "Нет совпадений", "cancelled": "Отменено", "scheduled": "Запланировано", "disabled": "Отключено" @@ -958,17 +961,36 @@ "title": "Нет подходящих задач", "description": "Измените фильтры источника или статуса." }, + "failures": { + "title": "Неудачные файлы ({{count}})", + "errorTitle": "Сведения об ошибке задачи", + "loading": "Загрузка сведений об ошибках", + "loadFailed": "Не удалось загрузить сведения об ошибках. Повторите попытку.", + "empty": "Нет записей об ошибках отдельных файлов. Они могли быть удалены или отправлены на повтор; сохранённая ошибка задачи показана выше.", + "unknownFile": "Имя файла не записано", + "unknownReason": "Причина ошибки не записана", + "openMessage": "Открыть исходное сообщение #{{id}} ↗", + "noLink": "Сообщение #{{id}}: недостаточно данных для точной ссылки", + "range": "{{start}}–{{end}} из {{total}} (по 10 на странице)", + "previous": "Назад", + "next": "Далее", + "retry": "Загрузить заново" + }, "progress": { "speed": "Скорость: {{speed}}", "eta": "Осталось примерно {{eta}}", "items": "Обработано: {{completed}}/{{total}}", "channelItems": "Обработано {{processed}}/{{total}} · Успешно {{completed}} · Ошибок {{failed}} · Пропущено {{skipped}}", "savedData": "Сохранено {{transferred}} / Найдено {{total}}", + "selectedData": "Сохранено {{transferred}} / Выбранные файлы {{total}}", + "excluded": "Фильтром исключено {{count}} файлов (не учитываются в прогрессе скачивания)", "channelQueue": "В обработке {{active}} · В очереди {{pending}}", "channelScan": "Канал: проверено сообщений — {{messages}} · найдено файлов — {{files}}", "commentScan": "Комментарии: проверено сообщений — {{messages}} · найдено файлов — {{files}}", "scanDate": "Сканирование дошло до {{time}}", + "scanWait": "Лимит аккаунта до {{time}}; ожидание или смена аккаунта, файлы сохранены", "scanningHint": "Сканирование продолжается; число файлов может увеличиться", + "selectionHint": "Сканирование завершено. Выберите типы файлов и подтвердите скачивание в карточке задачи Telegram.", "unknownSizes": "Размер {{count}} сохранённых файлов неизвестен (+ — известный минимум)", "failed": ", ошибок: {{count}}", "data": "Передано: {{transferred}} / {{total}}" @@ -1555,6 +1577,16 @@ "title": "Личные аккаунты Telegram ещё не подключены", "description": "Добавьте аккаунт и войдите по QR-коду или номеру телефона." }, + "reauth": { + "action": "Войти снова", + "expiredHint": "Срок действия сеанса истёк. Войдите снова в тот же аккаунт Telegram, чтобы обновить данные для входа.", + "title": "Повторный вход: {{name}}", + "description": "Обновление данных для входа в {{name}} (Telegram ID: {{id}}). Используйте тот же аккаунт Telegram для сканирования QR-кода или входа по телефону. Настройки и включённое/отключённое состояние сохранятся.", + "closeAria": "Закрыть повторный вход в аккаунт", + "completeTitle": "Данные для входа обновлены", + "completeDescription": "Аккаунт и его настройки сохранены. Фактическое состояние подключения смотрите в списке аккаунтов. Отключённые аккаунты остаются отключёнными, пока вы не включите их вручную.", + "notice": "Данные для входа обновлены. Текущее состояние смотрите в списке аккаунтов; отключённые аккаунты остаются отключёнными." + }, "account": { "permissions": "Доступ к источникам", "activeDownloads": "Скачивается: {{count}}", diff --git a/frontend/src/locales/zh-CN/management.ts b/frontend/src/locales/zh-CN/management.ts index cd1e1be..9a576e7 100644 --- a/frontend/src/locales/zh-CN/management.ts +++ b/frontend/src/locales/zh-CN/management.ts @@ -11,6 +11,16 @@ export const management = { summary: { enabled: '已启用账号', ready: '可用账号', cooldown: '暂时受限', permissions: '来源权限汇总' }, scheduling: { title: "账号调度", description: '逐文件选账号:已确认来源权限优先,按权重与负载分配,同负载轮换。合适账号都忙或请求受限时,新请求自动等待,不可用账号跳过。' }, empty: { title: '尚未绑定 Telegram 用户账号', description: "添加账号后,可通过二维码或手机号登录。" }, + reauth: { + action: '重新登录', + expiredHint: '登录已失效,请使用原 Telegram 账号重新登录以更新凭证。', + title: '重新登录:{{name}}', + description: '正在为 {{name}}(Telegram ID:{{id}})更新登录凭证。请使用同一个 Telegram 账号扫码或通过手机号登录。原有配置与启用/停用状态会保留。', + closeAria: '关闭账号重新登录', + completeTitle: '登录凭证已更新', + completeDescription: '原账号及配置保持不变,实际连接状态请查看账号列表。已停用的账号仍保持停用,需手动启用后才会参与调度。', + notice: '登录凭证已更新,实际状态请查看账号列表;已停用的账号仍保持停用。', + }, account: { permissions: '来源权限', activeDownloads: '下载中:{{count}}', lastChecked: '权限检测', weight: '调度权重:{{value}}', cooldownUntil: '预计恢复新请求:{{time}}', rateLimitHint: 'Telegram 限流等待时间较长,此账号暂时无法接收新请求,系统会自动重试。', pendingUpdate: "等待更新", lastError: '最近错误', disabledHint: "已停用,不再分配新任务;登录信息保留。", disable: '停用', enable: '重新启用', delete: '删除账号' }, login: { title: '添加 Telegram 用户账号', description: '支持二维码或手机号登录;手机号登录需要验证码,必要时再输入两步验证密码。登录凭证只在服务端加密保存。', closeAria: '关闭添加账号', chooseTitle: '选择登录方式', chooseDescription: "使用二维码或手机号登录。", qr: '二维码登录', qrDescription: '打开 Telegram 扫描二维码,无需输入手机号。', phone: '手机号登录', phoneDescription: '输入手机号和 Telegram 验证码,必要时再输入两步验证密码。', next: '下一步', qrInstructions: '打开 Telegram,进入“设置 → 设备 → 连接桌面设备”并扫码。', qrAria: 'Telegram 登录二维码', generatingQr: '正在生成二维码', refreshQr: '刷新二维码', usePhone: '改用手机号', back: '返回选择登录方式', phoneLabel: '手机号', phoneHint: '请包含国家或地区区号。输入内容不会在账号列表中显示。', phonePlaceholder: '例如 +86…', sendCode: '发送验证码', backQr: '返回二维码登录', codeLabel: '验证码', codeSent: '验证码已发送到 Telegram', codePlaceholder: '输入验证码', verify: '验证', passwordLabel: '两步验证密码', passwordHint: '此账号已开启 Telegram 两步验证,请输入云密码完成绑定。', passwordPlaceholder: '输入两步验证密码', signIn: '登录', completeTitle: '账号已绑定并自动启用', completeDescription: "下载任务会按账号权限、连接状态和负载分配。", done: '完成' }, errors: { qrCreate: '二维码生成失败,请改用手机号登录', qrExpired: '二维码已过期,请刷新后重新扫码', login: '登录失败,请重试', status: '登录状态查询失败,请重试', codeSend: '验证码发送失败', codeVerify: '验证码校验失败', password: '两步验证失败', load: '账号列表加载失败', operation: '账号操作失败', operationTitle: '操作失败' }, diff --git a/frontend/src/locales/zh-CN/remainingPages.ts b/frontend/src/locales/zh-CN/remainingPages.ts index 404bdf1..a7f4c03 100644 --- a/frontend/src/locales/zh-CN/remainingPages.ts +++ b/frontend/src/locales/zh-CN/remainingPages.ts @@ -25,7 +25,7 @@ export const securityCards = { export const maintenanceCards = { chunkConcurrency: { title: '单文件分片并发', description: '与 Bot /download_workers 共用。' }, - fileConcurrency: { title: '同时下载文件数', description: '与 Bot /file_concurrency 共用。' }, + fileConcurrency: { title: '同时下载文件数', description: '与 Bot /file_concurrency 共用。', limitDescription: '已启用 Telegram 用户账号:{{enabledAccounts}} 个,当前上限:{{limit}}。上限为已启用账号数的 2 倍;无账号时默认为 2,最多 10。' }, duplicateMode: { title: '重复文件处理', description: '同名、同目录、同大小文件的统一策略。', copy: '生成副本', skip: '跳过重复' }, skipPhotos: { title: '跳过频道普通图片', description: "订阅、按日期和按标签批量下载将跳过所有普通图片,仍下载作为文件发送的图片和其他文件。仅适合只需保留原图文件的频道;只发送普通图片的频道将不会下载图片。" }, cleanupOrphans: { title: '自动清理未索引临时文件', description: "仅清理超过保护期、未被索引引用的本地临时文件,不删除文件索引或云端文件。" }, diff --git a/frontend/src/locales/zh.json b/frontend/src/locales/zh.json index 021c3cc..6b99265 100644 --- a/frontend/src/locales/zh.json +++ b/frontend/src/locales/zh.json @@ -330,10 +330,12 @@ "paused": "已暂停", "pausing": "正在暂停", "waiting": "等待恢复", + "awaitingSelection": "等待选择文件类型", "failed": "失败", "interrupted": "已中断", "retryRequired": "需要重试", "completed": "已完成", + "noMatches": "无匹配", "cancelled": "已取消", "scheduled": "已计划", "disabled": "已停用" @@ -407,17 +409,36 @@ "title": "没有符合条件的任务", "description": "调整来源或状态筛选后再查看。" }, + "failures": { + "title": "失败明细({{count}})", + "errorTitle": "任务错误详情", + "loading": "正在加载失败明细", + "loadFailed": "无法加载失败明细,请重试。", + "empty": "暂无逐文件失败记录,可能已清理或进入重试;任务级错误如有记录会保留显示。", + "unknownFile": "文件名未记录", + "unknownReason": "失败原因未记录", + "openMessage": "打开原消息 #{{id}} ↗", + "noLink": "消息 #{{id}}:缺少准确来源,无法生成直达链接", + "range": "第 {{start}}–{{end}} 条,共 {{total}} 条(每页 10 条)", + "previous": "上一页", + "next": "下一页", + "retry": "重新加载" + }, "progress": { "speed": "速度 {{speed}}", "eta": "预计剩余 {{eta}}", "items": "条目 {{completed}}/{{total}}", "channelItems": "已处理 {{processed}}/{{total}} · 成功 {{completed}} · 失败 {{failed}} · 跳过 {{skipped}}", "savedData": "已保存 {{transferred}} / 已发现 {{total}}", + "selectedData": "已保存 {{transferred}} / 所选文件 {{total}}", + "excluded": "筛选已排除{{count}}个文件(不计入下载进度)", "channelQueue": "处理中 {{active}} · 排队 {{pending}}", "channelScan": "频道已检查消息 {{messages}} 条 · 发现文件 {{files}} 个", "commentScan": "评论已检查消息 {{messages}} 条 · 发现文件 {{files}} 个", "scanDate": "扫描到 {{time}}", + "scanWait": "Telegram 账号限流至 {{time}};等待或切换账号,已发现文件保留", "scanningHint": "仍在扫描,文件总数会继续增加", + "selectionHint": "扫描已完成,请在 Telegram 任务卡中多选文件类型并确认下载", "unknownSizes": "另有 {{count}} 个已保存文件大小未知(+ 表示已知下限)", "failed": ",失败 {{count}}", "data": "数据 {{transferred}} / {{total}}" diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 0a35e5f..3f82256 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -35,6 +35,7 @@ import type { TaskDismissalPreview, TaskDismissalResult, TaskFilters, + TaskFailurePage, TelegramBotPublicConfig, TelegramAdDecisionList, TelegramAdFilterMode, @@ -239,6 +240,10 @@ class FileAPI { return tasksClient.getTasks(filters); } + async getTaskFailures(sourceType: UnifiedTaskSource, id: string, offset = 0, signal?: AbortSignal): Promise { + return tasksClient.getTaskFailures(sourceType, id, offset, signal); + } + async controlTask(sourceType: UnifiedTaskSource, id: string, action: 'cancel' | 'retry' | 'pause' | 'resume'): Promise { return tasksClient.controlTask(sourceType, id, action); } @@ -761,8 +766,8 @@ class FileAPI { return this.telegramUserRequest('/api/storage/config/telegram-user/accounts', 'GET'); } - async startTelegramUserQrLogin(): Promise { - const payload = await this.telegramUserRequest('/api/storage/config/telegram-user/login/qr', 'POST'); + async startTelegramUserQrLogin(accountId?: string): Promise { + const payload = await this.telegramUserRequest('/api/storage/config/telegram-user/login/qr', 'POST', accountId === undefined ? undefined : { accountId }); return this.normalizeTelegramUserLoginStatus(payload) as TelegramUserQrLoginStarted; } @@ -775,8 +780,8 @@ class FileAPI { return this.telegramUserRequest(`/api/storage/config/telegram-user/login/${encodeURIComponent(flowId)}`, 'DELETE'); } - async startTelegramUserPhoneLogin(phone: string): Promise { - const payload = await this.telegramUserRequest('/api/storage/config/telegram-accounts/login/phone', 'POST', { phone }); + async startTelegramUserPhoneLogin(phone: string, accountId?: string): Promise { + const payload = await this.telegramUserRequest('/api/storage/config/telegram-accounts/login/phone', 'POST', { phone, ...(accountId === undefined ? {} : { accountId }) }); return this.normalizeTelegramUserLoginStatus(payload) as TelegramUserPhoneLoginStarted; } @@ -844,7 +849,7 @@ class FileAPI { ...(qrCode ? { qrCode, qrExpiresAt: String(payload?.qrExpiresAt || payload?.expiresAt || '') } : {}), ...(payload?.expiresAt ? { expiresAt: String(payload.expiresAt) } : {}), ...(payload?.account ? { account: payload.account } : {}), - ...(payload?.delivery ? { delivery: payload.delivery, message: payload.delivery === 'sms' ? tr('errors.services.telegram.deliverySms') : tr('errors.services.telegram.deliveryApp') } : payload?.message ? { message: String(payload.message) } : {}), + ...(payload?.error ? { message: String(payload.error) } : payload?.delivery ? { delivery: payload.delivery, message: payload.delivery === 'sms' ? tr('errors.services.telegram.deliverySms') : tr('errors.services.telegram.deliveryApp') } : payload?.message ? { message: String(payload.message) } : {}), } as TelegramUserLoginStatus; } diff --git a/frontend/src/services/apiTypes.ts b/frontend/src/services/apiTypes.ts index ef06df7..58a6427 100644 --- a/frontend/src/services/apiTypes.ts +++ b/frontend/src/services/apiTypes.ts @@ -143,6 +143,23 @@ export interface UnifiedTaskList { generatedAt: string; } +export interface TaskFailure { + id: string; + fileName: string | null; + error: string | null; + messageId: number | null; + messageUrl: string | null; + attempts: number; + updatedAt: string | null; +} + +export interface TaskFailurePage { + items: TaskFailure[]; + total: number; + offset: number; + limit: number; +} + export type TelegramAdFilterMode = 'off' | 'conservative' | 'aggressive'; export type TelegramAdRuleKind = 'keyword' | 'domain' | 'username' | 'template' | 'media'; export type TelegramAdRuleAction = 'allow' | 'block'; @@ -492,6 +509,8 @@ export type { BatchDeleteResult }; export interface AdvancedTaskSettings { telegramDownloadWorkers: number; telegramFileConcurrency: number; + telegramFileConcurrencyLimit: number; + telegramEnabledAccounts: number; duplicateMode: 'copy' | 'skip'; autoCleanupOrphans: boolean; skipTelegramPhotosInBatch: boolean; diff --git a/frontend/src/services/clients/tasksClient.ts b/frontend/src/services/clients/tasksClient.ts index 71b3ce0..9f5a114 100644 --- a/frontend/src/services/clients/tasksClient.ts +++ b/frontend/src/services/clients/tasksClient.ts @@ -3,6 +3,7 @@ import type { TaskDismissalInput, TaskDismissalPreview, TaskDismissalResult, + TaskFailurePage, TaskFilters, UnifiedTaskList, UnifiedTaskSource, @@ -30,6 +31,17 @@ export class TasksClient { return response.json(); } + async getTaskFailures(sourceType: UnifiedTaskSource, id: string, offset = 0, signal?: AbortSignal): Promise { + const params = new URLSearchParams({ offset: String(offset), limit: '10' }); + const response = await apiRequest(`/api/tasks/${encodeURIComponent(sourceType)}/${encodeURIComponent(id)}/failures?${params}`, { + credentials: 'include', + headers: getApiHeaders(), + signal, + fallback: tr('tasks.failures.loadFailed'), + }); + return response.json(); + } + async controlTask(sourceType: UnifiedTaskSource, id: string, action: 'cancel' | 'retry' | 'pause' | 'resume'): Promise { const sourcePath = encodeURIComponent(sourceType); const taskPath = encodeURIComponent(id); diff --git a/frontend/src/services/taskQuickFilters.ts b/frontend/src/services/taskQuickFilters.ts index 2a00abd..6808660 100644 --- a/frontend/src/services/taskQuickFilters.ts +++ b/frontend/src/services/taskQuickFilters.ts @@ -1,7 +1,7 @@ export type TaskQuickFilter = 'all' | 'active' | 'attention' | 'completed'; const FILTER_STATUSES: Record, readonly string[]> = { - active: ['scheduled', 'pending', 'running', 'pausing', 'paused', 'waiting', 'completing'], + active: ['scheduled', 'pending', 'running', 'pausing', 'paused', 'waiting', 'awaiting_selection', 'completing'], attention: ['failed', 'interrupted', 'retry_required'], completed: ['completed'], }; diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index ca43abb..3387bba 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -7,10 +7,15 @@ export default { darkMode: "class", theme: { extend: { - fontFamily: { - sans: "var(--font-sans)", - }, colors: { + // Preserve the v3 palette used by status badges and notifications. + amber: { 300: "#fcd34d", 500: "#f59e0b" }, + blue: { 500: "#3b82f6" }, + emerald: { 200: "#a7f3d0", 600: "#059669" }, + green: { 500: "#22c55e" }, + red: { 200: "#fecaca", 500: "#ef4444", 600: "#dc2626" }, + sky: { 100: "#e0f2fe", 200: "#bae6fd", 700: "#0369a1", 950: "#082f49" }, + yellow: { 500: "#eab308" }, border: "hsl(var(--border))", input: "hsl(var(--input))", ring: "hsl(var(--ring))", @@ -46,10 +51,17 @@ export default { }, }, borderRadius: { + DEFAULT: "0.25rem", lg: `var(--radius)`, md: `calc(var(--radius) - 2px)`, sm: `calc(var(--radius) - 4px)`, }, + boxShadow: { + sm: "0 1px 2px 0 rgb(0 0 0 / 0.05)", + }, + blur: { + DEFAULT: "8px", + }, }, }, plugins: [], diff --git a/init.sql b/init.sql index d67dad9..35c7ceb 100644 --- a/init.sql +++ b/init.sql @@ -586,6 +586,7 @@ CREATE TABLE IF NOT EXISTS telegram_download_items ( channel_post_id INT, file_name TEXT, mime_type TEXT, + file_category TEXT, generated_name BOOLEAN DEFAULT false, total_size BIGINT DEFAULT 0, folder_override TEXT, @@ -609,6 +610,7 @@ ALTER TABLE telegram_download_items ADD COLUMN IF NOT EXISTS origin TEXT DEFAULT 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 file_category 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;