diff --git a/README.md b/README.md index b80e882..0c30577 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Forks

- Release v2.2.1 + Release v2.3.0 Telegram Bot Docker Compose React TypeScript diff --git a/backend/Dockerfile b/backend/Dockerfile index 5a4ab5c..b4d82cb 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -42,6 +42,7 @@ RUN npm ci --omit=dev COPY --from=builder /app/dist ./dist # 复制数据库方案文件 (esbuild 模式下与 index.js 同级) COPY --from=builder /app/src/db/schema.sql ./dist/schema.sql +COPY --from=builder /app/src/db/migrations ./dist/migrations # 创建数据目录 RUN mkdir -p /data/uploads /data/thumbnails /data/previews diff --git a/backend/dist/index.js b/backend/dist/index.js index 14a8e17..29b2f24 100644 --- a/backend/dist/index.js +++ b/backend/dist/index.js @@ -56,87 +56,117 @@ var init_dbLogging = __esm({ // src/db/index.ts import pg from "pg"; -import fs from "fs/promises"; -import path from "path"; +import fs from "node:fs/promises"; +import path from "node:path"; import dotenv from "dotenv"; -import { fileURLToPath } from "url"; +import { fileURLToPath } from "node:url"; import crypto from "node:crypto"; -async function applyCanonicalSchemaMigration(schemaSql) { - const checksum = crypto.createHash("sha256").update(schemaSql).digest("hex"); - await pool.query( - `INSERT INTO schema_migrations (version, name, checksum) - VALUES ($1, $2, $3) - ON CONFLICT (version) DO UPDATE SET checksum = EXCLUDED.checksum`, - [CANONICAL_SCHEMA_VERSION, "canonical-expand-schema", checksum] - ); +async function listMigrations() { + let entries; + try { + entries = await fs.readdir(MIGRATIONS_DIR); + } catch (error) { + if (error?.code === "ENOENT") return []; + throw error; + } + return Promise.all(entries.filter((name) => /^\d+_[a-z0-9_-]+\.sql$/i.test(name)).sort().map(async (fileName) => { + const separator = fileName.indexOf("_"); + const version2 = Number(fileName.slice(0, separator)); + if (!Number.isSafeInteger(version2)) throw new Error(`invalid migration version: ${fileName}`); + const sql = await fs.readFile(path.join(MIGRATIONS_DIR, fileName), "utf8"); + return { + version: version2, + name: fileName.slice(separator + 1, -4), + checksum: crypto.createHash("sha256").update(sql).digest("hex"), + sql + }; + })); +} +async function ensureMigrationLedger(client2) { + await client2.query(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + version BIGINT PRIMARY KEY, + name TEXT NOT NULL, + checksum VARCHAR(64) NOT NULL, + applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `); +} +async function applyMigrations(client2) { + for (const migration of await listMigrations()) { + const existing = await client2.query( + "SELECT name, checksum FROM schema_migrations WHERE version = $1", + [migration.version] + ); + if (existing.rowCount) { + if (existing.rows[0].name !== migration.name || existing.rows[0].checksum !== migration.checksum) { + throw new Error(`migration ${migration.version} checksum/name mismatch`); + } + continue; + } + await client2.query("BEGIN"); + try { + await client2.query(migration.sql); + await client2.query( + "INSERT INTO schema_migrations (version, name, checksum) VALUES ($1, $2, $3)", + [migration.version, migration.name, migration.checksum] + ); + await client2.query("COMMIT"); + console.log(`\u2705 \u6570\u636E\u5E93\u8FC1\u79FB ${migration.version}_${migration.name} \u5DF2\u5E94\u7528`); + } catch (error) { + await client2.query("ROLLBACK").catch(() => void 0); + throw error; + } + } +} +async function bootstrapNewDatabase(client2) { + const schemaPath = path.join(__dirname, "schema.sql"); + const schemaSql = await fs.readFile(schemaPath, "utf8"); + await client2.query("BEGIN"); + try { + await client2.query(schemaSql); + await client2.query("COMMIT"); + } catch (error) { + await client2.query("ROLLBACK").catch(() => void 0); + throw error; + } } async function initializeDatabase() { + const client2 = await pool.connect(); try { - const schemaPath = path.join(__dirname, "schema.sql"); - const schemaSql = await fs.readFile(schemaPath, "utf-8"); - const statements = []; - let current3 = ""; - let inDollarQuote = false; - for (let i = 0; i < schemaSql.length; i++) { - const char = schemaSql[i]; - current3 += char; - if (char === "$" && schemaSql[i + 1] === "$") { - inDollarQuote = !inDollarQuote; - current3 += "$"; - i++; - } else if (char === ";" && !inDollarQuote) { - const stmt = current3.trim(); - if (stmt.length > 1) { - const withoutLeadingLineComments = stmt.replace(/^\s*(--[^\n]*\n\s*)+/g, "").trim(); - if (withoutLeadingLineComments.length > 0) { - statements.push(withoutLeadingLineComments.slice(0, -1)); - } - } - current3 = ""; - } - } - const lastStmt = current3.trim(); - if (lastStmt.length > 0) { - const withoutLeadingLineComments = lastStmt.replace(/^\s*(--[^\n]*\n\s*)+/g, "").trim(); - if (withoutLeadingLineComments.length > 0) { - statements.push(withoutLeadingLineComments); - } - } - for (const statement of statements) { - try { - await pool.query(statement); - } catch (err) { - if (err.message?.includes("already exists")) { - continue; - } - throw err; - } - } - await applyCanonicalSchemaMigration(schemaSql); + await client2.query("SELECT pg_advisory_lock($1)", [MIGRATION_LOCK_KEY]); + const exists = await client2.query("SELECT to_regclass('public.schema_migrations') AS ledger"); + if (!exists.rows[0]?.ledger) await bootstrapNewDatabase(client2); + await ensureMigrationLedger(client2); + await applyMigrations(client2); console.log("\u2705 \u6570\u636E\u5E93\u8868\u7ED3\u6784\u521D\u59CB\u5316\u5B8C\u6210"); - } catch (err) { - console.error("\u274C \u6570\u636E\u5E93\u521D\u59CB\u5316\u5931\u8D25:", err); - throw err; + } catch (error) { + console.error("\u274C \u6570\u636E\u5E93\u521D\u59CB\u5316\u5931\u8D25:", error); + throw error; + } finally { + await client2.query("SELECT pg_advisory_unlock($1)", [MIGRATION_LOCK_KEY]).catch(() => void 0); + client2.release(); } } function ensureDatabaseInitialized() { if (!initializationPromise) initializationPromise = initializeDatabase(); return initializationPromise; } -var __filename, __dirname, Pool, pool, initializationPromise, CANONICAL_SCHEMA_VERSION, sqlLoggingConfig, query; +var __filename, __dirname, MIGRATIONS_DIR, MIGRATION_LOCK_KEY, Pool, pool, initializationPromise, sqlLoggingConfig, query; var init_db = __esm({ "src/db/index.ts"() { "use strict"; init_dbLogging(); __filename = fileURLToPath(import.meta.url); __dirname = path.dirname(__filename); + MIGRATIONS_DIR = path.join(__dirname, "migrations"); + MIGRATION_LOCK_KEY = 1413961281; dotenv.config(); ({ Pool } = pg); pool = new Pool({ - connectionString: process.env.DATABASE_URL || "postgresql://tgvault:password@localhost:5432/tgvault" + connectionString: process.env.DATABASE_URL || "postgresql://tgvault:***@localhost:5432/tgvault" }); initializationPromise = null; - CANONICAL_SCHEMA_VERSION = 2026082401; sqlLoggingConfig = parseSqlLoggingConfig(); pool.on("connect", () => { if (process.env.LOG_LEVEL === "debug") console.log(JSON.stringify({ event: "db.connected" })); @@ -390,44 +420,6 @@ var init_settings = __esm({ } }); -// src/utils/localPath.ts -import fs3 from "fs"; -import path3 from "path"; -function isPathInside(baseDir, targetPath) { - const resolvedBase = path3.resolve(baseDir); - const resolvedTarget = path3.resolve(targetPath); - return resolvedTarget === resolvedBase || resolvedTarget.startsWith(resolvedBase + path3.sep); -} -function safeJoin(baseDir, ...segments) { - const resolvedBase = path3.resolve(baseDir); - const resolvedTarget = path3.resolve(resolvedBase, ...segments); - if (!isPathInside(resolvedBase, resolvedTarget)) { - throw new Error("Unsafe path outside storage directory"); - } - return resolvedTarget; -} -function getRelativeStoragePath(baseDir, targetPath) { - const resolvedBase = path3.resolve(baseDir); - const resolvedTarget = path3.resolve(targetPath); - if (!isPathInside(resolvedBase, resolvedTarget)) return null; - return path3.relative(resolvedBase, resolvedTarget).split(path3.sep).join("/"); -} -async function safeUnlink(filePath, baseDir) { - if (!filePath) return false; - if (!isPathInside(baseDir, filePath)) { - console.warn(`Refusing to delete path outside storage directory: ${filePath}`); - return false; - } - if (!fs3.existsSync(filePath)) return false; - await fs3.promises.unlink(filePath); - return true; -} -var init_localPath = __esm({ - "src/utils/localPath.ts"() { - "use strict"; - } -}); - // src/utils/storageProbe.ts function safeErrorCode(value) { const code = String(value ?? "").trim(); @@ -514,6 +506,132 @@ var init_mediaProxyError = __esm({ } }); +// src/services/storage/contracts.ts +var StorageProbeError; +var init_contracts = __esm({ + "src/services/storage/contracts.ts"() { + "use strict"; + StorageProbeError = class extends Error { + constructor(provider, message, causeCode) { + super(message); + this.provider = provider; + this.causeCode = causeCode; + this.name = "StorageProbeError"; + } + provider; + causeCode; + }; + } +}); + +// src/utils/localPath.ts +import fs3 from "fs"; +import path3 from "path"; +function isPathInside(baseDir, targetPath) { + const resolvedBase = path3.resolve(baseDir); + const resolvedTarget = path3.resolve(targetPath); + return resolvedTarget === resolvedBase || resolvedTarget.startsWith(resolvedBase + path3.sep); +} +function safeJoin(baseDir, ...segments) { + const resolvedBase = path3.resolve(baseDir); + const resolvedTarget = path3.resolve(resolvedBase, ...segments); + if (!isPathInside(resolvedBase, resolvedTarget)) { + throw new Error("Unsafe path outside storage directory"); + } + return resolvedTarget; +} +function getRelativeStoragePath(baseDir, targetPath) { + const resolvedBase = path3.resolve(baseDir); + const resolvedTarget = path3.resolve(targetPath); + if (!isPathInside(resolvedBase, resolvedTarget)) return null; + return path3.relative(resolvedBase, resolvedTarget).split(path3.sep).join("/"); +} +async function safeUnlink(filePath, baseDir) { + if (!filePath) return false; + if (!isPathInside(baseDir, filePath)) { + console.warn(`Refusing to delete path outside storage directory: ${filePath}`); + return false; + } + if (!fs3.existsSync(filePath)) return false; + await fs3.promises.unlink(filePath); + return true; +} +var init_localPath = __esm({ + "src/utils/localPath.ts"() { + "use strict"; + } +}); + +// src/services/storage/localStorageProvider.ts +import fs4 from "node:fs"; +import path4 from "node:path"; +var LocalStorageProvider; +var init_localStorageProvider = __esm({ + "src/services/storage/localStorageProvider.ts"() { + "use strict"; + init_localPath(); + init_contracts(); + LocalStorageProvider = class { + name = "local"; + uploadDir; + constructor(uploadDir = process.env.UPLOAD_DIR || "./data/uploads") { + this.uploadDir = path4.resolve(uploadDir); + if (!fs4.existsSync(this.uploadDir)) { + fs4.mkdirSync(this.uploadDir, { recursive: true }); + } + } + async probe() { + const stats = await fs4.promises.stat(this.uploadDir); + if (!stats.isDirectory()) throw new StorageProbeError(this.name, "\u672C\u5730\u5B58\u50A8\u8DEF\u5F84\u4E0D\u662F\u76EE\u5F55"); + await fs4.promises.access(this.uploadDir, fs4.constants.R_OK | fs4.constants.W_OK); + } + async saveFile(tempPath, fileName, _mimeType, folder) { + const destDir = folder ? safeJoin(this.uploadDir, folder) : this.uploadDir; + if (!fs4.existsSync(destDir)) { + fs4.mkdirSync(destDir, { recursive: true }); + } + const destPath = safeJoin(destDir, fileName); + try { + await fs4.promises.rename(tempPath, destPath); + } catch (error) { + if (error.code === "EXDEV") { + await fs4.promises.copyFile(tempPath, destPath); + await fs4.promises.unlink(tempPath); + } else { + throw error; + } + } + return destPath; + } + async getFileStream(storedPath) { + const safePath = safeJoin(this.uploadDir, path4.relative(this.uploadDir, storedPath)); + if (safePath !== path4.resolve(storedPath)) { + throw new Error("Unsafe local file path"); + } + if (!fs4.existsSync(safePath)) { + throw new Error(`File not found: ${safePath}`); + } + return fs4.createReadStream(safePath); + } + async getPreviewUrl(storedPath) { + return ""; + } + async deleteFile(storedPath) { + const safePath = safeJoin(this.uploadDir, path4.relative(this.uploadDir, storedPath)); + if (safePath !== path4.resolve(storedPath)) { + throw new Error("Unsafe local file path"); + } + if (fs4.existsSync(safePath)) { + await fs4.promises.unlink(safePath); + } + } + async createShareLink(storedPath, password, expiration) { + return { link: "", error: "\u672C\u5730\u5B58\u50A8\u6682\u4E0D\u652F\u6301\u751F\u6210\u5206\u4EAB\u94FE\u63A5\uFF0C\u8BF7\u4F7F\u7528 OneDrive \u5B58\u50A8\u3002" }; + } + }; + } +}); + // src/services/storageCooldown.ts async function ensureStorageCooldownSchema() { await query(` @@ -857,8 +975,8 @@ function pinnedLookup(addresses) { callback(null, addresses.map((item) => ({ ...item }))); return; } - const selected = addresses[cursor++ % addresses.length]; - callback(null, selected.address, selected.family); + const selected3 = addresses[cursor++ % addresses.length]; + callback(null, selected3.address, selected3.family); }; } async function fetchPinnedPublicUrl(rawUrl, init) { @@ -958,23 +1076,23 @@ var init_networkSecurity = __esm({ }); // src/services/openListStorage.ts -import fs4 from "node:fs"; +import fs5 from "node:fs"; import os from "node:os"; import crypto6 from "node:crypto"; -import path4 from "node:path"; +import path5 from "node:path"; import { Readable } from "node:stream"; function normalizeAddress(value) { return value.trim().replace(/\/+$/g, ""); } function normalizeRoot(value) { - const normalized = path4.posix.normalize(`/${String(value || "/").replace(/\\/g, "/")}`); + const normalized = path5.posix.normalize(`/${String(value || "/").replace(/\\/g, "/")}`); return normalized === "." ? "/" : normalized; } function joinRemotePath(root, folder, name) { const segments = [root]; if (folder) segments.push(String(folder).replace(/\\/g, "/")); if (name) segments.push(name); - return path4.posix.join(...segments); + return path5.posix.join(...segments); } function encodeFilePath(value) { return encodeURIComponent(value); @@ -985,7 +1103,7 @@ function copyResponseMetadata(stream, response) { enriched.upstreamHeaders = response.headers; return enriched; } -async function fetchResponseWithBodyTimeout(url, init, timeoutMs, fetchImpl = fetch) { +async function fetchResponseWithBodyTimeout(url, init, timeoutMs2, fetchImpl = fetch) { const controller = new AbortController(); let reader = null; let finished = false; @@ -1001,7 +1119,7 @@ async function fetchResponseWithBodyTimeout(url, init, timeoutMs, fetchImpl = fe timedOut = true; controller.abort(timeoutError); void reader?.cancel(timeoutError).catch(() => void 0); - }, timeoutMs); + }, timeoutMs2); try { const response = await fetchImpl(url, { ...init, signal: controller.signal }); if (!response.body) { @@ -1098,9 +1216,9 @@ var init_openListStorage = __esm({ if (!force && this.token) return this.token; return this.login(); } - async fetchWithTimeout(relativeUrl, init, timeoutMs) { + async fetchWithTimeout(relativeUrl, init, timeoutMs2) { const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(new Error("OpenList request timed out")), timeoutMs); + const timer = setTimeout(() => controller.abort(new Error("OpenList request timed out")), timeoutMs2); try { return await this.requestFetch(`${this.address}${relativeUrl}`, { ...init, signal: controller.signal }); } catch (error) { @@ -1127,18 +1245,18 @@ var init_openListStorage = __esm({ } return envelope; } - async api(relativeUrl, init, timeoutMs = this.requestTimeoutMs, retry = true) { + async api(relativeUrl, init, timeoutMs2 = this.requestTimeoutMs, retry = true) { const token = await this.getToken(); const headers = new Headers(init.headers); headers.set("Authorization", token); - const response = await this.fetchWithTimeout(relativeUrl, { ...init, headers }, timeoutMs); + const response = await this.fetchWithTimeout(relativeUrl, { ...init, headers }, timeoutMs2); try { return await this.parseEnvelope(response); } catch (error) { const code = Number(error?.code || error?.status || 0); if (retry && (code === 401 || code === 403)) { await this.getToken(true); - return this.api(relativeUrl, init, timeoutMs, false); + return this.api(relativeUrl, init, timeoutMs2, false); } throw error; } @@ -1178,15 +1296,15 @@ var init_openListStorage = __esm({ body: JSON.stringify({ path: this.rootPath }) }, this.probeTimeoutMs); } - async probeWritable(timeoutMs = this.probeTimeoutMs) { + async probeWritable(timeoutMs2 = this.probeTimeoutMs) { const originalRequestTimeout = this.requestTimeoutMs; const originalUploadTimeout = this.uploadTimeoutMs; - this.requestTimeoutMs = Math.min(originalRequestTimeout, timeoutMs); - this.uploadTimeoutMs = Math.min(originalUploadTimeout, timeoutMs); + this.requestTimeoutMs = Math.min(originalRequestTimeout, timeoutMs2); + this.uploadTimeoutMs = Math.min(originalUploadTimeout, timeoutMs2); const markerName = `.tgvault-probe-${crypto6.randomUUID()}.txt`; - const tempPath = path4.join(os.tmpdir(), markerName); + const tempPath = path5.join(os.tmpdir(), markerName); const expected = Buffer.from(`tg-vault-openlist-probe:${markerName}`, "utf8"); - await fs4.promises.writeFile(tempPath, expected, { flag: "wx" }); + await fs5.promises.writeFile(tempPath, expected, { flag: "wx" }); let storedPath = null; try { storedPath = await this.saveFile(tempPath, markerName, "text/plain"); @@ -1197,7 +1315,7 @@ var init_openListStorage = __esm({ } finally { try { if (storedPath) await this.deleteFile(storedPath).catch(() => void 0); - await fs4.promises.rm(tempPath, { force: true }); + await fs5.promises.rm(tempPath, { force: true }); } finally { this.requestTimeoutMs = originalRequestTimeout; this.uploadTimeoutMs = originalUploadTimeout; @@ -1205,13 +1323,13 @@ var init_openListStorage = __esm({ } } async saveFile(tempPath, fileName, mimeType, folder) { - const stats = await fs4.promises.stat(tempPath); + const stats = await fs5.promises.stat(tempPath); const remoteDirectory = joinRemotePath(this.rootPath, folder); const storedPath = joinRemotePath(remoteDirectory, null, fileName); await this.ensureDirectory(remoteDirectory); const uploadOnce = async () => { const token = await this.getToken(); - const body = Readable.toWeb(fs4.createReadStream(tempPath)); + const body = Readable.toWeb(fs5.createReadStream(tempPath)); const response = await this.fetchWithTimeout("/api/fs/put", { method: "PUT", headers: { @@ -1269,8 +1387,8 @@ var init_openListStorage = __esm({ if (!response.body) throw new OpenListRequestError("OpenList \u6587\u4EF6\u8BFB\u53D6\u54CD\u5E94\u4E3A\u7A7A"); return copyResponseMetadata(Readable.fromWeb(response.body), response); } - async fetchWithTimeoutAbsolute(url, init, timeoutMs) { - return fetchResponseWithBodyTimeout(url, init, timeoutMs, this.requestFetch); + async fetchWithTimeoutAbsolute(url, init, timeoutMs2) { + return fetchResponseWithBodyTimeout(url, init, timeoutMs2, this.requestFetch); } async getFileAvailability(storedPath) { const info = await this.getInfo(storedPath); @@ -1293,7 +1411,7 @@ var init_openListStorage = __esm({ await this.api("/api/fs/remove", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ dir: path4.posix.dirname(normalized), names: [path4.posix.basename(normalized)] }) + body: JSON.stringify({ dir: path5.posix.dirname(normalized), names: [path5.posix.basename(normalized)] }) }); } }; @@ -1315,8 +1433,7 @@ __export(storage_exports, { isStorageQuotaCooldownError: () => isStorageQuotaCooldownError, storageManager: () => storageManager }); -import fs5 from "fs"; -import path5 from "path"; +import fs6 from "fs"; import axios from "axios"; import OSS from "ali-oss"; import { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand, HeadObjectCommand, ListObjectsV2Command } from "@aws-sdk/client-s3"; @@ -1350,10 +1467,10 @@ function configuredStorageProbeTimeoutMs() { const configured2 = Number(process.env.STORAGE_PROBE_TIMEOUT_MS); return Number.isFinite(configured2) && configured2 > 0 ? Math.max(1e3, configured2) : 15e3; } -async function withStorageProbeDeadline(operation, provider, timeoutMs) { +async function withStorageProbeDeadline(operation, provider, timeoutMs2) { let timeout; const deadline = new Promise((_resolve, reject) => { - timeout = setTimeout(() => reject(new Error(`${provider} probe timed out after ${timeoutMs}ms`)), timeoutMs); + timeout = setTimeout(() => reject(new Error(`${provider} probe timed out after ${timeoutMs2}ms`)), timeoutMs2); }); try { return await Promise.race([operation, deadline]); @@ -1369,14 +1486,17 @@ function webDavDeleteError(error) { } return wrapped; } -var StorageQuotaCooldownError, StorageProbeError, LocalStorageProvider, AliyunOSSStorageProvider, S3StorageProvider, WebDAVStorageProvider, OneDriveStorageProvider, GoogleDriveStorageProvider, StorageManager, storageManager; +var StorageQuotaCooldownError, AliyunOSSStorageProvider, S3StorageProvider, WebDAVStorageProvider, OneDriveStorageProvider, GoogleDriveStorageProvider, StorageManager, storageManager; var init_storage = __esm({ "src/services/storage.ts"() { "use strict"; init_db(); - init_localPath(); init_storageProbe(); init_mediaProxyError(); + init_contracts(); + init_localStorageProvider(); + init_contracts(); + init_localStorageProvider(); init_credentialCrypto(); init_storageCooldown(); init_storageAccountLifecycle(); @@ -1398,74 +1518,6 @@ var init_storage = __esm({ this.originalError = options.originalError; } }; - StorageProbeError = class extends Error { - constructor(provider, message, causeCode) { - super(message); - this.provider = provider; - this.causeCode = causeCode; - this.name = "StorageProbeError"; - } - provider; - causeCode; - }; - LocalStorageProvider = class { - name = "local"; - uploadDir; - constructor(uploadDir = process.env.UPLOAD_DIR || "./data/uploads") { - this.uploadDir = path5.resolve(uploadDir); - if (!fs5.existsSync(this.uploadDir)) { - fs5.mkdirSync(this.uploadDir, { recursive: true }); - } - } - async probe() { - const stats = await fs5.promises.stat(this.uploadDir); - if (!stats.isDirectory()) throw new StorageProbeError(this.name, "\u672C\u5730\u5B58\u50A8\u8DEF\u5F84\u4E0D\u662F\u76EE\u5F55"); - await fs5.promises.access(this.uploadDir, fs5.constants.R_OK | fs5.constants.W_OK); - } - async saveFile(tempPath, fileName, _mimeType, folder) { - const destDir = folder ? safeJoin(this.uploadDir, folder) : this.uploadDir; - if (!fs5.existsSync(destDir)) { - fs5.mkdirSync(destDir, { recursive: true }); - } - const destPath = safeJoin(destDir, fileName); - try { - await fs5.promises.rename(tempPath, destPath); - } catch (error) { - if (error.code === "EXDEV") { - await fs5.promises.copyFile(tempPath, destPath); - await fs5.promises.unlink(tempPath); - } else { - throw error; - } - } - return destPath; - } - async getFileStream(storedPath) { - const safePath = safeJoin(this.uploadDir, path5.relative(this.uploadDir, storedPath)); - if (safePath !== path5.resolve(storedPath)) { - throw new Error("Unsafe local file path"); - } - if (!fs5.existsSync(safePath)) { - throw new Error(`File not found: ${safePath}`); - } - return fs5.createReadStream(safePath); - } - async getPreviewUrl(storedPath) { - return ""; - } - async deleteFile(storedPath) { - const safePath = safeJoin(this.uploadDir, path5.relative(this.uploadDir, storedPath)); - if (safePath !== path5.resolve(storedPath)) { - throw new Error("Unsafe local file path"); - } - if (fs5.existsSync(safePath)) { - await fs5.promises.unlink(safePath); - } - } - async createShareLink(storedPath, password, expiration) { - return { link: "", error: "\u672C\u5730\u5B58\u50A8\u6682\u4E0D\u652F\u6301\u751F\u6210\u5206\u4EAB\u94FE\u63A5\uFF0C\u8BF7\u4F7F\u7528 OneDrive \u5B58\u50A8\u3002" }; - } - }; AliyunOSSStorageProvider = class { constructor(id, region, accessKeyId, accessKeySecret, bucket, probeTimeoutMs = configuredStorageProbeTimeoutMs()) { this.id = id; @@ -1597,11 +1649,11 @@ var init_storage = __esm({ async saveFile(tempPath, fileName, mimeType, folder) { try { const objectKey = folder ? `${folder}/${fileName}` : fileName; - const stats = await fs5.promises.stat(tempPath); + const stats = await fs6.promises.stat(tempPath); const command = new PutObjectCommand({ Bucket: this.bucket, Key: objectKey, - Body: fs5.createReadStream(tempPath), + Body: fs6.createReadStream(tempPath), ContentType: mimeType, ContentLength: stats.size }); @@ -1694,14 +1746,14 @@ var init_storage = __esm({ probeTimeoutMs; name = "webdav"; client; - async withRequestTimeout(operation, label, timeoutMs = this.requestTimeoutMs) { + async withRequestTimeout(operation, label, timeoutMs2 = this.requestTimeoutMs) { const controller = new AbortController(); let timeout; const refresh = () => { clearTimeout(timeout); timeout = setTimeout(() => { - controller.abort(new Error(`${label} timed out after ${timeoutMs}ms`)); - }, timeoutMs); + controller.abort(new Error(`${label} timed out after ${timeoutMs2}ms`)); + }, timeoutMs2); }; refresh(); try { @@ -1734,7 +1786,7 @@ var init_storage = __esm({ ); } await this.withRequestTimeout( - (signal) => this.client.putFileContents(`/${remotePath}`, fs5.createReadStream(tempPath), { signal }), + (signal) => this.client.putFileContents(`/${remotePath}`, fs6.createReadStream(tempPath), { signal }), "WebDAV upload", this.uploadTimeoutMs ); @@ -1961,7 +2013,7 @@ var init_storage = __esm({ */ async saveFile(tempPath, fileName, mimeType, folder) { const token = await this.getAccessToken(); - const stats = await fs5.promises.stat(tempPath); + const stats = await fs6.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); @@ -1969,7 +2021,7 @@ var init_storage = __esm({ try { if (fileSize < 4 * 1024 * 1024) { console.log("[OneDrive] Using simple upload for small file"); - const fileBuffer = await fs5.promises.readFile(tempPath); + const fileBuffer = await fs6.promises.readFile(tempPath); const response = await axios.put( `https://graph.microsoft.com/v1.0/me/drive/root:/${this.encodeOneDrivePath(targetPath)}:/content`, fileBuffer, @@ -2009,7 +2061,7 @@ var init_storage = __esm({ const CHUNK_SIZE = 320 * 1024 * 10; let uploadedBytes = 0; let lastResponse = null; - const fd = await fs5.promises.open(tempPath, "r"); + const fd = await fs6.promises.open(tempPath, "r"); try { while (uploadedBytes < fileSize) { const chunkSize = Math.min(CHUNK_SIZE, fileSize - uploadedBytes); @@ -2383,7 +2435,7 @@ var init_storage = __esm({ }; const media = { mimeType, - body: fs5.createReadStream(tempPath) + body: fs6.createReadStream(tempPath) }; try { const file = await this.drive.files.create(this.withSharedDriveSupport({ @@ -3389,13 +3441,11 @@ __export(telegramState_exports, { revokeAuthenticatedUser: () => revokeAuthenticatedUser, userStates: () => userStates }); -async function revokeAuthenticatedUser(userId) { - authenticatedUsers.delete(userId); - try { - await query("DELETE FROM telegram_auth WHERE user_id = $1", [userId]); - } catch (error) { - console.error("\u{1F916} \u64A4\u9500 Telegram \u6388\u6743\u7528\u6237\u5931\u8D25:", error); - } +async function revokeAuthenticatedUser(userId, dependencies = {}) { + const runQuery = dependencies.query || query; + const cache = dependencies.cache || authenticatedUsers; + await runQuery("DELETE FROM telegram_auth WHERE user_id = $1", [userId]); + cache.delete(userId); } async function reconcileTelegramAllowedUsers(userIds, dependencies = {}) { const allowed = [...new Set(userIds.filter((id) => Number.isSafeInteger(id) && id > 0))].sort((a, b) => a - b); @@ -3480,8 +3530,8 @@ var init_telegramState = __esm({ import express from "express"; import cors from "cors"; import dotenv3 from "dotenv"; -import path23 from "path"; -import fs19 from "fs"; +import path24 from "path"; +import fs21 from "fs"; // src/routes/files.ts import { Router as Router2 } from "express"; @@ -3525,11 +3575,11 @@ function buildStorageStatsPayload(input) { // src/routes/files.ts init_db(); -import fs13 from "fs"; +import fs14 from "fs"; import path17 from "path"; // src/middleware/signedUrl.ts -import crypto21 from "crypto"; +import crypto22 from "crypto"; // src/utils/config.ts init_secretStore(); @@ -3691,32 +3741,45 @@ import axios2 from "axios"; // src/services/telegramBot.ts init_storage(); init_telegramState(); -import { TelegramClient as TelegramClient6, Api as Api8 } from "telegram"; -import { StringSession as StringSession3 } from "telegram/sessions/index.js"; +import { TelegramClient as TelegramClient8, Api as Api9 } from "telegram"; +import { StringSession as StringSession5 } from "telegram/sessions/index.js"; import { NewMessage } from "telegram/events/index.js"; -import { Raw } from "telegram/events/index.js"; -import fs12 from "fs"; +import { Raw as Raw2 } from "telegram/events/index.js"; +import fs13 from "fs"; import path16 from "path"; -import crypto20 from "crypto"; +import crypto21 from "crypto"; // src/services/telegramCommands.ts init_db(); -import { Api as Api7 } from "telegram"; +import { Api as Api8 } from "telegram"; import { getPeerId as getPeerId2 } from "telegram/Utils.js"; import checkDiskSpaceModule from "check-disk-space"; import os3 from "os"; -import fs11 from "fs"; +import fs12 from "fs"; import path15 from "path"; // src/utils/telegramUtils.ts import path6 from "path"; -function formatBytes(bytes) { - if (bytes === 0) return "0 B"; - const k = 1024; - const sizes = ["B", "KB", "MB", "GB", "TB"]; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; + +// src/utils/fileMetadata.ts +function getFileType(mimeType) { + const normalized = mimeType?.toLowerCase() || ""; + if (normalized.startsWith("image/")) return "image"; + if (normalized.startsWith("video/")) return "video"; + if (normalized.startsWith("audio/")) return "audio"; + if (normalized.startsWith("text/") || normalized.includes("pdf") || normalized.includes("document") || normalized.includes("sheet") || normalized.includes("presentation") || normalized.includes("word") || normalized.includes("excel") || normalized.includes("powerpoint")) return "document"; + return "other"; } +function formatBytes(bytes, binary = false) { + if (!Number.isFinite(bytes) || bytes <= 0) return "0 B"; + const base = 1024; + const units = binary ? ["B", "KiB", "MiB", "GiB", "TiB"] : ["B", "KB", "MB", "GB", "TB"]; + const unit = Math.min(Math.floor(Math.log(bytes) / Math.log(base)), units.length - 1); + const value = bytes / base ** unit; + return `${Number(value.toFixed(unit === 0 ? 0 : 2))} ${units[unit]}`; +} + +// src/utils/telegramUtils.ts function getTypeEmoji(mimeType) { if (!mimeType) return "\u{1F4C1}"; if (mimeType.startsWith("image/")) return "\u{1F5BC}\uFE0F"; @@ -3737,14 +3800,6 @@ function getTypeEmoji(mimeType) { if (mimeType.includes("pdf") || mimeType.includes("document")) return "\u{1F4C4}"; return "\u{1F4C1}"; } -function getFileType(mimeType) { - if (!mimeType) return "other"; - if (mimeType.startsWith("image/")) return "image"; - if (mimeType.startsWith("video/")) return "video"; - if (mimeType.startsWith("audio/")) return "audio"; - if (mimeType.includes("pdf") || mimeType.includes("document") || mimeType.includes("text") || mimeType.includes("word") || mimeType.includes("excel") || mimeType.includes("spreadsheet") || mimeType.includes("powerpoint") || mimeType.includes("presentation") || mimeType.includes("markdown") || mimeType.includes("json") || mimeType.includes("xml") || mimeType.includes("sql") || mimeType.includes("javascript") || mimeType.includes("typescript")) return "document"; - return "other"; -} function getMimeTypeFromFilename(filename) { const ext = path6.extname(filename).toLowerCase(); const mimeTypes = { @@ -3825,101 +3880,38 @@ function sanitizeFilename(name) { // src/utils/telegramMessages.ts import { Api } from "telegram"; -// src/utils/telegramCommandRegistry.ts -var BOT_COMMANDS = [ - { command: "start", description: "\u5F00\u59CB\u4F7F\u7528 / \u9A8C\u8BC1\u8EAB\u4EFD", helpDescription: "\u8EAB\u4EFD\u8BA4\u8BC1 / \u5F00\u59CB\u4F7F\u7528", category: "main", menu: true, help: true, requiresAuth: false }, - { command: "tasks", description: "\u67E5\u770B\u5B9E\u65F6\u4EFB\u52A1", helpDescription: "\u67E5\u770B\u5B9E\u65F6\u4F20\u8F93\u4EFB\u52A1\u961F\u5217", category: "main", aliases: ["task"], menu: true, help: true }, - { command: "storage", description: "\u5B58\u50A8\u72B6\u6001 / \u5220\u9664\u672C\u5730\u5B9E\u4F53\u6587\u4EF6", helpDescription: "\u67E5\u770B\u5B58\u50A8\u72B6\u6001\uFF1B\u53EF\u786E\u8BA4\u5220\u9664\u672C\u5730\u5B9E\u4F53\u6587\u4EF6", category: "main", menu: true, help: true }, - { command: "path_rules", description: "\u4FDD\u5B58\u4F4D\u7F6E / \u81EA\u5B9A\u4E49\u76EE\u5F55", helpDescription: "\u6253\u5F00\u4FDD\u5B58\u4F4D\u7F6E\u4E0E\u81EA\u5B9A\u4E49\u76EE\u5F55\u9762\u677F", category: "main", aliases: ["path", "save_rules"], menu: true, help: true }, - { command: "tg_download", description: "\u6309\u65E5\u671F / \u6807\u7B7E\u4E0B\u8F7D\u9891\u9053\u6587\u4EF6", helpDescription: "\u6253\u5F00\u6309\u65E5\u671F\u6216\u6807\u7B7E\u4E0B\u8F7D\u5411\u5BFC", category: "main", aliases: ["tg_dl"], menu: true, help: true }, - { command: "list", description: "\u67E5\u770B\u6700\u8FD1\u6587\u4EF6", helpDescription: "\u67E5\u770B\u6700\u8FD1\u6587\u4EF6\u548C\u53EF\u590D\u5236\u7684\u6587\u4EF6 ID", category: "files", usage: "[\u6570\u91CF] [\u9875\u7801]", menu: true, help: true }, - { command: "find", description: "\u641C\u7D22\u548C\u64CD\u4F5C\u6587\u4EF6", helpDescription: "\u6309\u540D\u79F0\u3001\u7C7B\u578B\u3001\u76EE\u5F55\u3001\u65E5\u671F\u6216\u6536\u85CF\u641C\u7D22\u6587\u4EF6\u5E76\u6253\u5F00\u64CD\u4F5C\u5361", category: "files", usage: "[\u5173\u952E\u8BCD] [type:image|video|audio|document] [folder:\u76EE\u5F55] [after:YYYY-MM-DD] [before:YYYY-MM-DD] [fav]", menu: true, help: true }, - { command: "tg_sub", description: "\u7BA1\u7406\u9891\u9053\u81EA\u52A8\u540C\u6B65", helpDescription: "\u6253\u5F00\u9891\u9053\u8BA2\u9605\u7BA1\u7406\u5411\u5BFC", category: "channels", aliases: ["tg_subscribe"], menu: true, help: true }, - { command: "storage_switch", description: "\u5207\u6362\u7CFB\u7EDF\u9ED8\u8BA4\u5B58\u50A8", helpDescription: "\u5207\u6362\u6240\u6709\u65B0\u4EFB\u52A1\u4F7F\u7528\u7684\u7CFB\u7EDF\u9ED8\u8BA4\u5B58\u50A8", category: "settings", aliases: ["switch_storage", "storage_source"], menu: true, help: true }, - { command: "target", description: "\u8BBE\u7F6E\u5F53\u524D\u804A\u5929\u5B58\u50A8\u76EE\u6807", helpDescription: "\u8BBE\u7F6E\u4E0B\u4E00\u6B21\u6216\u5F53\u524D\u804A\u5929\u6301\u7EED\u4F7F\u7528\u7684\u5B58\u50A8\u8D26\u6237\uFF1B\u4E0D\u4FEE\u6539\u7CFB\u7EDF\u9ED8\u8BA4", category: "files", usage: "[once|session|clear] [local|\u8D26\u6237ID]", menu: true, help: true }, - { command: "ytdlp", description: "\u4E0B\u8F7D\u89C6\u9891\u94FE\u63A5", helpDescription: "\u89E3\u6790\u5E76\u4E0B\u8F7D\u4E00\u4E2A\u89C6\u9891\u94FE\u63A5", category: "files", usage: "", menu: true, help: true }, - { command: "help", description: "\u67E5\u770B\u5B8C\u6574\u5E2E\u52A9", helpDescription: "\u663E\u793A\u6B64\u5E2E\u52A9", category: "main", menu: true, help: true, requiresAuth: false }, - { command: "setup_2fa", description: "\u914D\u7F6E\u53CC\u91CD\u9A8C\u8BC1", helpDescription: "\u914D\u7F6E\u53CC\u91CD\u9A8C\u8BC1 (TOTP)", category: "security", aliases: ["setup-2fa"], menu: false, help: true }, - { command: "logout", description: "\u64A4\u9500\u672C\u8BBE\u5907 Bot \u8BA4\u8BC1", helpDescription: "\u7ACB\u5373\u9000\u51FA\u5E76\u64A4\u9500\u5F53\u524D Telegram \u7528\u6237\u7684 Bot \u8BA4\u8BC1", category: "security", menu: false, help: true }, - { command: "p", description: "\u8BBE\u7F6E\u4E0B\u4E00\u6B21\u4FDD\u5B58\u76EE\u5F55", helpDescription: "\u4E0B\u4E00\u6B21\u4E0B\u8F7D\u4FDD\u5B58\u5230\u6307\u5B9A\u76EE\u5F55", category: "files", usage: "<\u76EE\u5F55>", help: true }, - { command: "ps", description: "\u8BBE\u7F6E\u672C\u4F1A\u8BDD\u4FDD\u5B58\u76EE\u5F55", helpDescription: "\u672C\u4F1A\u8BDD\u6301\u7EED\u4FDD\u5B58\u5230\u6307\u5B9A\u76EE\u5F55", category: "files", usage: "<\u76EE\u5F55>", help: true }, - { command: "pc", description: "\u6E05\u9664\u81EA\u5B9A\u4E49\u4FDD\u5B58\u76EE\u5F55", helpDescription: "\u6E05\u9664\u4E0B\u4E00\u6B21\u548C\u672C\u4F1A\u8BDD\u81EA\u5B9A\u4E49\u76EE\u5F55", category: "files", help: true }, - { command: "delete", description: "\u5220\u9664\u6307\u5B9A\u6587\u4EF6", helpDescription: "\u6309\u81F3\u5C11 8 \u4F4D\u6587\u4EF6 ID \u524D\u7F00\u5220\u9664\u6587\u4EF6", category: "files", usage: "<\u6587\u4EF6 ID \u524D\u7F00>", help: true }, - { command: "task_pause", description: "\u6682\u505C\u4EFB\u52A1", helpDescription: "\u6682\u505C\u5F53\u524D\u804A\u5929\u4EFB\u52A1\u6216\u6307\u5B9A\u4EFB\u52A1", category: "settings", usage: "[\u4EFB\u52A1 ID]", help: true }, - { command: "task_resume", description: "\u7EE7\u7EED\u4EFB\u52A1", helpDescription: "\u7EE7\u7EED\u5F53\u524D\u804A\u5929\u4EFB\u52A1\u6216\u6307\u5B9A\u4EFB\u52A1", category: "settings", usage: "[\u4EFB\u52A1 ID]", help: true }, - { command: "task_cancel", description: "\u53D6\u6D88\u4EFB\u52A1", helpDescription: "\u9884\u89C8\u5E76\u53D6\u6D88\u6307\u5B9A\u4EFB\u52A1\u6216\u5F53\u524D\u804A\u5929\u5168\u90E8\u4EFB\u52A1", category: "settings", usage: "<\u4EFB\u52A1 ID|all>", help: true }, - { command: "tg_retry", description: "\u91CD\u8BD5\u5931\u8D25\u4EFB\u52A1", helpDescription: "\u91CD\u8BD5\u6700\u8FD1\u5931\u8D25\u7684 Telegram \u4E0B\u8F7D\u4EFB\u52A1", category: "channels", usage: "[\u6570\u91CF] [\u4EFB\u52A1 ID]", help: true }, - { command: "stop_tasks", description: "\u505C\u6B62\u5F53\u524D\u804A\u5929\u4EFB\u52A1", helpDescription: "\u517C\u5BB9\u5165\u53E3\uFF1A\u9884\u89C8\u5E76\u505C\u6B62\u5F53\u524D\u804A\u5929\u4E0B\u8F7D\u4EFB\u52A1", category: "settings", aliases: ["stop", "cancel_tasks"], help: true }, - { command: "download_workers", description: "\u5355\u6587\u4EF6\u5206\u7247\u5E76\u53D1", helpDescription: "\u8BBE\u7F6E\u5355\u6587\u4EF6\u5206\u7247\u4E0B\u8F7D\u5E76\u53D1", category: "settings", aliases: ["workers"], help: true }, - { command: "file_concurrency", description: "\u6587\u4EF6\u7EA7\u4E0B\u8F7D\u5E76\u53D1", helpDescription: "\u8BBE\u7F6E 1 / 2 / 3 / 4 \u4E2A\u6587\u4EF6\u5E76\u884C\uFF0C\u907F\u514D\u628A\u5206\u7247\u5E76\u53D1\u548C\u6587\u4EF6\u7EA7\u5E76\u53D1\u6DF7\u6DC6", category: "settings", aliases: ["file_workers", "download_files"], menu: false, help: true }, - { command: "status", description: "\u7CFB\u7EDF\u8BCA\u65AD\u72B6\u6001", helpDescription: "\u67E5\u770B Bot\u3001\u8D26\u53F7\u4E0B\u8F7D\u5668\u3001\u5B58\u50A8\u3001\u78C1\u76D8\u3001\u961F\u5217\u3001\u8BA2\u9605\u548C\u5BF9\u8D26\u72B6\u6001", category: "settings", menu: true, help: true }, - { command: "notifications", description: "\u901A\u77E5\u504F\u597D", helpDescription: "\u8BBE\u7F6E\u6210\u529F/\u5931\u8D25/\u8BA2\u9605\u6458\u8981\u3001\u65F6\u533A\u548C\u5B89\u9759\u65F6\u6BB5\uFF1B\u5B89\u5168\u544A\u8B66\u59CB\u7EC8\u5373\u65F6", category: "settings", usage: "[timezone|quiet|success|failure|subscription] [\u503C]", menu: true, help: true }, - { command: "duplicate_mode", description: "\u91CD\u590D\u6587\u4EF6\u5904\u7406", helpDescription: "\u8BBE\u7F6E\u91CD\u590D\u6587\u4EF6\u5904\u7406\u7B56\u7565", category: "settings", aliases: ["duplicate", "dup"], help: true }, - { command: "cleanup_settings", description: "\u81EA\u52A8\u6E05\u7406\u672A\u7D22\u5F15\u4E34\u65F6\u6587\u4EF6", helpDescription: "\u8BBE\u7F6E\u81EA\u52A8\u6E05\u7406\u672A\u767B\u8BB0\u4E34\u65F6\u6587\u4EF6\u7684\u5F00\u5173", category: "settings", aliases: ["cleanup"], help: true }, - { command: "tg_subs", description: "\u67E5\u770B\u9891\u9053\u8BA2\u9605", helpDescription: "\u67E5\u770B\u9891\u9053\u8BA2\u9605\u5217\u8868", category: "channels", aliases: ["tg_subscriptions"], help: true }, - { command: "tg_unsub", description: "\u53D6\u6D88\u9891\u9053\u8BA2\u9605", helpDescription: "\u6309\u9891\u9053\u6216\u8BA2\u9605 ID \u8BF7\u6C42\u53D6\u6D88\u8BA2\u9605", category: "channels", aliases: ["tg_unsubscribe"], usage: "<\u9891\u9053|\u8BA2\u9605 ID>", help: true } -]; -var lookup = /* @__PURE__ */ new Map(); -for (const definition of BOT_COMMANDS) { - definition.handlerKey ||= definition.command; - definition.requiresAuth ??= definition.command !== "start" && definition.command !== "help"; - lookup.set(definition.command, definition); - definition.aliases?.forEach((alias) => lookup.set(alias, definition)); +// src/utils/providerMetadata.ts +var PROVIDERS = { + local: { id: "local", label: "\u672C\u5730\u5B58\u50A8", emoji: "\u{1F4BE}" }, + onedrive: { id: "onedrive", label: "OneDrive", emoji: "\u2601\uFE0F" }, + google_drive: { id: "google_drive", label: "Google Drive", emoji: "\u2601\uFE0F" }, + aliyun_oss: { id: "aliyun_oss", label: "\u963F\u91CC\u4E91 OSS", emoji: "\u2601\uFE0F" }, + s3: { id: "s3", label: "S3 \u5B58\u50A8", emoji: "\u{1F4E6}" }, + webdav: { id: "webdav", label: "WebDAV", emoji: "\u{1F310}" }, + openlist: { id: "openlist", label: "OpenList", emoji: "\u{1F5C2}\uFE0F" } +}; +function getProviderMetadata(providerId) { + return PROVIDERS[providerId] || { id: providerId, label: providerId, emoji: "\u{1F4E6}" }; } -function buildBotCommandMenu() { - return BOT_COMMANDS.filter((command) => command.menu).map(({ command, description }) => ({ command, description })); -} -function normalizeBotCommandText(input) { - const match = input.match(/^(\s*)\/([^\s@]+)(?:@\w+)?([\s\S]*)$/); - if (!match) return input; - const definition = lookup.get(match[2].toLowerCase()); - return definition ? `${match[1]}/${definition.command}${match[3]}` : input; -} -function buildBotHelpSections() { - const labels = [ - ["main", "\u5E38\u7528\u5165\u53E3"], - ["files", "\u6587\u4EF6\u4E0E\u4FDD\u5B58\u4F4D\u7F6E"], - ["channels", "\u9891\u9053\u4E0E\u8BA2\u9605"], - ["settings", "\u4EFB\u52A1\u4E0E\u7CFB\u7EDF\u8BBE\u7F6E"], - ["security", "\u5B89\u5168"] - ]; - return labels.map(([category, title]) => ({ - title, - commands: BOT_COMMANDS.filter((command) => command.help && command.category === category) - })).filter((section) => section.commands.length > 0); +function getProviderDisplayName(providerId) { + const provider = getProviderMetadata(providerId); + return `${provider.emoji} ${provider.label}`; } // src/utils/telegramMessages.ts -var PROVIDER_DISPLAY_MAP = { - onedrive: "\u2601\uFE0F OneDrive", - aliyun_oss: "\u2601\uFE0F \u963F\u91CC\u4E91 OSS", - s3: "\u{1F4E6} S3 \u5B58\u50A8", - webdav: "\u{1F310} WebDAV", - google_drive: "\u2601\uFE0F Google Drive", - local: "\u{1F4BE} \u672C\u5730\u5B58\u50A8" -}; -function getProviderDisplayName(providerName) { - return PROVIDER_DISPLAY_MAP[providerName] || `\u{1F4E6} ${providerName}`; -} function buildTaskControlLines(taskId, queuePaused = false, pauseReason, systemPause) { - if (!taskId) return [`\u{1F4A1} \u53D1\u9001 /tasks \u67E5\u770B\u5B9E\u65F6\u4EFB\u52A1\u72B6\u6001`]; + if (!taskId) return []; if (queuePaused) { const systemPaused = Boolean(systemPause); const pausing = !systemPause && /随后暂停|完成当前文件/.test(pauseReason || ""); - const recoveryLine = systemPause ? systemPause.autoResume ? systemPause.retryAt ? `\u267B\uFE0F \u9884\u8BA1\u5728 ${systemPause.retryAt} \u540E\u81EA\u52A8\u6062\u590D\uFF1B\u65E0\u9700\u624B\u52A8\u64CD\u4F5C` : systemPause.recheckMs ? `\u267B\uFE0F \u7CFB\u7EDF\u6BCF ${Math.max(1, Math.round(systemPause.recheckMs / 1e3))} \u79D2\u91CD\u65B0\u68C0\u67E5\uFF0C\u6761\u4EF6\u6EE1\u8DB3\u540E\u81EA\u52A8\u6062\u590D` : `\u267B\uFE0F \u7CFB\u7EDF\u4F1A\u6301\u7EED\u68C0\u67E5\uFF0C\u6761\u4EF6\u6EE1\u8DB3\u540E\u81EA\u52A8\u6062\u590D` : `\u26A0\uFE0F \u6B64\u72B6\u6001\u4E0D\u4F1A\u81EA\u52A8\u6062\u590D\uFF0C\u8BF7\u6309\u539F\u56E0\u5904\u7406\u540E\u91CD\u8BD5` : pausing ? `\u25B6\uFE0F \u53EF\u70B9\u51FB\u201C\u7EE7\u7EED\u201D\u64A4\u9500\u6682\u505C\u8BF7\u6C42` : `\u25B6\uFE0F \u70B9\u51FB\u4E0B\u65B9\u201C\u7EE7\u7EED\u201D\u4F1A\u6062\u590D\u4E0B\u8F7D\u961F\u5217`; + const recoveryLine = systemPause ? systemPause.autoResume ? systemPause.retryAt ? `\u267B\uFE0F \u9884\u8BA1\u5728 ${systemPause.retryAt} \u540E\u81EA\u52A8\u6062\u590D` : systemPause.recheckMs ? `\u267B\uFE0F \u6BCF ${Math.max(1, Math.round(systemPause.recheckMs / 1e3))} \u79D2\u91CD\u65B0\u68C0\u67E5\uFF0C\u6761\u4EF6\u6EE1\u8DB3\u540E\u81EA\u52A8\u6062\u590D` : `\u267B\uFE0F \u6761\u4EF6\u6EE1\u8DB3\u540E\u81EA\u52A8\u6062\u590D` : `\u26A0\uFE0F \u5904\u7406\u539F\u56E0\u540E\u53EF\u91CD\u8BD5` : pausing ? `\u70B9\u51FB\u201C\u7EE7\u7EED\u201D\u53EF\u64A4\u9500\u6682\u505C` : `\u70B9\u51FB\u201C\u7EE7\u7EED\u201D\u6062\u590D\u4EFB\u52A1`; return [ - `\u23F8\uFE0F **\u5F53\u524D\u72B6\u6001\uFF1A${systemPaused ? "\u7CFB\u7EDF\u4FDD\u62A4\u6682\u505C" : pausing ? "\u6B63\u5728\u6682\u505C" : "\u7528\u6237\u6682\u505C"}**`, - pauseReason ? `\u{1F4CC} \u539F\u56E0\uFF1A${pauseReason}` : `\u{1F4CC} \u7B49\u5F85\u4E2D\u7684\u4E0B\u8F7D\u4EFB\u52A1\u4E0D\u4F1A\u7EE7\u7EED\u5F00\u59CB`, - recoveryLine, - `\u{1F6D1} \u70B9\u51FB\u201C\u53D6\u6D88\u201D\u53EA\u4F1A\u7ED3\u675F\u8FD9\u5F20\u540E\u53F0\u4EFB\u52A1\u5361\uFF1B\u4E0D\u4F1A\u518D\u8BEF\u6E05\u7A7A\u5176\u5B83\u4EFB\u52A1` - ]; + systemPaused ? `\u5F53\u524D\u72B6\u6001\uFF1A\u7CFB\u7EDF\u4FDD\u62A4\u6682\u505C` : pausing ? `\u23F8\uFE0F \u6B63\u5728\u6682\u505C` : `\u5F53\u524D\u72B6\u6001\uFF1A\u7528\u6237\u6682\u505C`, + pauseReason ? `\u539F\u56E0\uFF1A${pauseReason}` : "", + recoveryLine + ].filter(Boolean); } - return [ - `\u{1F4A1} \u961F\u5217\u63A7\u5236\uFF1A\u6309\u94AE\u53EA\u5BF9\u5F53\u524D\u804A\u5929\u7684\u4EFB\u52A1\u5361\u6709\u6548`, - `\u23F8 \u6682\u505C\uFF1A\u5B8C\u6210\u5F53\u524D\u6587\u4EF6\u540E\u6682\u505C\u8BE5\u4EFB\u52A1`, - `\u25B6\uFE0F \u7EE7\u7EED\uFF1A\u7EE7\u7EED\u8BE5\u4EFB\u52A1\uFF1B\u82E5\u662F\u7528\u6237\u6682\u505C\uFF0C\u4E5F\u4F1A\u89E3\u9664\u7528\u6237\u6682\u505C\u72B6\u6001`, - `\u{1F6D1} \u53D6\u6D88\uFF1A\u7ED3\u675F\u5F53\u524D\u4EFB\u52A1\u5361\u5E76\u79FB\u9664\u6309\u94AE\uFF0C\u4E0D\u4F1A\u8BEF\u53D6\u6D88\u5176\u5B83\u804A\u5929\u4EFB\u52A1` - ]; + return ["\u{1F447} \u4F7F\u7528\u4E0B\u65B9\u6309\u94AE\u7BA1\u7406\u6B64\u4EFB\u52A1\u3002"]; } function buildTaskControlButtons(taskId, queuePaused = false, systemPause, queuePausing = false, userPaused = queuePaused && !systemPause, failedCount = 0) { if (!taskId) return void 0; @@ -4024,28 +4016,17 @@ function buildStartPrompt() { \u{1F510} \u8BF7\u4F7F\u7528\u4E0B\u65B9\u952E\u76D8\u8F93\u5165\u5BC6\u7801\uFF1A`; } -function formatRegistryCommand(command) { - const usage = command.usage ? ` ${command.usage}` : ""; - return ` /${command.command}${usage} \u2014 ${command.helpDescription}`; -} function buildHelp() { - const sections = buildBotHelpSections().flatMap((section) => [ - `**${section.title}**`, - ...section.commands.map(formatRegistryCommand), - `` - ]); return [ - `\u{1F4D6} **TG Vault Bot \u5E2E\u52A9**`, - LINE, - ``, - `**\u{1F4E4} \u6587\u4EF6\u4E0A\u4F20**`, - ` \u76F4\u63A5\u53D1\u9001\u6216\u8F6C\u53D1\u6587\u4EF6\u5373\u53EF\u81EA\u52A8\u4E0A\u4F20`, - ` \u652F\u6301\u6240\u6709\u7C7B\u578B\uFF0C\u6700\u5927 2 GB\uFF0C\u8D26\u53F7\u7EA7\u4E0B\u8F7D\u5668\u4E0D\u53D7\u6B64\u9650\u5236`, - ` \u591A\u6587\u4EF6\u540C\u65F6\u53D1\u9001\u4F1A\u81EA\u52A8\u5F52\u4E3A\u4E00\u7EC4`, - ``, - ...sections, - LINE, - `\u{1F4A1} **\u63D0\u793A**\uFF1A\u8F6C\u53D1\u6587\u4EF6\u7ED9 Bot \u5373\u53EF\u5F00\u59CB\u4E0A\u4F20\uFF1B\u81EA\u52A8\u6E05\u7406\u4E34\u65F6\u6587\u4EF6\u3001\u5220\u9664\u672C\u5730\u5B9E\u4F53\u6587\u4EF6\u548C\u5220\u9664\u4EFB\u52A1\u5386\u53F2\u662F\u4E09\u79CD\u4E0D\u540C\u64CD\u4F5C\u3002` + "\u{1F4D6} **\u4F7F\u7528\u5E2E\u52A9**", + "", + "\u{1F4E4} \u53D1\u9001\u6216\u8F6C\u53D1\u6587\u4EF6\uFF1A\u76F4\u63A5\u4E0A\u4F20", + "\u{1F517} \u53D1\u9001\u89C6\u9891\u94FE\u63A5\uFF1A\u89E3\u6790\u540E\u9009\u62E9\u683C\u5F0F", + "\u{1F4E5} \u4EFB\u52A1\uFF1A\u67E5\u770B\u8FDB\u5EA6\u3001\u6682\u505C\u6216\u53D6\u6D88", + "\u{1F4C1} \u4FDD\u5B58\u4F4D\u7F6E\uFF1A\u8BBE\u7F6E\u76EE\u5F55\u548C\u5B58\u50A8\u76EE\u6807", + "\u{1F4E1} \u9891\u9053\uFF1A\u6309\u65E5\u671F/\u6807\u7B7E\u4E0B\u8F7D\u6216\u7BA1\u7406\u8BA2\u9605", + "", + "\u{1F447} \u70B9\u51FB\u4E0B\u65B9\u6309\u94AE\u9009\u62E9\u529F\u80FD\u3002" ].join("\n"); } function build2FASetupCaption() { @@ -4083,12 +4064,17 @@ function buildStorageReport(data) { ` \u{1F504} \u5904\u7406\u4E2D ${data.queueActive}\u3000\u23F3 \u7B49\u5F85\u4E2D ${data.queuePending}` ].join("\n"); } +function compactTelegramText(value, maxLength) { + const text = String(value || "").replace(/[\r\n\t]+/g, " ").replace(/[*_`[\]\\]/g, "").trim(); + return text.length > maxLength ? `${text.slice(0, Math.max(1, maxLength - 1))}\u2026` : text; +} function buildFileList(files, total) { + const visibleFiles = files.slice(0, 12); const lines = [ - `\u{1F4CB} **\u6700\u8FD1\u4E0A\u4F20\u7684\u6587\u4EF6** (${total} \u6761)`, + `\u{1F4CB} **\u6700\u8FD1\u4E0A\u4F20\u7684\u6587\u4EF6**\uFF08\u672C\u9875 ${visibleFiles.length} \u6761\uFF09`, LINE ]; - files.forEach((file, index) => { + visibleFiles.forEach((file, index) => { const typeEmoji = getTypeEmoji( file.type === "image" ? "image/" : file.type === "video" ? "video/" : file.type === "audio" ? "audio/" : "other" ); @@ -4099,18 +4085,14 @@ function buildFileList(files, total) { hour: "2-digit", minute: "2-digit" }); - let displayName = file.name; - if (displayName.length > 25) { - displayName = displayName.substring(0, 22) + "..."; - } - lines.push(`${index + 1}. ${typeEmoji} **${displayName}**`); - lines.push(` ${size} \xB7 ${date}${file.folder ? ` \xB7 \u{1F4C1} ${file.folder}` : ""}`); + const displayName = compactTelegramText(file.name, 36); + const folder = compactTelegramText(file.folder, 48); + lines.push(`${index + 1}. ${typeEmoji} **${displayName || "\u672A\u547D\u540D\u6587\u4EF6"}**`); + lines.push(` ${size} \xB7 ${date}${folder ? ` \xB7 \u{1F4C1} ${folder}` : ""}`); lines.push(` ID: \`${file.id.substring(0, 8)}\``); }); lines.push(""); - lines.push(`\u{1F4A1} \u5220\u9664\u6587\u4EF6: \u590D\u5236\u4E0A\u65B9 ID \u540E\u53D1\u9001 /delete `); - lines.push(` \u4F8B\uFF1A/delete ${files[0]?.id?.substring(0, 8) || "a1b2c3d4"}`); - lines.push(`\u{1F4C4} \u66F4\u591A\u8BB0\u5F55: /list 20 \u6216 /list 20 2`); + lines.push("\u{1F4A1} \u9700\u8981\u641C\u7D22\u6216\u64CD\u4F5C\u6587\u4EF6\uFF0C\u8BF7\u6253\u5F00\u201C\u641C\u7D22\u548C\u64CD\u4F5C\u6587\u4EF6\u201D\u3002"); return lines.join("\n"); } function buildUploadSuccess(fileName, size, fileType, providerName, folder, fileId, duplicateOutcome) { @@ -4129,7 +4111,7 @@ function buildUploadSuccess(fileName, size, fileType, providerName, folder, file ...fileId ? [`\u{1F194} ${fileId.slice(0, 13)}`] : [], ...duplicateOutcome === "copied" ? ["\u267B\uFE0F \u91CD\u590D\u5904\u7406\uFF1A\u5DF2\u751F\u6210\u526F\u672C"] : duplicateOutcome === "skipped" ? ["\u23ED\uFE0F \u91CD\u590D\u5904\u7406\uFF1A\u5DF2\u8DF3\u8FC7"] : [], ``, - ...fileId ? ["\u64CD\u4F5C\uFF1A/find \u53EF\u641C\u7D22\u540C\u76EE\u5F55\uFF1B\u590D\u5236\u4E0A\u65B9 ID\uFF1B/delete \u53EF\u8FDB\u5165\u5220\u9664\u786E\u8BA4\u3002"] : [] + ...fileId ? ["\u{1F447} \u53EF\u5728\u201C\u641C\u7D22\u548C\u64CD\u4F5C\u6587\u4EF6\u201D\u4E2D\u7EE7\u7EED\u7BA1\u7406\u3002"] : [] ].join("\n"); } function buildUploadFail(fileName, error) { @@ -4151,7 +4133,7 @@ function buildDuplicateSkipped(fileName, folder, existingId) { ...folder ? [`\u{1F4C1} ${folder}`] : [], ...existingId ? [`\u{1F194} \u5DF2\u5B58\u5728: ${existingId.substring(0, 8)}`] : [], ``, - `\u5982\u9700\u4FDD\u7559\u526F\u672C\uFF0C\u8BF7\u53D1\u9001 /duplicate_mode \u5207\u6362\u4E3A\u201C\u751F\u6210\u526F\u672C\u201D\u3002` + `\u5982\u9700\u4FDD\u7559\u526F\u672C\uFF0C\u8BF7\u6253\u5F00\u201C\u91CD\u590D\u6587\u4EF6\u5904\u7406\u201D\u5E76\u9009\u62E9\u201C\u751F\u6210\u526F\u672C\u201D\u3002` ].join("\n"); } function buildDownloadProgress(fileName, downloaded, total, typeEmoji, startTime) { @@ -4462,278 +4444,468 @@ function buildCleanupNotice(deletedCount, freedSpace) { init_telegramState(); // src/services/telegramUpload.ts +import { Api as Api6 } from "telegram"; + +// src/services/telegramAccountRepository.ts init_db(); -import { Api as Api5 } from "telegram"; -import fs8 from "fs"; -import path11 from "path"; -import crypto14 from "crypto"; -import bigInt from "big-integer"; - -// src/utils/thumbnail.ts -import path7 from "path"; -import sharp from "sharp"; -import ffmpeg from "fluent-ffmpeg"; -import fs6 from "fs"; -import crypto8 from "crypto"; -var THUMBNAIL_DIR = path7.resolve(process.env.THUMBNAIL_DIR || "./data/thumbnails"); -if (!fs6.existsSync(THUMBNAIL_DIR)) { - fs6.mkdirSync(THUMBNAIL_DIR, { recursive: true }); -} -var PREVIEW_DIR = path7.resolve(process.env.PREVIEW_DIR || "./data/previews"); -if (!fs6.existsSync(PREVIEW_DIR)) { - fs6.mkdirSync(PREVIEW_DIR, { recursive: true }); -} -function isMp4Like(mimeType, filePath) { - const lower = filePath.toLowerCase(); - return mimeType === "video/mp4" || lower.endsWith(".mp4") || lower.endsWith(".m4v") || lower.endsWith(".mov"); -} -function ffmpegRun(command, label) { - return new Promise((resolve, reject) => { - command.on("start", (cmd) => console.log(`[Preview] ${label} CMD: ${cmd}`)).on("end", () => resolve()).on("error", (err) => reject(err)).run(); - }); -} -async function generateMediaPreview(filePath, storedName, mimeType) { - const absFilePath = path7.resolve(filePath); - if (!fs6.existsSync(absFilePath)) return null; - try { - if (mimeType.startsWith("image/") && mimeType !== "image/gif") { - const previewName = `preview_${crypto8.randomUUID()}.webp`; - const previewPath = path7.join(PREVIEW_DIR, previewName); - await sharp(absFilePath).rotate().resize(2048, 2048, { fit: "inside", withoutEnlargement: true }).webp({ quality: 86, effort: 4 }).toFile(previewPath); - console.log(`[Preview] \u2705 Image preview created: ${previewName}`); - return previewPath; - } - if (mimeType.startsWith("video/")) { - const previewName = `preview_${crypto8.randomUUID()}.mp4`; - const previewPath = path7.join(PREVIEW_DIR, previewName); - const mp4Like = isMp4Like(mimeType, storedName || absFilePath); - if (mp4Like) { - try { - await ffmpegRun( - ffmpeg(absFilePath).outputOptions(["-c copy", "-movflags +faststart"]).output(previewPath), - "Video faststart" - ); - if (fs6.existsSync(previewPath) && fs6.statSync(previewPath).size > 0) { - console.log(`[Preview] \u2705 Video faststart preview created: ${previewName}`); - return previewPath; - } - } catch (copyError) { - console.warn(`[Preview] \u26A0\uFE0F Faststart copy failed, fallback to transcode: ${copyError.message}`); - try { - if (fs6.existsSync(previewPath)) fs6.unlinkSync(previewPath); - } catch { - } - } - } - await ffmpegRun( - ffmpeg(absFilePath).videoCodec("libx264").audioCodec("aac").size("?x720").outputOptions([ - "-preset veryfast", - "-crf 23", - "-movflags +faststart", - "-pix_fmt yuv420p", - "-profile:v baseline", - "-level 3.1", - "-b:a 128k" - ]).output(previewPath), - "Video transcode" - ); - if (fs6.existsSync(previewPath) && fs6.statSync(previewPath).size > 0) { - console.log(`[Preview] \u2705 Video transcoded preview created: ${previewName}`); - return previewPath; - } - } - } catch (error) { - console.error(`[Preview] \u274C Generate preview failed for ${storedName}:`, error.message); - } - return null; -} -async function generateThumbnail(filePath, storedName, mimeType) { - const absFilePath = path7.resolve(filePath); - const thumbName = `thumb_${crypto8.randomUUID()}.webp`; - const thumbPath = path7.join(THUMBNAIL_DIR, thumbName); - console.log(`[Thumbnail] \u{1F680} Starting generation for: ${storedName}`); - console.log(`[Thumbnail] Source: ${absFilePath}`); - console.log(`[Thumbnail] Target: ${thumbPath}`); - console.log(`[Thumbnail] MIME: ${mimeType}`); - if (!fs6.existsSync(absFilePath)) { - console.error(`[Thumbnail] \u274C Source file does not exist: ${absFilePath}`); - return null; - } - if (mimeType === "image/gif") { - console.log(`[Thumbnail] \u23E9 Skipping GIF to preserve animation`); - return null; - } - try { - if (mimeType.startsWith("image/")) { - console.log(`[Thumbnail] \u{1F5BC}\uFE0F Processing image with Sharp...`); - await sharp(absFilePath).resize(400, 300, { fit: "inside", withoutEnlargement: true }).webp({ quality: 80 }).toFile(thumbPath); - console.log(`[Thumbnail] \u2705 Image thumbnail created: ${thumbName}`); - return thumbPath; - } else if (mimeType.startsWith("video/")) { - console.log(`[Thumbnail] \u{1F3AC} Processing video with Ffmpeg...`); - const tryScreenshot = (timestamp) => { - return new Promise((resolve) => { - console.log(`[Thumbnail] \u{1F4F8} Attempting screenshot at ${timestamp}`); - ffmpeg(absFilePath).screenshots({ - count: 1, - folder: THUMBNAIL_DIR, - filename: thumbName, - size: "400x300", - timestamps: [timestamp] - }).on("start", (cmd) => console.log(`[Thumbnail] FFmpeg CMD: ${cmd}`)).on("end", () => { - if (fs6.existsSync(thumbPath)) { - console.log(`[Thumbnail] \u2705 Video thumbnail created at ${timestamp}`); - resolve(true); - } else { - console.warn(`[Thumbnail] \u26A0\uFE0F FFmpeg finished but file not found at ${timestamp}`); - resolve(false); - } - }).on("error", (err) => { - console.error(`[Thumbnail] \u274C FFmpeg error at ${timestamp}:`, err.message); - resolve(false); - }); - }); - }; - let success = await tryScreenshot("10%"); - if (!success) { - console.log(`[Thumbnail] \u{1F504} Retrying at 1s mark...`); - success = await tryScreenshot("00:00:01"); - } - if (success) { - return thumbPath; - } - } - } catch (error) { - console.error(`[Thumbnail] \u274C Unexpected error:`, error.message); - } - return null; -} -async function getImageDimensions(filePath, mimeType) { - const absFilePath = path7.resolve(filePath); - console.log(`[Dimensions] \u{1F4CF} Getting dimensions for: ${absFilePath} (${mimeType})`); - try { - if (mimeType.startsWith("image/")) { - const metadata = await sharp(absFilePath).metadata(); - const result = { width: metadata.width || 0, height: metadata.height || 0 }; - console.log(`[Dimensions] \u2705 Image dimensions: ${result.width}x${result.height}`); - return result; - } else if (mimeType.startsWith("video/")) { - return new Promise((resolve) => { - ffmpeg.ffprobe(absFilePath, (err, metadata) => { - if (err) { - console.error(`[Dimensions] \u274C Probe failed:`, err.message); - resolve({ width: 0, height: 0 }); - } else { - const stream = metadata.streams.find((s) => s.width && s.height); - const result = { - width: stream?.width || 0, - height: stream?.height || 0 - }; - console.log(`[Dimensions] \u2705 Video dimensions: ${result.width}x${result.height}`); - resolve(result); - } - }); - }); - } - } catch (error) { - console.error("Get dimensions failed:", error); - } - return { width: 0, height: 0 }; -} - -// src/services/telegramUpload.ts -init_storage(); - -// src/services/taskAbortRegistry.ts -var TaskAbortRegistry = class { - controllers = /* @__PURE__ */ new Map(); - acquire(taskId) { - const current3 = this.controllers.get(taskId); - if (current3 && !current3.controller.signal.aborted) { - current3.references += 1; - return current3.controller; - } - const controller = new AbortController(); - this.controllers.set(taskId, { controller, references: 1 }); - return controller; - } - get(taskId) { - return this.controllers.get(taskId)?.controller; - } - cancel(taskId, reason = "\u4EFB\u52A1\u5DF2\u53D6\u6D88") { - const entry = this.controllers.get(taskId); - if (!entry || entry.controller.signal.aborted) return false; - entry.controller.abort(reason); - this.controllers.delete(taskId); - return true; - } - release(taskId, controller) { - const entry = this.controllers.get(taskId); - if (!entry || entry.controller !== controller) return; - entry.references -= 1; - if (entry.references <= 0) this.controllers.delete(taskId); - } -}; - -// src/services/storageCooldownGuard.ts -init_storage(); -init_storageCooldown(); -function formatStorageCooldownNotice(cooldownUntil) { - return [ - "\u23F8\uFE0F Google Drive \u4ECA\u65E5\u4E0A\u4F20\u989D\u5EA6\u5DF2\u8FBE\u4E0A\u9650", - "", - "\u5F53\u524D\u4EFB\u52A1\u5DF2\u81EA\u52A8\u6682\u505C\uFF0C\u5269\u4F59\u6587\u4EF6\u4E0D\u4F1A\u4E22\u5931\uFF1B\u65E0\u9700\u70B9\u51FB\u201C\u7EE7\u7EED\u201D\u3002", - describeStorageCooldownRecovery(cooldownUntil), - "", - `\u6062\u590D\u65F6\u95F4\uFF1A${cooldownUntil.toISOString()}` - ].join("\n"); -} -function buildStorageCooldownHttpError(error) { +init_credentialCrypto(); +function mapAccount(row) { return { - status: 429, - body: { - error: error.message || "Google Drive \u4ECA\u65E5\u4E0A\u4F20\u989D\u5EA6\u5DF2\u8FBE\u4E0A\u9650\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002", - code: "storage_account_cooling", - provider: error.provider, - reason: error.reason, - retryAt: error.cooldownUntil.toISOString() - } + id: String(row.id), + telegramUserId: String(row.telegram_user_id || ""), + username: row.username || null, + displayName: row.display_name || null, + session: String(row.session_ciphertext || ""), + enabled: Boolean(row.enabled), + healthState: row.health_state, + cooldownUntil: row.cooldown_until || null, + weight: Number(row.weight || 1), + priority: Number(row.priority || 0), + maxConnections: Number(row.max_connections || 1), + lastError: row.last_error || null, + isLegacy: Boolean(row.is_legacy) }; } -function sendStorageCooldownHttpError(res, error) { - const payload = buildStorageCooldownHttpError(error); - res.status(payload.status).json(payload.body); +function mapAccess(row) { + return { + accountId: String(row.account_id), + sourceKey: String(row.source_key), + scope: row.scope, + accessState: row.access_state, + lastError: row.last_error || null, + checkedAt: row.checked_at || null + }; } -async function getStorageCooldown(target) { - if (target.provider.name !== "google_drive" || !target.accountId) return null; - return getStorageAccountCooldown(target.accountId, target.provider.name, STORAGE_COOLDOWN_REASON_DAILY_UPLOAD_LIMIT); -} -async function assertStorageTargetWritable(target) { - const cooldown = await getStorageCooldown(target); - if (!cooldown) return; - throw new StorageQuotaCooldownError("Google Drive \u4ECA\u65E5\u4E0A\u4F20\u989D\u5EA6\u5DF2\u8FBE\u4E0A\u9650\uFF0C\u8BF7\u7B49\u5F85\u81EA\u52A8\u6062\u590D\u540E\u518D\u4E0A\u4F20\uFF0C\u6216\u4E34\u65F6\u5207\u6362\u5176\u5B83\u5B58\u50A8\u6E90\u3002", { - provider: cooldown.provider, - reason: cooldown.reason, - storageAccountId: cooldown.storageAccountId, - cooldownUntil: cooldown.cooldownUntil +var TelegramAccountRepository = class { + constructor(db = { query }) { + this.db = db; + } + db; + async migrateLegacySystemSettings() { + const result = await this.db.query(` + INSERT INTO telegram_user_accounts + (telegram_user_id, username, session_ciphertext, enabled, health_state, is_legacy) + SELECT + COALESCE(NULLIF(user_id.value, ''), 'legacy'), + NULLIF(username.value, ''), + session.value, + COALESCE(enabled.value, 'false') = 'true', + CASE WHEN COALESCE(enabled.value, 'false') = 'true' THEN 'degraded' ELSE 'healthy' END, + TRUE + FROM system_settings session + LEFT JOIN system_settings user_id ON user_id.key = 'telegram_user_id' + LEFT JOIN system_settings username ON username.key = 'telegram_user_username' + LEFT JOIN system_settings enabled ON enabled.key = 'telegram_user_download_enabled' + WHERE session.key = 'telegram_user_session' AND session.value <> '' + AND NOT EXISTS ( + SELECT 1 FROM telegram_user_accounts + WHERE is_legacy = TRUE AND deleted_at IS NULL + ) + ON CONFLICT (telegram_user_id) DO NOTHING + RETURNING id + `); + return result.rows[0]?.id ? String(result.rows[0].id) : null; + } + async upsertAccount(input) { + const result = await this.db.query(` + INSERT INTO telegram_user_accounts + (telegram_user_id, username, display_name, session_ciphertext, enabled, health_state, weight, priority, max_connections, is_legacy, last_error, session_expired_at) + VALUES ($1, $2, $3, $4, $5, 'healthy', $6, $7, $8, $9, NULL, NULL) + ON CONFLICT (telegram_user_id) DO UPDATE SET + username = EXCLUDED.username, display_name = EXCLUDED.display_name, + session_ciphertext = EXCLUDED.session_ciphertext, enabled = EXCLUDED.enabled, + health_state = 'healthy', weight = EXCLUDED.weight, priority = EXCLUDED.priority, + max_connections = EXCLUDED.max_connections, is_legacy = EXCLUDED.is_legacy, last_error = NULL, + session_expired_at = NULL, deleted_at = NULL, updated_at = NOW() + RETURNING * + `, [ + input.telegramUserId, + input.username || null, + input.displayName || null, + 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 + ]); + return mapAccount(result.rows[0]); + } + async listEnabledAccounts() { + const result = await this.db.query(` + SELECT * FROM telegram_user_accounts + WHERE enabled = TRUE AND deleted_at IS NULL + ORDER BY priority DESC, created_at, id + `); + return result.rows.map(mapAccount); + } + async listAccounts() { + const result = await this.db.query(` + SELECT * FROM telegram_user_accounts WHERE deleted_at IS NULL + ORDER BY priority DESC, created_at, id + `); + return result.rows.map(mapAccount); + } + async getAccount(accountId) { + const result = await this.db.query("SELECT * FROM telegram_user_accounts WHERE id = $1 AND deleted_at IS NULL", [accountId]); + return result.rows[0] ? mapAccount(result.rows[0]) : null; + } + async setEnabled(accountId, enabled) { + const result = await this.db.query( + `UPDATE telegram_user_accounts SET enabled = $2, updated_at = NOW() + WHERE id = $1 AND deleted_at IS NULL`, + [accountId, enabled] + ); + return result.rowCount === 1; + } + async deleteAccount(accountId) { + await this.db.query("DELETE FROM telegram_account_source_access WHERE account_id = $1", [accountId]); + const result = await this.db.query( + `UPDATE telegram_user_accounts + SET enabled = FALSE, session_ciphertext = '', health_state = 'session_expired', + cooldown_until = NULL, deleted_at = NOW(), updated_at = NOW() + WHERE id = $1 AND deleted_at IS NULL`, + [accountId] + ); + return result.rowCount === 1; + } + async listAccessForAccount(accountId) { + const result = await this.db.query(` + SELECT * FROM telegram_account_source_access + WHERE account_id = $1 ORDER BY checked_at DESC NULLS LAST, source_key, scope + `, [accountId]); + return result.rows.map(mapAccess); + } + async getAccessSummaryForAccount(accountId) { + const result = await this.db.query(` + SELECT COUNT(*)::int AS total, + COUNT(*) FILTER (WHERE access_state = 'allowed')::int AS allowed, + COUNT(*) FILTER (WHERE access_state = 'denied')::int AS denied, + COUNT(*) FILTER (WHERE access_state = 'unknown')::int AS unknown, + MAX(checked_at) AS last_checked_at + FROM telegram_account_source_access + WHERE account_id = $1 AND scope = 'download' + `, [accountId]); + const row = result.rows[0] || {}; + const total = Number(row.total || 0); + return { allowed: Number(row.allowed || 0), denied: Number(row.denied || 0), unknown: Number(row.unknown || 0), total, lastCheckedAt: row.last_checked_at || null }; + } + async updateSession(accountId, session) { + 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() + WHERE id = $1 AND deleted_at IS NULL + `, [accountId, encryptCredential(session)]); + return result.rowCount === 1; + } + async recordHealthy(accountId) { + const result = await this.db.query(` + UPDATE telegram_user_accounts SET health_state = 'healthy', last_error = NULL, + last_connected_at = NOW(), updated_at = NOW() WHERE id = $1 AND deleted_at IS NULL + `, [accountId]); + return result.rowCount === 1; + } + async recordFailure(accountId, error) { + const result = await this.db.query(` + UPDATE telegram_user_accounts SET health_state = 'degraded', last_error = $2, + last_failure_at = NOW(), updated_at = NOW() WHERE id = $1 AND deleted_at IS NULL + `, [accountId, error]); + return result.rowCount === 1; + } + async markCooldown(accountId, seconds, error = null) { + 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'), + health_state = 'degraded', last_error = $3, last_failure_at = NOW(), updated_at = NOW() + WHERE id = $1 AND deleted_at IS NULL + `, [accountId, cooldownSeconds, error]); + return result.rowCount === 1; + } + async markSessionExpired(accountId, error = null) { + const result = await this.db.query(` + UPDATE telegram_user_accounts SET health_state = 'session_expired', session_expired_at = NOW(), + cooldown_until = NULL, last_error = $2, last_failure_at = NOW(), updated_at = NOW() + WHERE id = $1 AND deleted_at IS NULL + `, [accountId, error]); + return result.rowCount === 1; + } + async markSourceAccess(accountId, sourceKey, scope, accessState, error = null) { + const result = await this.db.query(` + INSERT INTO telegram_account_source_access + (account_id, source_key, scope, access_state, last_error, checked_at) + VALUES ($1, $2, $3, $4, $5, NOW()) + ON CONFLICT (account_id, source_key, scope) DO UPDATE SET + access_state = EXCLUDED.access_state, last_error = EXCLUDED.last_error, + checked_at = NOW(), updated_at = NOW() + RETURNING * + `, [accountId, sourceKey, scope, accessState, error]); + return mapAccess(result.rows[0]); + } + async probeSourceAccess(accountId, sourceKey, scope, accessState, error = null) { + return this.markSourceAccess(accountId, sourceKey, scope, accessState, error); + } + async getSourceAccess(accountId, sourceKey, scope) { + const result = await this.db.query(` + SELECT * FROM telegram_account_source_access + WHERE account_id = $1 AND source_key = $2 AND scope = $3 + `, [accountId, sourceKey, scope]); + return result.rows[0] ? mapAccess(result.rows[0]) : null; + } + async listSourceAccess(sourceKey, scope) { + const result = await this.db.query(` + SELECT * FROM telegram_account_source_access + WHERE source_key = $1 AND scope = $2 ORDER BY checked_at DESC NULLS LAST, account_id + `, [sourceKey, scope]); + return result.rows.map(mapAccess); + } + async getLatestAccessCheckForAccount(accountId) { + const result = await this.db.query( + "SELECT MAX(checked_at) AS checked_at FROM telegram_account_source_access WHERE account_id = $1", + [accountId] + ); + return result.rows[0]?.checked_at || null; + } + async startDownloadAttempt(input) { + const result = await this.db.query(` + INSERT INTO telegram_download_attempts + (account_id, source_key, scope, job_id, item_id, lease_token, status) + VALUES ($1, $2, $3, $4, $5, $6, 'running') RETURNING id + `, [input.accountId, input.sourceKey, input.scope || "download", input.jobId || null, input.itemId || null, input.leaseToken || null]); + return String(result.rows[0].id); + } + async finishDownloadAttempt(attemptId, status, error = null) { + const result = await this.db.query(` + UPDATE telegram_download_attempts SET status = $2, error = $3, finished_at = NOW() + WHERE id = $1 AND status = 'running' + `, [attemptId, status, error]); + return result.rowCount === 1; + } +}; +var telegramAccountRepository = new TelegramAccountRepository(); + +// src/services/telegramUserClientPool.ts +init_credentialCrypto(); +import { TelegramClient } from "telegram"; +import { StringSession } from "telegram/sessions/index.js"; + +// src/services/telegramAccountScheduler.ts +function selectWeightedLeastConnectedTelegramAccount(candidates, options = {}) { + const now = (options.now || /* @__PURE__ */ new Date()).getTime(); + const excluded = new Set(options.excludeAccountIds || []); + const runnable = candidates.filter((candidate) => { + const cooldownUntil = candidate.cooldownUntil ? new Date(candidate.cooldownUntil).getTime() : 0; + return candidate.enabled && candidate.healthState !== "session_expired" && candidate.sourceAccessState !== "denied" && !excluded.has(candidate.accountId) && candidate.activeConnections < Math.max(1, candidate.maxConnections) && (!Number.isFinite(cooldownUntil) || cooldownUntil <= now); }); -} -function isStorageCooldownError(error) { - return isStorageQuotaCooldownError(error); + runnable.sort((left, right) => { + const accessDifference = Number(right.sourceAccessState === "allowed") - Number(left.sourceAccessState === "allowed"); + if (accessDifference) return accessDifference; + const leftLoad = (left.activeConnections + 1) / Math.max(Number.EPSILON, left.weight); + const rightLoad = (right.activeConnections + 1) / Math.max(Number.EPSILON, right.weight); + if (leftLoad !== rightLoad) return leftLoad - rightLoad; + const priorityDifference = (right.priority || 0) - (left.priority || 0); + if (priorityDifference) return priorityDifference; + return left.accountId.localeCompare(right.accountId); + }); + return runnable[0] || null; } -// src/services/telegramUpload.ts -init_storageCooldown(); +// src/services/telegramUserClientPool.ts +function errorName(error) { + const value = error; + return String(value?.errorMessage || value?.message || error || "Telegram account connection failed"); +} +function isTelegramSessionExpiredError(error) { + return /(AUTH_KEY_UNREGISTERED|SESSION_(REVOKED|EXPIRED)|USER_DEACTIVATED|SESSION_EXPIRED)/i.test(errorName(error)); +} +var TelegramUserClientPool = class { + constructor(deps) { + this.deps = deps; + } + deps; + entries = /* @__PURE__ */ new Map(); + credentials = null; + initializationTail = Promise.resolve(); + async initialize(credentials) { + const run = this.initializationTail.then(async () => { + await this.shutdown(); + this.credentials = credentials; + await this.deps.repository.migrateLegacySystemSettings(); + const accounts = await this.deps.repository.listEnabledAccounts(); + for (const account of accounts) await this.connectAccount(account); + }); + this.initializationTail = run.catch(() => void 0); + await run; + } + async refresh() { + if (!this.credentials) return; + await this.initialize(this.credentials); + } + async connectAccount(account) { + if (!this.credentials || !account.enabled || account.healthState === "session_expired") return; + let client2 = null; + try { + const session = this.deps.decryptSession(account.session); + client2 = this.deps.createClient(session, this.credentials, account.id); + await client2.connect(); + if (!await client2.checkAuthorization()) throw new Error("SESSION_EXPIRED"); + await client2.getMe(); + const saved = this.deps.saveSession?.(client2) || 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: client2, + activeConnections: 0 + }); + } catch (error) { + if (client2) await this.closeClient(client2); + const message = errorName(error); + if (isTelegramSessionExpiredError(error)) await this.deps.repository.markSessionExpired(account.id, message); + else await this.deps.repository.recordFailure(account.id, message); + } + } + async select(sourceKey, options = {}) { + const scope = options.scope || "download"; + const access = await this.deps.repository.listSourceAccess(sourceKey, scope); + const accessByAccount = new Map(access.map((row) => [row.accountId, row])); + const selected3 = selectWeightedLeastConnectedTelegramAccount([...this.entries.values()].map((entry2) => ({ + accountId: entry2.account.id, + enabled: entry2.account.enabled, + healthState: entry2.account.healthState, + cooldownUntil: entry2.account.cooldownUntil, + weight: entry2.account.weight, + priority: entry2.account.priority, + activeConnections: entry2.activeConnections, + maxConnections: entry2.account.maxConnections, + sourceAccessState: accessByAccount.get(entry2.account.id)?.accessState || "unknown" + })), options); + if (!selected3) return null; + const entry = this.entries.get(selected3.accountId); + if (!entry) return null; + entry.activeConnections += 1; + let released = false; + return { + accountId: entry.account.id, + client: entry.client, + release: () => { + if (released) return; + released = true; + entry.activeConnections = Math.max(0, entry.activeConnections - 1); + } + }; + } + getDefaultClient() { + return [...this.entries.values()].sort((left, right) => right.account.priority - left.account.priority || left.account.id.localeCompare(right.account.id))[0]?.client || null; + } + getAccountClient(accountId) { + return this.entries.get(accountId)?.client || null; + } + getActiveConnections(accountId) { + return this.entries.get(accountId)?.activeConnections || 0; + } + getReadyAccountIds() { + return [...this.entries.keys()]; + } + getRuntimeState() { + return [...this.entries.values()].map((entry) => ({ + accountId: entry.account.id, + connected: Boolean(entry.client.connected), + activeConnections: entry.activeConnections + })); + } + updateCooldown(accountId, cooldownUntil, error) { + const entry = this.entries.get(accountId); + if (entry) entry.account = { ...entry.account, cooldownUntil, healthState: "degraded", lastError: error }; + } + updateSourceAccess(_accountId, _sourceKey, _scope, _state) { + } + async expireAccount(accountId) { + const entry = this.entries.get(accountId); + this.entries.delete(accountId); + if (entry) await this.closeClient(entry.client); + } + async shutdown() { + const entries = [...this.entries.values()]; + this.entries.clear(); + await Promise.all(entries.map((entry) => this.closeClient(entry.client))); + } + async closeClient(client2) { + try { + await client2.disconnect(); + } catch { + } + try { + await client2.destroy(); + } catch { + } + } +}; +var telegramUserClientPool = new TelegramUserClientPool({ + repository: telegramAccountRepository, + decryptSession: decryptCredential, + createClient: (session, credentials) => new TelegramClient( + new StringSession(session), + credentials.apiId, + credentials.apiHash, + { + connectionRetries: 15, + retryDelay: 2e3, + useWSS: false, + deviceModel: "TG Vault User Downloader", + systemVersion: "1.0.0", + appVersion: "1.0.0", + floodSleepThreshold: 120 + } + ), + saveSession: (client2) => client2.session.save() +}); +var currentCredentials = null; +async function initializeTelegramUserClientPool(credentials) { + currentCredentials = credentials; + await telegramUserClientPool.initialize(credentials); +} +async function reloadTelegramUserClientPool() { + if (currentCredentials) await telegramUserClientPool.initialize(currentCredentials); +} +async function listTelegramUserAccounts() { + const accounts = await telegramAccountRepository.listAccounts(); + const ready = new Set(telegramUserClientPool.getReadyAccountIds()); + return accounts.map(({ session: _session, ...account }) => ({ + ...account, + connected: ready.has(account.id), + activeConnections: telegramUserClientPool.getActiveConnections(account.id) + })); +} +async function upsertTelegramUserAccount(input) { + const account = await telegramAccountRepository.upsertAccount(input); + await reloadTelegramUserClientPool(); + const { session: _session, ...publicAccount } = account; + return { ...publicAccount, connected: telegramUserClientPool.getReadyAccountIds().includes(account.id), activeConnections: 0 }; +} +async function setTelegramUserAccountEnabled(accountId, enabled) { + const changed = await telegramAccountRepository.setEnabled(accountId, enabled); + if (changed) await reloadTelegramUserClientPool(); + return changed; +} +async function deleteTelegramUserAccount(accountId) { + await telegramUserClientPool.expireAccount(accountId); + return telegramAccountRepository.deleteAccount(accountId); +} +var getTelegramAccountSourceAccess = telegramAccountRepository.getSourceAccess.bind(telegramAccountRepository); +var listTelegramAccountSourceAccess = telegramAccountRepository.listSourceAccess.bind(telegramAccountRepository); +var probeTelegramAccountSourceAccess = telegramAccountRepository.probeSourceAccess.bind(telegramAccountRepository); +var startTelegramDownloadAttempt = telegramAccountRepository.startDownloadAttempt.bind(telegramAccountRepository); +var finishTelegramDownloadAttempt = telegramAccountRepository.finishDownloadAttempt.bind(telegramAccountRepository); -// src/services/telegramUserClient.ts -import fs7 from "node:fs"; -import { Api as Api2, TelegramClient as TelegramClient2 } from "telegram"; -import { StringSession as StringSession2 } from "telegram/sessions/index.js"; +// src/services/telegramMultiAccountLogin.ts +import { Api as Api2, TelegramClient as TelegramClient3 } from "telegram"; +import { StringSession as StringSession3 } from "telegram/sessions/index.js"; +import { Raw } from "telegram/events/index.js"; // src/services/telegramBotConfig.ts init_settings(); -import { TelegramClient } from "telegram"; -import { StringSession } from "telegram/sessions/index.js"; +import { TelegramClient as TelegramClient2 } from "telegram"; +import { StringSession as StringSession2 } from "telegram/sessions/index.js"; // src/services/telegramBotStatus.ts var requiredOverride = null; @@ -4917,7 +5089,7 @@ async function getTelegramBotPublicConfig() { }; } async function testTelegramBotCredentials(credentials) { - const client2 = new TelegramClient(new StringSession(""), credentials.apiId, credentials.apiHash, { + const client2 = new TelegramClient2(new StringSession2(""), credentials.apiId, credentials.apiHash, { connectionRetries: 3, retryDelay: 1e3, useWSS: false, @@ -4977,8 +5149,8 @@ async function deleteTelegramBotConfig() { lastBotIdentity = null; } -// src/services/telegramUserWebLogin.ts -import crypto9 from "node:crypto"; +// src/services/telegramMultiAccountLoginFlows.ts +import crypto8 from "node:crypto"; var TelegramUserLoginFlowError = class extends Error { constructor(code, message) { super(message); @@ -4992,6 +5164,1103 @@ function telegramErrorName(error) { return String(error.errorMessage || error.message || ""); } function normalizeAccount(me) { + const displayName = [me.firstName, me.lastName].filter(Boolean).join(" ").trim(); + return { + userId: String(me.id ?? ""), + username: me.username || null, + displayName: displayName || null + }; +} +var TelegramMultiAccountLoginFlows = class { + constructor(deps) { + this.deps = deps; + this.now = deps.now || Date.now; + this.ttlMs = deps.ttlMs ?? 5 * 6e4; + this.maxErrors = deps.maxErrors ?? 3; + } + deps; + flows = /* @__PURE__ */ new Map(); + ttlMs; + maxErrors; + now; + /** Backward-compatible alias for the existing phone-login service API. */ + start(owner, rawPhone) { + return this.startPhone(owner, rawPhone); + } + async startPhone(owner, rawPhone) { + const phone = String(rawPhone || "").replace(/[\s()-]/g, ""); + if (!/^\+[1-9]\d{6,14}$/.test(phone)) { + throw new TelegramUserLoginFlowError("INVALID_PHONE", "\u8BF7\u8F93\u5165\u542B\u56FD\u5BB6\u533A\u53F7\u7684\u6709\u6548\u624B\u673A\u53F7"); + } + await this.removeOwnerFlows(owner); + const credentials = await this.requireCredentials(); + const client2 = this.deps.createClient(credentials); + try { + await client2.connect(); + const sent = await client2.sendCode(credentials, phone); + const id = this.newFlowId(); + const expiresAt = this.now() + this.ttlMs; + const flow = { + id, + owner, + expiresAt, + errors: 0, + client: client2, + cleanupTimer: this.scheduleCleanup(id, expiresAt), + kind: "phone", + phone, + phoneCodeHash: sent.phoneCodeHash, + step: "code" + }; + this.flows.set(id, flow); + return { + flowId: id, + delivery: sent.isCodeViaApp ? "app" : "sms", + expiresAt: new Date(expiresAt).toISOString() + }; + } catch (error) { + await this.closeClient(client2); + throw this.publicError(error); + } + } + async startQr(owner) { + await this.removeOwnerFlows(owner); + const credentials = await this.requireCredentials(); + const client2 = this.deps.createClient(credentials); + const id = this.newFlowId(); + const expiresAt = this.now() + this.ttlMs; + const flow = { + id, + owner, + expiresAt, + errors: 0, + client: client2, + cleanupTimer: this.scheduleCleanup(id, expiresAt), + kind: "qr", + status: "pending", + qrData: null, + tokenExpiresAt: null, + version: 0, + passwordHint: null, + account: null, + error: null, + operation: Promise.resolve() + }; + try { + await client2.connect(); + client2.setQrLoginTokenHandler(async () => { + try { + await this.advanceQr(flow); + } catch (error) { + await this.failQr(flow, error); + } + }); + this.flows.set(id, flow); + await this.advanceQr(flow); + return this.qrResponse(flow); + } catch (error) { + this.flows.delete(id); + clearTimeout(flow.cleanupTimer); + client2.setQrLoginTokenHandler(null); + await this.closeClient(client2); + throw this.publicError(error); + } + } + async refreshQr(owner, flowId) { + const flow = await this.requireQrFlow(owner, flowId); + if (flow.status !== "pending") return this.qrResponse(flow); + try { + await this.advanceQr(flow); + } catch (error) { + await this.failQr(flow, error); + throw this.publicError(error); + } + return this.qrResponse(flow); + } + async getQrStatus(owner, flowId) { + return this.qrResponse(await this.requireQrFlow(owner, flowId)); + } + async submitCode(owner, flowId, rawCode) { + const flow = await this.requirePhoneFlow(owner, flowId, "code"); + const code = String(rawCode || "").replace(/\s/g, ""); + if (!/^\d{5,6}$/.test(code)) { + throw new TelegramUserLoginFlowError("INVALID_CODE", "\u8BF7\u8F93\u5165\u6709\u6548\u9A8C\u8BC1\u7801"); + } + try { + const result = await this.requireClient(flow).signInCode(flow.phone, flow.phoneCodeHash, code); + if (result === "password_needed") { + flow.step = "password"; + return { step: "password_required" }; + } + return await this.completePhone(flow); + } catch (error) { + if (telegramErrorName(error).includes("SESSION_PASSWORD_NEEDED")) { + flow.step = "password"; + return { step: "password_required" }; + } + await this.recordError(flow); + throw this.publicError(error, "INVALID_CODE"); + } + } + async submitPassword(owner, flowId, password) { + const flow = await this.requireFlow(owner, flowId); + if (!password) throw new TelegramUserLoginFlowError("INVALID_PASSWORD", "\u8BF7\u8F93\u5165\u4E24\u6B65\u9A8C\u8BC1\u5BC6\u7801"); + if (flow.kind === "phone" && flow.step !== "password") { + throw new TelegramUserLoginFlowError("FLOW_NOT_FOUND", "\u767B\u5F55\u6B65\u9AA4\u65E0\u6548\uFF0C\u8BF7\u91CD\u65B0\u5F00\u59CB\u767B\u5F55"); + } + if (flow.kind === "qr" && flow.status !== "password_required") { + throw new TelegramUserLoginFlowError("FLOW_NOT_FOUND", "\u767B\u5F55\u6B65\u9AA4\u65E0\u6548\uFF0C\u8BF7\u91CD\u65B0\u5F00\u59CB\u767B\u5F55"); + } + try { + await this.requireClient(flow).signInPassword(password); + if (flow.kind === "phone") return await this.completePhone(flow); + const account = await this.authorizeAndClose(flow); + flow.status = "complete"; + flow.account = account; + flow.qrData = null; + flow.tokenExpiresAt = null; + flow.passwordHint = null; + return { step: "complete", account }; + } catch (error) { + await this.recordError(flow); + throw this.publicError(error, "INVALID_PASSWORD"); + } + } + async cancel(owner, flowId) { + const flow = await this.requireFlow(owner, flowId); + await this.deleteAndClose(flow); + return { success: true }; + } + async advanceQr(flow) { + const previous = flow.operation; + let release; + flow.operation = new Promise((resolve) => { + release = resolve; + }); + await previous; + try { + if (flow.status !== "pending" || !this.flows.has(flow.id)) return; + const result = await this.requireClient(flow).exportQrLoginToken(); + if (result.kind === "token") { + flow.qrData = `tg://login?token=${result.token.toString("base64url")}`; + flow.tokenExpiresAt = Math.min(result.expiresAt, flow.expiresAt); + flow.version += 1; + return; + } + flow.qrData = null; + flow.tokenExpiresAt = null; + if (result.kind === "password_required") { + flow.status = "password_required"; + flow.passwordHint = result.hint || null; + return; + } + flow.account = await this.authorizeAndClose(flow); + flow.status = "complete"; + } finally { + release(); + } + } + async failQr(flow, error) { + if (!this.flows.has(flow.id) || flow.status === "complete") return; + flow.status = "error"; + flow.qrData = null; + flow.tokenExpiresAt = null; + flow.error = this.publicError(error).message; + await this.detachAndClose(flow); + } + async completePhone(flow) { + try { + const account = await this.authorize(flow); + this.flows.delete(flow.id); + clearTimeout(flow.cleanupTimer); + return { step: "complete", account }; + } finally { + await this.detachAndClose(flow); + } + } + async authorizeAndClose(flow) { + try { + return await this.authorize(flow); + } finally { + await this.detachAndClose(flow); + } + } + async authorize(flow) { + const client2 = this.requireClient(flow); + const account = normalizeAccount(await client2.getMe()); + if (!account.userId) throw new TelegramUserLoginFlowError("TELEGRAM_ERROR", "Telegram \u767B\u5F55\u672A\u8FD4\u56DE\u7528\u6237\u8EAB\u4EFD"); + await this.deps.onAuthorized({ session: client2.saveSession(), account }); + return account; + } + qrResponse(flow) { + const response = { + flowId: flow.id, + status: flow.status, + qrData: flow.status === "pending" ? flow.qrData : null, + expiresAt: new Date(flow.status === "pending" && flow.tokenExpiresAt ? flow.tokenExpiresAt : flow.expiresAt).toISOString(), + version: flow.version + }; + if (flow.status === "password_required") response.passwordHint = flow.passwordHint; + if (flow.status === "complete" && flow.account) response.account = flow.account; + if (flow.status === "error" && flow.error) response.error = flow.error; + return response; + } + async requireFlow(owner, id) { + const flow = this.flows.get(String(id || "")); + if (!flow || flow.owner !== owner) { + throw new TelegramUserLoginFlowError("FLOW_NOT_FOUND", "\u767B\u5F55\u6D41\u7A0B\u4E0D\u5B58\u5728\uFF0C\u8BF7\u91CD\u65B0\u5F00\u59CB\u767B\u5F55"); + } + if (flow.expiresAt <= this.now()) { + await this.deleteAndClose(flow); + throw new TelegramUserLoginFlowError("FLOW_EXPIRED", "\u767B\u5F55\u6D41\u7A0B\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u5F00\u59CB\u767B\u5F55"); + } + if (flow.errors >= this.maxErrors) { + await this.deleteAndClose(flow); + throw new TelegramUserLoginFlowError("TOO_MANY_ERRORS", "\u9519\u8BEF\u6B21\u6570\u8FC7\u591A\uFF0C\u8BF7\u91CD\u65B0\u5F00\u59CB\u767B\u5F55"); + } + return flow; + } + async requirePhoneFlow(owner, id, step) { + const flow = await this.requireFlow(owner, id); + if (flow.kind !== "phone" || flow.step !== step) { + throw new TelegramUserLoginFlowError("FLOW_NOT_FOUND", "\u767B\u5F55\u6B65\u9AA4\u65E0\u6548\uFF0C\u8BF7\u91CD\u65B0\u5F00\u59CB\u767B\u5F55"); + } + return flow; + } + async requireQrFlow(owner, id) { + const flow = await this.requireFlow(owner, id); + if (flow.kind !== "qr") { + throw new TelegramUserLoginFlowError("FLOW_NOT_FOUND", "\u4E8C\u7EF4\u7801\u767B\u5F55\u6D41\u7A0B\u4E0D\u5B58\u5728\uFF0C\u8BF7\u91CD\u65B0\u5F00\u59CB\u767B\u5F55"); + } + return flow; + } + async recordError(flow) { + flow.errors += 1; + if (flow.errors >= this.maxErrors) await this.deleteAndClose(flow); + } + async removeOwnerFlows(owner) { + for (const flow of [...this.flows.values()]) { + if (flow.owner === owner) await this.deleteAndClose(flow); + } + } + scheduleCleanup(id, expiresAt) { + const timer = setTimeout(() => { + const flow = this.flows.get(id); + if (flow) void this.deleteAndClose(flow); + }, Math.max(1, expiresAt - this.now())); + timer.unref?.(); + return timer; + } + async deleteAndClose(flow) { + this.flows.delete(flow.id); + clearTimeout(flow.cleanupTimer); + await this.detachAndClose(flow); + } + async detachAndClose(flow) { + const client2 = flow.client; + flow.client = null; + if (!client2) return; + if (flow.kind === "qr") client2.setQrLoginTokenHandler(null); + await this.closeClient(client2); + } + requireClient(flow) { + if (!flow.client) throw new TelegramUserLoginFlowError("FLOW_NOT_FOUND", "\u767B\u5F55\u6D41\u7A0B\u5DF2\u7ECF\u7ED3\u675F"); + return flow.client; + } + async requireCredentials() { + const credentials = await this.deps.credentials(); + if (!credentials?.apiId || !credentials.apiHash) { + throw new TelegramUserLoginFlowError("API_NOT_CONFIGURED", "\u8BF7\u5148\u914D\u7F6E\u6709\u6548\u7684 Telegram API ID \u548C API Hash"); + } + return credentials; + } + newFlowId() { + return crypto8.randomBytes(24).toString("base64url"); + } + publicError(error, fallback = "TELEGRAM_ERROR") { + if (error instanceof TelegramUserLoginFlowError) return error; + const name = telegramErrorName(error); + if (/PHONE_CODE_(INVALID|EXPIRED|EMPTY)/.test(name)) { + return new TelegramUserLoginFlowError("INVALID_CODE", "\u9A8C\u8BC1\u7801\u65E0\u6548\u6216\u5DF2\u8FC7\u671F"); + } + if (/PASSWORD_HASH_INVALID/.test(name)) { + return new TelegramUserLoginFlowError("INVALID_PASSWORD", "\u4E24\u6B65\u9A8C\u8BC1\u5BC6\u7801\u9519\u8BEF"); + } + if (/PHONE_NUMBER_INVALID/.test(name)) { + return new TelegramUserLoginFlowError("INVALID_PHONE", "\u624B\u673A\u53F7\u65E0\u6548"); + } + return new TelegramUserLoginFlowError( + fallback, + fallback === "INVALID_PASSWORD" ? "\u4E24\u6B65\u9A8C\u8BC1\u5BC6\u7801\u9519\u8BEF" : fallback === "INVALID_CODE" ? "\u9A8C\u8BC1\u7801\u65E0\u6548\u6216\u5DF2\u8FC7\u671F" : "Telegram \u767B\u5F55\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5" + ); + } + async closeClient(client2) { + try { + await client2.disconnect(); + } catch { + } + try { + await client2.destroy(); + } catch { + } + } +}; + +// src/services/telegramMultiAccountLogin.ts +var adapter = null; +function registerTelegramMultiAccountAuthorizedAdapter(next) { + adapter = next; +} +async function getCredentials() { + const effective = await getEffectiveTelegramBotConfig(); + if (effective.credentials) { + return { apiId: effective.credentials.apiId, apiHash: effective.credentials.apiHash }; + } + const apiId = Number.parseInt(process.env.TELEGRAM_API_ID || "0", 10); + const apiHash = process.env.TELEGRAM_API_HASH || ""; + return apiId && apiHash ? { apiId, apiHash } : null; +} +function makeClient(credentials) { + return new TelegramClient3(new StringSession3(""), credentials.apiId, credentials.apiHash, { + connectionRetries: 15, + retryDelay: 2e3, + useWSS: false, + deviceModel: "TG Vault Multi-Account Login", + systemVersion: "1.0.0", + appVersion: "1.0.0", + floodSleepThreshold: 120 + }); +} +var GramJsMultiAccountLoginClient = class { + constructor(client2, credentials) { + this.client = client2; + this.credentials = credentials; + } + client; + credentials; + qrHandler = null; + qrEvent = new Raw({ types: [Api2.UpdateLoginToken] }); + async connect() { + await this.client.connect(); + } + sendCode(credentials, phone) { + return this.client.sendCode(credentials, phone); + } + async signInCode(phone, phoneCodeHash, code) { + try { + await this.client.invoke(new Api2.auth.SignIn({ phoneNumber: phone, phoneCodeHash, phoneCode: code })); + return "authorized"; + } catch (error) { + if (this.errorName(error).includes("SESSION_PASSWORD_NEEDED")) return "password_needed"; + throw error; + } + } + async signInPassword(password) { + let captured; + await this.client.signInWithPassword(this.credentials, { + password: async () => password, + onError: async (error) => { + captured = error; + return true; + } + }).catch((error) => { + throw captured || error; + }); + } + setQrLoginTokenHandler(handler) { + if (this.qrHandler) this.client.removeEventHandler(this.qrHandler, this.qrEvent); + this.qrHandler = handler; + if (handler) this.client.addEventHandler(handler, this.qrEvent); + } + async exportQrLoginToken() { + let result; + try { + result = await this.client.invoke(new Api2.auth.ExportLoginToken({ + apiId: this.credentials.apiId, + apiHash: this.credentials.apiHash, + exceptIds: [] + })); + } catch (error) { + if (this.errorName(error).includes("SESSION_PASSWORD_NEEDED")) { + return { kind: "password_required" }; + } + throw error; + } + if (result instanceof Api2.auth.LoginToken) { + return { + kind: "token", + token: Buffer.from(result.token), + expiresAt: Number(result.expires) * 1e3 + }; + } + let imported = result; + if (result instanceof Api2.auth.LoginTokenMigrateTo) { + await this.client._switchDC(result.dcId); + imported = await this.client.invoke(new Api2.auth.ImportLoginToken({ token: result.token })); + } + if (imported instanceof Api2.auth.LoginTokenSuccess) return { kind: "authorized" }; + if (imported instanceof Api2.auth.LoginToken) { + return { + kind: "token", + token: Buffer.from(imported.token), + expiresAt: Number(imported.expires) * 1e3 + }; + } + throw new Error("TELEGRAM_QR_LOGIN_UNEXPECTED_RESPONSE"); + } + async getMe() { + return await this.client.getMe(); + } + saveSession() { + return this.client.session.save(); + } + disconnect() { + return this.client.disconnect(); + } + destroy() { + return this.client.destroy(); + } + errorName(error) { + if (!error || typeof error !== "object") return ""; + return String(error.errorMessage || error.message || ""); + } +}; +async function onAuthorized(input) { + if (!adapter) { + throw new Error("Telegram \u591A\u8D26\u53F7\u4ED3\u5E93\u5C1A\u672A\u6CE8\u518C\uFF0C\u65E0\u6CD5\u4FDD\u5B58\u767B\u5F55\u8D26\u53F7"); + } + await adapter.upsertByTelegramUserId(input); +} +var telegramMultiAccountLoginFlows = new TelegramMultiAccountLoginFlows({ + credentials: getCredentials, + createClient: (credentials) => new GramJsMultiAccountLoginClient(makeClient(credentials), credentials), + onAuthorized +}); + +// src/services/telegramMultiAccountLoginAdapter.ts +function createTelegramMultiAccountAuthorizedAdapter(deps) { + return { + async upsertByTelegramUserId({ session, account }) { + const persisted = await deps.repository.upsertAccount({ + telegramUserId: account.userId, + username: account.username, + displayName: account.displayName, + session, + enabled: true + }); + await deps.pool.refresh(); + const accountId = String(persisted?.id || ""); + if (accountId && deps.accessSweep) { + await deps.accessSweep.trigger({ accountIds: [accountId], reason: "account_created" }); + } + } + }; +} + +// src/services/telegramAccountAccessSweep.ts +var DENIED_ERROR_CODES = /* @__PURE__ */ new Set([ + "CHANNEL_INVALID", + "CHANNEL_PRIVATE", + "CHAT_ADMIN_REQUIRED", + "CHAT_FORBIDDEN", + "CHAT_RESTRICTED", + "GROUP_PRIVATE", + "INVITE_HASH_EXPIRED", + "INVITE_HASH_INVALID", + "MESSAGE_ID_INVALID", + "PEER_ID_INVALID", + "USER_BANNED_IN_CHANNEL", + "USER_NOT_PARTICIPANT" +]); +var EMPTY_COUNTS = () => ({ + accounts: 0, + sources: 0, + probes: 0, + allowed: 0, + denied: 0, + error: 0 +}); +var configuredDependencies = null; +var currentSummary = { + runId: null, + status: "idle", + reason: null, + startedAt: null, + completedAt: null, + counts: EMPTY_COUNTS(), + lastError: null +}; +var triggerTail = Promise.resolve(); +var nextRunSequence = 0; +function errorText2(error) { + if (error instanceof Error) return error.message; + if (typeof error === "string") return error; + try { + return JSON.stringify(error); + } catch { + return String(error); + } +} +function getTelegramAccessErrorCode(error) { + const candidate = error; + const raw = [candidate?.errorMessage, candidate?.code, candidate?.message, error].find((value) => typeof value === "string" && value.trim()); + if (!raw) return "UNKNOWN_ERROR"; + const normalized = raw.toUpperCase().trim(); + if (/^[A-Z][A-Z0-9_]*$/.test(normalized)) return normalized; + const tokens = normalized.match(/[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+/g); + return tokens?.at(-1) || "UNKNOWN_ERROR"; +} +function classifyTelegramAccessError(error) { + const code = getTelegramAccessErrorCode(error); + return DENIED_ERROR_CODES.has(code) || /(?:PRIVATE|FORBIDDEN|NOT_PARTICIPANT|BANNED)/.test(code) ? "denied" : "error"; +} +function failureResult(input, error, now) { + return { + accountId: input.accountId, + sourceId: input.sourceId, + source: input.source, + scope: input.scope, + state: classifyTelegramAccessError(error), + checkedAt: now().toISOString(), + latestMessageId: null, + errorCode: getTelegramAccessErrorCode(error), + errorMessage: errorText2(error) + }; +} +async function probeTelegramAccountSource(input) { + const now = input.now || (() => /* @__PURE__ */ new Date()); + try { + 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) { + await input.client.getMessages(input.source, { limit: 1, replyTo: latestMessageId }); + } + return { + accountId: input.accountId, + sourceId: input.sourceId, + source: input.source, + scope: input.scope, + state: "allowed", + checkedAt: now().toISOString(), + latestMessageId + }; + } catch (error) { + return failureResult(input, error, now); + } +} +async function mapWithConcurrency(values, concurrency2, worker) { + let cursor = 0; + const workerCount = Math.min(values.length, Math.max(1, Math.floor(concurrency2))); + await Promise.all(Array.from({ length: workerCount }, async () => { + while (cursor < values.length) { + const index = cursor; + cursor += 1; + await worker(values[index]); + } + })); +} +function normalizeScopes(source) { + const requested = source.scopes?.length ? source.scopes : ["channel"]; + return Array.from(new Set(requested.filter((scope) => scope === "channel" || scope === "comments"))); +} +function selected(values, key, identifiers) { + if (!identifiers) return [...values]; + const wanted = new Set(identifiers); + return values.filter((value) => wanted.has(String(value[key]))); +} +function runId(now) { + nextRunSequence += 1; + return `${now.getTime()}-${nextRunSequence}`; +} +async function runTelegramAccountAccessSweep(dependencies, options = {}) { + const now = dependencies.now || (() => /* @__PURE__ */ new Date()); + const startedAt = now(); + const reason = options.reason || "automatic"; + const summary = { + runId: runId(startedAt), + status: "running", + reason, + startedAt: startedAt.toISOString(), + completedAt: null, + counts: EMPTY_COUNTS(), + lastError: null + }; + currentSummary = summary; + try { + const [accountRows, sourceRows] = await Promise.all([ + dependencies.listTelegramAccounts(), + dependencies.listTelegramChannelSubscriptions() + ]); + const accounts = selected(accountRows.filter((account) => account.enabled), "accountId", options.accountIds); + const sources = selected(sourceRows.filter((source) => source.enabled), "sourceId", options.sourceIds); + summary.counts.accounts = accounts.length; + summary.counts.sources = sources.length; + const work = []; + for (const account of accounts) { + for (const source of sources) { + for (const scope of normalizeScopes(source)) work.push({ account, source, scope }); + } + } + summary.counts.probes = work.length; + const runtimePromises = /* @__PURE__ */ new Map(); + const getRuntime = (accountId) => { + let runtime = runtimePromises.get(accountId); + if (!runtime) { + runtime = dependencies.getTelegramAccountRuntime(accountId); + runtimePromises.set(accountId, runtime); + } + return runtime; + }; + await mapWithConcurrency(work, options.concurrency ?? 2, async (item) => { + let result; + try { + const runtime = await getRuntime(item.account.accountId); + if (!runtime?.client) throw new Error("TELEGRAM_ACCOUNT_RUNTIME_UNAVAILABLE"); + result = await probeTelegramAccountSource({ + accountId: item.account.accountId, + sourceId: item.source.sourceId, + source: item.source.source, + scope: item.scope, + client: runtime.client, + now + }); + } catch (error) { + result = failureResult({ + accountId: item.account.accountId, + sourceId: item.source.sourceId, + source: item.source.source, + scope: item.scope + }, error, now); + } + summary.counts[result.state] += 1; + await dependencies.markTelegramAccountSourceAccess(result); + }); + summary.status = "completed"; + summary.completedAt = now().toISOString(); + currentSummary = summary; + return summary; + } catch (error) { + summary.status = "failed"; + summary.completedAt = now().toISOString(); + summary.lastError = errorText2(error); + currentSummary = summary; + throw error; + } +} +function configureTelegramAccountAccessSweep(dependencies) { + configuredDependencies = dependencies; +} +function triggerTelegramAccountAccessSweep(options = {}) { + if (!configuredDependencies) { + return Promise.reject(new Error("TELEGRAM_ACCOUNT_ACCESS_SWEEP_NOT_CONFIGURED")); + } + const dependencies = configuredDependencies; + currentSummary = { + ...currentSummary, + status: "queued", + reason: options.reason || "manual", + completedAt: null, + lastError: null + }; + const run = triggerTail.then(() => runTelegramAccountAccessSweep(dependencies, { + ...options, + reason: options.reason || "manual" + })); + triggerTail = run.then(() => void 0, () => void 0); + return run; +} +function getTelegramAccountAccessSweepSummary() { + return { + ...currentSummary, + counts: { ...currentSummary.counts } + }; +} + +// src/services/telegramAccountAccessSweepAdapter.ts +init_db(); +function createTelegramAccountAccessSweepDependencies(options) { + const repository = options.repository || telegramAccountRepository; + const querySubscriptions = options.querySubscriptions || query; + return { + async listTelegramAccounts() { + const accounts = await repository.listEnabledAccounts(); + return accounts.map((account) => ({ accountId: account.id, enabled: account.enabled })); + }, + async listTelegramChannelSubscriptions() { + const result = await querySubscriptions( + `SELECT id, source, enabled + FROM telegram_channel_subscriptions + WHERE enabled = TRUE + ORDER BY created_at, id` + ); + return result.rows.map((row) => ({ + sourceId: String(row.id), + source: String(row.source), + enabled: Boolean(row.enabled), + scopes: ["channel", "comments"] + })); + }, + async getTelegramAccountRuntime(accountId) { + const client2 = options.clientPool.getAccountClient(accountId); + return client2 ? { client: client2 } : null; + }, + async markTelegramAccountSourceAccess(result) { + const state = result.state === "error" ? "unknown" : result.state; + const error = result.errorCode || result.errorMessage || null; + const scope = result.scope === "channel" ? "scan" : "metadata"; + await repository.markSourceAccess(result.accountId, result.source, scope, state, error); + if (result.scope === "channel") { + await repository.markSourceAccess(result.accountId, result.source, "download", state, error); + } + }, + now: options.now + }; +} +function installTelegramAccountAccessSweep(options) { + const dependencies = createTelegramAccountAccessSweepDependencies(options); + configureTelegramAccountAccessSweep(dependencies); + return dependencies; +} + +// src/services/telegramMultiAccountRuntime.ts +var installed = false; +var initializationPromise2 = null; +async function initializeTelegramMultiAccountRuntime(credentials) { + if (initializationPromise2) return initializationPromise2; + const run = (async () => { + if (!installed) { + installTelegramAccountAccessSweep({ clientPool: telegramUserClientPool }); + registerTelegramMultiAccountAuthorizedAdapter(createTelegramMultiAccountAuthorizedAdapter({ + repository: telegramAccountRepository, + pool: telegramUserClientPool, + accessSweep: { trigger: (options) => triggerTelegramAccountAccessSweep(options) } + })); + installed = true; + } + await initializeTelegramUserClientPool(credentials); + })(); + initializationPromise2 = run; + try { + await run; + } finally { + if (initializationPromise2 === run) initializationPromise2 = null; + } +} +async function selectTelegramDownloadAccount(sourceKey, options = {}) { + const normalized = typeof options?.[Symbol.iterator] === "function" ? { excludeAccountIds: options } : options; + return telegramUserClientPool.select(sourceKey, { ...normalized, scope: normalized.scope || "download" }); +} +async function markTelegramAccountCooldown(accountId, seconds, error = null) { + await telegramAccountRepository.markCooldown(accountId, seconds, error); + const safeSeconds = Number.isFinite(seconds) ? Math.max(1, seconds) : 60; + telegramUserClientPool.updateCooldown(accountId, new Date(Date.now() + safeSeconds * 1e3), error); +} +async function markTelegramAccountSourceAccess(accountId, sourceKey, scope, state, error = null) { + await telegramAccountRepository.markSourceAccess(accountId, sourceKey, scope, state, error); + telegramUserClientPool.updateSourceAccess(accountId, sourceKey, scope, state); +} +async function markTelegramAccountSessionExpired(accountId, error = null) { + await telegramAccountRepository.markSessionExpired(accountId, error); + await telegramUserClientPool.expireAccount(accountId); +} +function classifyTelegramDownloadAccountError(error) { + const value = error; + const text = `${value?.errorMessage || ""} ${value?.message || ""}`; + if (isTelegramSessionExpiredError(error)) return "session_expired"; + if (/CHANNEL_PRIVATE|USER_NOT_PARTICIPANT|CHAT_FORBIDDEN|CHAT_ADMIN_REQUIRED|Could not find the input entity|Cannot find any entity|forbidden|privacy/i.test(text)) return "permission_denied"; + if (Number(value?.seconds || value?.value || text.match(/FLOOD_WAIT_?(\d+)/i)?.[1] || 0) > 0 || /FLOOD|Too many requests/i.test(text)) return "flood_wait"; + return "retryable"; +} +function telegramFloodWaitSeconds(error) { + const value = error; + const text = `${value?.errorMessage || ""} ${value?.message || ""}`; + return Math.max(30, Number(value?.seconds || value?.value || text.match(/FLOOD_WAIT_?(\d+)/i)?.[1] || 60)); +} + +// src/services/telegramUpload.ts +init_db(); +import fs9 from "fs"; +import path11 from "path"; +import crypto15 from "crypto"; +import bigInt from "big-integer"; + +// src/utils/thumbnail.ts +import path7 from "path"; +import sharp from "sharp"; +import ffmpeg from "fluent-ffmpeg"; +import fs7 from "fs"; +import crypto9 from "crypto"; +var THUMBNAIL_DIR = path7.resolve(process.env.THUMBNAIL_DIR || "./data/thumbnails"); +if (!fs7.existsSync(THUMBNAIL_DIR)) { + fs7.mkdirSync(THUMBNAIL_DIR, { recursive: true }); +} +var PREVIEW_DIR = path7.resolve(process.env.PREVIEW_DIR || "./data/previews"); +if (!fs7.existsSync(PREVIEW_DIR)) { + fs7.mkdirSync(PREVIEW_DIR, { recursive: true }); +} +function isMp4Like(mimeType, filePath) { + const lower = filePath.toLowerCase(); + return mimeType === "video/mp4" || lower.endsWith(".mp4") || lower.endsWith(".m4v") || lower.endsWith(".mov"); +} +function ffmpegRun(command, label) { + return new Promise((resolve, reject) => { + command.on("start", (cmd) => console.log(`[Preview] ${label} CMD: ${cmd}`)).on("end", () => resolve()).on("error", (err) => reject(err)).run(); + }); +} +async function generateMediaPreview(filePath, storedName, mimeType) { + const absFilePath = path7.resolve(filePath); + if (!fs7.existsSync(absFilePath)) return null; + try { + if (mimeType.startsWith("image/") && mimeType !== "image/gif") { + const previewName = `preview_${crypto9.randomUUID()}.webp`; + const previewPath = path7.join(PREVIEW_DIR, previewName); + await sharp(absFilePath).rotate().resize(2048, 2048, { fit: "inside", withoutEnlargement: true }).webp({ quality: 86, effort: 4 }).toFile(previewPath); + console.log(`[Preview] \u2705 Image preview created: ${previewName}`); + return previewPath; + } + if (mimeType.startsWith("video/")) { + const previewName = `preview_${crypto9.randomUUID()}.mp4`; + const previewPath = path7.join(PREVIEW_DIR, previewName); + const mp4Like = isMp4Like(mimeType, storedName || absFilePath); + if (mp4Like) { + try { + await ffmpegRun( + ffmpeg(absFilePath).outputOptions(["-c copy", "-movflags +faststart"]).output(previewPath), + "Video faststart" + ); + if (fs7.existsSync(previewPath) && fs7.statSync(previewPath).size > 0) { + console.log(`[Preview] \u2705 Video faststart preview created: ${previewName}`); + return previewPath; + } + } catch (copyError) { + console.warn(`[Preview] \u26A0\uFE0F Faststart copy failed, fallback to transcode: ${copyError.message}`); + try { + if (fs7.existsSync(previewPath)) fs7.unlinkSync(previewPath); + } catch { + } + } + } + await ffmpegRun( + ffmpeg(absFilePath).videoCodec("libx264").audioCodec("aac").size("?x720").outputOptions([ + "-preset veryfast", + "-crf 23", + "-movflags +faststart", + "-pix_fmt yuv420p", + "-profile:v baseline", + "-level 3.1", + "-b:a 128k" + ]).output(previewPath), + "Video transcode" + ); + if (fs7.existsSync(previewPath) && fs7.statSync(previewPath).size > 0) { + console.log(`[Preview] \u2705 Video transcoded preview created: ${previewName}`); + return previewPath; + } + } + } catch (error) { + console.error(`[Preview] \u274C Generate preview failed for ${storedName}:`, error.message); + } + return null; +} +async function generateThumbnail(filePath, storedName, mimeType) { + const absFilePath = path7.resolve(filePath); + const thumbName = `thumb_${crypto9.randomUUID()}.webp`; + const thumbPath = path7.join(THUMBNAIL_DIR, thumbName); + console.log(`[Thumbnail] \u{1F680} Starting generation for: ${storedName}`); + console.log(`[Thumbnail] Source: ${absFilePath}`); + console.log(`[Thumbnail] Target: ${thumbPath}`); + console.log(`[Thumbnail] MIME: ${mimeType}`); + if (!fs7.existsSync(absFilePath)) { + console.error(`[Thumbnail] \u274C Source file does not exist: ${absFilePath}`); + return null; + } + if (mimeType === "image/gif") { + console.log(`[Thumbnail] \u23E9 Skipping GIF to preserve animation`); + return null; + } + try { + if (mimeType.startsWith("image/")) { + console.log(`[Thumbnail] \u{1F5BC}\uFE0F Processing image with Sharp...`); + await sharp(absFilePath).resize(400, 300, { fit: "inside", withoutEnlargement: true }).webp({ quality: 80 }).toFile(thumbPath); + console.log(`[Thumbnail] \u2705 Image thumbnail created: ${thumbName}`); + return thumbPath; + } else if (mimeType.startsWith("video/")) { + console.log(`[Thumbnail] \u{1F3AC} Processing video with Ffmpeg...`); + const tryScreenshot = (timestamp) => { + return new Promise((resolve) => { + console.log(`[Thumbnail] \u{1F4F8} Attempting screenshot at ${timestamp}`); + ffmpeg(absFilePath).screenshots({ + count: 1, + folder: THUMBNAIL_DIR, + filename: thumbName, + size: "400x300", + timestamps: [timestamp] + }).on("start", (cmd) => console.log(`[Thumbnail] FFmpeg CMD: ${cmd}`)).on("end", () => { + if (fs7.existsSync(thumbPath)) { + console.log(`[Thumbnail] \u2705 Video thumbnail created at ${timestamp}`); + resolve(true); + } else { + console.warn(`[Thumbnail] \u26A0\uFE0F FFmpeg finished but file not found at ${timestamp}`); + resolve(false); + } + }).on("error", (err) => { + console.error(`[Thumbnail] \u274C FFmpeg error at ${timestamp}:`, err.message); + resolve(false); + }); + }); + }; + let success = await tryScreenshot("10%"); + if (!success) { + console.log(`[Thumbnail] \u{1F504} Retrying at 1s mark...`); + success = await tryScreenshot("00:00:01"); + } + if (success) { + return thumbPath; + } + } + } catch (error) { + console.error(`[Thumbnail] \u274C Unexpected error:`, error.message); + } + return null; +} +async function getImageDimensions(filePath, mimeType) { + const absFilePath = path7.resolve(filePath); + console.log(`[Dimensions] \u{1F4CF} Getting dimensions for: ${absFilePath} (${mimeType})`); + try { + if (mimeType.startsWith("image/")) { + const metadata = await sharp(absFilePath).metadata(); + const result = { width: metadata.width || 0, height: metadata.height || 0 }; + console.log(`[Dimensions] \u2705 Image dimensions: ${result.width}x${result.height}`); + return result; + } else if (mimeType.startsWith("video/")) { + return new Promise((resolve) => { + ffmpeg.ffprobe(absFilePath, (err, metadata) => { + if (err) { + console.error(`[Dimensions] \u274C Probe failed:`, err.message); + resolve({ width: 0, height: 0 }); + } else { + const stream = metadata.streams.find((s) => s.width && s.height); + const result = { + width: stream?.width || 0, + height: stream?.height || 0 + }; + console.log(`[Dimensions] \u2705 Video dimensions: ${result.width}x${result.height}`); + resolve(result); + } + }); + }); + } + } catch (error) { + console.error("Get dimensions failed:", error); + } + return { width: 0, height: 0 }; +} + +// src/services/telegramUpload.ts +init_storage(); + +// src/services/taskAbortRegistry.ts +var TaskAbortRegistry = class { + controllers = /* @__PURE__ */ new Map(); + acquire(taskId) { + const current3 = this.controllers.get(taskId); + if (current3 && !current3.controller.signal.aborted) { + current3.references += 1; + return current3.controller; + } + const controller = new AbortController(); + this.controllers.set(taskId, { controller, references: 1 }); + return controller; + } + get(taskId) { + return this.controllers.get(taskId)?.controller; + } + cancel(taskId, reason = "\u4EFB\u52A1\u5DF2\u53D6\u6D88") { + const entry = this.controllers.get(taskId); + if (!entry || entry.controller.signal.aborted) return false; + entry.controller.abort(reason); + this.controllers.delete(taskId); + return true; + } + release(taskId, controller) { + const entry = this.controllers.get(taskId); + if (!entry || entry.controller !== controller) return; + entry.references -= 1; + if (entry.references <= 0) this.controllers.delete(taskId); + } +}; + +// src/services/storageCooldownGuard.ts +init_storage(); +init_storageCooldown(); +function formatStorageCooldownNotice(cooldownUntil) { + return [ + "\u23F8\uFE0F Google Drive \u4ECA\u65E5\u4E0A\u4F20\u989D\u5EA6\u5DF2\u8FBE\u4E0A\u9650", + "", + "\u5F53\u524D\u4EFB\u52A1\u5DF2\u81EA\u52A8\u6682\u505C\uFF0C\u5269\u4F59\u6587\u4EF6\u4E0D\u4F1A\u4E22\u5931\uFF1B\u65E0\u9700\u70B9\u51FB\u201C\u7EE7\u7EED\u201D\u3002", + describeStorageCooldownRecovery(cooldownUntil), + "", + `\u6062\u590D\u65F6\u95F4\uFF1A${cooldownUntil.toISOString()}` + ].join("\n"); +} +function buildStorageCooldownHttpError(error) { + return { + status: 429, + body: { + error: error.message || "Google Drive \u4ECA\u65E5\u4E0A\u4F20\u989D\u5EA6\u5DF2\u8FBE\u4E0A\u9650\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002", + code: "storage_account_cooling", + provider: error.provider, + reason: error.reason, + retryAt: error.cooldownUntil.toISOString() + } + }; +} +function sendStorageCooldownHttpError(res, error) { + const payload = buildStorageCooldownHttpError(error); + res.status(payload.status).json(payload.body); +} +async function getStorageCooldown(target) { + if (target.provider.name !== "google_drive" || !target.accountId) return null; + return getStorageAccountCooldown(target.accountId, target.provider.name, STORAGE_COOLDOWN_REASON_DAILY_UPLOAD_LIMIT); +} +async function assertStorageTargetWritable(target) { + const cooldown = await getStorageCooldown(target); + if (!cooldown) return; + throw new StorageQuotaCooldownError("Google Drive \u4ECA\u65E5\u4E0A\u4F20\u989D\u5EA6\u5DF2\u8FBE\u4E0A\u9650\uFF0C\u8BF7\u7B49\u5F85\u81EA\u52A8\u6062\u590D\u540E\u518D\u4E0A\u4F20\uFF0C\u6216\u4E34\u65F6\u5207\u6362\u5176\u5B83\u5B58\u50A8\u6E90\u3002", { + provider: cooldown.provider, + reason: cooldown.reason, + storageAccountId: cooldown.storageAccountId, + cooldownUntil: cooldown.cooldownUntil + }); +} +function isStorageCooldownError(error) { + return isStorageQuotaCooldownError(error); +} + +// src/services/telegramUpload.ts +init_storageCooldown(); + +// src/services/telegramUserClient.ts +import fs8 from "node:fs"; +import { Api as Api3, TelegramClient as TelegramClient4 } from "telegram"; +import { StringSession as StringSession4 } from "telegram/sessions/index.js"; + +// src/services/telegramUserWebLogin.ts +import crypto10 from "node:crypto"; +var TelegramUserLoginFlowError2 = class extends Error { + constructor(code, message) { + super(message); + this.code = code; + this.name = "TelegramUserLoginFlowError"; + } + code; +}; +function telegramErrorName2(error) { + if (!error || typeof error !== "object") return ""; + return String(error.errorMessage || error.message || ""); +} +function normalizeAccount2(me) { const displayName = [me.firstName, me.lastName].filter(Boolean).join(" ").trim(); return { userId: String(me.id ?? ""), username: me.username || null, displayName: displayName || null }; } @@ -5009,15 +6278,15 @@ var TelegramUserWebLoginFlows = class { now; async start(owner, rawPhone) { const phone = String(rawPhone || "").replace(/[\s()-]/g, ""); - if (!/^\+[1-9]\d{6,14}$/.test(phone)) throw new TelegramUserLoginFlowError("INVALID_PHONE", "\u8BF7\u8F93\u5165\u542B\u56FD\u5BB6\u533A\u53F7\u7684\u6709\u6548\u624B\u673A\u53F7"); + if (!/^\+[1-9]\d{6,14}$/.test(phone)) throw new TelegramUserLoginFlowError2("INVALID_PHONE", "\u8BF7\u8F93\u5165\u542B\u56FD\u5BB6\u533A\u53F7\u7684\u6709\u6548\u624B\u673A\u53F7"); await this.removeOwnerFlows(owner); const credentials = await this.deps.credentials(); - if (!credentials?.apiId || !credentials.apiHash) throw new TelegramUserLoginFlowError("API_NOT_CONFIGURED", "\u8BF7\u5148\u914D\u7F6E\u6709\u6548\u7684 Telegram API ID \u548C API Hash"); + if (!credentials?.apiId || !credentials.apiHash) throw new TelegramUserLoginFlowError2("API_NOT_CONFIGURED", "\u8BF7\u5148\u914D\u7F6E\u6709\u6548\u7684 Telegram API ID \u548C API Hash"); const client2 = this.deps.createClient(credentials); try { await client2.connect(); const sent = await client2.sendCode(credentials, phone); - const flowId = crypto9.randomBytes(24).toString("base64url"); + const flowId = crypto10.randomBytes(24).toString("base64url"); const expiresAt = this.now() + this.ttlMs; this.flows.set(flowId, { id: flowId, owner, phone, phoneCodeHash: sent.phoneCodeHash, expiresAt, errors: 0, step: "code", client: client2 }); return { flowId, delivery: sent.isCodeViaApp ? "app" : "sms", expiresAt: new Date(expiresAt).toISOString() }; @@ -5029,7 +6298,7 @@ var TelegramUserWebLoginFlows = class { async submitCode(owner, flowId, rawCode) { const flow = await this.requireFlow(owner, flowId, "code"); const code = String(rawCode || "").replace(/\s/g, ""); - if (!/^\d{5,6}$/.test(code)) throw new TelegramUserLoginFlowError("INVALID_CODE", "\u8BF7\u8F93\u5165\u6709\u6548\u9A8C\u8BC1\u7801"); + if (!/^\d{5,6}$/.test(code)) throw new TelegramUserLoginFlowError2("INVALID_CODE", "\u8BF7\u8F93\u5165\u6709\u6548\u9A8C\u8BC1\u7801"); try { const result = await flow.client.signInCode(flow.phone, flow.phoneCodeHash, code); if (result === "password_needed") { @@ -5038,7 +6307,7 @@ var TelegramUserWebLoginFlows = class { } return await this.complete(flow); } catch (error) { - if (telegramErrorName(error).includes("SESSION_PASSWORD_NEEDED")) { + if (telegramErrorName2(error).includes("SESSION_PASSWORD_NEEDED")) { flow.step = "password"; return { step: "password_required" }; } @@ -5048,7 +6317,7 @@ var TelegramUserWebLoginFlows = class { } async submitPassword(owner, flowId, password) { const flow = await this.requireFlow(owner, flowId, "password"); - if (!password) throw new TelegramUserLoginFlowError("INVALID_PASSWORD", "\u8BF7\u8F93\u5165\u4E24\u6B65\u9A8C\u8BC1\u5BC6\u7801"); + if (!password) throw new TelegramUserLoginFlowError2("INVALID_PASSWORD", "\u8BF7\u8F93\u5165\u4E24\u6B65\u9A8C\u8BC1\u5BC6\u7801"); try { await flow.client.signInPassword(password); return await this.complete(flow); @@ -5066,7 +6335,7 @@ var TelegramUserWebLoginFlows = class { } async complete(flow) { try { - const account = normalizeAccount(await flow.client.getMe()); + const account = normalizeAccount2(await flow.client.getMe()); await this.deps.persistAndActivate(flow.client.saveSession(), account); this.flows.delete(flow.id); return { step: "complete", account }; @@ -5076,30 +6345,30 @@ var TelegramUserWebLoginFlows = class { } async requireFlow(owner, id, step) { const flow = this.flows.get(id); - if (!flow || flow.owner !== owner) throw new TelegramUserLoginFlowError("FLOW_NOT_FOUND", "\u767B\u5F55\u6D41\u7A0B\u4E0D\u5B58\u5728\uFF0C\u8BF7\u91CD\u65B0\u53D1\u9001\u9A8C\u8BC1\u7801"); + if (!flow || flow.owner !== owner) throw new TelegramUserLoginFlowError2("FLOW_NOT_FOUND", "\u767B\u5F55\u6D41\u7A0B\u4E0D\u5B58\u5728\uFF0C\u8BF7\u91CD\u65B0\u53D1\u9001\u9A8C\u8BC1\u7801"); if (flow.expiresAt <= this.now()) { this.flows.delete(id); await this.closeClient(flow.client); - throw new TelegramUserLoginFlowError("FLOW_EXPIRED", "\u767B\u5F55\u6D41\u7A0B\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u53D1\u9001\u9A8C\u8BC1\u7801"); + throw new TelegramUserLoginFlowError2("FLOW_EXPIRED", "\u767B\u5F55\u6D41\u7A0B\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u53D1\u9001\u9A8C\u8BC1\u7801"); } if (flow.errors >= this.maxErrors) { this.flows.delete(id); await this.closeClient(flow.client); - throw new TelegramUserLoginFlowError("TOO_MANY_ERRORS", "\u9519\u8BEF\u6B21\u6570\u8FC7\u591A\uFF0C\u8BF7\u91CD\u65B0\u5F00\u59CB\u767B\u5F55"); + throw new TelegramUserLoginFlowError2("TOO_MANY_ERRORS", "\u9519\u8BEF\u6B21\u6570\u8FC7\u591A\uFF0C\u8BF7\u91CD\u65B0\u5F00\u59CB\u767B\u5F55"); } - if (flow.step !== step) throw new TelegramUserLoginFlowError("FLOW_NOT_FOUND", "\u767B\u5F55\u6B65\u9AA4\u65E0\u6548\uFF0C\u8BF7\u91CD\u65B0\u5F00\u59CB\u767B\u5F55"); + if (flow.step !== step) throw new TelegramUserLoginFlowError2("FLOW_NOT_FOUND", "\u767B\u5F55\u6B65\u9AA4\u65E0\u6548\uFF0C\u8BF7\u91CD\u65B0\u5F00\u59CB\u767B\u5F55"); return flow; } async recordError(flow) { flow.errors += 1; } publicError(error, fallback = "TELEGRAM_ERROR") { - if (error instanceof TelegramUserLoginFlowError) return error; - const name = telegramErrorName(error); - if (/PHONE_CODE_(INVALID|EXPIRED|EMPTY)/.test(name)) return new TelegramUserLoginFlowError("INVALID_CODE", "\u9A8C\u8BC1\u7801\u65E0\u6548\u6216\u5DF2\u8FC7\u671F"); - if (/PASSWORD_HASH_INVALID/.test(name)) return new TelegramUserLoginFlowError("INVALID_PASSWORD", "\u4E24\u6B65\u9A8C\u8BC1\u5BC6\u7801\u9519\u8BEF"); - if (/PHONE_NUMBER_INVALID/.test(name)) return new TelegramUserLoginFlowError("INVALID_PHONE", "\u624B\u673A\u53F7\u65E0\u6548"); - return new TelegramUserLoginFlowError(fallback, fallback === "INVALID_PASSWORD" ? "\u4E24\u6B65\u9A8C\u8BC1\u5BC6\u7801\u9519\u8BEF" : fallback === "INVALID_CODE" ? "\u9A8C\u8BC1\u7801\u65E0\u6548\u6216\u5DF2\u8FC7\u671F" : "Telegram \u767B\u5F55\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5"); + if (error instanceof TelegramUserLoginFlowError2) return error; + const name = telegramErrorName2(error); + if (/PHONE_CODE_(INVALID|EXPIRED|EMPTY)/.test(name)) return new TelegramUserLoginFlowError2("INVALID_CODE", "\u9A8C\u8BC1\u7801\u65E0\u6548\u6216\u5DF2\u8FC7\u671F"); + if (/PASSWORD_HASH_INVALID/.test(name)) return new TelegramUserLoginFlowError2("INVALID_PASSWORD", "\u4E24\u6B65\u9A8C\u8BC1\u5BC6\u7801\u9519\u8BEF"); + if (/PHONE_NUMBER_INVALID/.test(name)) return new TelegramUserLoginFlowError2("INVALID_PHONE", "\u624B\u673A\u53F7\u65E0\u6548"); + return new TelegramUserLoginFlowError2(fallback, fallback === "INVALID_PASSWORD" ? "\u4E24\u6B65\u9A8C\u8BC1\u5BC6\u7801\u9519\u8BEF" : fallback === "INVALID_CODE" ? "\u9A8C\u8BC1\u7801\u65E0\u6548\u6216\u5DF2\u8FC7\u671F" : "Telegram \u767B\u5F55\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5"); } async closeClient(client2) { try { @@ -5167,31 +6436,32 @@ async function getUserCredentials() { function getSessionFilePath() { return process.env.TELEGRAM_USER_SESSION_FILE || "./data/telegram_user_session.txt"; } -async function stopClient() { +async function stopLegacyClient() { const current3 = userClient; userClient = null; - if (!current3) return; - try { - await current3.disconnect(); - } catch { - } - try { - await current3.destroy(); - } catch { + if (current3) { + try { + await current3.disconnect(); + } catch { + } + try { + await current3.destroy(); + } catch { + } } } async function migrateLegacyTelegramUserSession() { const stored = await getSetting(TELEGRAM_USER_SESSION_SETTING, ""); if (stored) return stored; userSessionFilePath = getSessionFilePath(); - if (!fs7.existsSync(userSessionFilePath)) return ""; - const legacy = fs7.readFileSync(userSessionFilePath, "utf8").trim(); + if (!fs8.existsSync(userSessionFilePath)) return ""; + const legacy = fs8.readFileSync(userSessionFilePath, "utf8").trim(); if (!legacy) return ""; await setSetting(TELEGRAM_USER_SESSION_SETTING, legacy); return legacy; } -function makeClient(session, credentials) { - return new TelegramClient2(new StringSession2(session), credentials.apiId, credentials.apiHash, { +function makeClient2(session, credentials) { + return new TelegramClient4(new StringSession4(session), credentials.apiId, credentials.apiHash, { connectionRetries: 15, retryDelay: 2e3, useWSS: false, @@ -5202,13 +6472,22 @@ function makeClient(session, credentials) { }); } async function initTelegramUserClient(credentials) { - await stopClient(); + await stopLegacyClient(); const resolved = credentials || await getUserCredentials(); if (!resolved) { recordTelegramUserClientFailure("not_configured", "\u672A\u914D\u7F6E Telegram API"); return; } const sessionString = await migrateLegacyTelegramUserSession(); + await initializeTelegramMultiAccountRuntime(resolved); + const pooledClient = telegramUserClientPool.getDefaultClient(); + if (pooledClient) { + const me = await pooledClient.getMe(); + recordTelegramUserClientReady({ userId: String(me?.id || ""), username: me?.username || null }); + const legacyPath = getSessionFilePath(); + if (fs8.existsSync(legacyPath)) fs8.rmSync(legacyPath, { force: true }); + return; + } if (!sessionString) { recordTelegramUserClientFailure("missing_session", "\u5C1A\u672A\u767B\u5F55 Telegram \u7528\u6237\u8D26\u53F7"); return; @@ -5217,7 +6496,7 @@ async function initTelegramUserClient(credentials) { recordTelegramUserClientFailure("disabled", ""); return; } - const client2 = makeClient(sessionString, resolved); + const client2 = makeClient2(sessionString, resolved); try { await client2.connect(); if (!await client2.checkAuthorization()) throw new Error("SESSION_EXPIRED"); @@ -5227,7 +6506,7 @@ async function initTelegramUserClient(credentials) { const me = await client2.getMe(); recordTelegramUserClientReady({ userId: String(me?.id || ""), username: me?.username || null }); const legacyPath = getSessionFilePath(); - if (fs7.existsSync(legacyPath)) fs7.rmSync(legacyPath, { force: true }); + if (fs8.existsSync(legacyPath)) fs8.rmSync(legacyPath, { force: true }); } catch (error) { try { await client2.disconnect(); @@ -5247,6 +6526,14 @@ async function persistAndActivate(session, account) { [TELEGRAM_USER_ID_SETTING, account.userId], [TELEGRAM_USER_USERNAME_SETTING, account.username || ""] ]); + await upsertTelegramUserAccount({ + telegramUserId: account.userId, + username: account.username, + displayName: account.displayName, + session, + enabled: true, + isLegacy: true + }); await initTelegramUserClient(); } var GramJsWebLoginClient = class { @@ -5264,7 +6551,7 @@ var GramJsWebLoginClient = class { } async signInCode(phone, phoneCodeHash, code) { try { - await this.client.invoke(new Api2.auth.SignIn({ phoneNumber: phone, phoneCodeHash, phoneCode: code })); + await this.client.invoke(new Api3.auth.SignIn({ phoneNumber: phone, phoneCodeHash, phoneCode: code })); return "authorized"; } catch (error) { const name = String(error?.errorMessage || error.message || ""); @@ -5299,7 +6586,7 @@ var GramJsWebLoginClient = class { }; var telegramUserWebLogin = new TelegramUserWebLoginFlows({ credentials: getUserCredentials, - createClient: (credentials) => new GramJsWebLoginClient(makeClient("", credentials), credentials), + createClient: (credentials) => new GramJsWebLoginClient(makeClient2("", credentials), credentials), persistAndActivate }); async function getTelegramUserAccountStatus() { @@ -5316,25 +6603,36 @@ async function getTelegramUserAccountStatus() { } async function disableTelegramUserAccount() { await setSetting(TELEGRAM_USER_ENABLED_SETTING, "false"); - await stopClient(); + for (const account of await listTelegramUserAccounts()) { + if (account.isLegacy) await setTelegramUserAccountEnabled(account.id, false); + } + await stopLegacyClient(); + await reloadTelegramUserClientPool(); recordTelegramUserClientFailure("disabled", ""); } async function enableTelegramUserAccount() { await setSetting(TELEGRAM_USER_ENABLED_SETTING, "true"); + for (const account of await listTelegramUserAccounts()) { + if (account.isLegacy) await setTelegramUserAccountEnabled(account.id, true); + } await initTelegramUserClient(); } async function unlinkTelegramUserAccount() { - await stopClient(); + await stopLegacyClient(); + for (const account of await listTelegramUserAccounts()) { + if (account.isLegacy) await deleteTelegramUserAccount(account.id); + } await deleteSettings([TELEGRAM_USER_SESSION_SETTING, TELEGRAM_USER_ENABLED_SETTING, TELEGRAM_USER_ID_SETTING, TELEGRAM_USER_USERNAME_SETTING]); + await reloadTelegramUserClientPool(); const legacyPath = getSessionFilePath(); - if (fs7.existsSync(legacyPath)) fs7.rmSync(legacyPath, { force: true }); + if (fs8.existsSync(legacyPath)) fs8.rmSync(legacyPath, { force: true }); recordTelegramUserClientFailure("missing_session", "\u5C1A\u672A\u767B\u5F55 Telegram \u7528\u6237\u8D26\u53F7"); } function getTelegramUserClient() { - return userClient; + return telegramUserClientPool.getDefaultClient() || userClient; } function isTelegramUserClientReady() { - return Boolean(userClient?.connected); + return Boolean(getTelegramUserClient()?.connected); } // src/services/telegramUpload.ts @@ -5342,7 +6640,7 @@ init_settings(); init_telegramState(); // src/utils/telegramMedia.ts -import { Api as Api3 } from "telegram"; +import { Api as Api4 } from "telegram"; function getDownloadableMedia(message) { if (!message.media) return null; const media = message.media; @@ -5422,7 +6720,7 @@ function extractFileInfo(message) { mimeType = "audio/ogg"; } else { const media = message.media; - if (media.document && media.document instanceof Api3.Document) { + if (media.document && media.document instanceof Api4.Document) { const doc = media.document; const fileNameAttr = doc.attributes?.find((a) => a.className === "DocumentAttributeFilename"); generatedName = !fileNameAttr?.fileName; @@ -5454,12 +6752,12 @@ function extractFileInfo(message) { // src/utils/fileUtils.ts import path8 from "path"; -import crypto10 from "crypto"; +import crypto11 from "crypto"; async function getUniqueStoredName(originalName, _folder = null, _storageAccountId = null) { const sanitizedName = sanitizeFilename(originalName); const ext = path8.extname(sanitizedName); const rawBaseName = ext ? sanitizedName.slice(0, -ext.length) : sanitizedName; - const suffix = `--${crypto10.randomUUID()}`; + const suffix = `--${crypto11.randomUUID()}`; const maxBaseLength = Math.max(1, 255 - ext.length - suffix.length); const baseName = rawBaseName.slice(0, maxBaseLength) || "file"; return `${baseName}${suffix}${ext}`; @@ -5625,7 +6923,7 @@ async function getTelegramBatchFolderName(message, fallback) { // src/utils/telegramNaming.ts import path10 from "path"; -import crypto11 from "crypto"; +import crypto12 from "crypto"; function normalizeExtension(extension) { if (!extension) return ""; const trimmed = extension.trim(); @@ -5697,7 +6995,7 @@ function buildTelegramGeneratedFileName(options) { return appendSequenceNumber(nameWithExtension, options.sequenceNumber); } } - const suffix = sanitizeFilename(options.randomSuffix || crypto11.randomBytes(4).toString("hex")).replace(/\s+/g, "_"); + const suffix = sanitizeFilename(options.randomSuffix || crypto12.randomBytes(4).toString("hex")).replace(/\s+/g, "_"); return appendSequenceNumber(`${fallbackPrefix(options.mimeType)}_${suffix}${ext}`, options.sequenceNumber); } function resolveTelegramGeneratedFileName(options) { @@ -5750,9 +7048,9 @@ async function getOrCreateTelegramMediaGroupQueue(queues, creations, queueKey, c if (existing) return existing; const inFlight = creations.get(queueKey); if (inFlight) return inFlight; - const creation = create().then((queue) => { - queues.set(queueKey, queue); - return queue; + const creation = create().then((queue2) => { + queues.set(queueKey, queue2); + return queue2; }).finally(() => { creations.delete(queueKey); }); @@ -5835,7 +7133,7 @@ async function prefetchForwardedSourceMessages(userClient2, messages) { } // src/utils/telegramPathSettings.ts -import { Api as Api4 } from "telegram"; +import { Api as Api5 } from "telegram"; init_settings(); // src/utils/telegramPathStateStore.ts @@ -6012,27 +7310,27 @@ async function resolveTelegramTaskStorageFolderPersistent(chatId, automaticFolde } function buildTelegramPathStateLines(chatId) { const state = getTelegramPathState(chatId); - const active = state.nextFolder || state.sessionFolder; + const active2 = state.nextFolder || state.sessionFolder; return [ - `\u5F53\u524D\u4FDD\u5B58\uFF1A${active ? `\`${active}\`\uFF08\u81EA\u5B9A\u4E49\u76EE\u5F55\uFF09` : "\u9ED8\u8BA4\u81EA\u52A8\u5206\u7C7B"}`, - active ? buildPathPreviewLine(active) : "\u9ED8\u8BA4\u793A\u4F8B\uFF1A`telegram/\u8D44\u6E90\u4E0B\u8F7D/images`", + `\u5F53\u524D\u4FDD\u5B58\uFF1A${active2 ? `\`${active2}\`\uFF08\u81EA\u5B9A\u4E49\u76EE\u5F55\uFF09` : "\u9ED8\u8BA4\u81EA\u52A8\u5206\u7C7B"}`, + active2 ? buildPathPreviewLine(active2) : "\u9ED8\u8BA4\u793A\u4F8B\uFF1A`telegram/\u8D44\u6E90\u4E0B\u8F7D/images`", `\u{1F4CC} \u4E0B\u4E00\u6B21\u76EE\u5F55\uFF1A${state.nextFolder ? `\`${state.nextFolder}\`` : "\u672A\u8BBE\u7F6E"}`, `\u{1F4CD} \u672C\u4F1A\u8BDD\u76EE\u5F55\uFF1A${state.sessionFolder ? `\`${state.sessionFolder}\`` : "\u672A\u8BBE\u7F6E"}` ]; } function buildPathSettingsKeyboard(_state) { - return new Api4.ReplyInlineMarkup({ + return new Api5.ReplyInlineMarkup({ rows: [ - new Api4.KeyboardButtonRow({ + new Api5.KeyboardButtonRow({ buttons: [ - new Api4.KeyboardButtonCallback({ text: "\u{1F4CC} \u8BBE\u7F6E\u4E0B\u4E00\u6B21\u76EE\u5F55", data: Buffer.from("pr_help_once") }), - new Api4.KeyboardButtonCallback({ text: "\u{1F4CD} \u8BBE\u7F6E\u4F1A\u8BDD\u76EE\u5F55", data: Buffer.from("pr_help_session") }) + new Api5.KeyboardButtonCallback({ text: "\u{1F4CC} \u8BBE\u7F6E\u4E0B\u4E00\u6B21\u76EE\u5F55", data: Buffer.from("pr_help_once") }), + new Api5.KeyboardButtonCallback({ text: "\u{1F4CD} \u8BBE\u7F6E\u4F1A\u8BDD\u76EE\u5F55", data: Buffer.from("pr_help_session") }) ] }), - new Api4.KeyboardButtonRow({ + new Api5.KeyboardButtonRow({ buttons: [ - new Api4.KeyboardButtonCallback({ text: "\u{1F558} \u6700\u8FD1\u76EE\u5F55", data: Buffer.from("pr_recent") }), - new Api4.KeyboardButtonCallback({ text: "\u{1F9F9} \u6E05\u9664\u81EA\u5B9A\u4E49\u76EE\u5F55", data: Buffer.from("pr_clear_custom") }) + new Api5.KeyboardButtonCallback({ text: "\u{1F558} \u6700\u8FD1\u76EE\u5F55", data: Buffer.from("pr_recent") }), + new Api5.KeyboardButtonCallback({ text: "\u{1F9F9} \u6E05\u9664\u81EA\u5B9A\u4E49\u76EE\u5F55", data: Buffer.from("pr_clear_custom") }) ] }) ] @@ -6050,13 +7348,7 @@ function buildPathSettingsText(_state, chatId) { "**\u5F53\u524D\u8DEF\u5F84\u72B6\u6001**", ...buildTelegramPathStateLines(chatId), "", - "**\u5FEB\u6377\u547D\u4EE4**", - "`/p <\u76EE\u5F55>` \u2014 \u4EC5\u4E0B\u4E00\u6B21\u4E0B\u8F7D\u4F7F\u7528", - "`/ps <\u76EE\u5F55>` \u2014 \u5F53\u524D\u4F1A\u8BDD\u6301\u7EED\u4F7F\u7528", - "`/pc` \u2014 \u6E05\u9664\u4E0B\u4E00\u6B21/\u4F1A\u8BDD\u76EE\u5F55", - "", - "\u4F18\u5148\u7EA7\uFF1A\u4E0B\u4E00\u6B21\u76EE\u5F55 > \u672C\u4F1A\u8BDD\u76EE\u5F55 > \u9ED8\u8BA4\u81EA\u52A8\u5206\u7C7B\u76EE\u5F55\u3002", - "\u8DEF\u5F84\u793A\u4F8B\uFF1A\u8BBE\u7F6E `/ps book` \u540E\uFF0C\u6587\u4EF6\u76F4\u63A5\u4FDD\u5B58\u5230 `book`\u3002" + "\u{1F447} \u70B9\u51FB\u6309\u94AE\u9009\u62E9\u4FDD\u5B58\u4F4D\u7F6E\u3002" ].join("\n"); } @@ -6369,9 +7661,9 @@ var DownloadTaskQueue = class { if (group.stateOverride === "paused" || group.stateOverride === "pausing" || group.stateOverride === "cancelling") { return this.controlResult("blocked", group); } - const selected = this.queue.filter((task) => task.groupId === group.id); - if (selected.length === 0) return this.controlResult("terminal", group); - this.queue = [...selected, ...this.queue.filter((task) => task.groupId !== group.id)]; + const selected3 = this.queue.filter((task) => task.groupId === group.id); + if (selected3.length === 0) return this.controlResult("terminal", group); + this.queue = [...selected3, ...this.queue.filter((task) => task.groupId !== group.id)]; group.updatedAt = this.nextGroupVersion(group); this.notifyGroup(group); this.processNext(); @@ -6497,10 +7789,10 @@ var DownloadTaskQueue = class { if (!normalized || normalized === "all") return this.forceStopAll(reason); const group = this.findGroup(normalized); if (group) { - const active2 = this.active.filter((task) => task.groupId === group.id).length; + const active3 = this.active.filter((task) => task.groupId === group.id).length; const pending2 = this.queue.filter((task) => task.groupId === group.id).length; this.cancelGroup(group.id, {}, reason); - return { active: active2, pending: pending2, total: active2 + pending2 }; + return { active: active3, pending: pending2, total: active3 + pending2 }; } const pendingIndex = this.queue.findIndex((task, index) => task.id.startsWith(normalized) || String(index + 1) === normalized || task.fileName.includes(normalized)); let pending = 0; @@ -6509,16 +7801,16 @@ var DownloadTaskQueue = class { this.settlePendingCancellation(task, reason); pending = 1; } - let active = 0; + let active2 = 0; for (const task of this.active) { if (task.id.startsWith(normalized) || task.fileName.includes(normalized)) { task.error = reason; if (!task.abortController.signal.aborted) task.abortController.abort(reason); - active += 1; + active2 += 1; } } this.processNext(); - return { active, pending, total: active + pending }; + return { active: active2, pending, total: active2 + pending }; } async retryFailed(limit = 10, scope = {}, groupId) { const failed = this.history.filter((task) => task.status === "failed").filter((task) => !groupId || task.groupId === groupId).filter((task) => { @@ -6537,21 +7829,21 @@ var DownloadTaskQueue = class { return { retried }; } cancelScope(scope, reason = "\u7528\u6237\u53D6\u6D88\u5F53\u524D\u804A\u5929\u4EFB\u52A1") { - let active = 0; + let active2 = 0; let pending = 0; const groupIds = Array.from(this.groups.values()).filter((group) => this.matchesScope(group, scope)).map((group) => group.id); for (const groupId of groupIds) { const result = this.cancelGroup(groupId, scope, reason, true); if (result.status !== "ok") continue; - active += result.active; + active2 += result.active; pending += result.pending; } this.scopedUserPauses.delete(this.scopePauseKey(scope)); this.processNext(); - return { active, pending, total: active + pending }; + return { active: active2, pending, total: active2 + pending }; } forceStopAll(reason = "\u7528\u6237\u5F3A\u5236\u505C\u6B62") { - const active = this.active.length; + const active2 = this.active.length; const pending = this.queue.length; const touchedGroups = new Set([...this.active, ...this.queue].map((task) => task.groupId)); const removed = this.queue.splice(0); @@ -6567,7 +7859,7 @@ var DownloadTaskQueue = class { group.updatedAt = this.nextGroupVersion(group); this.notifyGroup(group); } - return { active, pending, total: active + pending }; + return { active: active2, pending, total: active2 + pending }; } processNext() { while (!this.isGloballyPaused() && this.active.length < this.maxConcurrent && this.queue.length > 0) { @@ -6644,9 +7936,9 @@ var DownloadTaskQueue = class { } countsForScope(scope) { const groupIds = new Set(Array.from(this.groups.values()).filter((group) => this.matchesScope(group, scope)).map((group) => group.id)); - const active = this.active.filter((task) => groupIds.has(task.groupId)).length; + const active2 = this.active.filter((task) => groupIds.has(task.groupId)).length; const pending = this.queue.filter((task) => groupIds.has(task.groupId)).length; - return { active, pending, total: active + pending }; + return { active: active2, pending, total: active2 + pending }; } refreshDiskPressurePause() { if (this.diskPressureBlockers.size === 0) { @@ -7300,9 +8592,9 @@ async function saveAndIndexWithCompensation(provider, tempPath, storedName, mime // src/services/storageAccountLease.ts init_storageAccountLifecycle(); -import crypto12 from "node:crypto"; +import crypto13 from "node:crypto"; async function acquireStorageAccountLease(client2, accountId, purpose, ttlMs = 30 * 60 * 1e3) { - const leaseId = crypto12.randomUUID(); + const leaseId = crypto13.randomUUID(); const expiresAt = new Date(Date.now() + ttlMs); const result = await client2.query( `INSERT INTO storage_account_leases (id, storage_account_id, purpose, expires_at) @@ -7379,7 +8671,7 @@ async function acquireStorageAccountOperationLease(pool2, accountId, purpose, op init_storageAccountLifecycle(); // src/services/telegramWriteReconciliation.ts -import crypto13 from "node:crypto"; +import crypto14 from "node:crypto"; async function ownsTelegramReconciliationLease(db, operationId, leaseToken) { const result = await db.query( `UPDATE telegram_write_reconciliations @@ -7391,7 +8683,7 @@ async function ownsTelegramReconciliationLease(db, operationId, leaseToken) { return result.rowCount === 1; } async function beginTelegramWriteReconciliation(db, input) { - const operationId = crypto13.randomUUID(); + const operationId = crypto14.randomUUID(); const result = await db.query( `INSERT INTO telegram_write_reconciliations (operation_id, job_id, item_id, child_lease_token, provider, account_id, @@ -7569,11 +8861,11 @@ var TG_DEBUG_LOG_MAX_BYTES = Math.max(1024 * 1024, parseInt(process.env.TG_DEBUG function appendTelegramDebugLog(line) { if (process.env.TG_STATUS_DEBUG !== "1") return; try { - fs8.mkdirSync(path11.dirname(TG_DEBUG_LOG_PATH), { recursive: true }); - if (fs8.existsSync(TG_DEBUG_LOG_PATH) && fs8.statSync(TG_DEBUG_LOG_PATH).size > TG_DEBUG_LOG_MAX_BYTES) { - fs8.renameSync(TG_DEBUG_LOG_PATH, `${TG_DEBUG_LOG_PATH}.${Date.now()}.old`); + fs9.mkdirSync(path11.dirname(TG_DEBUG_LOG_PATH), { recursive: true }); + if (fs9.existsSync(TG_DEBUG_LOG_PATH) && fs9.statSync(TG_DEBUG_LOG_PATH).size > TG_DEBUG_LOG_MAX_BYTES) { + fs9.renameSync(TG_DEBUG_LOG_PATH, `${TG_DEBUG_LOG_PATH}.${Date.now()}.old`); } - fs8.appendFileSync(TG_DEBUG_LOG_PATH, line); + fs9.appendFileSync(TG_DEBUG_LOG_PATH, line); } catch { } } @@ -7670,13 +8962,13 @@ async function getCanonicalTelegramFileName(message, currentFileName, mimeType, return rebuildGeneratedTelegramDisplayName(message, currentFileName, mimeType, sharedCaption, sequenceNumber); } async function getDiskWatermarkState(requiredBytes = 0) { - const statfs = await fs8.promises.statfs(UPLOAD_DIR); + const statfs = await fs9.promises.statfs(UPLOAD_DIR); const availableBytes = Number(statfs.bavail) * Number(statfs.bsize); return { availableBytes, ok: availableBytes - requiredBytes >= TG_MIN_FREE_DISK_BYTES }; } async function waitForDiskWatermark(requiredBytes = 0, signal) { const startedAt = Date.now(); - const blockerId = crypto14.randomUUID(); + const blockerId = crypto15.randomUUID(); let announcedPause = false; try { while (true) { @@ -8403,10 +9695,10 @@ function cancelDownloadTaskGroup(groupId, chatId, userId) { return downloadQueue.cancelGroup(groupId, { chatId, userId }, "\u7528\u6237\u901A\u8FC7\u4EFB\u52A1\u4E2D\u5FC3\u53D6\u6D88\u4EFB\u52A1"); } function ordinaryGroupId(prefix, chatId, identity) { - return `${prefix}${crypto14.createHash("sha256").update(`${chatId}:${identity}`).digest("base64url").slice(0, 22)}`; + return `${prefix}${crypto15.createHash("sha256").update(`${chatId}:${identity}`).digest("base64url").slice(0, 22)}`; } function channelExecutionGroupId(key) { - return `j${crypto14.createHash("sha256").update(key).digest("base64url").slice(0, 22)}`; + return `j${crypto15.createHash("sha256").update(key).digest("base64url").slice(0, 22)}`; } function getChannelExecutionGroup(jobId) { return downloadQueue.getGroup(channelExecutionGroupId(jobId), {}, true); @@ -8486,7 +9778,7 @@ async function cancelSilentTask(client2, chatId, taskId, fallbackMessageId, user ``, `\u5DF2\u79FB\u9664\u6682\u505C / \u7EE7\u7EED / \u53D6\u6D88\u6309\u94AE\uFF0C\u6B64\u4EFB\u52A1\u4E0D\u4F1A\u518D\u54CD\u5E94\u65E7\u6309\u94AE\u64CD\u4F5C\u3002` ].join("\n"); - await safeEditMessage(client2, editChatId, { message: silentMsgId, text, buttons: new Api5.ReplyInlineMarkup({ rows: [] }) }); + await safeEditMessage(client2, editChatId, { message: silentMsgId, text, buttons: new Api6.ReplyInlineMarkup({ rows: [] }) }); } silentSessionMap.delete(chatIdStr); removeTaskControlScope(session?.taskId); @@ -8514,11 +9806,11 @@ var mediaGroupDebouncer = createTelegramMediaGroupDebouncer({ }); async function downloadAndSaveFile(client2, message, originalFileName, targetDir, onProgress, signal) { const ext = path11.extname(originalFileName) || ""; - const tempStoredName = `${crypto14.randomUUID()}${ext}`; + const tempStoredName = `${crypto15.randomUUID()}${ext}`; let saveDir = targetDir || UPLOAD_DIR; - if (!fs8.existsSync(saveDir)) { + if (!fs9.existsSync(saveDir)) { try { - fs8.mkdirSync(saveDir, { recursive: true }); + fs9.mkdirSync(saveDir, { recursive: true }); } catch (err) { console.error(`\u{1F916} \u521B\u5EFA\u4E0B\u8F7D\u76EE\u5F55\u5931\u8D25: ${saveDir}`, err); if (saveDir === UPLOAD_DIR) throw err; @@ -8544,11 +9836,11 @@ async function downloadAndSaveFile(client2, message, originalFileName, targetDir outputFile: filePath, progressCallback: onProgress ? ((downloaded2, total) => onProgress(Number(downloaded2), Number(total))) : void 0 }); - if (!downloaded || !fs8.existsSync(filePath)) { + if (!downloaded || !fs9.existsSync(filePath)) { throw new Error("Telegram \u56FE\u7247\u4E0B\u8F7D\u672A\u751F\u6210\u6587\u4EF6"); } } else if (workers > 1 && totalSize > 0) { - const fileHandle = await fs8.promises.open(filePath, "w"); + const fileHandle = await fs9.promises.open(filePath, "w"); try { await fileHandle.truncate(totalSize); await Promise.all(Array.from({ length: workers }, async (_, workerIndex) => { @@ -8578,7 +9870,7 @@ async function downloadAndSaveFile(client2, message, originalFileName, targetDir await fileHandle.close(); } } else { - const writeStream = fs8.createWriteStream(filePath); + const writeStream = fs9.createWriteStream(filePath); for await (const chunk of client2.iterDownload({ file: media, requestSize: TELEGRAM_DOWNLOAD_PART_SIZE @@ -8596,15 +9888,15 @@ async function downloadAndSaveFile(client2, message, originalFileName, targetDir writeStream.on("error", reject); }); } - const stats = fs8.statSync(filePath); + const stats = fs9.statSync(filePath); if (totalSize > 0 && stats.size !== totalSize) { throw new Error(`\u4E0B\u8F7D\u6587\u4EF6\u5927\u5C0F\u4E0D\u4E00\u81F4: expected=${totalSize}, actual=${stats.size}`); } return { filePath, actualSize: stats.size, tempStoredName }; } catch (error) { console.error("\u{1F916} \u4E0B\u8F7D\u6587\u4EF6\u5931\u8D25:", error); - if (fs8.existsSync(filePath)) { - fs8.unlinkSync(filePath); + if (fs9.existsSync(filePath)) { + fs9.unlinkSync(filePath); } return null; } @@ -8652,21 +9944,32 @@ async function waitForStorageCooldownRetry(initialRetryAt, signal, retry, onWait } return signal.aborted ? "cancelled" : "success"; } -async function processFileUpload(client2, file, queue, groupId, getExecutionControlState) { +function createTelegramDownloadAccountFailure(accountId, sourceKey, error) { + const failure = new Error(error instanceof Error ? error.message : String(error)); + failure.telegramAccountFailure = true; + failure.accountId = accountId; + failure.sourceKey = sourceKey; + failure.cause = error; + return failure; +} +function isTelegramDownloadAccountFailure(error) { + return Boolean(error?.telegramAccountFailure); +} +async function processFileUpload(client2, file, queue2, groupId, getExecutionControlState) { file.status = "queued"; const attemptUpload = async (signal, reportProgress) => { let localFilePath; let storedName; try { - const storageTarget = file.storageTarget || queue?.storageTarget || storageManager.getActiveTarget(); + const storageTarget = file.storageTarget || queue2?.storageTarget || storageManager.getActiveTarget(); file.storageTarget = storageTarget; const { provider, accountId: activeAccountId } = storageTarget; await assertStorageTargetWritable(storageTarget); const chatName = await getTelegramChatName(file.message); file.fileName = await getCanonicalTelegramFileName(file.message, file.fileName, file.mimeType, file.sharedCaption, file.groupIndex, file.generatedName !== false); - if (queue?.chatId) { + if (queue2?.chatId) { const batchId = file.message.groupedId?.toString(); - if (batchId) updateBatch(queue.chatId.toString(), batchId, { currentFileName: file.fileName, currentFileActive: true }); + if (batchId) updateBatch(queue2.chatId.toString(), batchId, { currentFileName: file.fileName, currentFileActive: true }); } const batchFolder = null; const storageRules = await getStoragePathRules(); @@ -8677,8 +9980,8 @@ async function processFileUpload(client2, file, queue, groupId, getExecutionCont mimeType: file.mimeType, fileName: file.fileName }, storageRules); - const chatIdForPath = queue?.chatId?.toString() || file.message.chatId?.toString() || "unknown"; - const storageFolder = file.folderOverride !== void 0 ? file.folderOverride : queue?.storageFolder !== void 0 ? queue.storageFolder : await resolveTelegramStorageFolderPersistent(chatIdForPath, automaticFolder); + const chatIdForPath = queue2?.chatId?.toString() || file.message.chatId?.toString() || "unknown"; + const storageFolder = file.folderOverride !== void 0 ? file.folderOverride : queue2?.storageFolder !== void 0 ? queue2.storageFolder : await resolveTelegramStorageFolderPersistent(chatIdForPath, automaticFolder); const downloadSource = await resolveDownloadSource(client2, file.message, file.forwardedSourceCache); const result = await downloadAndSaveFile(downloadSource.client, downloadSource.message, file.fileName, file.targetDir, reportProgress, signal); if (!result) { @@ -8695,13 +9998,13 @@ async function processFileUpload(client2, file, queue, groupId, getExecutionCont file.status = "success"; file.size = actualSize; file.fileType = fileType; - if (queue?.chatId) { - const chatIdStr = queue.chatId.toString(); + if (queue2?.chatId) { + const chatIdStr = queue2.chatId.toString(); const batchId = file.message.groupedId?.toString(); if (batchId) updateBatch(chatIdStr, batchId, { folderPath: storageFolder || void 0, providerName: storageManager.getProvider().name }); rememberTransferDestination(chatIdStr, storageFolder, storageManager.getProvider().name); } - if (localFilePath && fs8.existsSync(localFilePath)) fs8.unlinkSync(localFilePath); + if (localFilePath && fs9.existsSync(localFilePath)) fs9.unlinkSync(localFilePath); return true; } } @@ -8823,7 +10126,7 @@ async function processFileUpload(client2, file, queue, groupId, getExecutionCont } else { await leasedSave(); } - if (fs8.existsSync(localFilePath)) fs8.unlinkSync(localFilePath); + if (fs9.existsSync(localFilePath)) fs9.unlinkSync(localFilePath); localFilePath = void 0; if (signal?.aborted && !file.leaseSettled) { const compensation = indexedFileId ? await compensateIndexedWriteAfterCancel({ @@ -8840,8 +10143,8 @@ async function processFileUpload(client2, file, queue, groupId, getExecutionCont file.status = "success"; file.size = actualSize; file.fileType = fileType; - if (queue?.chatId) { - const chatIdStr = queue.chatId.toString(); + if (queue2?.chatId) { + const chatIdStr = queue2.chatId.toString(); const batchId = file.message.groupedId?.toString(); if (batchId) updateBatch(chatIdStr, batchId, { folderPath: storageFolder || void 0, providerName: provider.name }); rememberTransferDestination(chatIdStr, storageFolder, provider.name); @@ -8850,14 +10153,17 @@ async function processFileUpload(client2, file, queue, groupId, getExecutionCont }); } catch (error) { if (error?.name === "TelegramDownloadLeaseLostError") throw error; + if (file.persistentRef && classifyTelegramDownloadAccountError(error) !== "retryable") { + throw createTelegramDownloadAccountFailure(String(client2.__tgVaultAccountId || ""), String(file.persistentRef.source || ""), error); + } console.error("\u{1F916} \u6587\u4EF6\u4E0A\u4F20\u5931\u8D25:", error); if (isStorageQuotaCooldownError(error)) { await markStorageAccountCooldown(error.storageAccountId || file.storageTarget?.accountId, error.provider, error.reason, error.cooldownUntil, error.message); file.storageCooldownUntil = error.cooldownUntil; file.error = formatStorageCooldownNotice(error.cooldownUntil); - if (localFilePath && fs8.existsSync(localFilePath)) { + if (localFilePath && fs9.existsSync(localFilePath)) { try { - fs8.unlinkSync(localFilePath); + fs9.unlinkSync(localFilePath); } catch { } } @@ -8865,9 +10171,9 @@ async function processFileUpload(client2, file, queue, groupId, getExecutionCont } else { file.error = error.message; } - if (localFilePath && fs8.existsSync(localFilePath)) { + if (localFilePath && fs9.existsSync(localFilePath)) { try { - fs8.unlinkSync(localFilePath); + fs9.unlinkSync(localFilePath); console.log(`\u{1F916} \u4E0A\u4F20\u5C1D\u8BD5\u5931\u8D25\uFF0C\u5DF2\u81EA\u52A8\u6E05\u7406\u672C\u5730\u5783\u573E\u7F13\u5B58: ${localFilePath}`); } catch (e) { console.error("\u{1F916} \u81EA\u52A8\u6E05\u7406\u5783\u573E\u7F13\u5B58\u5931\u8D25:", e); @@ -8900,16 +10206,16 @@ async function processFileUpload(client2, file, queue, groupId, getExecutionCont } return file.status === "failed" ? { status: "failed", error: file.error || "\u4E0B\u8F7D\u5931\u8D25" } : { status: "success" }; } finally { - if (queue?.chatId) { + if (queue2?.chatId) { const batchId = file.message.groupedId?.toString(); - if (batchId) updateBatch(queue.chatId.toString(), batchId, { currentFileActive: false, currentFileName: void 0 }); + if (batchId) updateBatch(queue2.chatId.toString(), batchId, { currentFileActive: false, currentFileName: void 0 }); } } }; if (file.generatedName !== false) { file.fileName = await getCanonicalTelegramFileName(file.message, file.fileName, file.mimeType, file.sharedCaption, file.groupIndex, true); } - const taskDisplayName = queue?.folderName ? `${queue.folderName}/${file.fileName}` : file.fileName; + const taskDisplayName = queue2?.folderName ? `${queue2.folderName}/${file.fileName}` : file.fileName; if (!groupId) throw new Error(`\u4E0B\u8F7D\u4EFB\u52A1\u7F3A\u5C11\u4EFB\u52A1\u7EC4: ${taskDisplayName}`); const onPendingCancelled = () => { file.status = "failed"; @@ -8918,23 +10224,23 @@ async function processFileUpload(client2, file, queue, groupId, getExecutionCont return downloadQueue.add(groupId, taskDisplayName, queueTask, file.size || 0, onPendingCancelled); } async function processBatchUpload(client2, queueKey) { - const queue = mediaGroupQueues.get(queueKey); - if (!queue || queue.processingStarted) return; + const queue2 = mediaGroupQueues.get(queueKey); + if (!queue2 || queue2.processingStarted) return; mediaGroupDebouncer.cancel(queueKey); - queue.processingStarted = true; + queue2.processingStarted = true; try { - await processBatchUploadSnapshot(client2, queueKey, queue); + await processBatchUploadSnapshot(client2, queueKey, queue2); } catch (error) { - console.error(`\u{1F916} Telegram \u76F8\u518C\u5904\u7406\u5931\u8D25: group=${queue.mediaGroupId}`, error); - queue.processingStarted = false; + console.error(`\u{1F916} Telegram \u76F8\u518C\u5904\u7406\u5931\u8D25: group=${queue2.mediaGroupId}`, error); + queue2.processingStarted = false; mediaGroupDebouncer.bump(queueKey); } } -async function processBatchUploadSnapshot(client2, queueKey, queue) { - const mediaGroupId = queue.mediaGroupId; - const batchClient = client2 || queue.client || queue.files[0]?.message?.client; +async function processBatchUploadSnapshot(client2, queueKey, queue2) { + const mediaGroupId = queue2.mediaGroupId; + const batchClient = client2 || queue2.client || queue2.files[0]?.message?.client; if (!batchClient) throw new Error("Telegram \u76F8\u518C\u7F3A\u5C11\u53EF\u7528\u5BA2\u6237\u7AEF"); - const snapshot = takePendingMediaGroupSnapshot(queue.files); + const snapshot = takePendingMediaGroupSnapshot(queue2.files); if (snapshot.length === 0) { mediaGroupQueues.delete(queueKey); return; @@ -8944,7 +10250,7 @@ async function processBatchUploadSnapshot(client2, queueKey, queue) { annotateTelegramMediaGroup(snapshot); const mediaGroupCaption = snapshot[0]?.sharedCaption || ""; let folderName = mediaGroupCaption; - const chatId = queue.chatId; + const chatId = queue2.chatId; const batchId = mediaGroupId; const groupId = ordinaryGroupId("a", chatId.toString(), queueKey); if (!folderName || isOpaqueTelegramIdentifier(folderName)) { @@ -8955,10 +10261,10 @@ async function processBatchUploadSnapshot(client2, queueKey, queue) { kind: "album", title: folderName, chatId: chatId.toString(), - userId: queue.userId || firstMessage.senderId?.toJSNumber(), - targetProvider: queue.storageTarget.provider.name, - targetAccountId: queue.storageTarget.accountId, - targetFolder: queue.storageFolder, + userId: queue2.userId || firstMessage.senderId?.toJSNumber(), + targetProvider: queue2.storageTarget.provider.name, + targetAccountId: queue2.storageTarget.accountId, + targetFolder: queue2.storageFolder, expectedTotal: snapshot.length }; await admitOrdinaryTransferTask(initialAlbumGroup); @@ -8988,14 +10294,14 @@ async function processBatchUploadSnapshot(client2, queueKey, queue) { }); const sanitizedFolderName = sanitizeFilename(folderName); const targetDir = path11.join(UPLOAD_DIR, sanitizedFolderName); - if (!fs8.existsSync(targetDir)) { - fs8.mkdirSync(targetDir, { recursive: true }); + if (!fs9.existsSync(targetDir)) { + fs9.mkdirSync(targetDir, { recursive: true }); } - queue.folderName = sanitizedFolderName; - for (const file of queue.files) { + queue2.folderName = sanitizedFolderName; + for (const file of queue2.files) { file.targetDir = targetDir; } - const firstBatchFile = queue.files[0]; + const firstBatchFile = queue2.files[0]; if (firstBatchFile) { const chatName = await getTelegramChatName(firstBatchFile.message); const batchFolder = null; @@ -9008,21 +10314,21 @@ async function processBatchUploadSnapshot(client2, queueKey, queue) { fileName: firstBatchFile.fileName }, storageRules); const storageFolder = await resolveTelegramStorageFolderPersistent(chatId.toString(), automaticPreview); - queue.storageFolder = storageFolder; + queue2.storageFolder = storageFolder; downloadQueue.ensureGroup({ id: groupId, kind: "album", - title: queue.folderName || folderName, + title: queue2.folderName || folderName, chatId: chatId.toString(), - userId: queue.userId || firstMessage.senderId?.toJSNumber(), - targetProvider: queue.storageTarget.provider.name, - targetAccountId: queue.storageTarget.accountId, + userId: queue2.userId || firstMessage.senderId?.toJSNumber(), + targetProvider: queue2.storageTarget.provider.name, + targetAccountId: queue2.storageTarget.accountId, targetFolder: storageFolder, expectedTotal: snapshot.length }); const folderPreview = storageFolder; - updateBatch(chatId.toString(), batchId, { folderName: queue.folderName, folderPath: folderPreview || void 0 }); - queue.folderPath = folderPreview || void 0; + updateBatch(chatId.toString(), batchId, { folderName: queue2.folderName, folderPath: folderPreview || void 0 }); + queue2.folderPath = folderPreview || void 0; rememberTransferDestination(chatId.toString(), folderPreview, storageManager.getProvider().name); } if (!silentSessionMap.has(chatId.toString())) { @@ -9032,10 +10338,10 @@ async function processBatchUploadSnapshot(client2, queueKey, queue) { }); } const onBatchProgress = async () => { - const completed = queue.files.filter((f) => f.status === "success" || f.status === "failed").length; - const successful = queue.files.filter((f) => f.status === "success").length; - const failed = queue.files.filter((f) => f.status === "failed").length; - const currentFile = queue.files.find((f) => f.status === "uploading"); + const completed = queue2.files.filter((f) => f.status === "success" || f.status === "failed").length; + const successful = queue2.files.filter((f) => f.status === "success").length; + const failed = queue2.files.filter((f) => f.status === "failed").length; + const currentFile = queue2.files.find((f) => f.status === "uploading"); const stats = downloadQueue.getStats(); updateBatch(chatId.toString(), batchId, { completed, @@ -9081,7 +10387,7 @@ async function processBatchUploadSnapshot(client2, queueKey, queue) { file.targetDir = targetDir; file.forwardedSourceCache = forwardedSourceCache; queuedFilePromises.push( - processFileUpload(batchClient, file, queue, groupId).finally(refreshBatchProgressAndFinalizeSilent) + processFileUpload(batchClient, file, queue2, groupId).finally(refreshBatchProgressAndFinalizeSilent) ); } } @@ -9094,8 +10400,8 @@ async function processBatchUploadSnapshot(client2, queueKey, queue) { setTimeout(() => { removeBatch(chatId.toString(), batchId); }, 8e3); - if (queue.files.some((file) => file.status === "pending")) { - queue.processingStarted = false; + if (queue2.files.some((file) => file.status === "pending")) { + queue2.processingStarted = false; mediaGroupDebouncer.bump(queueKey); } else { mediaGroupQueues.delete(queueKey); @@ -9109,8 +10415,8 @@ async function handleCleanupCallback(cleanupId) { return { success: false, message: "\u8BE5\u6E05\u7406\u4EFB\u52A1\u5DF2\u8FC7\u671F\u6216\u4E0D\u5B58\u5728" }; } try { - if (cleanupInfo.localPath && fs8.existsSync(cleanupInfo.localPath)) { - fs8.unlinkSync(cleanupInfo.localPath); + if (cleanupInfo.localPath && fs9.existsSync(cleanupInfo.localPath)) { + fs9.unlinkSync(cleanupInfo.localPath); } pendingCleanups.delete(cleanupId); return { @@ -9136,323 +10442,365 @@ function normalizeTelegramDownloadRefs(refs, defaultSourceEntity) { return refs.filter((ref) => ref.id > 0).map((ref) => ({ ...ref, source: ref.source || defaultSourceEntity })); } async function downloadTelegramChannelRange(botClient, requestMessage, source, startMessageId, limit = 50, direction = "older", explicitIds, folderOverride, explicitRefs, onItemSettled, executionGroupKey, getExecutionControlState, taskSignal, ownerUserId, storageTarget = storageManager.getActiveTarget(), withItemLease) { - const userClient2 = getTelegramUserClient(); - if (!userClient2 || !isTelegramUserClientReady()) { - throw new Error("Telegram \u7528\u6237\u8D26\u53F7\u4E0B\u8F7D\u5668\u672A\u5C31\u7EEA\uFF1A\u8BF7\u5148\u914D\u7F6E TELEGRAM_API_ID / TELEGRAM_API_HASH \u5E76\u751F\u6210 user session"); + const selectedDownloadAccount = await selectTelegramDownloadAccount(String(source)); + if (selectedDownloadAccount) selectedDownloadAccount.client.__tgVaultAccountId = selectedDownloadAccount.accountId; + const userClient2 = selectedDownloadAccount?.client || getTelegramUserClient(); + if (!userClient2 || !selectedDownloadAccount && !isTelegramUserClientReady()) { + selectedDownloadAccount?.release(); + throw new Error("Telegram \u7528\u6237\u8D26\u53F7\u4E0B\u8F7D\u5668\u672A\u5C31\u7EEA\uFF1A\u8BF7\u5148\u914D\u7F6E Telegram API \u5E76\u81F3\u5C11\u767B\u5F55\u4E00\u4E2A\u53EF\u7528\u8D26\u53F7"); } - const safeLimit = Math.max(1, Math.floor(limit || TG_BATCH_DEFAULT_LIMIT)); - const sourceEntity = source.startsWith("@") || /^-?\d+$/.test(source) || /^https?:\/\//i.test(source) ? source : `@${source}`; - const normalizedExplicitRefs = normalizeTelegramDownloadRefs(explicitRefs, sourceEntity); - const ids = normalizedExplicitRefs?.map((ref) => ref.id) || explicitIds?.filter((id) => id > 0) || Array.from({ length: safeLimit }, (_, index) => direction === "newer" ? startMessageId + index : startMessageId - index).filter((id) => id > 0); - if (ids.length === 0) { - throw new Error("\u8D77\u59CB\u6D88\u606F ID \u65E0\u6548"); - } - const chatId = requestMessage.chatId; - if (!chatId) { - throw new Error("\u65E0\u6CD5\u8BC6\u522B\u5F53\u524D Bot \u4F1A\u8BDD"); - } - await checkAndResetSession(botClient, chatId); - let found = 0; - let skipped = 0; - const chatIdStr = chatId.toString(); - const taskFolderOverride = folderOverride !== void 0 ? folderOverride : void 0; - let taskResolvedStorageFolder; - const downloadableRefs = []; - const successfulMessageIds = []; - const failedMessageIds = []; - const skippedMessageIds = []; - if (normalizedExplicitRefs) { - for (const ref of normalizedExplicitRefs) { - const refSource = ref.source || sourceEntity; - const fileInfo = ref.fileInfo; - if (!fileInfo) { - skipped += 1; - skippedMessageIds.push(ref.id); - await onItemSettled?.(ref, "skipped"); - continue; - } - downloadableRefs.push({ - id: ref.id, - sourceKey: sourceKeyForDownloadRef(refSource), - sourceEntity: refSource, - persistentRef: ref, - origin: ref.origin || "channel", - channelPostId: ref.channelPostId, - fileInfo, - totalSize: ref.totalSize || 0, - message: ref.message, - groupedId: ref.groupedId, - sharedCaption: ref.sharedCaption, - groupIndex: ref.groupIndex, - groupSize: ref.groupSize - }); + let telegramDownloadAttemptId = null; + if (selectedDownloadAccount) { + if (executionGroupKey) { + await query( + `UPDATE telegram_background_jobs SET assigned_account_id = $2, updated_at = NOW() WHERE id = $1`, + [executionGroupKey, selectedDownloadAccount.accountId] + ); } - } 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 userClient2.getMessages(sourceEntity, { ids: scanIds }); - const returnedIds = /* @__PURE__ */ new Set(); - for (const sourceMessage of scanMessages) { - if (!sourceMessage) continue; - returnedIds.add(sourceMessage.id); - const fileInfo = extractFileInfo(sourceMessage); + telegramDownloadAttemptId = await startTelegramDownloadAttempt({ + accountId: selectedDownloadAccount.accountId, + sourceKey: String(source), + jobId: executionGroupKey || null + }); + } + try { + const safeLimit = Math.max(1, Math.floor(limit || TG_BATCH_DEFAULT_LIMIT)); + const sourceEntity = source.startsWith("@") || /^-?\d+$/.test(source) || /^https?:\/\//i.test(source) ? source : `@${source}`; + const normalizedExplicitRefs = normalizeTelegramDownloadRefs(explicitRefs, sourceEntity); + const ids = normalizedExplicitRefs?.map((ref) => ref.id) || explicitIds?.filter((id) => id > 0) || Array.from({ length: safeLimit }, (_, index) => direction === "newer" ? startMessageId + index : startMessageId - index).filter((id) => id > 0); + if (ids.length === 0) { + throw new Error("\u8D77\u59CB\u6D88\u606F ID \u65E0\u6548"); + } + const chatId = requestMessage.chatId; + if (!chatId) { + throw new Error("\u65E0\u6CD5\u8BC6\u522B\u5F53\u524D Bot \u4F1A\u8BDD"); + } + await checkAndResetSession(botClient, chatId); + let found = 0; + let skipped = 0; + const chatIdStr = chatId.toString(); + const taskFolderOverride = folderOverride !== void 0 ? folderOverride : void 0; + let taskResolvedStorageFolder; + const downloadableRefs = []; + const successfulMessageIds = []; + const failedMessageIds = []; + const skippedMessageIds = []; + if (normalizedExplicitRefs) { + for (const ref of normalizedExplicitRefs) { + const refSource = ref.source || sourceEntity; + const fileInfo = ref.fileInfo; if (!fileInfo) { skipped += 1; - skippedMessageIds.push(sourceMessage.id); + skippedMessageIds.push(ref.id); + await onItemSettled?.(ref, "skipped"); continue; } downloadableRefs.push({ - id: sourceMessage.id, - sourceKey: sourceKeyForDownloadRef(sourceEntity), - sourceEntity, - persistentRef: { id: sourceMessage.id, source: sourceEntity, origin: "channel", fileInfo }, - origin: "channel", + id: ref.id, + sourceKey: sourceKeyForDownloadRef(refSource), + sourceEntity: refSource, + persistentRef: ref, + origin: ref.origin || "channel", + channelPostId: ref.channelPostId, fileInfo, - totalSize: getEstimatedFileSize(sourceMessage) + totalSize: ref.totalSize || 0, + message: ref.message, + groupedId: ref.groupedId, + sharedCaption: ref.sharedCaption, + groupIndex: ref.groupIndex, + groupSize: ref.groupSize }); } - for (const requestedId of scanIds) { - if (!returnedIds.has(requestedId)) { - skipped += 1; - skippedMessageIds.push(requestedId); - } - } - } - } - const batchId = `tg-range-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - const channelGroupId = channelExecutionGroupId(executionGroupKey || `${chatIdStr}:${batchId}`); - if (executionGroupKey) { - const controlState = await getExecutionControlState?.(); - if (controlState && controlState !== "run") { - if (controlState === "cancelled") { - for (const ref of downloadableRefs) await onItemSettled?.(ref.persistentRef, "skipped", "\u4EFB\u52A1\u5DF2\u53D6\u6D88"); - } - return { - requested: ids.length, - found: 0, - skipped: controlState === "cancelled" ? downloadableRefs.length : 0, - failed: 0, - successful: 0, - successfulMessageIds, - failedMessageIds, - skippedMessageIds: controlState === "cancelled" ? downloadableRefs.map((ref) => ref.id) : skippedMessageIds, - firstId: ids[0], - lastId: ids[ids.length - 1] - }; - } - } - downloadQueue.ensureGroup({ - id: channelGroupId, - kind: "channel", - title: sourceEntity.toString(), - chatId: chatIdStr, - hidden: true, - expectedTotal: downloadableRefs.length - }); - if (downloadableRefs.length > 0) { - const firstRef = downloadableRefs[0]; - if (taskFolderOverride !== void 0) { - taskResolvedStorageFolder = taskFolderOverride; - } else if (firstRef) { - const firstMessage = (await userClient2.getMessages(firstRef.sourceEntity, { ids: [firstRef.id] }))[0]; - if (firstMessage) { - const chatName = await getTelegramChatName(firstMessage); - const storageRules = await getStoragePathRules(); - const automaticPreview = buildStorageFolderWithRules({ - source: "telegram", - chatName, - mimeType: firstRef.fileInfo.mimeType, - fileName: firstRef.fileInfo.fileName - }, storageRules); - taskResolvedStorageFolder = (await resolveTelegramTaskStorageFolderPersistent(chatIdStr, automaticPreview)).folder; - } - } - registerBatch(chatIdStr, batchId, { - id: batchId, - folderName: sourceEntity.toString(), - folderPath: taskResolvedStorageFolder || void 0, - totalFiles: downloadableRefs.length, - completed: 0, - successful: 0, - failed: 0, - providerName: storageManager.getProvider().name, - queuePending: 0 - }); - await trySilentMode(botClient, chatId, requestMessage, ownerUserId ?? requestMessage.senderId?.toJSNumber()); - await refreshConsolidatedMessage(botClient, chatId, requestMessage); - } - let completed = 0; - let successful = 0; - let failed = 0; - let lastStatusRefresh = 0; - const refreshSegmentStatus = async (force = false, currentFileName) => { - if (!shouldRefreshLargeTaskStatus(lastStatusRefresh, completed, force)) return; - lastStatusRefresh = Date.now(); - const stats = downloadQueue.getStats(); - updateBatch(chatIdStr, batchId, { - completed, - successful, - failed, - queuePending: stats.pending, - currentFileName - }); - if (silentSessionMap.has(chatIdStr)) { - await refreshSilentProgress(botClient, chatId); - await finalizeSilentSessionIfDone(botClient, chatId); } else { - await refreshConsolidatedMessage(botClient, chatId); - } - }; - 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 segmentBytes = segment.reduce((sum, item) => sum + (item.totalSize || 0), 0); - await waitForDiskWatermark(segmentBytes, taskSignal); - const segmentMessagesBySource = /* @__PURE__ */ new Map(); - const refsBySource = /* @__PURE__ */ new Map(); - for (const item of segment) { - const items = refsBySource.get(item.sourceKey) || []; - items.push(item); - refsBySource.set(item.sourceKey, items); - } - for (const [sourceKey, sourceItems] of refsBySource) { - const preloadedMessageById = /* @__PURE__ */ new Map(); - const missingSourceItems = []; - for (const sourceItem of sourceItems) { - if (sourceItem.message) { - preloadedMessageById.set(sourceItem.id, sourceItem.message); - } else { - missingSourceItems.push(sourceItem); - } - } - if (missingSourceItems.length > 0) { - const segmentIds = missingSourceItems.map((item) => item.id); - const segmentMessages = await userClient2.getMessages(sourceItems[0].sourceEntity, { ids: segmentIds }); - for (const segmentMessage of segmentMessages) { - if (segmentMessage) preloadedMessageById.set(segmentMessage.id, segmentMessage); - } - } - segmentMessagesBySource.set(sourceKey, preloadedMessageById); - } - await Promise.all(segment.map(async (item) => { - if (getExecutionControlState) { - const controlState = await getExecutionControlState(); - if (controlState !== "run") { - if (controlState === "cancelled") { + 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 userClient2.getMessages(sourceEntity, { ids: scanIds }); + const returnedIds = /* @__PURE__ */ new Set(); + for (const sourceMessage of scanMessages) { + if (!sourceMessage) continue; + returnedIds.add(sourceMessage.id); + const fileInfo = extractFileInfo(sourceMessage); + if (!fileInfo) { skipped += 1; - completed += 1; - skippedMessageIds.push(item.id); - await onItemSettled?.(item.persistentRef, "skipped", "\u4EFB\u52A1\u5DF2\u53D6\u6D88"); + skippedMessageIds.push(sourceMessage.id); + continue; + } + downloadableRefs.push({ + id: sourceMessage.id, + sourceKey: sourceKeyForDownloadRef(sourceEntity), + sourceEntity, + persistentRef: { id: sourceMessage.id, source: sourceEntity, origin: "channel", fileInfo }, + origin: "channel", + fileInfo, + totalSize: getEstimatedFileSize(sourceMessage) + }); + } + for (const requestedId of scanIds) { + if (!returnedIds.has(requestedId)) { + skipped += 1; + skippedMessageIds.push(requestedId); } - return; } } - const { fileName, mimeType } = item.fileInfo; - const message = segmentMessagesBySource.get(item.sourceKey)?.get(item.id); - if (!message) { - skipped += 1; - failed += 1; - completed += 1; - failedMessageIds.push(item.id); - await refreshSegmentStatus(false, fileName); - await onItemSettled?.(item.persistentRef, "failed", "\u6D88\u606F\u4E0D\u5B58\u5728\u6216\u65E0\u6CD5\u91CD\u65B0\u8BFB\u53D6"); - return; + } + const batchId = `tg-range-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const channelGroupId = channelExecutionGroupId(executionGroupKey || `${chatIdStr}:${batchId}`); + if (executionGroupKey) { + const controlState = await getExecutionControlState?.(); + if (controlState && controlState !== "run") { + if (controlState === "cancelled") { + for (const ref of downloadableRefs) await onItemSettled?.(ref.persistentRef, "skipped", "\u4EFB\u52A1\u5DF2\u53D6\u6D88"); + } + return { + requested: ids.length, + found: 0, + skipped: controlState === "cancelled" ? downloadableRefs.length : 0, + failed: 0, + successful: 0, + successfulMessageIds, + failedMessageIds, + skippedMessageIds: controlState === "cancelled" ? downloadableRefs.map((ref) => ref.id) : skippedMessageIds, + firstId: ids[0], + lastId: ids[ids.length - 1] + }; } - const uploadItem = { - fileName, - mimeType, - generatedName: item.fileInfo.generatedName, - message, - status: "pending", - sharedCaption: item.sharedCaption, - groupIndex: item.groupIndex, - groupSize: item.groupSize, - storageTarget, - persistentRef: item.persistentRef, - withLease: withItemLease ? (operation) => withItemLease(item.persistentRef, operation) : void 0 - }; - try { - if (taskResolvedStorageFolder !== void 0) { - updateBatch(chatIdStr, batchId, { folderPath: taskResolvedStorageFolder || void 0 }); - } else if (!getConsolidatedBatches(chatIdStr).find((batch) => batch.id === batchId)?.folderPath) { - const chatName = await getTelegramChatName(message); + } + downloadQueue.ensureGroup({ + id: channelGroupId, + kind: "channel", + title: sourceEntity.toString(), + chatId: chatIdStr, + hidden: true, + expectedTotal: downloadableRefs.length + }); + if (downloadableRefs.length > 0) { + const firstRef = downloadableRefs[0]; + if (taskFolderOverride !== void 0) { + taskResolvedStorageFolder = taskFolderOverride; + } else if (firstRef) { + const firstMessage = (await userClient2.getMessages(firstRef.sourceEntity, { ids: [firstRef.id] }))[0]; + if (firstMessage) { + const chatName = await getTelegramChatName(firstMessage); const storageRules = await getStoragePathRules(); const automaticPreview = buildStorageFolderWithRules({ source: "telegram", chatName, - mimeType, - fileName + mimeType: firstRef.fileInfo.mimeType, + fileName: firstRef.fileInfo.fileName }, storageRules); - const resolved = (await resolveTelegramTaskStorageFolderPersistent(chatIdStr, automaticPreview)).folder; - taskResolvedStorageFolder = resolved; - updateBatch(chatIdStr, batchId, { folderPath: resolved || void 0 }); + taskResolvedStorageFolder = (await resolveTelegramTaskStorageFolderPersistent(chatIdStr, automaticPreview)).folder; } - uploadItem.folderOverride = taskResolvedStorageFolder !== void 0 ? taskResolvedStorageFolder : taskFolderOverride; - await refreshSegmentStatus(true, fileName); - await processFileUpload(userClient2, uploadItem, void 0, channelGroupId, getExecutionControlState); - if (uploadItem.status === "success") { - successful += 1; - successfulMessageIds.push(item.id); - if (!uploadItem.leaseSettled) await onItemSettled?.(item.persistentRef, "success"); - } else if (uploadItem.storageCooldownUntil) { - throw new StorageQuotaCooldownError(uploadItem.error || "Google Drive \u4ECA\u65E5\u4E0A\u4F20\u989D\u5EA6\u5DF2\u8FBE\u4E0A\u9650\uFF0C\u4EFB\u52A1\u5C06\u81EA\u52A8\u6682\u505C 24 \u5C0F\u65F6\u540E\u7EE7\u7EED\u3002", { - provider: "google_drive", - reason: "daily_upload_limit", - storageAccountId: storageManager.getActiveAccountId() || void 0, - cooldownUntil: uploadItem.storageCooldownUntil - }); - } else { + } + registerBatch(chatIdStr, batchId, { + id: batchId, + folderName: sourceEntity.toString(), + folderPath: taskResolvedStorageFolder || void 0, + totalFiles: downloadableRefs.length, + completed: 0, + successful: 0, + failed: 0, + providerName: storageManager.getProvider().name, + queuePending: 0 + }); + await trySilentMode(botClient, chatId, requestMessage, ownerUserId ?? requestMessage.senderId?.toJSNumber()); + await refreshConsolidatedMessage(botClient, chatId, requestMessage); + } + let completed = 0; + let successful = 0; + let failed = 0; + let lastStatusRefresh = 0; + const refreshSegmentStatus = async (force = false, currentFileName) => { + if (!shouldRefreshLargeTaskStatus(lastStatusRefresh, completed, force)) return; + lastStatusRefresh = Date.now(); + const stats = downloadQueue.getStats(); + updateBatch(chatIdStr, batchId, { + completed, + successful, + failed, + queuePending: stats.pending, + currentFileName + }); + if (silentSessionMap.has(chatIdStr)) { + await refreshSilentProgress(botClient, chatId); + await finalizeSilentSessionIfDone(botClient, chatId); + } else { + await refreshConsolidatedMessage(botClient, chatId); + } + }; + 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 segmentBytes = segment.reduce((sum, item) => sum + (item.totalSize || 0), 0); + await waitForDiskWatermark(segmentBytes, taskSignal); + const segmentMessagesBySource = /* @__PURE__ */ new Map(); + const refsBySource = /* @__PURE__ */ new Map(); + for (const item of segment) { + const items = refsBySource.get(item.sourceKey) || []; + items.push(item); + refsBySource.set(item.sourceKey, items); + } + for (const [sourceKey, sourceItems] of refsBySource) { + const preloadedMessageById = /* @__PURE__ */ new Map(); + const missingSourceItems = []; + for (const sourceItem of sourceItems) { + if (sourceItem.message) { + preloadedMessageById.set(sourceItem.id, sourceItem.message); + } else { + missingSourceItems.push(sourceItem); + } + } + if (missingSourceItems.length > 0) { + const segmentIds = missingSourceItems.map((item) => item.id); + const segmentMessages = await userClient2.getMessages(sourceItems[0].sourceEntity, { ids: segmentIds }); + for (const segmentMessage of segmentMessages) { + if (segmentMessage) preloadedMessageById.set(segmentMessage.id, segmentMessage); + } + } + segmentMessagesBySource.set(sourceKey, preloadedMessageById); + } + await Promise.all(segment.map(async (item) => { + if (getExecutionControlState) { + const controlState = await getExecutionControlState(); + if (controlState !== "run") { + if (controlState === "cancelled") { + skipped += 1; + completed += 1; + skippedMessageIds.push(item.id); + await onItemSettled?.(item.persistentRef, "skipped", "\u4EFB\u52A1\u5DF2\u53D6\u6D88"); + } + return; + } + } + const { fileName, mimeType } = item.fileInfo; + const message = segmentMessagesBySource.get(item.sourceKey)?.get(item.id); + if (!message) { + skipped += 1; + failed += 1; + completed += 1; + failedMessageIds.push(item.id); + await refreshSegmentStatus(false, fileName); + await onItemSettled?.(item.persistentRef, "failed", "\u6D88\u606F\u4E0D\u5B58\u5728\u6216\u65E0\u6CD5\u91CD\u65B0\u8BFB\u53D6"); + return; + } + const uploadItem = { + fileName, + mimeType, + generatedName: item.fileInfo.generatedName, + message, + status: "pending", + sharedCaption: item.sharedCaption, + groupIndex: item.groupIndex, + groupSize: item.groupSize, + storageTarget, + persistentRef: item.persistentRef, + withLease: withItemLease ? (operation) => withItemLease(item.persistentRef, operation) : void 0 + }; + try { + if (taskResolvedStorageFolder !== void 0) { + updateBatch(chatIdStr, batchId, { folderPath: taskResolvedStorageFolder || void 0 }); + } else if (!getConsolidatedBatches(chatIdStr).find((batch) => batch.id === batchId)?.folderPath) { + const chatName = await getTelegramChatName(message); + const storageRules = await getStoragePathRules(); + const automaticPreview = buildStorageFolderWithRules({ + source: "telegram", + chatName, + mimeType, + fileName + }, storageRules); + const resolved = (await resolveTelegramTaskStorageFolderPersistent(chatIdStr, automaticPreview)).folder; + taskResolvedStorageFolder = resolved; + updateBatch(chatIdStr, batchId, { folderPath: resolved || void 0 }); + } + uploadItem.folderOverride = taskResolvedStorageFolder !== void 0 ? taskResolvedStorageFolder : taskFolderOverride; + await refreshSegmentStatus(true, fileName); + await processFileUpload(userClient2, uploadItem, void 0, channelGroupId, getExecutionControlState); + if (uploadItem.status === "success") { + successful += 1; + successfulMessageIds.push(item.id); + if (!uploadItem.leaseSettled) await onItemSettled?.(item.persistentRef, "success"); + } else if (uploadItem.storageCooldownUntil) { + throw new StorageQuotaCooldownError(uploadItem.error || "Google Drive \u4ECA\u65E5\u4E0A\u4F20\u989D\u5EA6\u5DF2\u8FBE\u4E0A\u9650\uFF0C\u4EFB\u52A1\u5C06\u81EA\u52A8\u6682\u505C 24 \u5C0F\u65F6\u540E\u7EE7\u7EED\u3002", { + provider: "google_drive", + reason: "daily_upload_limit", + storageAccountId: storageManager.getActiveAccountId() || void 0, + cooldownUntil: uploadItem.storageCooldownUntil + }); + } else { + failed += 1; + failedMessageIds.push(item.id); + await onItemSettled?.(item.persistentRef, "failed", uploadItem.error || "\u4E0B\u8F7D\u5931\u8D25"); + } + } catch (err) { + if (err?.name === "TelegramDownloadLeaseLostError") throw err; + if (isStorageQuotaCooldownError(err)) { + throw err; + } + const flood = (() => { + const anyErr = err; + const text = `${anyErr?.message || ""} ${anyErr?.errorMessage || ""}`; + const seconds = Number(anyErr?.seconds || anyErr?.value || text.match(/FLOOD_WAIT_?(\d+)/i)?.[1] || 0); + return seconds > 0 || /FLOOD|Too many requests/i.test(text) ? Math.max(30, seconds || 60) : 0; + })(); + if (flood > 0) { + const floodError = new Error(`Telegram FloodWait ${flood}s`); + floodError.seconds = flood; + throw floodError; + } + console.error(`\u{1F916} \u9891\u9053\u5206\u6BB5\u4E0B\u8F7D\u4EFB\u52A1\u5F02\u5E38: ${fileName}`, err); failed += 1; failedMessageIds.push(item.id); - await onItemSettled?.(item.persistentRef, "failed", uploadItem.error || "\u4E0B\u8F7D\u5931\u8D25"); + await onItemSettled?.(item.persistentRef, "failed", err instanceof Error ? err.message : String(err)); + } finally { + if (!uploadItem.storageCooldownUntil) { + completed += 1; + found += 1; + } + await refreshSegmentStatus(false, fileName); } - } catch (err) { - if (err?.name === "TelegramDownloadLeaseLostError") throw err; - if (isStorageQuotaCooldownError(err)) { - throw err; - } - const flood = (() => { - const anyErr = err; - const text = `${anyErr?.message || ""} ${anyErr?.errorMessage || ""}`; - const seconds = Number(anyErr?.seconds || anyErr?.value || text.match(/FLOOD_WAIT_?(\d+)/i)?.[1] || 0); - return seconds > 0 || /FLOOD|Too many requests/i.test(text) ? Math.max(30, seconds || 60) : 0; - })(); - if (flood > 0) { - const floodError = new Error(`Telegram FloodWait ${flood}s`); - floodError.seconds = flood; - throw floodError; - } - console.error(`\u{1F916} \u9891\u9053\u5206\u6BB5\u4E0B\u8F7D\u4EFB\u52A1\u5F02\u5E38: ${fileName}`, err); - failed += 1; - failedMessageIds.push(item.id); - await onItemSettled?.(item.persistentRef, "failed", err instanceof Error ? err.message : String(err)); - } finally { - if (!uploadItem.storageCooldownUntil) { - completed += 1; - found += 1; - } - await refreshSegmentStatus(false, fileName); + })); + await refreshSegmentStatus(true, segment[segment.length - 1]?.fileInfo.fileName); + } + if (downloadableRefs.length > 0) { + updateBatch(chatIdStr, batchId, { completed, successful, failed, queuePending: 0, currentFileName: void 0 }); + await refreshSegmentStatus(true); + await finalizeSilentSessionIfDone(botClient, chatId); + setTimeout(() => removeBatch(chatIdStr, batchId), 8e3); + } + return { + requested: ids.length, + found, + skipped, + failed, + successful, + successfulMessageIds, + failedMessageIds, + skippedMessageIds: skippedMessageIds.filter((id) => id > 0), + firstId: ids[0], + lastId: ids[ids.length - 1] + }; + } catch (error) { + if (telegramDownloadAttemptId) { + await finishTelegramDownloadAttempt(telegramDownloadAttemptId, "failed", error instanceof Error ? error.message : String(error)).catch(() => void 0); + telegramDownloadAttemptId = null; + } + const accountError = isTelegramDownloadAccountFailure(error) ? error.cause || error : error; + if (selectedDownloadAccount) { + const classification = classifyTelegramDownloadAccountError(accountError); + const text = accountError instanceof Error ? accountError.message : String(accountError); + if (classification === "flood_wait") { + await markTelegramAccountCooldown(selectedDownloadAccount.accountId, telegramFloodWaitSeconds(accountError), text); + } else if (classification === "session_expired") { + await markTelegramAccountSessionExpired(selectedDownloadAccount.accountId, text); + } else if (classification === "permission_denied") { + await markTelegramAccountSourceAccess(selectedDownloadAccount.accountId, String(source), "download", "denied", text); } - })); - await refreshSegmentStatus(true, segment[segment.length - 1]?.fileInfo.fileName); + } + throw accountError; + } finally { + if (telegramDownloadAttemptId) { + await finishTelegramDownloadAttempt(telegramDownloadAttemptId, "succeeded").catch(() => void 0); + } + selectedDownloadAccount?.release(); } - if (downloadableRefs.length > 0) { - updateBatch(chatIdStr, batchId, { completed, successful, failed, queuePending: 0, currentFileName: void 0 }); - await refreshSegmentStatus(true); - await finalizeSilentSessionIfDone(botClient, chatId); - setTimeout(() => removeBatch(chatIdStr, batchId), 8e3); - } - return { - requested: ids.length, - found, - skipped, - failed, - successful, - successfulMessageIds, - failedMessageIds, - skippedMessageIds: skippedMessageIds.filter((id) => id > 0), - firstId: ids[0], - lastId: ids[ids.length - 1] - }; } async function handleFileUpload(client2, event) { const message = event.message; @@ -9478,7 +10826,7 @@ async function handleFileUpload(client2, event) { } const queueKey = telegramMediaGroupQueueKey(message.chatId, mediaGroupId); const existingQueue = mediaGroupQueues.get(queueKey) || mediaGroupQueueCreations.has(queueKey); - const queue = await getOrCreateTelegramMediaGroupQueue(mediaGroupQueues, mediaGroupQueueCreations, queueKey, async () => { + const queue2 = await getOrCreateTelegramMediaGroupQueue(mediaGroupQueues, mediaGroupQueueCreations, queueKey, async () => { const chatKey = message.chatId?.toString() || String(senderId); const selectedTarget = await consumeOrGetTelegramTargetState(chatKey); const storageTarget = selectedTarget ? storageManager.getTarget(selectedTarget.provider, selectedTarget.accountId) : storageManager.getActiveTarget(); @@ -9496,21 +10844,21 @@ async function handleFileUpload(client2, event) { }; }); if (!existingQueue) { - const queueInstance = queue; + const queueInstance = queue2; setTimeout(() => { if (mediaGroupQueues.get(queueKey) === queueInstance && !queueInstance.processingStarted) { mediaGroupDebouncer.flush(queueKey); } }, MEDIA_GROUP_MAX_WAIT_MS); } - queue.files.push({ + queue2.files.push({ fileName, mimeType, generatedName, message, status: "pending" }); - queue.lastAddedAt = Date.now(); + queue2.lastAddedAt = Date.now(); mediaGroupDebouncer.bump(queueKey); if (message.chatId) { const chatIdStr = message.chatId.toString(); @@ -9519,9 +10867,9 @@ async function handleFileUpload(client2, event) { if (!batchMap || !batchMap.has(batchId)) { registerBatch(chatIdStr, batchId, { id: batchId, - folderName: queue.folderName || "media-group", + folderName: queue2.folderName || "media-group", folderPath: void 0, - totalFiles: queue.files.length, + totalFiles: queue2.files.length, completed: 0, successful: 0, failed: 0, @@ -9530,7 +10878,7 @@ async function handleFileUpload(client2, event) { }); } else { updateBatch(chatIdStr, batchId, { - totalFiles: queue.files.length + totalFiles: queue2.files.length }); } } @@ -9683,7 +11031,7 @@ async function handleFileUpload(client2, event) { if (duplicateMode === "skip") { const duplicate = await findDuplicateFile(finalFileName, storageFolder, actualSize, activeAccountId); if (duplicate) { - if (fs8.existsSync(localFilePath)) fs8.unlinkSync(localFilePath); + if (fs9.existsSync(localFilePath)) fs9.unlinkSync(localFilePath); lastLocalPath = void 0; updateUploadPhase(chatIdStr, uploadId, { phase: "success", size: actualSize, providerName: provider.name, fileType, folder: storageFolder }); rememberTransferDestination(chatIdStr, storageFolder, provider.name); @@ -9743,7 +11091,7 @@ async function handleFileUpload(client2, event) { indexedFileId = String(inserted.rows[0].id); }) ); - if (fs8.existsSync(localFilePath)) fs8.unlinkSync(localFilePath); + if (fs9.existsSync(localFilePath)) fs9.unlinkSync(localFilePath); lastLocalPath = void 0; localFilePath = void 0; } catch (err) { @@ -9785,9 +11133,9 @@ async function handleFileUpload(client2, event) { } else { lastError = error instanceof Error ? error.message : "\u672A\u77E5\u9519\u8BEF"; } - if (localFilePath && fs8.existsSync(localFilePath)) { + if (localFilePath && fs9.existsSync(localFilePath)) { try { - fs8.unlinkSync(localFilePath); + fs9.unlinkSync(localFilePath); } catch (e) { } } @@ -9803,9 +11151,9 @@ async function handleFileUpload(client2, event) { let success = await attemptSingleUpload(signal, reportQueueProgress); if (!success && !signal.aborted && !storageCooldownUntil && retryCount < maxRetries) { retryCount++; - if (lastLocalPath && fs8.existsSync(lastLocalPath)) { + if (lastLocalPath && fs9.existsSync(lastLocalPath)) { try { - fs8.unlinkSync(lastLocalPath); + fs9.unlinkSync(lastLocalPath); } catch (e) { } } @@ -9931,8 +11279,8 @@ init_storage(); // src/services/telegramChannelJobs.ts init_db(); init_storage(); -import { Api as Api6 } from "telegram"; -import crypto15 from "node:crypto"; +import { Api as Api7 } from "telegram"; +import crypto16 from "node:crypto"; import { getPeerId } from "telegram/Utils.js"; // src/services/telegramChannelJobAdmission.ts @@ -10112,10 +11460,10 @@ async function flushTelegramNotificationDigest(userId, chatId, deps) { digestFlushInProgress.add(scope); try { const runQuery = deps.runQuery || query; - const selected = { rows: await claimTelegramNotificationDigest(userId, chatId, runQuery) }; - if (selected.rows.length === 0) return 0; - const ids = selected.rows.map((row) => row.id); - const lines = selected.rows.map((row) => `\u2022 ${String(row.payload?.message || row.kind)}`); + const selected3 = { rows: await claimTelegramNotificationDigest(userId, chatId, runQuery) }; + if (selected3.rows.length === 0) return 0; + const ids = selected3.rows.map((row) => row.id); + const lines = selected3.rows.map((row) => `\u2022 ${String(row.payload?.message || row.kind)}`); try { await deps.send(chatId, ["\u{1F4EC} **\u901A\u77E5\u6458\u8981**", "", ...lines].join("\n")); } catch (error) { @@ -10131,7 +11479,7 @@ async function flushTelegramNotificationDigest(userId, chatId, deps) { WHERE id = ANY($1::uuid[]) AND delivered_at IS NULL`, [ids] ); - return selected.rows.length; + return selected3.rows.length; } finally { digestFlushInProgress.delete(scope); } @@ -10294,8 +11642,8 @@ function telegramInviteErrorMessage(error) { return `\u79C1\u5BC6\u9891\u9053/\u7FA4\u9080\u8BF7\u94FE\u63A5\u89E3\u6790\u5931\u8D25\uFF1A${anyErr?.message || anyErr?.errorMessage || String(error)}`; } function assertJoinedPrivateInvite(invite) { - if (invite instanceof Api6.ChatInviteAlready) return; - if (invite instanceof Api6.ChatInvite) { + if (invite instanceof Api7.ChatInviteAlready) return; + if (invite instanceof Api7.ChatInvite) { throw new Error("\u5F53\u524D Telegram \u7528\u6237\u8D26\u53F7\u5C1A\u672A\u52A0\u5165\u8FD9\u4E2A\u79C1\u5BC6\u9891\u9053/\u7FA4\uFF0C\u65E0\u6CD5\u8BFB\u53D6\u6D88\u606F\u3002\u8BF7\u5148\u4F7F\u7528\u751F\u6210\u7528\u6237 Session \u7684\u540C\u4E00\u4E2A Telegram \u8D26\u53F7\u6253\u5F00\u9080\u8BF7\u94FE\u63A5\u5E76\u52A0\u5165\uFF0C\u7136\u540E\u91CD\u65B0\u6267\u884C\u8BA2\u9605\u6216\u4E0B\u8F7D\u547D\u4EE4\u3002"); } } @@ -10309,7 +11657,7 @@ async function resolveTelegramSource(userClient2, sourceInput) { } let invite; try { - invite = await userClient2.invoke(new Api6.messages.CheckChatInvite({ hash: inviteHash })); + invite = await userClient2.invoke(new Api7.messages.CheckChatInvite({ hash: inviteHash })); } catch (error) { throw new Error(telegramInviteErrorMessage(error)); } @@ -10725,7 +12073,11 @@ async function subscribeTelegramChannel(userId, chatId, sourceInput, folderOverr RETURNING id, source, source_original, source_type, title, last_message_id, folder_override, enabled, disabled_reason, disabled_at`, [userId, chatId || null, resolved.source, resolved.originalSource, resolved.sourceType, title, latestMessageId, folderOverride || null] ); - return result.rows[0]; + const subscription = result.rows[0]; + if (subscription?.id) { + void triggerTelegramAccountAccessSweep({ sourceIds: [String(subscription.id)], reason: "subscription_created" }).catch((error) => console.error("Telegram \u65B0\u8BA2\u9605\u6743\u9650\u68C0\u6D4B\u5931\u8D25:", error)); + } + return subscription; } async function listTelegramSubscriptions(userId, includeDisabled = false) { const result = await query( @@ -11248,6 +12600,11 @@ async function downloadClaimedRefs(botClient, requestMessage, jobId, source, ref await restoreUnfinishedClaimedRefs(jobId, refs, error instanceof Error ? error.message : String(error)); return { found: 0, skipped: 0, failed: 0, successful: 0, successfulMessageIds: [], failedMessageIds: [], skippedMessageIds: [] }; } + const accountFailure = classifyTelegramDownloadAccountError(error); + if (accountFailure !== "retryable") { + await restoreUnfinishedClaimedRefs(jobId, refs, error instanceof Error ? error.message : String(error)); + return { found: 0, skipped: 0, failed: 0, successful: 0, successfulMessageIds: [], failedMessageIds: [], skippedMessageIds: [] }; + } for (const ref of refs) await markDownloadRefStatus(jobId, ref, "failed", error instanceof Error ? error.message : String(error)); throw error; } finally { @@ -11255,7 +12612,7 @@ async function downloadClaimedRefs(botClient, requestMessage, jobId, source, ref releaseChannelTaskAbortSignal(jobId, taskSignal); } } -async function downloadPendingForJob(botClient, requestMessage, jobId, source, folderOverride, options, drain = false) { +async function downloadPendingForJob(botClient, requestMessage, jobId, source, folderOverride, options, drain2 = false) { let aggregate = { found: 0, skipped: 0, @@ -11280,7 +12637,7 @@ async function downloadPendingForJob(botClient, requestMessage, jobId, source, f failedMessageIds: [...aggregate.failedMessageIds, ...result.failedMessageIds || []], skippedMessageIds: [...aggregate.skippedMessageIds, ...result.skippedMessageIds || []] }; - if (!drain) break; + if (!drain2) break; } return aggregate; } @@ -11854,7 +13211,6 @@ async function runSubscriptionScan(botClient) { last_result = jsonb_build_object('status', 'failed') WHERE id = $1`, [row.id, safeError]).catch(() => void 0); if (isTelegramSourceInaccessibleError(error)) { - recordTelegramUserClientFailure("permission_denied", "\u5F53\u524D Telegram \u7528\u6237\u8D26\u53F7\u65E0\u6CD5\u8BBF\u95EE\u8BA2\u9605\u6765\u6E90"); const reason = subscriptionDisabledReason(error); await pauseTelegramSubscriptionForError(row.id, reason).catch((updateError) => console.error("\u{1F916} \u6682\u505C\u4E0D\u53EF\u8BBF\u95EE\u7684 Telegram \u8BA2\u9605\u5931\u8D25:", updateError)); const targetChat = row.chat_id || row.user_id; @@ -12024,7 +13380,7 @@ async function recoverInterruptedTelegramJobs(botClient) { const lockResult = await client2.query(`SELECT pg_try_advisory_lock(hashtext('tg-vault:telegram-job-recovery')) AS locked`); lockHeld = Boolean(lockResult.rows[0]?.locked); if (!lockHeld) return; - const reconciliationLease = crypto15.randomUUID(); + const reconciliationLease = crypto16.randomUUID(); const pendingWrites = await claimTelegramWriteReconciliations(pool, reconciliationLease, 100); for (const pendingWrite of pendingWrites) { const target = storageManager.getTarget(pendingWrite.provider, pendingWrite.accountId); @@ -12110,7 +13466,7 @@ function startTelegramSubscriptionWorker(botClient) { runSubscriptionScan(botClient).catch((error) => console.error("\u{1F916} Telegram \u8BA2\u9605\u626B\u63CF\u5F02\u5E38:", error)); console.log(`\u{1F916} Telegram \u9891\u9053\u8BA2\u9605\u626B\u63CF\u5DF2\u542F\u52A8\uFF0C\u95F4\u9694 ${Math.round(SUBSCRIPTION_INTERVAL_MS / 1e3)} \u79D2`); } -async function stopTelegramBackgroundWorkers(timeoutMs = 3e4) { +async function stopTelegramBackgroundWorkers(timeoutMs2 = 3e4) { if (subscriptionTimer) clearInterval(subscriptionTimer); if (recoveryTimeout) clearTimeout(recoveryTimeout); if (recoveryTimer) clearInterval(recoveryTimer); @@ -12118,7 +13474,7 @@ async function stopTelegramBackgroundWorkers(timeoutMs = 3e4) { recoveryTimeout = null; recoveryTimer = null; recoveryStarted = false; - const deadline = Date.now() + timeoutMs; + const deadline = Date.now() + timeoutMs2; while (subscriptionScanRunning || recoveryRunning) { if (Date.now() >= deadline) throw new Error("Telegram \u540E\u53F0\u4EFB\u52A1\u4ECD\u5728\u8FD0\u884C\uFF0C\u62D2\u7EDD\u5207\u6362 Bot \u5BA2\u6237\u7AEF"); await new Promise((resolve) => setTimeout(resolve, 100)); @@ -12131,12 +13487,12 @@ init_settings(); // src/services/orphanCleanup.ts init_db(); init_localPath(); -import fs9 from "fs"; -import path12 from "path"; +import fs10 from "node:fs/promises"; +import path12 from "node:path"; var UPLOAD_DIR2 = path12.resolve(process.env.UPLOAD_DIR || "./data/uploads"); -var THUMBNAIL_DIR2 = path12.resolve(process.env.THUMBNAIL_DIR || "./data/thumbnails"); var YTDLP_WORK_DIR = path12.resolve(process.env.YTDLP_WORK_DIR || path12.join(UPLOAD_DIR2, "ytdlp")); var ORPHAN_MIN_AGE_MS = Math.max(6e4, parseInt(process.env.ORPHAN_CLEANUP_MIN_AGE_MS || "600000", 10) || 6e5); +var YIELD_EVERY = Math.max(25, parseInt(process.env.ORPHAN_CLEANUP_YIELD_EVERY || "250", 10) || 250); function isReservedTransientUploadPath(filePath, reservedDirs = [YTDLP_WORK_DIR]) { const resolvedPath = path12.resolve(filePath); return reservedDirs.some((directory) => { @@ -12147,131 +13503,118 @@ function isReservedTransientUploadPath(filePath, reservedDirs = [YTDLP_WORK_DIR] function isAutoCleanupEnabled() { return ["1", "true", "yes", "on"].includes((process.env.AUTO_CLEANUP_ORPHANS || "true").toLowerCase()); } -function formatBytes2(bytes) { - if (bytes === 0) return "0 B"; - const k = 1024; - const sizes = ["B", "KB", "MB", "GB"]; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; +async function yieldToEventLoop() { + await new Promise((resolve) => setImmediate(resolve)); } -function getAllFiles(dirPath, arrayOfFiles = [], reservedDirs = [YTDLP_WORK_DIR]) { - if (isReservedTransientUploadPath(dirPath, reservedDirs)) { - return arrayOfFiles; - } - if (!fs9.existsSync(dirPath)) { - return arrayOfFiles; +async function* walkFiles(dirPath, reservedDirs = [YTDLP_WORK_DIR], state = { visited: 0 }) { + if (isReservedTransientUploadPath(dirPath, reservedDirs)) return; + let directory; + try { + directory = await fs10.opendir(dirPath); + } catch (error) { + if (error?.code !== "ENOENT") console.warn(`\u{1F9F9} \u65E0\u6CD5\u8BFB\u53D6\u76EE\u5F55: ${dirPath}`, error); + return; } try { - const files = fs9.readdirSync(dirPath); - for (const file of files) { - const fullPath = path12.join(dirPath, file); + for await (const entry of directory) { + const fullPath = path12.join(dirPath, entry.name); + if (isReservedTransientUploadPath(fullPath, reservedDirs)) continue; try { - const stat = fs9.lstatSync(fullPath); - if (stat.isSymbolicLink()) { - continue; - } - if (stat.isDirectory()) { - if (!isReservedTransientUploadPath(fullPath, reservedDirs)) { - getAllFiles(fullPath, arrayOfFiles, reservedDirs); - } - } else if (!isReservedTransientUploadPath(fullPath, reservedDirs)) { - arrayOfFiles.push({ - name: file, - path: fullPath, - size: stat.size - }); - } - } catch (e) { - console.warn(`\u{1F9F9} \u65E0\u6CD5\u8BFB\u53D6\u6587\u4EF6\u72B6\u6001: ${fullPath}`, e); - } - } - } catch (e) { - console.error(`\u{1F9F9} \u65E0\u6CD5\u8BFB\u53D6\u76EE\u5F55: ${dirPath}`, e); - } - return arrayOfFiles; -} -function removeEmptyDirectories(dirPath) { - if (!fs9.existsSync(dirPath)) return; - try { - const files = fs9.readdirSync(dirPath); - for (const file of files) { - const fullPath = path12.join(dirPath, file); - try { - if (isReservedTransientUploadPath(fullPath)) continue; - const stat = fs9.lstatSync(fullPath); + const stat = await fs10.lstat(fullPath); if (stat.isSymbolicLink()) continue; if (stat.isDirectory()) { - removeEmptyDirectories(fullPath); + yield* walkFiles(fullPath, reservedDirs, state); + } else if (stat.isFile()) { + yield { name: entry.name, path: fullPath, size: stat.size, mtimeMs: stat.mtimeMs }; } - } catch (e) { + } catch (error) { + if (error?.code !== "ENOENT") console.warn(`\u{1F9F9} \u65E0\u6CD5\u8BFB\u53D6\u6587\u4EF6\u72B6\u6001: ${fullPath}`, error); } - } - const remainingFiles = fs9.readdirSync(dirPath); - if (remainingFiles.length === 0 && dirPath !== UPLOAD_DIR2) { - fs9.rmdirSync(dirPath); - console.log(`\u{1F9F9} \u5220\u9664\u7A7A\u6587\u4EF6\u5939: ${dirPath}`); - } - } catch (e) { - console.warn(`\u{1F9F9} \u5220\u9664\u7A7A\u6587\u4EF6\u5939\u5931\u8D25: ${dirPath}`, e); - } -} -async function cleanupOrphanFiles() { - const stats = { - deletedCount: 0, - freedBytes: 0, - freedSpace: "0 B", - deletedFiles: [] - }; - console.log("\u{1F9F9} \u5F00\u59CB\u626B\u63CF\u5B64\u513F\u6587\u4EF6..."); - try { - const dbResult = await query(` - SELECT stored_name, folder, path - FROM files - WHERE source = 'local' - AND mime_type IS DISTINCT FROM 'application/x-directory' - `); - const dbFileSet = /* @__PURE__ */ new Set(); - for (const row of dbResult.rows) { - if (row.path) { - const relativePath = getRelativeStoragePath(UPLOAD_DIR2, row.path); - if (relativePath) dbFileSet.add(relativePath); - } - if (row.stored_name) { - const key = [row.folder, row.stored_name].filter(Boolean).join("/"); - if (key) dbFileSet.add(key); - } - } - console.log(`\u{1F9F9} \u6570\u636E\u5E93\u4E2D\u5DF2\u6CE8\u518C\u6587\u4EF6\u6570: ${dbFileSet.size}`); - const diskFiles = getAllFiles(UPLOAD_DIR2); - console.log(`\u{1F9F9} \u78C1\u76D8\u4E0A\u6587\u4EF6\u6570: ${diskFiles.length}`); - for (const file of diskFiles) { - const relativePath = getRelativeStoragePath(UPLOAD_DIR2, file.path); - if (relativePath && !dbFileSet.has(relativePath)) { - const ageMs = Date.now() - fs9.statSync(file.path).mtimeMs; - if (ageMs < ORPHAN_MIN_AGE_MS) continue; - try { - await safeUnlink(file.path, UPLOAD_DIR2); - stats.deletedCount++; - stats.freedBytes += file.size; - stats.deletedFiles.push(relativePath); - console.log(`\u{1F9F9} \u5220\u9664\u5B64\u513F\u6587\u4EF6: ${file.path} (${formatBytes2(file.size)})`); - } catch (e) { - console.error(`\u{1F9F9} \u5220\u9664\u6587\u4EF6\u5931\u8D25: ${file.path}`, e); - } - } - } - removeEmptyDirectories(UPLOAD_DIR2); - stats.freedSpace = formatBytes2(stats.freedBytes); - if (stats.deletedCount > 0) { - console.log(`\u{1F9F9} \u6E05\u7406\u5B8C\u6210: \u5220\u9664 ${stats.deletedCount} \u4E2A\u5B64\u513F\u6587\u4EF6\uFF0C\u91CA\u653E ${stats.freedSpace}`); - } else { - console.log("\u{1F9F9} \u626B\u63CF\u5B8C\u6210: \u6CA1\u6709\u53D1\u73B0\u5B64\u513F\u6587\u4EF6"); + state.visited += 1; + if (state.visited % YIELD_EVERY === 0) await yieldToEventLoop(); } } catch (error) { + if (error?.code !== "ENOENT") console.warn(`\u{1F9F9} \u626B\u63CF\u76EE\u5F55\u5931\u8D25: ${dirPath}`, error); + } +} +async function removeEmptyDirectories(dirPath, reservedDirs = [YTDLP_WORK_DIR]) { + if (isReservedTransientUploadPath(dirPath, reservedDirs)) return; + let entries; + try { + entries = await fs10.readdir(dirPath, { withFileTypes: true }); + } catch (error) { + if (error?.code !== "ENOENT") console.warn(`\u{1F9F9} \u65E0\u6CD5\u8BFB\u53D6\u5F85\u6E05\u7406\u76EE\u5F55: ${dirPath}`, error); + return; + } + for (const entry of entries) { + const fullPath = path12.join(dirPath, entry.name); + if (isReservedTransientUploadPath(fullPath, reservedDirs) || entry.isSymbolicLink()) continue; + if (entry.isDirectory()) await removeEmptyDirectories(fullPath, reservedDirs); + } + if (path12.resolve(dirPath) === UPLOAD_DIR2) return; + try { + const remaining = await fs10.readdir(dirPath); + if (remaining.length === 0) { + await fs10.rmdir(dirPath); + console.log(`\u{1F9F9} \u5220\u9664\u7A7A\u6587\u4EF6\u5939: ${dirPath}`); + } + } catch (error) { + if (!["ENOENT", "ENOTEMPTY"].includes(error?.code)) console.warn(`\u{1F9F9} \u5220\u9664\u7A7A\u6587\u4EF6\u5939\u5931\u8D25: ${dirPath}`, error); + } +} +async function runCleanup() { + const stats = { deletedCount: 0, freedBytes: 0, freedSpace: "0 B", deletedFiles: [] }; + console.log("\u{1F9F9} \u5F00\u59CB\u626B\u63CF\u5B64\u513F\u6587\u4EF6..."); + const dbResult = await query(` + SELECT stored_name, folder, path + FROM files + WHERE storage_account_id IS NULL + AND mime_type IS DISTINCT FROM 'application/x-directory' + `); + const dbFileSet = /* @__PURE__ */ new Set(); + for (const row of dbResult.rows) { + if (row.path) { + const relativePath = getRelativeStoragePath(UPLOAD_DIR2, row.path); + if (relativePath) dbFileSet.add(relativePath); + } + if (row.stored_name) { + const key = [row.folder, row.stored_name].filter(Boolean).join("/"); + if (key) dbFileSet.add(key); + } + } + console.log(`\u{1F9F9} \u6570\u636E\u5E93\u4E2D\u5DF2\u6CE8\u518C\u6587\u4EF6\u6570: ${dbFileSet.size}`); + let scannedCount = 0; + const now = Date.now(); + for await (const file of walkFiles(UPLOAD_DIR2)) { + scannedCount += 1; + const relativePath = getRelativeStoragePath(UPLOAD_DIR2, file.path); + if (!relativePath || dbFileSet.has(relativePath) || now - file.mtimeMs < ORPHAN_MIN_AGE_MS) continue; + try { + await safeUnlink(file.path, UPLOAD_DIR2); + stats.deletedCount += 1; + stats.freedBytes += file.size; + stats.deletedFiles.push(relativePath); + console.log(`\u{1F9F9} \u5220\u9664\u5B64\u513F\u6587\u4EF6: ${file.path} (${formatBytes(file.size)})`); + } catch (error) { + console.error(`\u{1F9F9} \u5220\u9664\u6587\u4EF6\u5931\u8D25: ${file.path}`, error); + } + } + console.log(`\u{1F9F9} \u78C1\u76D8\u4E0A\u6587\u4EF6\u6570: ${scannedCount}`); + await removeEmptyDirectories(UPLOAD_DIR2); + stats.freedSpace = formatBytes(stats.freedBytes); + console.log(stats.deletedCount > 0 ? `\u{1F9F9} \u6E05\u7406\u5B8C\u6210: \u5220\u9664 ${stats.deletedCount} \u4E2A\u5B64\u513F\u6587\u4EF6\uFF0C\u91CA\u653E ${stats.freedSpace}` : "\u{1F9F9} \u626B\u63CF\u5B8C\u6210: \u6CA1\u6709\u53D1\u73B0\u5B64\u513F\u6587\u4EF6"); + return stats; +} +var cleanupInFlight = null; +function cleanupOrphanFiles() { + if (cleanupInFlight) return cleanupInFlight; + cleanupInFlight = runCleanup().catch((error) => { console.error("\u{1F9F9} \u5B64\u513F\u6587\u4EF6\u6E05\u7406\u5931\u8D25:", error); throw error; - } - return stats; + }).finally(() => { + cleanupInFlight = null; + }); + return cleanupInFlight; } var cleanupInterval = null; function startPeriodicCleanup(intervalMs = 60 * 60 * 1e3) { @@ -12279,28 +13622,21 @@ function startPeriodicCleanup(intervalMs = 60 * 60 * 1e3) { console.log("\u{1F9F9} \u81EA\u52A8\u5B64\u513F\u6587\u4EF6\u6E05\u7406\u5DF2\u5173\u95ED (AUTO_CLEANUP_ORPHANS=false)"); return; } - if (cleanupInterval) { - clearInterval(cleanupInterval); - } - cleanupInterval = setInterval(async () => { + if (cleanupInterval) clearInterval(cleanupInterval); + cleanupInterval = setInterval(() => { console.log("\u{1F9F9} \u6267\u884C\u5B9A\u671F\u5B64\u513F\u6587\u4EF6\u6E05\u7406..."); - try { - const stats = await cleanupOrphanFiles(); - if (stats.deletedCount > 0) { - console.log(`\u{1F9F9} \u5B9A\u671F\u6E05\u7406\u5B8C\u6210: \u5220\u9664 ${stats.deletedCount} \u4E2A\u6587\u4EF6\uFF0C\u91CA\u653E ${stats.freedSpace}`); - } - } catch (e) { - console.error("\u{1F9F9} \u5B9A\u671F\u6E05\u7406\u5931\u8D25:", e); - } + void cleanupOrphanFiles().then((stats) => { + if (stats.deletedCount > 0) console.log(`\u{1F9F9} \u5B9A\u671F\u6E05\u7406\u5B8C\u6210: \u5220\u9664 ${stats.deletedCount} \u4E2A\u6587\u4EF6\uFF0C\u91CA\u653E ${stats.freedSpace}`); + }).catch((error) => console.error("\u{1F9F9} \u5B9A\u671F\u6E05\u7406\u5931\u8D25:", error)); }, intervalMs); + cleanupInterval.unref?.(); console.log(`\u{1F9F9} \u5DF2\u542F\u52A8\u5B9A\u671F\u6E05\u7406\u4EFB\u52A1 (\u95F4\u9694: ${intervalMs / 1e3 / 60} \u5206\u949F)`); } function stopPeriodicCleanup() { - if (cleanupInterval) { - clearInterval(cleanupInterval); - cleanupInterval = null; - console.log("\u{1F9F9} \u5DF2\u505C\u6B62\u5B9A\u671F\u6E05\u7406\u4EFB\u52A1"); - } + if (!cleanupInterval) return; + clearInterval(cleanupInterval); + cleanupInterval = null; + console.log("\u{1F9F9} \u5DF2\u505C\u6B62\u5B9A\u671F\u6E05\u7406\u4EFB\u52A1"); } // src/services/telegramCommands.ts @@ -12312,7 +13648,7 @@ init_localPath(); import path13 from "path"; var CLOUD_SOURCES = /* @__PURE__ */ new Set(["onedrive", "aliyun_oss", "s3", "webdav", "openlist", "google_drive"]); var UPLOAD_DIR3 = path13.resolve(process.env.UPLOAD_DIR || "./data/uploads"); -var THUMBNAIL_DIR3 = path13.resolve(process.env.THUMBNAIL_DIR || "./data/thumbnails"); +var THUMBNAIL_DIR2 = path13.resolve(process.env.THUMBNAIL_DIR || "./data/thumbnails"); var PREVIEW_DIR2 = path13.resolve(process.env.PREVIEW_DIR || "./data/previews"); async function getCurrentStorageScope() { const { storageManager: storageManager2 } = await Promise.resolve().then(() => (init_storage(), storage_exports)); @@ -12352,8 +13688,8 @@ async function removePhysicalFile(file) { await safeUnlink(filePath, UPLOAD_DIR3); } if (file.thumbnail_path) { - const thumbPath = path13.join(THUMBNAIL_DIR3, path13.basename(file.thumbnail_path)); - await safeUnlink(thumbPath, THUMBNAIL_DIR3); + const thumbPath = path13.join(THUMBNAIL_DIR2, path13.basename(file.thumbnail_path)); + await safeUnlink(thumbPath, THUMBNAIL_DIR2); } if (file.preview_path) { const previewPath = path13.join(PREVIEW_DIR2, path13.basename(file.preview_path)); @@ -12778,7 +14114,7 @@ function channelTaskCenterItem(row) { if (!rawId) return null; const id = rawId.slice(0, 12); const total = Math.max(safeNumber2(row.total_count), safeNumber2(row.item_count)); - const active = safeNumber2(row.downloading_count); + const active2 = safeNumber2(row.downloading_count); const pending = safeNumber2(row.pending_count); const completed = safeNumber2(row.success_count); const failed = safeNumber2(row.failed_count); @@ -12792,7 +14128,7 @@ function channelTaskCenterItem(row) { retryAt: cooldownUntil && !Number.isNaN(cooldownUntil.getTime()) ? cooldownUntil.toLocaleString("zh-CN", { hour12: false }) : void 0 } : void 0; const channelState = telegramChannelJobTaskState(row); - const state = inCooldown ? "cooling" : row.status === "paused" ? active > 0 ? "pausing" : "paused" : channelState.status === "cancelled" ? "failed" : channelState.status === "completed" ? "waiting" : channelState.status === "failed" ? "failed" : row.status === "queued" || row.status === "pending" ? "waiting" : active > 0 || channelState.stage === "scanning" || channelState.stage === "downloading" || row.is_actively_running ? "running" : "waiting"; + const state = inCooldown ? "cooling" : row.status === "paused" ? active2 > 0 ? "pausing" : "paused" : channelState.status === "cancelled" ? "failed" : channelState.status === "completed" ? "waiting" : channelState.status === "failed" ? "failed" : row.status === "queued" || row.status === "pending" ? "waiting" : active2 > 0 || channelState.stage === "scanning" || channelState.stage === "downloading" || row.is_actively_running ? "running" : "waiting"; const optionsSource = row.options ?? row.params ?? {}; let options; if (typeof optionsSource === "string") { @@ -12816,7 +14152,7 @@ function channelTaskCenterItem(row) { title: qualifier ? `${source} \xB7 ${qualifier}` : source, state, total, - active, + active: active2, pending, completed, failed, @@ -12836,8 +14172,8 @@ function channelTaskCenterItem(row) { // src/services/ytDlpDownload.ts init_db(); init_storage(); -import crypto16 from "node:crypto"; -import fs10 from "node:fs"; +import crypto17 from "node:crypto"; +import fs11 from "node:fs"; import os2 from "node:os"; import path14 from "node:path"; import { spawn } from "node:child_process"; @@ -12876,10 +14212,10 @@ function setYtDlpNotifier(notifier) { taskNotifier = notifier; } function ensureDir(directory) { - if (!fs10.existsSync(directory)) fs10.mkdirSync(directory, { recursive: true }); + if (!fs11.existsSync(directory)) fs11.mkdirSync(directory, { recursive: true }); } async function safeRmDir(directory) { - await fs10.promises.rm(directory, { recursive: true, force: true }).catch(() => void 0); + await fs11.promises.rm(directory, { recursive: true, force: true }).catch(() => void 0); } function isYtDlpSidecarOrTemporaryFile(fileName) { const lower = fileName.toLowerCase(); @@ -12887,12 +14223,12 @@ function isYtDlpSidecarOrTemporaryFile(fileName) { } function selectPrimaryOutputFile(taskDir) { const collectFiles = (directory) => { - const entries = fs10.readdirSync(directory, { withFileTypes: true }); + const entries = fs11.readdirSync(directory, { withFileTypes: true }); return entries.flatMap((entry) => { const fullPath = path14.join(directory, entry.name); if (entry.isDirectory()) return collectFiles(fullPath); if (!entry.isFile() || isYtDlpSidecarOrTemporaryFile(entry.name)) return []; - const size = fs10.existsSync(fullPath) ? fs10.statSync(fullPath).size : 0; + const size = fs11.existsSync(fullPath) ? fs11.statSync(fullPath).size : 0; return size > 0 ? [{ name: entry.name, fullPath, size }] : []; }); }; @@ -12974,7 +14310,7 @@ async function uploadDownloadedFile(task, execution, localFilePath, originalFile const fileType = getFileType(mimeType); const folder = task.targetFolder || "ytdlp"; const storedName = await getUniqueStoredName(safeName, folder, accountId); - const stats = await fs10.promises.stat(localFilePath); + const stats = await fs11.promises.stat(localFilePath); const size = stats.size; const duplicateMode = await getDuplicateMode(); if (duplicateMode === "skip") { @@ -13001,7 +14337,7 @@ async function uploadDownloadedFile(task, execution, localFilePath, originalFile thumbnailPath = null; } } - const operationId = crypto16.randomUUID(); + const operationId = crypto17.randomUUID(); await beginYtDlpWrite(pool, { operationId, taskId: task.id, @@ -13069,7 +14405,7 @@ async function uploadDownloadedFile(task, execution, localFilePath, originalFile }).catch(() => void 0); throw error; } - await fs10.promises.rm(localFilePath, { force: true }).catch(() => void 0); + await fs11.promises.rm(localFilePath, { force: true }).catch(() => void 0); return { finalPath, providerName: provider.name, size, storedName, folder, operationId, fileId }; } function classifyYtDlpError(error) { @@ -13087,7 +14423,7 @@ async function notifyTask(task, message) { await taskNotifier(task, message).catch(() => void 0); } async function executeYtDlpTask(id) { - const leaseToken = crypto16.randomUUID(); + const leaseToken = crypto17.randomUUID(); const execution = await claimYtDlpExecution(pool, id, leaseToken); if (!execution) return; const task = await getTransferTask("ytdlp", id); @@ -13294,7 +14630,7 @@ async function retryYtDlpTask(id) { } async function createYtDlpTask(input) { await assertPublicHttpUrl(input.url); - const id = `yd-${crypto16.randomBytes(8).toString("hex")}`; + const id = `yd-${crypto17.randomBytes(8).toString("hex")}`; const target = input.target || storageManager.getActiveTarget(); await assertStorageTargetWritable(target); const client2 = target.accountId ? await pool.connect() : null; @@ -13366,7 +14702,7 @@ async function handleYtDlpCommand(message, url, explicitTarget, options = {}) { } // src/services/destructiveConfirmation.ts -import crypto17 from "crypto"; +import crypto18 from "crypto"; var DestructiveConfirmationStore = class { confirmations = /* @__PURE__ */ new Map(); ttlMs; @@ -13375,7 +14711,7 @@ var DestructiveConfirmationStore = class { constructor(options = {}) { this.ttlMs = options.ttlMs ?? 5 * 60 * 1e3; this.now = options.now ?? (() => Date.now()); - this.tokenFactory = options.tokenFactory ?? (() => crypto17.randomBytes(18).toString("base64url")); + this.tokenFactory = options.tokenFactory ?? (() => crypto18.randomBytes(18).toString("base64url")); } issue(binding) { const token = this.tokenFactory(); @@ -13617,6 +14953,10 @@ function parseTelegramFileCallback(data) { const match = data.match(/^fb_(detail|copy|favorite|link|move|rename|delete)_([0-9a-f-]{36})$/i); return match ? { action: match[1].toLowerCase(), fileId: match[2].toLowerCase() } : null; } +function compactText(value, maxLength) { + const text = String(value || "").replace(/[\r\n\t]+/g, " ").replace(/[*_`[\]\\]/g, "").trim(); + return text.length > maxLength ? `${text.slice(0, Math.max(1, maxLength - 1))}\u2026` : text; +} function buildTelegramFileActionRows(file) { const id = String(file.id); const rows = [ @@ -13630,11 +14970,11 @@ function buildTelegramFileActionRows(file) { } function buildTelegramFileDetail(file) { return [ - `\u{1F4C4} **${file.name}**`, + `\u{1F4C4} **${compactText(file.name, 80) || "\u672A\u547D\u540D\u6587\u4EF6"}**`, `\u{1F194} ${file.id}`, - `\u{1F4E6} ${Number(file.size || 0)} bytes \xB7 ${file.type || "other"}`, - `\u{1F4CD} ${file.source || "local"}`, - `\u{1F4C1} ${file.folder || "\u6839\u76EE\u5F55"}`, + `\u{1F4E6} ${formatBytes(Number(file.size || 0))} \xB7 ${compactText(file.type || "\u5176\u4ED6", 20)}`, + `\u{1F4CD} ${compactText(file.source || "\u672C\u5730\u5B58\u50A8", 50)}`, + `\u{1F4C1} ${compactText(file.folder || "\u6839\u76EE\u5F55", 100)}`, `\u{1F552} ${file.created_at ? new Date(file.created_at).toLocaleString("zh-CN", { hour12: false }) : "\u672A\u77E5"}` ].join("\n"); } @@ -13658,15 +14998,17 @@ async function queryTelegramFiles(rawOptions, dependencies = {}) { function buildTelegramFileCard(file, index) { const shortId = String(file.id || "").slice(0, 12); const createdAt = file.created_at ? new Date(file.created_at).toLocaleString("zh-CN", { hour12: false }) : "\u672A\u77E5"; + const name = compactText(file.name, 46) || "\u672A\u547D\u540D\u6587\u4EF6"; + const folder = compactText(file.folder || "\u6839\u76EE\u5F55", 60); return [ - `${index + 1}. ${file.is_favorite ? "\u2B50 " : ""}${file.name}`, - ` \u{1F194} ${shortId} \xB7 ${file.type || "other"} \xB7 ${Number(file.size || 0)} bytes`, - ` \u{1F4C1} ${file.folder || "\u6839\u76EE\u5F55"} \xB7 ${createdAt}` + `${index + 1}. ${file.is_favorite ? "\u2B50 " : ""}${name}`, + ` \u{1F194} ${shortId} \xB7 ${compactText(file.type || "\u5176\u4ED6", 16)} \xB7 ${formatBytes(Number(file.size || 0))}`, + ` \u{1F4C1} ${folder} \xB7 ${createdAt}` ].join("\n"); } function buildTelegramFileBrowserText(page, queryText) { return [ - `\u{1F50E} **\u6587\u4EF6\u641C\u7D22**\uFF1A${queryText || "\u6700\u8FD1\u6587\u4EF6"}`, + `\u{1F50E} **\u6587\u4EF6\u641C\u7D22**\uFF1A${compactText(queryText || "\u6700\u8FD1\u6587\u4EF6", 80)}`, "", ...page.files.length > 0 ? page.files.map(buildTelegramFileCard) : ["\u6CA1\u6709\u5339\u914D\u6587\u4EF6\u3002"], "", @@ -13684,22 +15026,45 @@ function sanitizeTelegramStatusText(value) { function percent(used, total) { return total > 0 ? Math.max(0, Math.min(100, Math.round(used / total * 100))) : 0; } +function formatBytesCompact(value) { + if (!Number.isFinite(value) || value <= 0) return "0 B"; + const units = ["B", "KB", "MB", "GB", "TB"]; + const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1); + const amount = value / 1024 ** index; + return `${amount >= 10 || index === 0 ? amount.toFixed(0) : amount.toFixed(1)} ${units[index]}`; +} +var STATUS_LABELS = { + ready: "\u6B63\u5E38", + running: "\u8FD0\u884C\u4E2D", + connected: "\u5DF2\u8FDE\u63A5", + disabled: "\u672A\u542F\u7528", + expired: "\u767B\u5F55\u5DF2\u8FC7\u671F", + failed: "\u5F02\u5E38", + error: "\u5F02\u5E38", + unknown: "\u672A\u77E5", + available: "\u6B63\u5E38", + healthy: "\u6B63\u5E38", + cooldown: "\u51B7\u5374\u4E2D" +}; +function statusLabel(value) { + return STATUS_LABELS[String(value || "unknown").toLowerCase()] || sanitizeTelegramStatusText(value || "\u672A\u77E5"); +} function buildTelegramStatusPanel(input) { const used = Math.max(0, input.disk.totalBytes - input.disk.freeBytes); return [ "\u{1FA7A} **TG Vault \u8BCA\u65AD\u72B6\u6001**", `\u64CD\u4F5C ID\uFF1A${sanitizeTelegramStatusText(input.requestId)}`, "", - `Bot\uFF1A${input.bot.status}${input.bot.degraded ? "\uFF08\u964D\u7EA7\uFF09" : ""} \xB7 \u91CD\u8FDE ${input.bot.reconnectCount || 0}`, - `\u8D26\u53F7\u4E0B\u8F7D\u5668\uFF1A${input.userClient.status}${input.userClient.username ? ` / @${sanitizeTelegramStatusText(input.userClient.username)}` : ""}`, + `Bot\uFF1A${statusLabel(input.bot.status)}${input.bot.degraded ? "\uFF08\u964D\u7EA7\uFF09" : ""} \xB7 \u91CD\u8FDE ${input.bot.reconnectCount || 0} \u6B21`, + `\u8D26\u53F7\u4E0B\u8F7D\u5668\uFF1A${statusLabel(input.userClient.status)}${input.userClient.username ? ` \xB7 @${sanitizeTelegramStatusText(input.userClient.username)}` : ""}`, input.userClient.action ? `\u8D26\u53F7\u6062\u590D\uFF1A${sanitizeTelegramStatusText(input.userClient.action)}` : null, "", - `Target\uFF1A${input.target.provider} / ${sanitizeTelegramStatusText(input.target.accountName)}`, - `Probe\uFF1A${input.target.probeStatus || "unknown"}`, - input.target.cooldownUntil ? `Cooldown\uFF1A${sanitizeTelegramStatusText(input.target.cooldownUntil)}` : "Cooldown\uFF1A\u65E0", - input.target.probeError ? `Probe \u9519\u8BEF\uFF1A${sanitizeTelegramStatusText(input.target.probeError)}` : null, + `\u5F53\u524D\u5B58\u50A8\uFF1A${input.target.provider} \xB7 ${sanitizeTelegramStatusText(input.target.accountName)}`, + `\u8FDE\u63A5\u68C0\u67E5\uFF1A${statusLabel(input.target.probeStatus)}`, + input.target.cooldownUntil ? `\u6062\u590D\u65F6\u95F4\uFF1A${sanitizeTelegramStatusText(input.target.cooldownUntil)}` : null, + input.target.probeError ? `\u5B58\u50A8\u9519\u8BEF\uFF1A${sanitizeTelegramStatusText(input.target.probeError)}` : null, "", - `\u4E34\u65F6\u78C1\u76D8\uFF1A${input.disk.freeBytes}/${input.disk.totalBytes} bytes \u53EF\u7528 \xB7 \u5DF2\u7528 ${percent(used, input.disk.totalBytes)}%`, + `\u4E34\u65F6\u78C1\u76D8\uFF1A\u53EF\u7528 ${formatBytesCompact(input.disk.freeBytes)} / ${formatBytesCompact(input.disk.totalBytes)} \xB7 \u5DF2\u7528 ${percent(used, input.disk.totalBytes)}%`, `\u961F\u5217\uFF1A\u6D3B\u8DC3 ${input.queue.active} \xB7 \u7B49\u5F85 ${input.queue.pending} \xB7 \u5931\u8D25 ${input.queue.failed}${input.queue.paused ? " \xB7 \u5DF2\u6682\u505C" : ""}`, `\u8BA2\u9605\uFF1A\u542F\u7528 ${input.subscriptions.enabled} \xB7 \u6700\u8FD1\u626B\u63CF ${sanitizeTelegramStatusText(input.subscriptions.lastScanAt || "\u65E0")}`, input.subscriptions.lastError ? `\u8BA2\u9605\u9519\u8BEF\uFF1A${sanitizeTelegramStatusText(input.subscriptions.lastError)}` : null, @@ -13709,8 +15074,110 @@ function buildTelegramStatusPanel(input) { ].filter(Boolean).join("\n"); } +// src/services/telegramNotificationSettings.ts +var modeLabels = { + immediate: "\u7ACB\u5373\u901A\u77E5", + digest: "\u5408\u5E76\u6458\u8981", + off: "\u4E0D\u901A\u77E5" +}; +function selected2(active2, label) { + return `${active2 ? "\u2705" : "\u25AB\uFE0F"} ${label}`; +} +function buildNotificationSettingsButtonRows(preferences) { + const quietEnabled = Boolean(preferences.quietStart && preferences.quietEnd); + return [ + [ + { text: selected2(preferences.successMode === "immediate", "\u6210\u529F\xB7\u7ACB\u5373"), data: "nt_success_immediate" }, + { text: selected2(preferences.successMode === "digest", "\u6210\u529F\xB7\u6458\u8981"), data: "nt_success_digest" }, + { text: selected2(preferences.successMode === "off", "\u6210\u529F\xB7\u5173\u95ED"), data: "nt_success_off" } + ], + [ + { text: selected2(preferences.failureImmediate, "\u5931\u8D25\xB7\u7ACB\u5373"), data: "nt_failure_immediate" }, + { text: selected2(!preferences.failureImmediate, "\u5931\u8D25\xB7\u6458\u8981"), data: "nt_failure_digest" } + ], + [ + { text: selected2(!preferences.subscriptionDigest, "\u8BA2\u9605\xB7\u7ACB\u5373"), data: "nt_subscription_immediate" }, + { text: selected2(preferences.subscriptionDigest, "\u8BA2\u9605\xB7\u6458\u8981"), data: "nt_subscription_digest" } + ], + [ + { text: selected2(quietEnabled && preferences.quietStart === "22:00" && preferences.quietEnd === "07:00", "\u5B89\u9759 22:00\u201307:00"), data: "nt_quiet_22_07" }, + { text: selected2(!quietEnabled, "\u5173\u95ED\u5B89\u9759\u65F6\u6BB5"), data: "nt_quiet_off" } + ], + [ + { text: selected2(preferences.timezone === "Asia/Shanghai", "\u65F6\u533A\xB7\u4E0A\u6D77"), data: "nt_timezone_asia_shanghai" }, + { text: selected2(preferences.timezone === "UTC", "\u65F6\u533A\xB7UTC"), data: "nt_timezone_utc" } + ] + ]; +} +function buildNotificationSettingsText(preferences) { + const quiet = preferences.quietStart && preferences.quietEnd ? `${preferences.quietStart}\u2013${preferences.quietEnd}` : "\u672A\u5F00\u542F"; + return [ + "\u{1F514} **\u901A\u77E5\u8BBE\u7F6E**", + "", + `\u5931\u8D25\uFF1A${preferences.failureImmediate ? "\u7ACB\u5373" : "\u6458\u8981"} \uFF5C \u6210\u529F\uFF1A${modeLabels[preferences.successMode]}`, + `\u8BA2\u9605\uFF1A${preferences.subscriptionDigest ? "\u6458\u8981" : "\u7ACB\u5373"} \uFF5C \u5B89\u9759\uFF1A${quiet}`, + `\u65F6\u533A\uFF1A${preferences.timezone}`, + "\u5B89\u5168\u544A\u8B66\u59CB\u7EC8\u7ACB\u5373\u901A\u77E5\u3002", + "", + "\u{1F447} \u70B9\u51FB\u6309\u94AE\u4FEE\u6539" + ].join("\n"); +} +function updateNotificationPreference(current3, args) { + const [rawKey = "", rawValue = ""] = args; + const key = rawKey.toLowerCase(); + const value = rawValue.trim(); + const update = { ...current3 }; + if (key === "timezone") { + if (!value) throw new Error("\u8BF7\u63D0\u4F9B\u65F6\u533A\uFF0C\u4F8B\u5982 Asia/Shanghai"); + update.timezone = value; + } else if (key === "quiet") { + if (["off", "none", "disable"].includes(value.toLowerCase())) { + update.quietStart = null; + update.quietEnd = null; + return update; + } + const match = value.match(/^([01]\d|2[0-3]):([0-5]\d)-([01]\d|2[0-3]):([0-5]\d)$/); + if (!match) throw new Error("\u5B89\u9759\u65F6\u6BB5\u683C\u5F0F\u5E94\u4E3A HH:MM-HH:MM\uFF0C\u4F8B\u5982 22:00-07:00\uFF1B\u5173\u95ED\u8BF7\u4F7F\u7528 quiet off"); + update.quietStart = `${match[1]}:${match[2]}`; + update.quietEnd = `${match[3]}:${match[4]}`; + } else if (key === "success") { + if (!["immediate", "digest", "off"].includes(value)) { + throw new Error("\u6210\u529F\u901A\u77E5\u53EF\u9009\u503C\uFF1Aimmediate\uFF08\u7ACB\u5373\uFF09\u3001digest\uFF08\u6458\u8981\uFF09\u3001off\uFF08\u5173\u95ED\uFF09"); + } + update.successMode = value; + } else if (key === "failure") { + if (!["immediate", "digest"].includes(value)) { + throw new Error("\u5931\u8D25\u901A\u77E5\u53EF\u9009\u503C\uFF1Aimmediate\uFF08\u7ACB\u5373\uFF09\u6216 digest\uFF08\u6458\u8981\uFF09"); + } + update.failureImmediate = value === "immediate"; + } else if (key === "subscription") { + if (!["immediate", "digest"].includes(value)) { + throw new Error("\u8BA2\u9605\u901A\u77E5\u53EF\u9009\u503C\uFF1Aimmediate\uFF08\u7ACB\u5373\uFF09\u6216 digest\uFF08\u6458\u8981\uFF09"); + } + update.subscriptionDigest = value === "digest"; + } else { + throw new Error("\u672A\u77E5\u8BBE\u7F6E\u3002\u8BF7\u76F4\u63A5\u53D1\u9001 /notifications \u67E5\u770B\u53EF\u7528\u9009\u9879"); + } + return update; +} +function notificationCallbackArgs(data) { + const fixed = { + nt_failure_immediate: ["failure", "immediate"], + nt_failure_digest: ["failure", "digest"], + nt_subscription_immediate: ["subscription", "immediate"], + nt_subscription_digest: ["subscription", "digest"], + nt_quiet_22_07: ["quiet", "22:00-07:00"], + nt_quiet_off: ["quiet", "off"], + nt_timezone_asia_shanghai: ["timezone", "Asia/Shanghai"], + nt_timezone_utc: ["timezone", "UTC"] + }; + if (fixed[data]) return fixed[data]; + const success = data.match(/^nt_success_(immediate|digest|off)$/); + return success ? ["success", success[1]] : null; +} + // src/services/telegramCommands.ts -import crypto18 from "crypto"; +import crypto19 from "crypto"; // src/utils/folderPath.ts var INVALID_SEGMENT_CHARACTERS = /[\\:*?"<>|\x00-\x1f\x7f]/; @@ -13756,24 +15223,24 @@ var FILE_CONCURRENCY_OPTIONS = [1, 2, 3, 4]; var STORAGE_TYPE_ORDER = ["local", "onedrive", "google_drive", "aliyun_oss", "s3", "webdav"]; var ON_VALUES = /* @__PURE__ */ new Set(["1", "true", "yes", "on"]); var UPLOAD_DIR4 = process.env.UPLOAD_DIR || "./data/uploads"; -var THUMBNAIL_DIR4 = process.env.THUMBNAIL_DIR || "./data/thumbnails"; +var THUMBNAIL_DIR3 = process.env.THUMBNAIL_DIR || "./data/thumbnails"; var pendingDeleteConfirmations = /* @__PURE__ */ new Map(); var pendingTelegramFileMutations = /* @__PURE__ */ new Map(); var pendingStorageClearSnapshots = /* @__PURE__ */ new Map(); var pendingBulkTaskCancellations = /* @__PURE__ */ new Map(); var destructiveConfirmations = new DestructiveConfirmationStore(); function buildFileActionKeyboard(file) { - return new Api7.ReplyInlineMarkup({ - rows: buildTelegramFileActionRows(file).map((row) => new Api7.KeyboardButtonRow({ - buttons: row.map((button) => new Api7.KeyboardButtonCallback({ text: button.text, data: Buffer.from(button.data) })) + return new Api8.ReplyInlineMarkup({ + rows: buildTelegramFileActionRows(file).map((row) => new Api8.KeyboardButtonRow({ + buttons: row.map((button) => new Api8.KeyboardButtonCallback({ text: button.text, data: Buffer.from(button.data) })) })) }); } function buildFileSearchKeyboard(files) { if (files.length === 0) return void 0; - return new Api7.ReplyInlineMarkup({ - rows: files.slice(0, 8).map((file) => new Api7.KeyboardButtonRow({ - buttons: [new Api7.KeyboardButtonCallback({ + return new Api8.ReplyInlineMarkup({ + rows: files.slice(0, 8).map((file) => new Api8.KeyboardButtonRow({ + buttons: [new Api8.KeyboardButtonCallback({ text: `${file.is_favorite ? "\u2B50 " : ""}${String(file.name).slice(0, 38)}`, data: Buffer.from(encodeTelegramFileCallback("detail", String(file.id))) })] @@ -13781,21 +15248,21 @@ function buildFileSearchKeyboard(files) { }); } function buildDeleteConfirmKeyboard(confirmId) { - return new Api7.ReplyInlineMarkup({ - rows: [new Api7.KeyboardButtonRow({ + return new Api8.ReplyInlineMarkup({ + rows: [new Api8.KeyboardButtonRow({ buttons: [ - new Api7.KeyboardButtonCallback({ text: "\u26A0\uFE0F \u786E\u8BA4\u5220\u9664", data: Buffer.from(`del_confirm_${confirmId}`) }), - new Api7.KeyboardButtonCallback({ text: "\u53D6\u6D88", data: Buffer.from(`del_cancel_${confirmId}`) }) + new Api8.KeyboardButtonCallback({ text: "\u26A0\uFE0F \u786E\u8BA4\u5220\u9664", data: Buffer.from(`del_confirm_${confirmId}`) }), + new Api8.KeyboardButtonCallback({ text: "\u53D6\u6D88", data: Buffer.from(`del_cancel_${confirmId}`) }) ] })] }); } function buildBulkTaskCancelKeyboard(confirmId) { - return new Api7.ReplyInlineMarkup({ - rows: [new Api7.KeyboardButtonRow({ + return new Api8.ReplyInlineMarkup({ + rows: [new Api8.KeyboardButtonRow({ buttons: [ - new Api7.KeyboardButtonCallback({ text: "\u26A0\uFE0F \u786E\u8BA4\u53D6\u6D88\u5168\u90E8", data: Buffer.from(`bulk_task_confirm_${confirmId}`) }), - new Api7.KeyboardButtonCallback({ text: "\u8FD4\u56DE", data: Buffer.from(`bulk_task_cancel_${confirmId}`) }) + new Api8.KeyboardButtonCallback({ text: "\u26A0\uFE0F \u786E\u8BA4\u53D6\u6D88\u5168\u90E8", data: Buffer.from(`bulk_task_confirm_${confirmId}`) }), + new Api8.KeyboardButtonCallback({ text: "\u8FD4\u56DE", data: Buffer.from(`bulk_task_cancel_${confirmId}`) }) ] })] }); @@ -13810,29 +15277,29 @@ async function getCurrentDownloadWorkers() { } function buildDownloadWorkersKeyboard(current3, confirmValue) { if (confirmValue) { - return new Api7.ReplyInlineMarkup({ + return new Api8.ReplyInlineMarkup({ rows: [ - new Api7.KeyboardButtonRow({ + new Api8.KeyboardButtonRow({ buttons: [ - new Api7.KeyboardButtonCallback({ text: `\u26A0\uFE0F \u786E\u8BA4\u4F7F\u7528 ${confirmValue}`, data: Buffer.from(`dw_confirm_${confirmValue}`) }), - new Api7.KeyboardButtonCallback({ text: "\u53D6\u6D88", data: Buffer.from("dw_cancel") }) + new Api8.KeyboardButtonCallback({ text: `\u26A0\uFE0F \u786E\u8BA4\u4F7F\u7528 ${confirmValue}`, data: Buffer.from(`dw_confirm_${confirmValue}`) }), + new Api8.KeyboardButtonCallback({ text: "\u53D6\u6D88", data: Buffer.from("dw_cancel") }) ] }) ] }); } - return new Api7.ReplyInlineMarkup({ + return new Api8.ReplyInlineMarkup({ rows: [ - new Api7.KeyboardButtonRow({ + new Api8.KeyboardButtonRow({ buttons: [ - new Api7.KeyboardButtonCallback({ text: `${current3 === 4 ? "\u2705 " : ""}4`, data: Buffer.from("dw_set_4") }), - new Api7.KeyboardButtonCallback({ text: `${current3 === 8 ? "\u2705 " : ""}8`, data: Buffer.from("dw_set_8") }) + new Api8.KeyboardButtonCallback({ text: `${current3 === 4 ? "\u2705 " : ""}4`, data: Buffer.from("dw_set_4") }), + new Api8.KeyboardButtonCallback({ text: `${current3 === 8 ? "\u2705 " : ""}8`, data: Buffer.from("dw_set_8") }) ] }), - new Api7.KeyboardButtonRow({ + new Api8.KeyboardButtonRow({ buttons: [ - new Api7.KeyboardButtonCallback({ text: `${current3 === 12 ? "\u2705 " : ""}12 \u26A0\uFE0F`, data: Buffer.from("dw_set_12") }), - new Api7.KeyboardButtonCallback({ text: `${current3 === 16 ? "\u2705 " : ""}16 \u26A0\uFE0F`, data: Buffer.from("dw_set_16") }) + new Api8.KeyboardButtonCallback({ text: `${current3 === 12 ? "\u2705 " : ""}12 \u26A0\uFE0F`, data: Buffer.from("dw_set_12") }), + new Api8.KeyboardButtonCallback({ text: `${current3 === 16 ? "\u2705 " : ""}16 \u26A0\uFE0F`, data: Buffer.from("dw_set_16") }) ] }) ] @@ -13840,14 +15307,14 @@ function buildDownloadWorkersKeyboard(current3, confirmValue) { } function buildStorageMaintenanceKeyboard(localFileCount, confirmationToken) { if (localFileCount <= 0) return void 0; - return new Api7.ReplyInlineMarkup({ + return new Api8.ReplyInlineMarkup({ rows: [ - new Api7.KeyboardButtonRow({ + new Api8.KeyboardButtonRow({ buttons: confirmationToken ? [ - new Api7.KeyboardButtonCallback({ text: "\u26A0\uFE0F \u786E\u8BA4\u5220\u9664\u672C\u5730\u5168\u90E8\u4E0B\u8F7D\u6587\u4EF6", data: Buffer.from(`storage_clear_confirm_${confirmationToken}`) }), - new Api7.KeyboardButtonCallback({ text: "\u53D6\u6D88", data: Buffer.from(`storage_clear_cancel_${confirmationToken}`) }) + new Api8.KeyboardButtonCallback({ text: "\u26A0\uFE0F \u786E\u8BA4\u5220\u9664\u672C\u5730\u5168\u90E8\u4E0B\u8F7D\u6587\u4EF6", data: Buffer.from(`storage_clear_confirm_${confirmationToken}`) }), + new Api8.KeyboardButtonCallback({ text: "\u53D6\u6D88", data: Buffer.from(`storage_clear_cancel_${confirmationToken}`) }) ] : [ - new Api7.KeyboardButtonCallback({ text: `\u{1F9F9} \u5220\u9664\u672C\u5730\u5168\u90E8\u4E0B\u8F7D\u6587\u4EF6 (${localFileCount})`, data: Buffer.from("storage_clear_ask") }) + new Api8.KeyboardButtonCallback({ text: `\u{1F9F9} \u5220\u9664\u672C\u5730\u5168\u90E8\u4E0B\u8F7D\u6587\u4EF6 (${localFileCount})`, data: Buffer.from("storage_clear_ask") }) ] }) ] @@ -13868,24 +15335,24 @@ function buildStorageAccountKeyboard(accounts, activeAccountId) { const isActive = account.is_active || account.id === activeAccountId; const providerLabel = getProviderDisplayName(account.type).replace(/^[^\p{L}\p{N}]+/u, "").trim(); const accountName = shortenStorageAccountName(account.name || "\u672A\u547D\u540D\u8D26\u6237"); - return new Api7.KeyboardButtonRow({ - buttons: [new Api7.KeyboardButtonCallback({ + return new Api8.KeyboardButtonRow({ + buttons: [new Api8.KeyboardButtonCallback({ text: `${isActive ? "\u2705" : "\u2B1C"} ${providerLabel} \xB7 ${accountName}`, data: Buffer.from(`storage_switch_${account.id}`) })] }); }); - return new Api7.ReplyInlineMarkup({ + return new Api8.ReplyInlineMarkup({ rows: [ - new Api7.KeyboardButtonRow({ - buttons: [new Api7.KeyboardButtonCallback({ + new Api8.KeyboardButtonRow({ + buttons: [new Api8.KeyboardButtonCallback({ text: `${!activeAccountId ? "\u2705" : "\u2B1C"} \u{1F4BE} \u672C\u5730\u5B58\u50A8`, data: Buffer.from("storage_switch_local") })] }), ...accountButtons, - new Api7.KeyboardButtonRow({ - buttons: [new Api7.KeyboardButtonCallback({ text: "\u{1F504} \u5237\u65B0\u5217\u8868", data: Buffer.from("storage_switch_refresh") })] + new Api8.KeyboardButtonRow({ + buttons: [new Api8.KeyboardButtonCallback({ text: "\u{1F504} \u5237\u65B0\u5217\u8868", data: Buffer.from("storage_switch_refresh") })] }) ] }); @@ -13937,21 +15404,21 @@ async function editStorageSwitchMessage(client2, update, toast) { throw error; } } - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: toast })); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: toast })); } async function scanLocalDownloadFiles() { const baseDir = path15.resolve(UPLOAD_DIR4); const paths = []; let totalSize = 0; - if (!fs11.existsSync(baseDir)) return { count: 0, totalSize: 0, paths }; + if (!fs12.existsSync(baseDir)) return { count: 0, totalSize: 0, paths }; async function walk(dir) { - const entries = await fs11.promises.readdir(dir, { withFileTypes: true }); + const entries = await fs12.promises.readdir(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path15.join(dir, entry.name); if (entry.isDirectory()) { await walk(fullPath); } else if (entry.isFile()) { - const stat = await fs11.promises.stat(fullPath); + const stat = await fs12.promises.stat(fullPath); totalSize += stat.size; paths.push(fullPath); } @@ -13961,10 +15428,10 @@ async function scanLocalDownloadFiles() { return { count: paths.length, totalSize, paths }; } async function pruneEmptyDirs(dir, baseDir = path15.resolve(UPLOAD_DIR4)) { - if (!fs11.existsSync(dir) || path15.resolve(dir) === baseDir) return; - const entries = await fs11.promises.readdir(dir); + if (!fs12.existsSync(dir) || path15.resolve(dir) === baseDir) return; + const entries = await fs12.promises.readdir(dir); if (entries.length === 0) { - await fs11.promises.rmdir(dir); + await fs12.promises.rmdir(dir); await pruneEmptyDirs(path15.dirname(dir), baseDir); } } @@ -13972,15 +15439,11 @@ function buildDownloadWorkersText(current3) { return [ "\u2699\uFE0F **Telegram \u5206\u7247\u5E76\u53D1\u8BBE\u7F6E**", "", - `\u5F53\u524D worker \u6570\uFF1A**${current3}**`, + `\u5F53\u524D\u5206\u7247\u6570\uFF1A**${current3}**`, "", - "\u8BF4\u660E\uFF1ATelegram \u5355\u6B21\u8BF7\u6C42\u4E0A\u9650\u4ECD\u662F 512KB\uFF0C\u8FD9\u91CC\u8C03\u6574\u7684\u662F\u5355\u4E2A\u6587\u4EF6\u5185\u90E8\u7684\u5E76\u53D1\u5206\u7247\u8BF7\u6C42\u6570\u3002", - "\u5982\u679C\u8981\u8C03\u6574\u201C\u4E00\u6B21\u540C\u65F6\u4E0B\u8F7D\u51E0\u4E2A\u6587\u4EF6\u201D\uFF0C\u8BF7\u4F7F\u7528 /file_concurrency\u3002", + "\u8FD9\u91CC\u63A7\u5236\u5355\u4E2A\u6587\u4EF6\u540C\u65F6\u4E0B\u8F7D\u591A\u5C11\u4E2A\u5206\u7247\uFF1B\u6570\u503C\u8D8A\u9AD8\u8D8A\u5FEB\uFF0C\u4E5F\u8D8A\u5BB9\u6613\u89E6\u53D1\u9650\u6D41\u3002", "", - "\u5EFA\u8BAE\uFF1A", - "- `4`\uFF1A\u7A33\u5B9A\u4F18\u5148", - "- `8`\uFF1A\u901F\u5EA6/\u7A33\u5B9A\u5E73\u8861", - "- `12` / `16`\uFF1A\u6FC0\u8FDB\u6A21\u5F0F\uFF0C\u53EF\u80FD\u89E6\u53D1\u98CE\u63A7\u3001\u65AD\u6D41\u3001\u9650\u901F\uFF0C\u751A\u81F3\u8D26\u53F7\u98CE\u9669\uFF0C\u9700\u8981\u4E8C\u6B21\u786E\u8BA4" + "\u5EFA\u8BAE\uFF1A4 \u7A33\u5B9A\u4F18\u5148\uFF0C8 \u901F\u5EA6\u4E0E\u7A33\u5B9A\u5E73\u8861\uFF1B12 \u6216 16 \u5C5E\u4E8E\u6FC0\u8FDB\u6A21\u5F0F\uFF0C\u9700\u8981\u4E8C\u6B21\u786E\u8BA4\u3002" ].join("\n"); } function normalizeFileConcurrency(value) { @@ -13993,29 +15456,29 @@ async function getCurrentFileConcurrency() { } function buildFileConcurrencyKeyboard(current3, confirmValue) { if (confirmValue) { - return new Api7.ReplyInlineMarkup({ + return new Api8.ReplyInlineMarkup({ rows: [ - new Api7.KeyboardButtonRow({ + new Api8.KeyboardButtonRow({ buttons: [ - new Api7.KeyboardButtonCallback({ text: `\u26A0\uFE0F \u786E\u8BA4\u540C\u65F6\u4E0B\u8F7D ${confirmValue} \u4E2A\u6587\u4EF6`, data: Buffer.from(`fc_confirm_${confirmValue}`) }), - new Api7.KeyboardButtonCallback({ text: "\u53D6\u6D88", data: Buffer.from("fc_cancel") }) + new Api8.KeyboardButtonCallback({ text: `\u26A0\uFE0F \u786E\u8BA4\u540C\u65F6\u4E0B\u8F7D ${confirmValue} \u4E2A\u6587\u4EF6`, data: Buffer.from(`fc_confirm_${confirmValue}`) }), + new Api8.KeyboardButtonCallback({ text: "\u53D6\u6D88", data: Buffer.from("fc_cancel") }) ] }) ] }); } - return new Api7.ReplyInlineMarkup({ + return new Api8.ReplyInlineMarkup({ rows: [ - new Api7.KeyboardButtonRow({ + new Api8.KeyboardButtonRow({ buttons: [ - new Api7.KeyboardButtonCallback({ text: `${current3 === 1 ? "\u2705 " : ""}1`, data: Buffer.from("fc_set_1") }), - new Api7.KeyboardButtonCallback({ text: `${current3 === 2 ? "\u2705 " : ""}2`, data: Buffer.from("fc_set_2") }) + new Api8.KeyboardButtonCallback({ text: `${current3 === 1 ? "\u2705 " : ""}1`, data: Buffer.from("fc_set_1") }), + new Api8.KeyboardButtonCallback({ text: `${current3 === 2 ? "\u2705 " : ""}2`, data: Buffer.from("fc_set_2") }) ] }), - new Api7.KeyboardButtonRow({ + new Api8.KeyboardButtonRow({ buttons: [ - new Api7.KeyboardButtonCallback({ text: `${current3 === 3 ? "\u2705 " : ""}3`, data: Buffer.from("fc_set_3") }), - new Api7.KeyboardButtonCallback({ text: `${current3 === 4 ? "\u2705 " : ""}4 \u26A0\uFE0F`, data: Buffer.from("fc_set_4") }) + new Api8.KeyboardButtonCallback({ text: `${current3 === 3 ? "\u2705 " : ""}3`, data: Buffer.from("fc_set_3") }), + new Api8.KeyboardButtonCallback({ text: `${current3 === 4 ? "\u2705 " : ""}4 \u26A0\uFE0F`, data: Buffer.from("fc_set_4") }) ] }) ] @@ -14029,16 +15492,11 @@ function buildFileConcurrencyText(current3) { `\u5F53\u524D\u540C\u65F6\u4E0B\u8F7D\u6587\u4EF6\u6570\uFF1A**${current3}**`, `\u5F53\u524D\u961F\u5217\uFF1A\u8FDB\u884C\u4E2D ${stats.active}\uFF0C\u7B49\u5F85\u4E2D ${stats.pending}`, "", - "\u8BF4\u660E\uFF1A\u8FD9\u91CC\u63A7\u5236\u201C\u4E00\u6B21\u540C\u65F6\u4E0B\u8F7D\u51E0\u4E2A\u6587\u4EF6\u201D\u3002", - "\u5B83\u4E0D\u540C\u4E8E /download_workers\uFF1A\u540E\u8005\u63A7\u5236\u5355\u4E2A\u6587\u4EF6\u5185\u90E8\u7684 512KB \u5206\u7247\u5E76\u53D1\u3002", + "\u8FD9\u91CC\u63A7\u5236\u4E00\u6B21\u540C\u65F6\u4E0B\u8F7D\u591A\u5C11\u4E2A\u6587\u4EF6\u3002", "", - "\u5EFA\u8BAE\uFF1A", - "- `1`\uFF1A\u6700\u7A33\uFF0C\u9002\u5408\u98CE\u63A7/\u9650\u901F\u65F6\u4F7F\u7528", - "- `2`\uFF1A\u9ED8\u8BA4\u63A8\u8350\uFF0C\u901F\u5EA6\u4E0E\u7A33\u5B9A\u5E73\u8861", - "- `3`\uFF1A\u901F\u5EA6\u4F18\u5148\uFF0C\u9002\u5408\u7EBF\u8DEF\u7A33\u5B9A\u65F6\u4F7F\u7528", - "- `4`\uFF1A\u6FC0\u8FDB\u6A21\u5F0F\uFF0C\u53EF\u80FD\u89E6\u53D1 Telegram \u9650\u6D41\u6216\u4E91\u76D8\u4E0A\u4F20\u9650\u901F\uFF0C\u9700\u8981\u4E8C\u6B21\u786E\u8BA4", + "\u5EFA\u8BAE\uFF1A1 \u6700\u7A33\u5B9A\uFF0C2 \u9ED8\u8BA4\u63A8\u8350\uFF0C3 \u901F\u5EA6\u4F18\u5148\uFF1B4 \u5C5E\u4E8E\u6FC0\u8FDB\u6A21\u5F0F\uFF0C\u9700\u8981\u4E8C\u6B21\u786E\u8BA4\u3002", "", - "\u4FEE\u6539\u540E\u4F1A\u7ACB\u5373\u5F71\u54CD\u961F\u5217\u4E2D\u65B0\u542F\u52A8\u7684\u6587\u4EF6\u4E0B\u8F7D\uFF1B\u5DF2\u5728\u8FDB\u884C\u4E2D\u7684\u6587\u4EF6\u4E0D\u4F1A\u88AB\u4E2D\u65AD\u3002" + "\u4FEE\u6539\u540E\u53EA\u5F71\u54CD\u65B0\u5F00\u59CB\u7684\u6587\u4EF6\uFF0C\u6B63\u5728\u4E0B\u8F7D\u7684\u6587\u4EF6\u4E0D\u4F1A\u4E2D\u65AD\u3002" ].join("\n"); } function isOn(value, defaultValue = true) { @@ -14049,12 +15507,12 @@ async function getPathCenterState() { return { automaticBySource: true, automaticByType: true }; } function buildDuplicateModeKeyboard(mode) { - return new Api7.ReplyInlineMarkup({ + return new Api8.ReplyInlineMarkup({ rows: [ - new Api7.KeyboardButtonRow({ + new Api8.KeyboardButtonRow({ buttons: [ - new Api7.KeyboardButtonCallback({ text: `${mode === "skip" ? "\u2705" : "\u2B1C"} \u8DF3\u8FC7\u91CD\u590D`, data: Buffer.from("dm_set_skip") }), - new Api7.KeyboardButtonCallback({ text: `${mode === "copy" ? "\u2705" : "\u2B1C"} \u751F\u6210\u526F\u672C`, data: Buffer.from("dm_set_copy") }) + new Api8.KeyboardButtonCallback({ text: `${mode === "skip" ? "\u2705" : "\u2B1C"} \u8DF3\u8FC7\u91CD\u590D`, data: Buffer.from("dm_set_skip") }), + new Api8.KeyboardButtonCallback({ text: `${mode === "copy" ? "\u2705" : "\u2B1C"} \u751F\u6210\u526F\u672C`, data: Buffer.from("dm_set_copy") }) ] }) ] @@ -14066,10 +15524,10 @@ function buildDuplicateModeText(mode) { "", `\u5F53\u524D\u6A21\u5F0F\uFF1A${mode === "skip" ? "\u8DF3\u8FC7\u91CD\u590D" : "\u751F\u6210\u526F\u672C"}`, "", - "- \u8DF3\u8FC7\u91CD\u590D\uFF1A\u540C\u540D + \u540C\u76EE\u5F55 + \u540C\u5927\u5C0F\u65F6\u4E0D\u518D\u4FDD\u5B58", - "- \u751F\u6210\u526F\u672C\uFF1A\u81EA\u52A8\u6539\u540D\u4E3A `\u6587\u4EF6 (1).ext` \u4FDD\u7559\u526F\u672C", + "\u2022 \u8DF3\u8FC7\u91CD\u590D\uFF1A\u540D\u79F0\u3001\u76EE\u5F55\u548C\u5927\u5C0F\u90FD\u76F8\u540C\u65F6\u4E0D\u518D\u4FDD\u5B58", + "\u2022 \u751F\u6210\u526F\u672C\uFF1A\u81EA\u52A8\u6539\u540D\u5E76\u4FDD\u7559\u4E00\u4EFD\u526F\u672C", "", - "\u8BF4\u660E\uFF1A\u4FEE\u6539\u540E\u53EA\u5F71\u54CD\u540E\u7EED\u65B0\u4E0A\u4F20/\u8F6C\u5B58\u6587\u4EF6\u3002" + "\u53EA\u5F71\u54CD\u4E4B\u540E\u4FDD\u5B58\u7684\u6587\u4EF6\u3002" ].join("\n"); } async function getCleanupEnabledSetting() { @@ -14077,12 +15535,12 @@ async function getCleanupEnabledSetting() { return isOn(value, true); } function buildCleanupSettingsKeyboard(enabled) { - return new Api7.ReplyInlineMarkup({ + return new Api8.ReplyInlineMarkup({ rows: [ - new Api7.KeyboardButtonRow({ + new Api8.KeyboardButtonRow({ buttons: [ - new Api7.KeyboardButtonCallback({ text: `${!enabled ? "\u2705" : "\u2B1C"} \u5173\u95ED\u81EA\u52A8\u6E05\u7406`, data: Buffer.from("cs_set_off") }), - new Api7.KeyboardButtonCallback({ text: `${enabled ? "\u2705" : "\u2B1C"} \u5F00\u542F\u81EA\u52A8\u6E05\u7406`, data: Buffer.from("cs_set_on") }) + new Api8.KeyboardButtonCallback({ text: `${!enabled ? "\u2705" : "\u2B1C"} \u5173\u95ED\u81EA\u52A8\u6E05\u7406`, data: Buffer.from("cs_set_off") }), + new Api8.KeyboardButtonCallback({ text: `${enabled ? "\u2705" : "\u2B1C"} \u5F00\u542F\u81EA\u52A8\u6E05\u7406`, data: Buffer.from("cs_set_on") }) ] }) ] @@ -14094,10 +15552,10 @@ function buildCleanupSettingsText(enabled) { "", `\u5F53\u524D\u72B6\u6001\uFF1A${enabled ? "\u2705 \u5F00\u542F" : "\u2B1C \u5173\u95ED"}`, "", - "\u5F00\u542F\u540E\u4F1A\u81EA\u52A8\u5220\u9664\u672C\u5730 uploads \u4E2D\u672A\u767B\u8BB0\u5230\u6587\u4EF6\u7D22\u5F15\u3001\u4E14\u8D85\u8FC7\u4FDD\u62A4\u671F\u7684\u4E34\u65F6\u6587\u4EF6\u3002", - "\u8FD9\u4E0D\u4F1A\u5220\u9664\u4EFB\u52A1\u5386\u53F2\u3001\u5DF2\u767B\u8BB0\u6587\u4EF6\u6216\u7B2C\u4E09\u65B9\u4E91\u7AEF\u5B9E\u4F53\u3002", + "\u5F00\u542F\u540E\u6BCF\u5C0F\u65F6\u68C0\u67E5\u670D\u52A1\u5668\u4E0B\u8F7D\u76EE\u5F55\uFF0C\u53EA\u5220\u9664\u8D85\u8FC7 10 \u5206\u949F\u4E14\u672A\u51FA\u73B0\u5728\u6587\u4EF6\u5217\u8868\u4E2D\u7684\u4E34\u65F6\u6587\u4EF6\u3002", + "\u4E0D\u4F1A\u5220\u9664\u4EFB\u52A1\u8BB0\u5F55\u3001\u5DF2\u767B\u8BB0\u6587\u4EF6\u6216\u4E91\u7AEF\u6587\u4EF6\u3002", "", - "\u5982\u679C\u672C\u5730 uploads \u4E2D\u6709\u7ED5\u8FC7 TG Vault \u5199\u5165\u7684\u6587\u4EF6\uFF0C\u8BF7\u4FDD\u6301\u5173\u95ED\uFF0C\u907F\u514D\u5176\u88AB\u8BC6\u522B\u4E3A\u672A\u7D22\u5F15\u4E34\u65F6\u6587\u4EF6\u3002" + "\u5982\u679C\u4F60\u4F1A\u7ED5\u8FC7 TG Vault \u76F4\u63A5\u5199\u5165\u670D\u52A1\u5668\u4E0B\u8F7D\u76EE\u5F55\uFF0C\u8BF7\u4FDD\u6301\u5173\u95ED\u3002" ].join("\n"); } function getCallbackChatKey(update) { @@ -14114,8 +15572,18 @@ async function handleStart(message, senderId, buttons) { passwordInputState.set(senderId, { password: "" }); } } -async function handleHelp(message) { - await message.reply({ message: buildHelp() }); +async function handleHelp(message, buttons) { + await message.reply({ message: buildHelp(), buttons }); +} +function buildNotificationSettingsKeyboard(current3) { + return new Api8.ReplyInlineMarkup({ + rows: buildNotificationSettingsButtonRows(current3).map((row) => new Api8.KeyboardButtonRow({ + buttons: row.map((button) => new Api8.KeyboardButtonCallback({ + text: button.text, + data: Buffer.from(button.data) + })) + })) + }); } async function handleNotifications(message, args = []) { const userId = message.senderId?.toJSNumber(); @@ -14126,34 +15594,76 @@ async function handleNotifications(message, args = []) { const chatId = canonicalTelegramChatKey(message.chatId?.toString() || userId); const current3 = await getTelegramNotificationPreferences(userId, chatId); if (args.length === 0) { - await message.reply({ message: [ - "\u{1F514} **\u901A\u77E5\u504F\u597D**", - `\u5931\u8D25\uFF1A${current3.failureImmediate ? "\u5373\u65F6" : "\u6458\u8981"}`, - `\u6210\u529F\uFF1A${current3.successMode}`, - `\u5B89\u5168\uFF1A\u59CB\u7EC8\u5373\u65F6\uFF08\u4E0D\u53EF\u5173\u95ED\uFF09`, - `\u8BA2\u9605\uFF1A${current3.subscriptionDigest ? "\u6458\u8981" : "\u5373\u65F6"}`, - `\u65F6\u533A\uFF1A${current3.timezone}`, - `\u5B89\u9759\u65F6\u6BB5\uFF1A${current3.quietStart && current3.quietEnd ? `${current3.quietStart}-${current3.quietEnd}` : "\u5173\u95ED"}`, - "", - "\u7528\u6CD5\uFF1A/notifications timezone Asia/Shanghai\uFF1B/notifications quiet 22:00-07:00\uFF1B/notifications success digest|immediate|off\uFF1B/notifications failure immediate|digest\uFF1B/notifications subscription digest|immediate" - ].join("\n") }); + await message.reply({ + message: buildNotificationSettingsText(current3), + buttons: buildNotificationSettingsKeyboard(current3) + }); return; } - const [key, value = ""] = args; - const update = { ...current3 }; - if (key === "timezone") update.timezone = value; - else if (key === "quiet") { - const match = value.match(/^([0-2]\d:[0-5]\d)-([0-2]\d:[0-5]\d)$/); - if (!match) throw new Error("\u5B89\u9759\u65F6\u6BB5\u683C\u5F0F\u5FC5\u987B\u662F HH:MM-HH:MM"); - update.quietStart = match[1]; - update.quietEnd = match[2]; - } else if (key === "success") update.successMode = value; - else if (key === "failure") update.failureImmediate = value === "immediate"; - else if (key === "subscription") update.subscriptionDigest = value !== "immediate"; - else throw new Error("\u672A\u77E5\u901A\u77E5\u8BBE\u7F6E"); - const saved = await setTelegramNotificationPreferences(userId, chatId, update); - await message.reply({ message: `\u2705 \u901A\u77E5\u504F\u597D\u5DF2\u66F4\u65B0\u3002\u5B89\u5168\u544A\u8B66\u4ECD\u59CB\u7EC8\u5373\u65F6\u3002 -\u65F6\u533A\uFF1A${saved.timezone}\uFF1B\u5B89\u9759\u65F6\u6BB5\uFF1A${saved.quietStart || "-"}-${saved.quietEnd || "-"}` }); + try { + const saved = await setTelegramNotificationPreferences( + userId, + chatId, + updateNotificationPreference(current3, args) + ); + await message.reply({ + message: `${buildNotificationSettingsText(saved)} + +\u2705 \u8BBE\u7F6E\u5DF2\u4FDD\u5B58\u3002`, + buttons: buildNotificationSettingsKeyboard(saved) + }); + } catch (error) { + await message.reply({ + message: `\u274C ${error.message} + +\u53D1\u9001 /notifications \u53EF\u91CD\u65B0\u67E5\u770B\u8BF4\u660E\u548C\u5FEB\u6377\u6309\u94AE\u3002` + }); + } +} +async function handleNotificationsCallback(client2, update, data) { + const userId = update.userId.toJSNumber(); + if (!await isAuthenticatedAsync(userId)) { + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ + queryId: update.queryId, + message: MSG.AUTH_REQUIRED, + alert: true + })); + return; + } + try { + const args = notificationCallbackArgs(data); + if (!args) return; + const chatId = getCallbackChatKey(update); + const current3 = await getTelegramNotificationPreferences(userId, chatId); + const next = updateNotificationPreference(current3, args); + if (JSON.stringify(next) === JSON.stringify(current3)) { + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ + queryId: update.queryId, + message: "\u5DF2\u662F\u5F53\u524D\u8BBE\u7F6E" + })); + return; + } + const saved = await setTelegramNotificationPreferences( + userId, + chatId, + next + ); + await client2.editMessage(update.peer, { + message: Number(update.msgId), + text: buildNotificationSettingsText(saved), + buttons: buildNotificationSettingsKeyboard(saved) + }); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ + queryId: update.queryId, + message: "\u901A\u77E5\u8BBE\u7F6E\u5DF2\u66F4\u65B0" + })); + } catch (error) { + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ + queryId: update.queryId, + message: `\u8BBE\u7F6E\u5931\u8D25\uFF1A${error.message}`, + alert: true + })); + } } async function handleStatus(message) { const senderId = message.senderId?.toJSNumber(); @@ -14161,13 +15671,13 @@ async function handleStatus(message) { await message.reply({ message: MSG.AUTH_REQUIRED }); return; } - const requestId = `tg-${crypto18.randomBytes(6).toString("hex")}`; + const requestId = `tg-${crypto19.randomBytes(6).toString("hex")}`; try { const target = storageManager.getActiveTarget(); const accounts = await storageManager.getAccounts(); const account = target.accountId ? accounts.find((row) => row.id === target.accountId) : null; const diskSpace = await checkDiskSpace(path15.resolve(UPLOAD_DIR4)); - const queue = getDownloadQueueStats(); + const queue2 = getDownloadQueueStats(); const [subscriptionRows, reconciliation] = await Promise.all([ query(`SELECT COUNT(*)::int AS enabled, MAX(last_scan_at) AS last_scan_at, MAX(last_error) FILTER (WHERE last_error IS NOT NULL) AS last_error @@ -14194,7 +15704,7 @@ async function handleStatus(message) { probeError: account?.last_probe_error || null }, disk: { freeBytes: Number(diskSpace.free || 0), totalBytes: Number(diskSpace.size || 0) }, - queue: { active: queue.active, pending: queue.pending, failed: getTaskStatus().history.filter((task) => task.status === "failed").length, paused: queue.paused }, + queue: { active: queue2.active, pending: queue2.pending, failed: getTaskStatus().history.filter((task) => task.status === "failed").length, paused: queue2.paused }, subscriptions: { enabled: Number(subscription.enabled || 0), lastScanAt: subscription.last_scan_at || null, lastError: subscription.last_error || null }, reconciliation: { pending: Number(reconcile.pending || 0), operatorRequired: Number(reconcile.operator_required || 0) } }) }); @@ -14239,6 +15749,19 @@ async function handleStorage(message) { await message.reply({ message: MSG.ERR_STORAGE }); } } +function buildTargetKeyboard() { + return new Api8.ReplyInlineMarkup({ + rows: [ + new Api8.KeyboardButtonRow({ buttons: [ + new Api8.KeyboardButtonCallback({ text: "\u{1F4CC} \u4E0B\u4E00\u6B21\u4F7F\u7528\u5F53\u524D\u5B58\u50A8", data: Buffer.from("target_once_active") }), + new Api8.KeyboardButtonCallback({ text: "\u{1F4CD} \u672C\u804A\u5929\u4F7F\u7528\u5F53\u524D\u5B58\u50A8", data: Buffer.from("target_session_active") }) + ] }), + new Api8.KeyboardButtonRow({ buttons: [ + new Api8.KeyboardButtonCallback({ text: "\u{1F9F9} \u6062\u590D\u7CFB\u7EDF\u9ED8\u8BA4", data: Buffer.from("target_clear") }) + ] }) + ] + }); +} async function handleTarget(message, args = []) { const senderId = message.senderId?.toJSNumber(); if (!senderId || !await isAuthenticatedAsync(senderId)) { @@ -14255,21 +15778,25 @@ async function handleTarget(message, args = []) { if (!modeInput) { const once = await getTelegramTargetState(void 0, chatId, "once"); const session = await getTelegramTargetState(void 0, chatId, "session"); - const active = storageManager.getActiveTarget(); + const active2 = storageManager.getActiveTarget(); await message.reply({ message: [ "\u{1F3AF} **\u5F53\u524D\u804A\u5929\u5B58\u50A8\u76EE\u6807**", - `\u4E0B\u4E00\u6B21\uFF1A${once ? `${once.provider} / ${once.accountId || "local"}` : "\u672A\u8BBE\u7F6E"}`, - `\u4F1A\u8BDD\uFF1A${session ? `${session.provider} / ${session.accountId || "local"}` : "\u672A\u8BBE\u7F6E"}`, - `\u7CFB\u7EDF\u9ED8\u8BA4\uFF1A${active.provider.name} / ${active.accountId || "local"}`, + `\u4E0B\u4E00\u6B21\uFF1A${once ? `${getProviderDisplayName(once.provider)}\uFF08\u5DF2\u8BBE\u7F6E\uFF09` : "\u7CFB\u7EDF\u9ED8\u8BA4"}`, + `\u672C\u804A\u5929\uFF1A${session ? `${getProviderDisplayName(session.provider)}\uFF08\u5DF2\u8BBE\u7F6E\uFF09` : "\u7CFB\u7EDF\u9ED8\u8BA4"}`, + `\u7CFB\u7EDF\u9ED8\u8BA4\uFF1A${getProviderDisplayName(active2.provider.name)}`, "", - "\u7528\u6CD5\uFF1A`/target once `\u3001`/target session `\u3001`/target clear`" - ].join("\n") + "\u{1F447} \u70B9\u51FB\u6309\u94AE\uFF0C\u7528\u5F53\u524D\u7CFB\u7EDF\u5B58\u50A8\u8BBE\u7F6E\u4E34\u65F6\u76EE\u6807\u3002" + ].join("\n"), + buttons: buildTargetKeyboard() }); return; } if (!["once", "session"].includes(modeInput) || !args[1]) { - await message.reply({ message: "\u274C \u7528\u6CD5\uFF1A/target once \u3001/target session \u3001/target clear" }); + await message.reply({ + message: "\u274C \u65E0\u6CD5\u8BC6\u522B\u8FD9\u4E2A\u5B58\u50A8\u76EE\u6807\u3002\u8BF7\u4F7F\u7528\u4E0B\u65B9\u6309\u94AE\u3002", + buttons: buildTargetKeyboard() + }); return; } const mode = modeInput; @@ -14294,6 +15821,44 @@ async function handleTarget(message, args = []) { await message.reply({ message: `\u2705 \u5DF2\u8BBE\u7F6E${mode === "once" ? "\u4E0B\u4E00\u6B21" : "\u5F53\u524D\u804A\u5929\u4F1A\u8BDD"}\u76EE\u6807\uFF1A${provider} / ${accountName} \u4E0D\u4F1A\u4FEE\u6539\u7CFB\u7EDF\u5168\u5C40\u9ED8\u8BA4\u3002` }); } +async function handleTargetCallback(client2, update, data) { + const userId = update.userId.toJSNumber(); + if (!await isAuthenticatedAsync(userId)) { + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: MSG.AUTH_REQUIRED, alert: true })); + return; + } + const chatId = getCallbackChatKey(update); + if (data === "target_clear") { + await clearTelegramTargetState(chatId); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5DF2\u6062\u590D\u7CFB\u7EDF\u9ED8\u8BA4" })); + } else { + const mode = data === "target_once_active" ? "once" : "session"; + const active3 = storageManager.getActiveTarget(); + const expiresAt = new Date(Date.now() + (mode === "once" ? 24 * 60 * 60 * 1e3 : 7 * 24 * 60 * 60 * 1e3)); + await setTelegramTargetState(void 0, chatId, mode, active3.provider.name, active3.accountId, expiresAt); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ + queryId: update.queryId, + message: mode === "once" ? "\u5DF2\u8BBE\u7F6E\u4E0B\u4E00\u6B21\u5B58\u50A8" : "\u5DF2\u8BBE\u7F6E\u672C\u804A\u5929\u5B58\u50A8" + })); + } + const once = await getTelegramTargetState(void 0, chatId, "once"); + const session = await getTelegramTargetState(void 0, chatId, "session"); + const active2 = storageManager.getActiveTarget(); + await client2.editMessage(update.peer, { + message: Number(update.msgId), + text: [ + "\u{1F3AF} **\u5F53\u524D\u804A\u5929\u5B58\u50A8\u76EE\u6807**", + `\u4E0B\u4E00\u6B21\uFF1A${once ? `${getProviderDisplayName(once.provider)}\uFF08\u5DF2\u8BBE\u7F6E\uFF09` : "\u7CFB\u7EDF\u9ED8\u8BA4"}`, + `\u672C\u804A\u5929\uFF1A${session ? `${getProviderDisplayName(session.provider)}\uFF08\u5DF2\u8BBE\u7F6E\uFF09` : "\u7CFB\u7EDF\u9ED8\u8BA4"}`, + `\u7CFB\u7EDF\u9ED8\u8BA4\uFF1A${getProviderDisplayName(active2.provider.name)}`, + "", + "\u{1F447} \u70B9\u51FB\u6309\u94AE\uFF0C\u7528\u5F53\u524D\u7CFB\u7EDF\u5B58\u50A8\u8BBE\u7F6E\u4E34\u65F6\u76EE\u6807\u3002" + ].join("\n"), + buttons: buildTargetKeyboard() + }).catch((error) => { + if (!isTelegramMessageNotModified(error)) throw error; + }); +} async function handleStorageSwitch(message) { try { const view = await buildStorageSwitchView(); @@ -14306,7 +15871,7 @@ async function handleStorageSwitch(message) { async function handleStorageSwitchCallback(client2, update, data) { const userId = update.userId.toJSNumber(); if (!await isAuthenticatedAsync(userId)) { - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: MSG.AUTH_REQUIRED, alert: true })); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: MSG.AUTH_REQUIRED, alert: true })); return; } try { @@ -14316,7 +15881,7 @@ async function handleStorageSwitchCallback(client2, update, data) { } const accountId = data.replace(/^storage_switch_/, ""); if (!accountId) { - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u65E0\u6548\u7684\u5B58\u50A8\u6E90\u9009\u62E9", alert: true })); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u65E0\u6548\u7684\u5B58\u50A8\u6E90\u9009\u62E9", alert: true })); return; } if (accountId === "local") { @@ -14329,26 +15894,26 @@ async function handleStorageSwitchCallback(client2, update, data) { return; } const accounts = await storageManager.getAccounts(); - const selected = accounts.find((account) => account.id === accountId); - if (!selected) { + const selected3 = accounts.find((account) => account.id === accountId); + if (!selected3) { await editStorageSwitchMessage(client2, update, "\u8BE5\u5B58\u50A8\u8D26\u6237\u5DF2\u4E0D\u5B58\u5728"); return; } - if (selected.is_active || storageManager.getActiveAccountId() === accountId) { + if (selected3.is_active || storageManager.getActiveAccountId() === accountId) { await editStorageSwitchMessage(client2, update, "\u5F53\u524D\u5DF2\u7ECF\u5728\u4F7F\u7528\u8BE5\u8D26\u6237"); return; } await storageManager.switchAccount(accountId); - await editStorageSwitchMessage(client2, update, `\u5DF2\u5207\u6362\u5230 ${selected.name || getProviderDisplayName(selected.type)}`); + await editStorageSwitchMessage(client2, update, `\u5DF2\u5207\u6362\u5230 ${selected3.name || getProviderDisplayName(selected3.type)}`); } catch (error) { console.error("\u{1F916} \u5207\u6362\u5B58\u50A8\u6E90\u5931\u8D25:", error); - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: `\u5207\u6362\u5931\u8D25: ${error.message}`, alert: true })); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: `\u5207\u6362\u5931\u8D25: ${error.message}`, alert: true })); } } async function handleStorageCleanupCallback(client2, update, data) { const userId = update.userId.toJSNumber(); if (!await isAuthenticatedAsync(userId)) { - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: MSG.AUTH_REQUIRED, alert: true })); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: MSG.AUTH_REQUIRED, alert: true })); return; } try { @@ -14364,7 +15929,7 @@ async function handleStorageCleanupCallback(client2, update, data) { action: "clear_local_storage" }); if (!cancelled) { - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u6E05\u7406\u786E\u8BA4\u65E0\u6548\u6216\u5DF2\u8FC7\u671F", alert: true })); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u6E05\u7406\u786E\u8BA4\u65E0\u6548\u6216\u5DF2\u8FC7\u671F", alert: true })); return; } pendingStorageClearSnapshots.delete(tokenMatch[2]); @@ -14373,7 +15938,7 @@ async function handleStorageCleanupCallback(client2, update, data) { text: stats.count > 0 ? `\u5DF2\u53D6\u6D88\u6E05\u7406\u3002\u5F53\u524D\u672C\u5730\u4E0B\u8F7D\u6587\u4EF6\uFF1A${stats.count} \u4E2A\uFF0C\u5360\u7528 ${formatBytes(stats.totalSize)}\u3002` : "\u5DF2\u53D6\u6D88\u6E05\u7406\u3002\u5F53\u524D\u6CA1\u6709\u672C\u5730\u4E0B\u8F7D\u6587\u4EF6\u3002", buttons: buildStorageMaintenanceKeyboard(stats.count) }); - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5DF2\u53D6\u6D88" })); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5DF2\u53D6\u6D88" })); return; } if (data === "storage_clear_ask") { @@ -14401,7 +15966,7 @@ async function handleStorageCleanupCallback(client2, update, data) { ].join("\n"), buttons: buildStorageMaintenanceKeyboard(stats.count, confirmationToken) }); - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u9700\u8981\u4E8C\u6B21\u786E\u8BA4" })); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u9700\u8981\u4E8C\u6B21\u786E\u8BA4" })); return; } if (tokenMatch?.[1] === "confirm") { @@ -14414,7 +15979,7 @@ async function handleStorageCleanupCallback(client2, update, data) { const snapshot = pendingStorageClearSnapshots.get(tokenMatch[2]); pendingStorageClearSnapshots.delete(tokenMatch[2]); if (consumed.status !== "ok" || !snapshot) { - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u6E05\u7406\u786E\u8BA4\u65E0\u6548\u3001\u5DF2\u8FC7\u671F\u6216\u5DF2\u4F7F\u7528", alert: true })); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u6E05\u7406\u786E\u8BA4\u65E0\u6548\u3001\u5DF2\u8FC7\u671F\u6216\u5DF2\u4F7F\u7528", alert: true })); return; } let deletedCount = 0; @@ -14422,7 +15987,7 @@ async function handleStorageCleanupCallback(client2, update, data) { const indexed = snapshot.indexedIds.length > 0 ? await query(`SELECT * FROM files WHERE source = 'local' AND id = ANY($1::uuid[])`, [snapshot.indexedIds]) : { rows: [] }; for (const file of indexed.rows) { const filePath = path15.resolve(file.path || path15.join(UPLOAD_DIR4, file.stored_name)); - const size = fs11.existsSync(filePath) ? fs11.statSync(filePath).size : Number(file.size || 0); + const size = fs12.existsSync(filePath) ? fs12.statSync(filePath).size : Number(file.size || 0); try { await removePhysicalFile(file); await query("DELETE FROM files WHERE id = $1", [file.id]); @@ -14434,7 +15999,7 @@ async function handleStorageCleanupCallback(client2, update, data) { } } for (const resolved of snapshot.orphanPaths) { - const size = fs11.existsSync(resolved) ? fs11.statSync(resolved).size : 0; + const size = fs12.existsSync(resolved) ? fs12.statSync(resolved).size : 0; if (await safeUnlink(resolved, UPLOAD_DIR4)) { deletedCount += 1; deletedBytes += size; @@ -14453,15 +16018,15 @@ async function handleStorageCleanupCallback(client2, update, data) { ].join("\n"), buttons: buildStorageMaintenanceKeyboard(after.count) }); - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: `\u5DF2\u5220\u9664 ${deletedCount} \u4E2A\u6587\u4EF6` })); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: `\u5DF2\u5220\u9664 ${deletedCount} \u4E2A\u6587\u4EF6` })); return; } if (data.startsWith("storage_clear_confirm") || data.startsWith("storage_clear_cancel")) { - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u65E7\u6E05\u7406\u6309\u94AE\u5DF2\u5931\u6548\uFF0C\u8BF7\u91CD\u65B0\u53D1\u9001 /storage", alert: true })); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u65E7\u6E05\u7406\u6309\u94AE\u5DF2\u5931\u6548\uFF0C\u8BF7\u91CD\u65B0\u53D1\u9001 /storage", alert: true })); } } catch (error) { console.error("\u{1F916} \u6E05\u7406\u672C\u5730\u4E0B\u8F7D\u6587\u4EF6\u5931\u8D25:", error); - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: `\u6E05\u7406\u5931\u8D25: ${error.message}`, alert: true })); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: `\u6E05\u7406\u5931\u8D25: ${error.message}`, alert: true })); } } async function handleFind(message, args = []) { @@ -14497,8 +16062,8 @@ async function handleList(message, args) { let page = 1; if (args.length > 0) { const parsed = parseInt(args[0]); - if (!isNaN(parsed) && parsed > 0 && parsed <= 50) { - limit = parsed; + if (!isNaN(parsed) && parsed > 0) { + limit = Math.min(parsed, 12); } } if (args.length > 1) { @@ -14530,38 +16095,38 @@ async function handleList(message, args) { async function handleTelegramFileBrowserCallback(client2, update, data) { const actorId = update.userId.toJSNumber(); if (!await isAuthenticatedAsync(actorId)) { - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: MSG.AUTH_REQUIRED, alert: true })); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: MSG.AUTH_REQUIRED, alert: true })); return; } const parsed = parseTelegramFileCallback(data); if (!parsed) return; const file = await getScopedFileById(parsed.fileId); if (!file) { - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u6587\u4EF6\u5DF2\u4E0D\u5B58\u5728\u6216\u4E0D\u5728\u5F53\u524D\u5B58\u50A8\u8303\u56F4\u5185", alert: true })); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u6587\u4EF6\u5DF2\u4E0D\u5B58\u5728\u6216\u4E0D\u5728\u5F53\u524D\u5B58\u50A8\u8303\u56F4\u5185", alert: true })); return; } const chatId = getCallbackChatKey(update); const messageId = Number(update.msgId); if (parsed.action === "detail") { await client2.editMessage(update.peer, { message: messageId, text: buildTelegramFileDetail(file), buttons: buildFileActionKeyboard(file) }); - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u6587\u4EF6\u8BE6\u60C5" })); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u6587\u4EF6\u8BE6\u60C5" })); return; } if (parsed.action === "copy") { - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: String(file.id), alert: true })); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: String(file.id), alert: true })); return; } if (parsed.action === "favorite") { await updateScopedFileById(String(file.id), "is_favorite = $1, updated_at = NOW()", [!file.is_favorite]); file.is_favorite = !file.is_favorite; await client2.editMessage(update.peer, { message: messageId, text: buildTelegramFileDetail(file), buttons: buildFileActionKeyboard(file) }); - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: file.is_favorite ? "\u5DF2\u6536\u85CF" : "\u5DF2\u53D6\u6D88\u6536\u85CF" })); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: file.is_favorite ? "\u5DF2\u6536\u85CF" : "\u5DF2\u53D6\u6D88\u6536\u85CF" })); return; } if (parsed.action === "link") { const capabilities = buildStorageCapabilities(String(file.source)); if (!capabilities.share && String(file.source) !== "local") { - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5F53\u524D provider \u4E0D\u652F\u6301\u5206\u4EAB\uFF1B\u53EF\u5728 Web \u4E2D\u4E0B\u8F7D", alert: true })); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5F53\u524D provider \u4E0D\u652F\u6301\u5206\u4EAB\uFF1B\u53EF\u5728 Web \u4E2D\u4E0B\u8F7D", alert: true })); return; } const relative = getSignedUrl(String(file.id), "download", 3600); @@ -14569,14 +16134,14 @@ async function handleTelegramFileBrowserCallback(client2, update, data) { const link = base ? `${base}${relative}` : relative; await client2.sendMessage(update.peer, { message: `\u{1F517} 1 \u5C0F\u65F6\u7B7E\u540D\u94FE\u63A5\uFF1A ${link}` }); - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5DF2\u751F\u6210\u7B7E\u540D\u94FE\u63A5" })); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5DF2\u751F\u6210\u7B7E\u540D\u94FE\u63A5" })); return; } if (parsed.action === "move" || parsed.action === "rename") { const key = `${actorId}:${chatId}`; pendingTelegramFileMutations.set(key, { actorId, chatId, messageId, fileId: String(file.id), action: parsed.action, expiresAt: Date.now() + 5 * 60 * 1e3 }); await client2.sendMessage(update.peer, { message: parsed.action === "move" ? "\u8BF7\u53D1\u9001\u76EE\u6807\u76EE\u5F55\uFF1B\u53D1\u9001\u201C\u53D6\u6D88\u201D\u9000\u51FA\u3002" : "\u8BF7\u53D1\u9001\u65B0\u6587\u4EF6\u540D\uFF08\u5FC5\u987B\u4FDD\u7559\u539F\u6269\u5C55\u540D\uFF09\uFF1B\u53D1\u9001\u201C\u53D6\u6D88\u201D\u9000\u51FA\u3002" }); - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: parsed.action === "move" ? "\u7B49\u5F85\u76EE\u6807\u76EE\u5F55" : "\u7B49\u5F85\u65B0\u6587\u4EF6\u540D" })); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: parsed.action === "move" ? "\u7B49\u5F85\u76EE\u6807\u76EE\u5F55" : "\u7B49\u5F85\u65B0\u6587\u4EF6\u540D" })); return; } const sent = await client2.sendMessage(update.peer, { @@ -14585,7 +16150,7 @@ ${link}` }); const confirmId = destructiveConfirmations.issue({ actorId, chatId, messageId: sent.id, action: "delete_file", objectId: String(file.id) }); pendingDeleteConfirmations.set(confirmId, { fileId: String(file.id), name: String(file.name), size: Number(file.size || 0), selector: String(file.id), actorId, chatId, messageId: sent.id }); await sent.edit({ text: sent.message, buttons: buildDeleteConfirmKeyboard(confirmId) }); - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u9700\u8981\u4E8C\u6B21\u786E\u8BA4" })); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u9700\u8981\u4E8C\u6B21\u786E\u8BA4" })); } async function applyPendingTelegramFileMutation(message, actorId, input) { const chatId = canonicalTelegramChatKey(message.chatId?.toString()); @@ -14625,9 +16190,7 @@ async function applyPendingTelegramFileMutation(message, actorId, input) { } async function handleDelete(message, args) { if (args.length === 0) { - await message.reply({ - message: "\u274C \u8BF7\u63D0\u4F9B\u8981\u5220\u9664\u7684\u6587\u4EF6 ID \u524D\u7F00\n\n\u7528\u6CD5\uFF1A/delete <\u81F3\u5C11 8 \u4F4D ID \u524D\u7F00>\n\u63D0\u793A\uFF1A\u53D1\u9001 /list \u53EF\u67E5\u770B\u6700\u8FD1\u6587\u4EF6\u53CA ID\uFF0C\u4E5F\u53EF\u4ECE Web \u6587\u4EF6\u9884\u89C8\u590D\u5236\u5B8C\u6574 ID\u3002" - }); + await message.reply({ message: "\u8BF7\u4ECE\u201C\u641C\u7D22\u548C\u64CD\u4F5C\u6587\u4EF6\u201D\u4E2D\u9009\u62E9\u6587\u4EF6\uFF0C\u7136\u540E\u70B9\u51FB\u201C\u5220\u9664\u201D\u3002" }); return; } const selector = args[0].trim(); @@ -14703,7 +16266,7 @@ async function handleDelete(message, args) { async function handleDeleteConfirmCallback(client2, update, data) { const userId = update.userId.toJSNumber(); if (!await isAuthenticatedAsync(userId)) { - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: MSG.AUTH_REQUIRED, alert: true })); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: MSG.AUTH_REQUIRED, alert: true })); return; } const match = data.match(/^del_(confirm|cancel)_(.+)$/); @@ -14711,7 +16274,7 @@ async function handleDeleteConfirmCallback(client2, update, data) { const [, action, confirmId] = match; const pending = pendingDeleteConfirmations.get(confirmId); if (!pending) { - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5220\u9664\u786E\u8BA4\u65E0\u6548\u6216\u5DF2\u8FC7\u671F", alert: true })); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5220\u9664\u786E\u8BA4\u65E0\u6548\u6216\u5DF2\u8FC7\u671F", alert: true })); return; } const binding = { @@ -14723,17 +16286,17 @@ async function handleDeleteConfirmCallback(client2, update, data) { }; if (action === "cancel") { if (!destructiveConfirmations.cancel(confirmId, binding)) { - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5220\u9664\u786E\u8BA4\u4E0D\u5C5E\u4E8E\u4F60\u6216\u5DF2\u8FC7\u671F", alert: true })); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5220\u9664\u786E\u8BA4\u4E0D\u5C5E\u4E8E\u4F60\u6216\u5DF2\u8FC7\u671F", alert: true })); return; } pendingDeleteConfirmations.delete(confirmId); - await client2.editMessage(update.peer, { message: Number(update.msgId), text: `\u5DF2\u53D6\u6D88\u5220\u9664\uFF1A${pending.name}`, buttons: new Api7.ReplyInlineMarkup({ rows: [] }) }); - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5DF2\u53D6\u6D88" })); + await client2.editMessage(update.peer, { message: Number(update.msgId), text: `\u5DF2\u53D6\u6D88\u5220\u9664\uFF1A${pending.name}`, buttons: new Api8.ReplyInlineMarkup({ rows: [] }) }); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5DF2\u53D6\u6D88" })); return; } const consumed = destructiveConfirmations.consume(confirmId, binding); if (consumed.status !== "ok") { - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5220\u9664\u786E\u8BA4\u4E0D\u5C5E\u4E8E\u4F60\u3001\u5DF2\u8FC7\u671F\u6216\u5DF2\u4F7F\u7528", alert: true })); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5220\u9664\u786E\u8BA4\u4E0D\u5C5E\u4E8E\u4F60\u3001\u5DF2\u8FC7\u671F\u6216\u5DF2\u4F7F\u7528", alert: true })); return; } pendingDeleteConfirmations.delete(confirmId); @@ -14743,28 +16306,28 @@ async function handleDeleteConfirmCallback(client2, update, data) { const file = result.rows[0]; if (!file) { pendingDeleteConfirmations.delete(confirmId); - await client2.editMessage(update.peer, { message: Number(update.msgId), text: "\u274C \u6587\u4EF6\u5DF2\u4E0D\u5B58\u5728\u6216\u4E0D\u5728\u5F53\u524D\u5B58\u50A8\u8303\u56F4\u5185\u3002", buttons: new Api7.ReplyInlineMarkup({ rows: [] }) }); - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u6587\u4EF6\u4E0D\u5B58\u5728", alert: true })); + await client2.editMessage(update.peer, { message: Number(update.msgId), text: "\u274C \u6587\u4EF6\u5DF2\u4E0D\u5B58\u5728\u6216\u4E0D\u5728\u5F53\u524D\u5B58\u50A8\u8303\u56F4\u5185\u3002", buttons: new Api8.ReplyInlineMarkup({ rows: [] }) }); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u6587\u4EF6\u4E0D\u5B58\u5728", alert: true })); return; } if (file.source === "openlist") { - await client2.editMessage(update.peer, { message: Number(update.msgId), text: "\u274C OpenList \u5B58\u50A8\u4E0D\u63D0\u4F9B\u7528\u6237\u5220\u9664\u529F\u80FD\u3002", buttons: new Api7.ReplyInlineMarkup({ rows: [] }) }); - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5F53\u524D\u5B58\u50A8\u4E0D\u652F\u6301\u7528\u6237\u5220\u9664", alert: true })); + await client2.editMessage(update.peer, { message: Number(update.msgId), text: "\u274C OpenList \u5B58\u50A8\u4E0D\u63D0\u4F9B\u7528\u6237\u5220\u9664\u529F\u80FD\u3002", buttons: new Api8.ReplyInlineMarkup({ rows: [] }) }); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5F53\u524D\u5B58\u50A8\u4E0D\u652F\u6301\u7528\u6237\u5220\u9664", alert: true })); return; } await removePhysicalFile(file); await query("DELETE FROM files WHERE id = $1", [file.id]); - await client2.editMessage(update.peer, { message: Number(update.msgId), text: buildDeleteSuccess(file.name, file.id), buttons: new Api7.ReplyInlineMarkup({ rows: [] }) }); - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5DF2\u5220\u9664" })); + await client2.editMessage(update.peer, { message: Number(update.msgId), text: buildDeleteSuccess(file.name, file.id), buttons: new Api8.ReplyInlineMarkup({ rows: [] }) }); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5DF2\u5220\u9664" })); } catch (error) { console.error("\u{1F916} \u786E\u8BA4\u5220\u9664\u6587\u4EF6\u5931\u8D25:", error); - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: `\u5220\u9664\u5931\u8D25: ${error.message}`, alert: true })); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: `\u5220\u9664\u5931\u8D25: ${error.message}`, alert: true })); } } function buildTaskCenterMarkup(rows) { - return new Api7.ReplyInlineMarkup({ - rows: rows.map((row) => new Api7.KeyboardButtonRow({ - buttons: row.map((button) => new Api7.KeyboardButtonCallback({ + return new Api8.ReplyInlineMarkup({ + rows: rows.map((row) => new Api8.KeyboardButtonRow({ + buttons: row.map((button) => new Api8.KeyboardButtonCallback({ text: button.text, data: Buffer.from(button.data) })) @@ -14919,42 +16482,42 @@ function taskCenterCancelKey(userId, chatId, messageId) { async function handleTaskCenterCallback(client2, update, data) { const userId = update.userId.toJSNumber(); if (!await isAuthenticatedAsync(userId)) { - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: MSG.AUTH_REQUIRED, alert: true })); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: MSG.AUTH_REQUIRED, alert: true })); return; } const parsed = parseTaskCenterCallback(data); if (!parsed) { - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u4EFB\u52A1\u6309\u94AE\u65E0\u6548\u6216\u5DF2\u8FC7\u671F", alert: true })); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u4EFB\u52A1\u6309\u94AE\u65E0\u6548\u6216\u5DF2\u8FC7\u671F", alert: true })); return; } const chatId = getCallbackChatKey(update); const ownerKey = taskCenterCardKey(chatId, Number(update.msgId)); const owner = taskCenterCardOwners.get(ownerKey); if (!owner) { - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u65E7\u4EFB\u52A1\u5361\u5DF2\u5931\u6548\uFF0C\u8BF7\u91CD\u65B0\u53D1\u9001 /tasks", alert: true })); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u65E7\u4EFB\u52A1\u5361\u5DF2\u5931\u6548\uFF0C\u8BF7\u91CD\u65B0\u53D1\u9001 /tasks", alert: true })); return; } if (owner.expiresAt < Date.now() || owner.userId !== userId) { if (owner.expiresAt < Date.now()) taskCenterCardOwners.delete(ownerKey); - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u8BE5\u4EFB\u52A1\u5361\u4E0D\u5C5E\u4E8E\u4F60\u6216\u5DF2\u8FC7\u671F", alert: true })); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u8BE5\u4EFB\u52A1\u5361\u4E0D\u5C5E\u4E8E\u4F60\u6216\u5DF2\u8FC7\u671F", alert: true })); return; } owner.expiresAt = Date.now() + TASK_CENTER_CARD_TTL_MS; try { if (parsed.view === "list") { await renderTaskCenterList(client2, update, userId, chatId, parsed.page); - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u4EFB\u52A1\u5217\u8868\u5DF2\u5237\u65B0" })); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u4EFB\u52A1\u5217\u8868\u5DF2\u5237\u65B0" })); return; } const item = await findTaskCenterItem(parsed.sourceType, parsed.id, chatId, userId); if (!item) { await renderTaskCenterList(client2, update, userId, chatId, parsed.page); - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u4EFB\u52A1\u5DF2\u7ED3\u675F\u6216\u5DF2\u5931\u6548", alert: true })); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u4EFB\u52A1\u5DF2\u7ED3\u675F\u6216\u5DF2\u5931\u6548", alert: true })); return; } if (parsed.view === "detail") { await editTaskCenterView(client2, update, buildTaskCenterDetail(item, parsed.page)); - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId })); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId })); return; } if (parsed.action === "cancel_prompt") { @@ -14967,7 +16530,7 @@ async function handleTaskCenterCallback(client2, update, data) { expiresAt: Date.now() + TASK_CENTER_CONFIRM_TTL_MS }); await editTaskCenterView(client2, update, buildTaskCancelConfirm(item, parsed.page)); - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u8BF7\u786E\u8BA4\u662F\u5426\u53D6\u6D88" })); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u8BF7\u786E\u8BA4\u662F\u5426\u53D6\u6D88" })); return; } if (parsed.action === "cancel_confirm") { @@ -14975,12 +16538,12 @@ async function handleTaskCenterCallback(client2, update, data) { const pending = pendingTaskCenterCancels.get(confirmationKey); pendingTaskCenterCancels.delete(confirmationKey); if (!pending || pending.expiresAt < Date.now() || pending.sourceType !== parsed.sourceType || pending.taskId !== parsed.id) { - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u53D6\u6D88\u786E\u8BA4\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u8FDB\u5165\u4EFB\u52A1\u8BE6\u60C5", alert: true })); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u53D6\u6D88\u786E\u8BA4\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u8FDB\u5165\u4EFB\u52A1\u8BE6\u60C5", alert: true })); return; } } if (parsed.action === "retry" && parsed.sourceType !== "ytdlp") { - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u8BE5\u4EFB\u52A1\u7C7B\u578B\u4E0D\u652F\u6301\u6B64\u91CD\u8BD5\u6309\u94AE", alert: true })); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u8BE5\u4EFB\u52A1\u7C7B\u578B\u4E0D\u652F\u6301\u6B64\u91CD\u8BD5\u6309\u94AE", alert: true })); return; } let ok = false; @@ -15028,10 +16591,10 @@ async function handleTaskCenterCallback(client2, update, data) { await renderTaskCenterList(client2, update, userId, chatId, parsed.page); } } - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: toast, alert: !ok })); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: toast, alert: !ok })); } catch (error) { console.error("\u{1F916} \u4EFB\u52A1\u4E2D\u5FC3\u6309\u94AE\u64CD\u4F5C\u5931\u8D25:", error); - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: `\u64CD\u4F5C\u5931\u8D25: ${error.message}`, alert: true @@ -15118,7 +16681,7 @@ async function cancelTasksForScope(userId, chatId) { async function handleBulkTaskCancelCallback(client2, update, data) { const userId = update.userId.toJSNumber(); if (!await isAuthenticatedAsync(userId)) { - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: MSG.AUTH_REQUIRED, alert: true })); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: MSG.AUTH_REQUIRED, alert: true })); return; } const match = data.match(/^bulk_task_(confirm|cancel)_([A-Za-z0-9_-]+)$/); @@ -15126,7 +16689,7 @@ async function handleBulkTaskCancelCallback(client2, update, data) { const [, action, confirmId] = match; const pending = pendingBulkTaskCancellations.get(confirmId); if (!pending) { - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u6279\u91CF\u53D6\u6D88\u786E\u8BA4\u65E0\u6548\u6216\u5DF2\u8FC7\u671F", alert: true })); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u6279\u91CF\u53D6\u6D88\u786E\u8BA4\u65E0\u6548\u6216\u5DF2\u8FC7\u671F", alert: true })); return; } const binding = { @@ -15137,18 +16700,18 @@ async function handleBulkTaskCancelCallback(client2, update, data) { }; if (action === "cancel") { if (!destructiveConfirmations.cancel(confirmId, binding)) { - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u8BE5\u786E\u8BA4\u4E0D\u5C5E\u4E8E\u4F60\u6216\u5DF2\u8FC7\u671F", alert: true })); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u8BE5\u786E\u8BA4\u4E0D\u5C5E\u4E8E\u4F60\u6216\u5DF2\u8FC7\u671F", alert: true })); return; } pendingBulkTaskCancellations.delete(confirmId); - await client2.editMessage(update.peer, { message: Number(update.msgId), text: "\u5DF2\u8FD4\u56DE\uFF0C\u5F53\u524D\u804A\u5929\u4EFB\u52A1\u672A\u88AB\u53D6\u6D88\u3002", buttons: new Api7.ReplyInlineMarkup({ rows: [] }) }); - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5DF2\u8FD4\u56DE" })); + await client2.editMessage(update.peer, { message: Number(update.msgId), text: "\u5DF2\u8FD4\u56DE\uFF0C\u5F53\u524D\u804A\u5929\u4EFB\u52A1\u672A\u88AB\u53D6\u6D88\u3002", buttons: new Api8.ReplyInlineMarkup({ rows: [] }) }); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5DF2\u8FD4\u56DE" })); return; } const consumed = destructiveConfirmations.consume(confirmId, binding); pendingBulkTaskCancellations.delete(confirmId); if (consumed.status !== "ok") { - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u8BE5\u786E\u8BA4\u4E0D\u5C5E\u4E8E\u4F60\u3001\u5DF2\u8FC7\u671F\u6216\u5DF2\u4F7F\u7528", alert: true })); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u8BE5\u786E\u8BA4\u4E0D\u5C5E\u4E8E\u4F60\u3001\u5DF2\u8FC7\u671F\u6216\u5DF2\u4F7F\u7528", alert: true })); return; } try { @@ -15162,12 +16725,12 @@ async function handleBulkTaskCancelCallback(client2, update, data) { `\u9891\u9053\u4EFB\u52A1\uFF1A${result.channelTasks} \u4E2A`, `yt-dlp\uFF1A${result.ytdlpTasks} \u4E2A` ].join("\n"), - buttons: new Api7.ReplyInlineMarkup({ rows: [] }) + buttons: new Api8.ReplyInlineMarkup({ rows: [] }) }); - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5F53\u524D\u804A\u5929\u4EFB\u52A1\u5DF2\u53D6\u6D88" })); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5F53\u524D\u804A\u5929\u4EFB\u52A1\u5DF2\u53D6\u6D88" })); } catch (error) { console.error("\u{1F916} \u6279\u91CF\u53D6\u6D88\u5F53\u524D\u804A\u5929\u4EFB\u52A1\u5931\u8D25:", error); - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: `\u53D6\u6D88\u5931\u8D25: ${error.message}`, alert: true })); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: `\u53D6\u6D88\u5931\u8D25: ${error.message}`, alert: true })); } } async function handleStopTasks(message) { @@ -15302,7 +16865,7 @@ async function handleCancelTask(message, args) { async function handleChannelTaskQueueCallback(client2, update, data) { const userId = update.userId.toJSNumber(); if (!await isAuthenticatedAsync(userId)) { - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: MSG.AUTH_REQUIRED, alert: true })); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: MSG.AUTH_REQUIRED, alert: true })); return; } const match = data.match(/^ctq_(pause|resume|cancel)_([0-9a-f]{4,}|all)$/i); @@ -15310,7 +16873,7 @@ async function handleChannelTaskQueueCallback(client2, update, data) { const [, action, selector] = match; try { if (action === "cancel") { - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u65E7\u7248\u53D6\u6D88\u6309\u94AE\u5DF2\u5931\u6548\uFF0C\u8BF7\u4F7F\u7528\u65B0\u7248 /tasks \u91CD\u65B0\u8FDB\u5165\u4EFB\u52A1\u8BE6\u60C5\u5E76\u786E\u8BA4", alert: true @@ -15321,15 +16884,15 @@ async function handleChannelTaskQueueCallback(client2, update, data) { const callbackChatId = getCallbackChatKey(update); const matches = rows.filter((job) => String(job.chat_id || "") === callbackChatId && String(job.id).toLowerCase().startsWith(selector.toLowerCase())); if (matches.length !== 1) { - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: matches.length > 1 ? "\u4EFB\u52A1 ID \u524D\u7F00\u4E0D\u552F\u4E00\uFF0C\u8BF7\u4F7F\u7528\u65B0\u7248 /tasks \u5237\u65B0" : "\u4EFB\u52A1\u5DF2\u7ED3\u675F\u6216\u5DF2\u5931\u6548", alert: true })); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: matches.length > 1 ? "\u4EFB\u52A1 ID \u524D\u7F00\u4E0D\u552F\u4E00\uFF0C\u8BF7\u4F7F\u7528\u65B0\u7248 /tasks \u5237\u65B0" : "\u4EFB\u52A1\u5DF2\u7ED3\u675F\u6216\u5DF2\u5931\u6548", alert: true })); return; } const legacyAction = action; const result = await operateChannelTaskCenterItem(legacyAction, userId, callbackChatId, selector); - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: result.toast, alert: !result.ok })); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: result.toast, alert: !result.ok })); } catch (error) { console.error("\u{1F916} \u517C\u5BB9\u9891\u9053\u4EFB\u52A1\u6309\u94AE\u64CD\u4F5C\u5931\u8D25:", error); - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: `\u64CD\u4F5C\u5931\u8D25: ${error.message}`, alert: true })); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: `\u64CD\u4F5C\u5931\u8D25: ${error.message}`, alert: true })); } } async function handleRetryFailedTasks(message, args) { @@ -15373,7 +16936,7 @@ async function handleDownloadWorkers(message) { }); } catch (error) { console.error("\u{1F916} \u83B7\u53D6\u5206\u7247\u5E76\u53D1\u8BBE\u7F6E\u5931\u8D25:", error); - await message.reply({ message: `\u274C \u83B7\u53D6\u5206\u7247\u5E76\u53D1\u8BBE\u7F6E\u5931\u8D25: ${error.message}` }); + await message.reply({ message: "\u274C \u6682\u65F6\u65E0\u6CD5\u8BFB\u53D6\u5355\u6587\u4EF6\u5206\u7247\u5E76\u53D1\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002" }); } } async function handleFileConcurrency(message) { @@ -15386,7 +16949,7 @@ async function handleFileConcurrency(message) { }); } catch (error) { console.error("\u{1F916} \u83B7\u53D6\u6587\u4EF6\u7EA7\u5E76\u53D1\u8BBE\u7F6E\u5931\u8D25:", error); - await message.reply({ message: `\u274C \u83B7\u53D6\u6587\u4EF6\u7EA7\u5E76\u53D1\u8BBE\u7F6E\u5931\u8D25: ${error.message}` }); + await message.reply({ message: "\u274C \u6682\u65F6\u65E0\u6CD5\u8BFB\u53D6\u6587\u4EF6\u5E76\u53D1\u8BBE\u7F6E\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002" }); } } async function handlePathRules(message) { @@ -15399,7 +16962,7 @@ async function handlePathRules(message) { async function handlePathOnce(message, args) { const folder = args.join(" ").trim(); if (!folder) { - await message.reply({ message: "\u274C \u7528\u6CD5\uFF1A/p <\u76EE\u5F55>\n\u4F8B\u5982\uFF1A/p PIXIV/\u6BCF\u65E5Top50" }); + await message.reply({ message: "\u8BF7\u76F4\u63A5\u53D1\u9001\u4E0B\u4E00\u6B21\u8981\u4F7F\u7528\u7684\u76EE\u5F55\u540D\u79F0\u3002" }); return; } try { @@ -15415,7 +16978,7 @@ ${buildPathPreviewLine(normalized)} async function handlePathSession(message, args) { const folder = args.join(" ").trim(); if (!folder) { - await message.reply({ message: "\u274C \u7528\u6CD5\uFF1A/ps <\u76EE\u5F55>\n\u4F8B\u5982\uFF1A/ps \u76F8\u518C/2026-07" }); + await message.reply({ message: "\u8BF7\u76F4\u63A5\u53D1\u9001\u672C\u804A\u5929\u8981\u6301\u7EED\u4F7F\u7528\u7684\u76EE\u5F55\u540D\u79F0\u3002" }); return; } try { @@ -15423,7 +16986,7 @@ async function handlePathSession(message, args) { await message.reply({ message: `\u{1F4CD} \u5DF2\u8BBE\u7F6E\u672C\u4F1A\u8BDD\u4E0B\u8F7D\u76EE\u5F55\uFF1A\`${normalized}\` ${buildPathPreviewLine(normalized)} -\u540E\u7EED\u6B64\u804A\u5929\u4E2D\u7684\u4E0B\u8F7D\u4F1A\u4F18\u5148\u4FDD\u5B58\u5230\u8BE5\u76EE\u5F55\uFF0C\u53D1\u9001 /pc \u53EF\u6E05\u9664\u3002` }); +\u540E\u7EED\u6B64\u804A\u5929\u4E2D\u7684\u4E0B\u8F7D\u4F1A\u4F18\u5148\u4FDD\u5B58\u5230\u8BE5\u76EE\u5F55\uFF1B\u53EF\u5728\u201C\u4FDD\u5B58\u4F4D\u7F6E\u201D\u4E2D\u6E05\u9664\u3002` }); } catch (error) { await message.reply({ message: `\u274C \u8DEF\u5F84\u65E0\u6548\uFF1A${error.message}` }); } @@ -15435,7 +16998,7 @@ async function handlePathClear(message) { async function handlePathRulesCallback(client2, update, data) { const userId = update.userId.toJSNumber(); if (!await isAuthenticatedAsync(userId)) { - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: MSG.AUTH_REQUIRED, alert: true })); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: MSG.AUTH_REQUIRED, alert: true })); return; } try { @@ -15446,15 +17009,15 @@ async function handlePathRulesCallback(client2, update, data) { } else if (data === "pr_recent") { const recent = await getRecentTelegramPathsPersistent(chatKey); await client2.sendMessage(update.peer, { - message: recent.length > 0 ? ["\u{1F558} **\u6700\u8FD1\u4F7F\u7528\u76EE\u5F55**", "", ...recent.map((item, index) => `${index + 1}. ${item}`), "", "\u8981\u4F7F\u7528\u5176\u4E2D\u4E00\u4E2A\u76EE\u5F55\uFF0C\u8BF7\u76F4\u63A5\u590D\u5236\u53D1\u9001\uFF0C\u6216\u53D1\u9001 `/p <\u76EE\u5F55>` / `/ps <\u76EE\u5F55>`\u3002"].join("\n") : "\u{1F558} \u6682\u65E0\u6700\u8FD1\u4F7F\u7528\u76EE\u5F55\u3002\u8BBE\u7F6E\u8FC7 `/p`\u3001`/ps`\u3001\u8BA2\u9605\u4E13\u5C5E\u76EE\u5F55\u6216\u4E0B\u8F7D\u4EFB\u52A1\u4E13\u5C5E\u76EE\u5F55\u540E\u4F1A\u81EA\u52A8\u8BB0\u5F55\u3002" + message: recent.length > 0 ? ["\u{1F558} **\u6700\u8FD1\u4F7F\u7528\u76EE\u5F55**", "", ...recent.map((item, index) => `${index + 1}. ${item}`), "", "\u5982\u9700\u4F7F\u7528\u67D0\u4E2A\u76EE\u5F55\uFF0C\u8BF7\u5728\u201C\u4FDD\u5B58\u4F4D\u7F6E\u201D\u4E2D\u9009\u62E9\u4E0B\u4E00\u6B21\u6216\u672C\u804A\u5929\u76EE\u5F55\uFF0C\u7136\u540E\u53D1\u9001\u76EE\u5F55\u540D\u79F0\u3002"].join("\n") : "\u{1F558} \u6682\u65E0\u6700\u8FD1\u4F7F\u7528\u76EE\u5F55\u3002\u8BBE\u7F6E\u76EE\u5F55\u540E\u4F1A\u81EA\u52A8\u8BB0\u5F55\u3002" }); - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5DF2\u53D1\u9001\u6700\u8FD1\u76EE\u5F55" })); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5DF2\u53D1\u9001\u6700\u8FD1\u76EE\u5F55" })); return; } else if (data === "pr_help_once" || data === "pr_help_session") { const mode = data === "pr_help_once" ? "once" : "session"; setPendingTelegramPathInput(chatKey, userId, mode); await client2.sendMessage(update.peer, { message: await buildPendingPathPromptPersistent(mode, chatKey) }); - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u8BF7\u76F4\u63A5\u53D1\u9001\u76EE\u5F55\uFF0C\u6216\u53D1\u9001\u201C\u53D6\u6D88\u201D\u9000\u51FA" })); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u8BF7\u76F4\u63A5\u53D1\u9001\u76EE\u5F55\uFF0C\u6216\u53D1\u9001\u201C\u53D6\u6D88\u201D\u9000\u51FA" })); return; } await client2.editMessage(update.peer, { @@ -15462,10 +17025,10 @@ async function handlePathRulesCallback(client2, update, data) { text: buildPathSettingsText(pathCenterState, chatKey), buttons: buildPathSettingsKeyboard(pathCenterState) }); - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u4FDD\u5B58\u4F4D\u7F6E\u5DF2\u66F4\u65B0" })); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u4FDD\u5B58\u4F4D\u7F6E\u5DF2\u66F4\u65B0" })); } catch (error) { console.error("\u{1F916} \u8BBE\u7F6E\u4FDD\u5B58\u4F4D\u7F6E\u5931\u8D25:", error); - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: `\u8BBE\u7F6E\u5931\u8D25: ${error.message}`, alert: true })); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u8BBE\u7F6E\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5", alert: true })); } } async function handleDuplicateMode(message) { @@ -15478,7 +17041,7 @@ async function handleDuplicateMode(message) { async function handleDuplicateModeCallback(client2, update, data) { const userId = update.userId.toJSNumber(); if (!await isAuthenticatedAsync(userId)) { - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: MSG.AUTH_REQUIRED, alert: true })); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: MSG.AUTH_REQUIRED, alert: true })); return; } try { @@ -15491,10 +17054,10 @@ async function handleDuplicateModeCallback(client2, update, data) { text: buildDuplicateModeText(mode), buttons: buildDuplicateModeKeyboard(mode) }); - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: `\u5DF2\u8BBE\u7F6E\u4E3A${mode === "skip" ? "\u8DF3\u8FC7\u91CD\u590D" : "\u751F\u6210\u526F\u672C"}` })); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: `\u5DF2\u8BBE\u7F6E\u4E3A${mode === "skip" ? "\u8DF3\u8FC7\u91CD\u590D" : "\u751F\u6210\u526F\u672C"}` })); } catch (error) { console.error("\u{1F916} \u8BBE\u7F6E\u91CD\u590D\u6587\u4EF6\u5904\u7406\u5931\u8D25:", error); - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: `\u8BBE\u7F6E\u5931\u8D25: ${error.message}`, alert: true })); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u8BBE\u7F6E\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5", alert: true })); } } async function handleCleanupSettings(message) { @@ -15507,7 +17070,7 @@ async function handleCleanupSettings(message) { async function handleCleanupSettingsCallback(client2, update, data) { const userId = update.userId.toJSNumber(); if (!await isAuthenticatedAsync(userId)) { - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: MSG.AUTH_REQUIRED, alert: true })); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: MSG.AUTH_REQUIRED, alert: true })); return; } try { @@ -15524,16 +17087,16 @@ async function handleCleanupSettingsCallback(client2, update, data) { text: buildCleanupSettingsText(enabled), buttons: buildCleanupSettingsKeyboard(enabled) }); - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: enabled ? "\u5DF2\u5F00\u542F\u81EA\u52A8\u6E05\u7406" : "\u5DF2\u5173\u95ED\u81EA\u52A8\u6E05\u7406" })); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: enabled ? "\u5DF2\u5F00\u542F\u81EA\u52A8\u6E05\u7406" : "\u5DF2\u5173\u95ED\u81EA\u52A8\u6E05\u7406" })); } catch (error) { console.error("\u{1F916} \u8BBE\u7F6E\u81EA\u52A8\u6E05\u7406\u5931\u8D25:", error); - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: `\u8BBE\u7F6E\u5931\u8D25: ${error.message}`, alert: true })); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u8BBE\u7F6E\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5", alert: true })); } } async function handleDownloadWorkersCallback(client2, update, data) { const userId = update.userId.toJSNumber(); if (!await isAuthenticatedAsync(userId)) { - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: MSG.AUTH_REQUIRED, alert: true @@ -15548,7 +17111,7 @@ async function handleDownloadWorkersCallback(client2, update, data) { text: buildDownloadWorkersText(current3), buttons: buildDownloadWorkersKeyboard(current3) }); - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5DF2\u53D6\u6D88" })); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5DF2\u53D6\u6D88" })); return; } const setMatch = data.match(/^dw_set_(4|8|12|16)$/); @@ -15558,18 +17121,18 @@ async function handleDownloadWorkersCallback(client2, update, data) { await client2.editMessage(update.peer, { message: Number(update.msgId), text: [ - `\u26A0\uFE0F **\u786E\u8BA4\u4F7F\u7528 ${workers} workers\uFF1F**`, + `\u26A0\uFE0F **\u786E\u8BA4\u4F7F\u7528 ${workers} \u4E2A\u5206\u7247\uFF1F**`, "", "\u8FD9\u662F\u6FC0\u8FDB\u5206\u7247\u5E76\u53D1\u6A21\u5F0F\uFF0C\u53EF\u80FD\u51FA\u73B0\uFF1A", "- Telegram \u98CE\u63A7\u6216\u9650\u6D41", "- \u4E0B\u8F7D\u65AD\u6D41 / \u91CD\u8BD5\u589E\u591A", - "- user session \u8D26\u53F7\u98CE\u9669\uFF0C\u6781\u7AEF\u60C5\u51B5\u4E0B\u53EF\u80FD\u5F71\u54CD\u8D26\u53F7", + "- Telegram \u7528\u6237\u8D26\u53F7\u53EF\u80FD\u88AB\u9650\u6D41\uFF0C\u6781\u7AEF\u60C5\u51B5\u4E0B\u4F1A\u5F71\u54CD\u8D26\u53F7", "", "\u5982\u679C\u53EA\u662F\u65E5\u5E38\u4E0B\u8F7D\uFF0C\u5EFA\u8BAE\u4F7F\u7528 4 \u6216 8\u3002" ].join("\n"), buttons: buildDownloadWorkersKeyboard(workers, workers) }); - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u9700\u8981\u4E8C\u6B21\u786E\u8BA4" })); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u9700\u8981\u4E8C\u6B21\u786E\u8BA4" })); return; } await setSetting("telegram_download_workers", String(workers)); @@ -15577,10 +17140,10 @@ async function handleDownloadWorkersCallback(client2, update, data) { message: Number(update.msgId), text: `${buildDownloadWorkersText(workers)} -\u2705 \u5DF2\u5207\u6362\u4E3A ${workers} workers\uFF0C\u540E\u7EED\u65B0\u4E0B\u8F7D\u4EFB\u52A1\u7ACB\u5373\u751F\u6548\u3002`, +\u2705 \u5DF2\u5207\u6362\u4E3A ${workers} \u4E2A\u5206\u7247\uFF0C\u540E\u7EED\u65B0\u4E0B\u8F7D\u4EFB\u52A1\u7ACB\u5373\u751F\u6548\u3002`, buttons: buildDownloadWorkersKeyboard(workers) }); - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: `\u5DF2\u8BBE\u7F6E\u4E3A ${workers}` })); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: `\u5DF2\u8BBE\u7F6E\u4E3A ${workers}` })); return; } const confirmMatch = data.match(/^dw_confirm_(12|16)$/); @@ -15591,16 +17154,16 @@ async function handleDownloadWorkersCallback(client2, update, data) { message: Number(update.msgId), text: `${buildDownloadWorkersText(workers)} -\u26A0\uFE0F \u5DF2\u786E\u8BA4\u5E76\u5207\u6362\u4E3A ${workers} workers\u3002\u82E5\u51FA\u73B0\u65AD\u6D41\u3001\u9650\u901F\u3001\u98CE\u63A7\u63D0\u793A\uFF0C\u8BF7\u7ACB\u5373\u964D\u56DE 4 \u6216 8\u3002`, +\u26A0\uFE0F \u5DF2\u786E\u8BA4\u5E76\u5207\u6362\u4E3A ${workers} \u4E2A\u5206\u7247\u3002\u82E5\u51FA\u73B0\u65AD\u6D41\u3001\u9650\u901F\u3001\u98CE\u63A7\u63D0\u793A\uFF0C\u8BF7\u7ACB\u5373\u964D\u56DE 4 \u6216 8\u3002`, buttons: buildDownloadWorkersKeyboard(workers) }); - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: `\u5DF2\u786E\u8BA4 ${workers} workers`, alert: true })); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: `\u5DF2\u786E\u8BA4 ${workers} workers`, alert: true })); } } catch (error) { console.error("\u{1F916} \u8BBE\u7F6E\u5E76\u53D1\u4E0B\u8F7D worker \u5931\u8D25:", error); - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, - message: `\u8BBE\u7F6E\u5931\u8D25: ${error.message}`, + message: "\u8BBE\u7F6E\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5", alert: true })); } @@ -15608,7 +17171,7 @@ async function handleDownloadWorkersCallback(client2, update, data) { async function handleFileConcurrencyCallback(client2, update, data) { const userId = update.userId.toJSNumber(); if (!await isAuthenticatedAsync(userId)) { - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: MSG.AUTH_REQUIRED, alert: true @@ -15624,13 +17187,13 @@ async function handleFileConcurrencyCallback(client2, update, data) { text: buildFileConcurrencyText(current3), buttons: buildFileConcurrencyKeyboard(current3) }); - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5DF2\u53D6\u6D88" })); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5DF2\u53D6\u6D88" })); return; } const setMatch = data.match(/^fc_set_(1|2|3|4)$/); if (setMatch) { - const concurrency = Number(setMatch[1]); - if (concurrency === 4) { + const concurrency2 = Number(setMatch[1]); + if (concurrency2 === 4) { await client2.editMessage(update.peer, { message: Number(update.msgId), text: [ @@ -15643,13 +17206,13 @@ async function handleFileConcurrencyCallback(client2, update, data) { "", "\u5982\u679C\u53EA\u662F\u65E5\u5E38\u4E0B\u8F7D\uFF0C\u5EFA\u8BAE\u4F7F\u7528 2 \u6216 3\u3002" ].join("\n"), - buttons: buildFileConcurrencyKeyboard(concurrency, concurrency) + buttons: buildFileConcurrencyKeyboard(concurrency2, concurrency2) }); - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u9700\u8981\u4E8C\u6B21\u786E\u8BA4" })); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u9700\u8981\u4E8C\u6B21\u786E\u8BA4" })); return; } - await setSetting("telegram_file_download_concurrency", String(concurrency)); - const normalized = setFileDownloadConcurrency(concurrency); + await setSetting("telegram_file_download_concurrency", String(concurrency2)); + const normalized = setFileDownloadConcurrency(concurrency2); await client2.editMessage(update.peer, { message: Number(update.msgId), text: `${buildFileConcurrencyText(normalized)} @@ -15657,7 +17220,7 @@ async function handleFileConcurrencyCallback(client2, update, data) { \u2705 \u5DF2\u5207\u6362\u4E3A\u540C\u65F6\u4E0B\u8F7D ${normalized} \u4E2A\u6587\u4EF6\u3002`, buttons: buildFileConcurrencyKeyboard(normalized) }); - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: `\u5DF2\u8BBE\u7F6E\u4E3A ${normalized}` })); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: `\u5DF2\u8BBE\u7F6E\u4E3A ${normalized}` })); return; } const confirmMatch = data.match(/^fc_confirm_4$/); @@ -15671,13 +17234,13 @@ async function handleFileConcurrencyCallback(client2, update, data) { \u26A0\uFE0F \u5DF2\u786E\u8BA4\u5E76\u5207\u6362\u4E3A\u540C\u65F6\u4E0B\u8F7D 4 \u4E2A\u6587\u4EF6\u3002\u82E5\u51FA\u73B0\u9650\u6D41\u3001\u65AD\u6D41\u6216\u4E0A\u4F20\u5931\u8D25\uFF0C\u8BF7\u7ACB\u5373\u964D\u56DE 2 \u6216 3\u3002`, buttons: buildFileConcurrencyKeyboard(normalized) }); - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5DF2\u786E\u8BA4 4 \u4E2A\u6587\u4EF6\u5E76\u53D1", alert: true })); + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5DF2\u786E\u8BA4 4 \u4E2A\u6587\u4EF6\u5E76\u53D1", alert: true })); } } catch (error) { console.error("\u{1F916} \u8BBE\u7F6E\u6587\u4EF6\u7EA7\u5E76\u53D1\u5931\u8D25:", error); - await client2.invoke(new Api7.messages.SetBotCallbackAnswer({ + await client2.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, - message: `\u8BBE\u7F6E\u5931\u8D25: ${error.message}`, + message: "\u8BBE\u7F6E\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5", alert: true })); } @@ -15713,7 +17276,7 @@ function parseYtDlpProbeOutput(output) { } async function runYtDlpProbe(url, options = {}) { await (options.assertUrl || assertPublicHttpUrl)(url); - const timeoutMs = Math.max(1e3, options.timeoutMs ?? 2e4); + const timeoutMs2 = Math.max(1e3, options.timeoutMs ?? 2e4); const maxOutputBytes = Math.max(32, options.maxOutputBytes ?? 512 * 1024); const spawnProcess = options.spawnProcess || spawn2; const binLower = YTDLP_BIN2.toLowerCase(); @@ -15739,7 +17302,7 @@ async function runYtDlpProbe(url, options = {}) { child.kill("SIGTERM"); }; const abortChild = () => kill(abortError()); - const timeout = setTimeout(() => kill(new Error(`yt-dlp \u63A2\u6D4B\u8D85\u65F6\uFF08${timeoutMs}ms\uFF09`)), timeoutMs); + const timeout = setTimeout(() => kill(new Error(`yt-dlp \u63A2\u6D4B\u8D85\u65F6\uFF08${timeoutMs2}ms\uFF09`)), timeoutMs2); child.stdout.on("data", (chunk) => { const bytes = Buffer.from(chunk); if (stdout.length + bytes.length > maxOutputBytes) return kill(new Error("yt-dlp \u63A2\u6D4B\u8F93\u51FA\u8D85\u8FC7\u9650\u5236")); @@ -15766,7 +17329,7 @@ async function runYtDlpProbe(url, options = {}) { } // src/services/ytDlpConfirmation.ts -import crypto19 from "node:crypto"; +import crypto20 from "node:crypto"; var YtDlpConfirmationStore = class { values = /* @__PURE__ */ new Map(); ttlMs; @@ -15777,7 +17340,7 @@ var YtDlpConfirmationStore = class { this.ttlMs = Math.max(1, options.ttlMs ?? 5 * 6e4); this.maxEntries = Math.max(1, options.maxEntries ?? 500); this.now = options.now ?? Date.now; - this.tokenFactory = options.tokenFactory ?? (() => crypto19.randomBytes(12).toString("base64url")); + this.tokenFactory = options.tokenFactory ?? (() => crypto20.randomBytes(12).toString("base64url")); } cleanup() { const now = this.now(); @@ -15813,6 +17376,57 @@ init_db(); init_authSettings(); init_networkSecurity(); +// src/utils/telegramCommandRegistry.ts +var BOT_COMMANDS = [ + { command: "start", description: "\u5F00\u59CB\u4F7F\u7528 / \u9A8C\u8BC1\u8EAB\u4EFD", helpDescription: "\u8EAB\u4EFD\u8BA4\u8BC1 / \u5F00\u59CB\u4F7F\u7528", category: "main", menu: true, help: true, requiresAuth: false }, + { command: "tasks", description: "\u67E5\u770B\u5B9E\u65F6\u4EFB\u52A1", helpDescription: "\u67E5\u770B\u5B9E\u65F6\u4F20\u8F93\u4EFB\u52A1\u961F\u5217", category: "main", aliases: ["task"], menu: true, help: true }, + { command: "storage", description: "\u5B58\u50A8\u72B6\u6001 / \u5220\u9664\u672C\u5730\u5B9E\u4F53\u6587\u4EF6", helpDescription: "\u67E5\u770B\u5B58\u50A8\u72B6\u6001\uFF1B\u53EF\u786E\u8BA4\u5220\u9664\u672C\u5730\u5B9E\u4F53\u6587\u4EF6", category: "main", menu: true, help: true }, + { command: "path_rules", description: "\u4FDD\u5B58\u4F4D\u7F6E / \u81EA\u5B9A\u4E49\u76EE\u5F55", helpDescription: "\u6253\u5F00\u4FDD\u5B58\u4F4D\u7F6E\u4E0E\u81EA\u5B9A\u4E49\u76EE\u5F55\u9762\u677F", category: "main", aliases: ["path", "save_rules"], menu: true, help: true }, + { command: "tg_download", description: "\u6309\u65E5\u671F / \u6807\u7B7E\u4E0B\u8F7D\u9891\u9053\u6587\u4EF6", helpDescription: "\u6253\u5F00\u6309\u65E5\u671F\u6216\u6807\u7B7E\u4E0B\u8F7D\u5411\u5BFC", category: "main", aliases: ["tg_dl"], menu: true, help: true }, + { command: "list", description: "\u67E5\u770B\u6700\u8FD1\u6587\u4EF6", helpDescription: "\u67E5\u770B\u6700\u8FD1\u6587\u4EF6\u548C\u53EF\u590D\u5236\u7684\u6587\u4EF6 ID", category: "files", usage: "[\u6570\u91CF] [\u9875\u7801]", menu: true, help: true }, + { command: "find", description: "\u641C\u7D22\u548C\u64CD\u4F5C\u6587\u4EF6", helpDescription: "\u6309\u540D\u79F0\u3001\u7C7B\u578B\u3001\u76EE\u5F55\u3001\u65E5\u671F\u6216\u6536\u85CF\u641C\u7D22\u6587\u4EF6\u5E76\u6253\u5F00\u64CD\u4F5C\u5361", category: "files", usage: "[\u5173\u952E\u8BCD] [type:image|video|audio|document] [folder:\u76EE\u5F55] [after:YYYY-MM-DD] [before:YYYY-MM-DD] [fav]", menu: true, help: true }, + { command: "tg_sub", description: "\u7BA1\u7406\u9891\u9053\u81EA\u52A8\u540C\u6B65", helpDescription: "\u6253\u5F00\u9891\u9053\u8BA2\u9605\u7BA1\u7406\u5411\u5BFC", category: "channels", aliases: ["tg_subscribe"], menu: true, help: true }, + { command: "storage_switch", description: "\u5207\u6362\u7CFB\u7EDF\u9ED8\u8BA4\u5B58\u50A8", helpDescription: "\u5207\u6362\u6240\u6709\u65B0\u4EFB\u52A1\u4F7F\u7528\u7684\u7CFB\u7EDF\u9ED8\u8BA4\u5B58\u50A8", category: "settings", aliases: ["switch_storage", "storage_source"], menu: true, help: true }, + { command: "target", description: "\u8BBE\u7F6E\u5F53\u524D\u804A\u5929\u5B58\u50A8\u76EE\u6807", helpDescription: "\u8BBE\u7F6E\u4E0B\u4E00\u6B21\u6216\u5F53\u524D\u804A\u5929\u6301\u7EED\u4F7F\u7528\u7684\u5B58\u50A8\u8D26\u6237\uFF1B\u4E0D\u4FEE\u6539\u7CFB\u7EDF\u9ED8\u8BA4", category: "files", menu: true, help: true }, + { command: "ytdlp", description: "\u4E0B\u8F7D\u89C6\u9891\u94FE\u63A5", helpDescription: "\u89E3\u6790\u5E76\u4E0B\u8F7D\u4E00\u4E2A\u89C6\u9891\u94FE\u63A5", category: "files", menu: true, help: true }, + { command: "help", description: "\u67E5\u770B\u5B8C\u6574\u5E2E\u52A9", helpDescription: "\u663E\u793A\u6B64\u5E2E\u52A9", category: "main", menu: true, help: true, requiresAuth: false }, + { command: "setup_2fa", description: "\u914D\u7F6E\u53CC\u91CD\u9A8C\u8BC1", helpDescription: "\u914D\u7F6E\u53CC\u91CD\u9A8C\u8BC1 (TOTP)", category: "security", aliases: ["setup-2fa"], menu: false, help: true }, + { command: "logout", description: "\u64A4\u9500\u672C\u8BBE\u5907 Bot \u8BA4\u8BC1", helpDescription: "\u7ACB\u5373\u9000\u51FA\u5E76\u64A4\u9500\u5F53\u524D Telegram \u7528\u6237\u7684 Bot \u8BA4\u8BC1", category: "security", menu: false, help: true }, + { command: "p", description: "\u8BBE\u7F6E\u4E0B\u4E00\u6B21\u4FDD\u5B58\u76EE\u5F55", helpDescription: "\u4E0B\u4E00\u6B21\u4E0B\u8F7D\u4FDD\u5B58\u5230\u6307\u5B9A\u76EE\u5F55", category: "files", usage: "<\u76EE\u5F55>", help: true }, + { command: "ps", description: "\u8BBE\u7F6E\u672C\u4F1A\u8BDD\u4FDD\u5B58\u76EE\u5F55", helpDescription: "\u672C\u4F1A\u8BDD\u6301\u7EED\u4FDD\u5B58\u5230\u6307\u5B9A\u76EE\u5F55", category: "files", usage: "<\u76EE\u5F55>", help: true }, + { command: "pc", description: "\u6E05\u9664\u81EA\u5B9A\u4E49\u4FDD\u5B58\u76EE\u5F55", helpDescription: "\u6E05\u9664\u4E0B\u4E00\u6B21\u548C\u672C\u4F1A\u8BDD\u81EA\u5B9A\u4E49\u76EE\u5F55", category: "files", help: true }, + { command: "delete", description: "\u5220\u9664\u6307\u5B9A\u6587\u4EF6", helpDescription: "\u6309\u81F3\u5C11 8 \u4F4D\u6587\u4EF6 ID \u524D\u7F00\u5220\u9664\u6587\u4EF6", category: "files", usage: "<\u6587\u4EF6 ID \u524D\u7F00>", help: true }, + { command: "task_pause", description: "\u6682\u505C\u4EFB\u52A1", helpDescription: "\u6682\u505C\u5F53\u524D\u804A\u5929\u4EFB\u52A1\u6216\u6307\u5B9A\u4EFB\u52A1", category: "settings", usage: "[\u4EFB\u52A1 ID]", help: true }, + { command: "task_resume", description: "\u7EE7\u7EED\u4EFB\u52A1", helpDescription: "\u7EE7\u7EED\u5F53\u524D\u804A\u5929\u4EFB\u52A1\u6216\u6307\u5B9A\u4EFB\u52A1", category: "settings", usage: "[\u4EFB\u52A1 ID]", help: true }, + { command: "task_cancel", description: "\u53D6\u6D88\u4EFB\u52A1", helpDescription: "\u9884\u89C8\u5E76\u53D6\u6D88\u6307\u5B9A\u4EFB\u52A1\u6216\u5F53\u524D\u804A\u5929\u5168\u90E8\u4EFB\u52A1", category: "settings", usage: "<\u4EFB\u52A1 ID|all>", help: true }, + { command: "tg_retry", description: "\u91CD\u8BD5\u5931\u8D25\u4EFB\u52A1", helpDescription: "\u91CD\u8BD5\u6700\u8FD1\u5931\u8D25\u7684 Telegram \u4E0B\u8F7D\u4EFB\u52A1", category: "channels", usage: "[\u6570\u91CF] [\u4EFB\u52A1 ID]", help: true }, + { command: "stop_tasks", description: "\u505C\u6B62\u5F53\u524D\u804A\u5929\u4EFB\u52A1", helpDescription: "\u517C\u5BB9\u5165\u53E3\uFF1A\u9884\u89C8\u5E76\u505C\u6B62\u5F53\u524D\u804A\u5929\u4E0B\u8F7D\u4EFB\u52A1", category: "settings", aliases: ["stop", "cancel_tasks"], help: true }, + { command: "download_workers", description: "\u5355\u6587\u4EF6\u5206\u7247\u5E76\u53D1", helpDescription: "\u8BBE\u7F6E\u5355\u6587\u4EF6\u5206\u7247\u4E0B\u8F7D\u5E76\u53D1", category: "settings", aliases: ["workers"], help: true }, + { command: "file_concurrency", description: "\u6587\u4EF6\u7EA7\u4E0B\u8F7D\u5E76\u53D1", helpDescription: "\u8BBE\u7F6E 1 / 2 / 3 / 4 \u4E2A\u6587\u4EF6\u5E76\u884C\uFF0C\u907F\u514D\u628A\u5206\u7247\u5E76\u53D1\u548C\u6587\u4EF6\u7EA7\u5E76\u53D1\u6DF7\u6DC6", category: "settings", aliases: ["file_workers", "download_files"], menu: false, help: true }, + { command: "status", description: "\u7CFB\u7EDF\u8BCA\u65AD\u72B6\u6001", helpDescription: "\u67E5\u770B Bot\u3001\u8D26\u53F7\u4E0B\u8F7D\u5668\u3001\u5B58\u50A8\u3001\u78C1\u76D8\u3001\u961F\u5217\u3001\u8BA2\u9605\u548C\u5BF9\u8D26\u72B6\u6001", category: "settings", menu: true, help: true }, + { command: "notifications", description: "\u901A\u77E5\u504F\u597D", helpDescription: "\u8BBE\u7F6E\u6210\u529F/\u5931\u8D25/\u8BA2\u9605\u6458\u8981\u3001\u65F6\u533A\u548C\u5B89\u9759\u65F6\u6BB5\uFF1B\u5B89\u5168\u544A\u8B66\u59CB\u7EC8\u5373\u65F6", category: "settings", menu: true, help: true }, + { command: "duplicate_mode", description: "\u91CD\u590D\u6587\u4EF6\u5904\u7406", helpDescription: "\u8BBE\u7F6E\u91CD\u590D\u6587\u4EF6\u5904\u7406\u7B56\u7565", category: "settings", aliases: ["duplicate", "dup"], help: true }, + { command: "cleanup_settings", description: "\u81EA\u52A8\u6E05\u7406\u672A\u7D22\u5F15\u4E34\u65F6\u6587\u4EF6", helpDescription: "\u8BBE\u7F6E\u81EA\u52A8\u6E05\u7406\u672A\u767B\u8BB0\u4E34\u65F6\u6587\u4EF6\u7684\u5F00\u5173", category: "settings", aliases: ["cleanup"], help: true }, + { command: "tg_subs", description: "\u67E5\u770B\u9891\u9053\u8BA2\u9605", helpDescription: "\u67E5\u770B\u9891\u9053\u8BA2\u9605\u5217\u8868", category: "channels", aliases: ["tg_subscriptions"], help: true }, + { command: "tg_unsub", description: "\u53D6\u6D88\u9891\u9053\u8BA2\u9605", helpDescription: "\u6309\u9891\u9053\u6216\u8BA2\u9605 ID \u8BF7\u6C42\u53D6\u6D88\u8BA2\u9605", category: "channels", aliases: ["tg_unsubscribe"], usage: "<\u9891\u9053|\u8BA2\u9605 ID>", help: true } +]; +var lookup = /* @__PURE__ */ new Map(); +for (const definition of BOT_COMMANDS) { + definition.handlerKey ||= definition.command; + definition.requiresAuth ??= definition.command !== "start" && definition.command !== "help"; + lookup.set(definition.command, definition); + definition.aliases?.forEach((alias) => lookup.set(alias, definition)); +} +function buildBotCommandMenu() { + return BOT_COMMANDS.filter((command) => command.menu).map(({ command, description }) => ({ command, description })); +} +function normalizeBotCommandText(input) { + const match = input.match(/^(\s*)\/([^\s@]+)(?:@\w+)?([\s\S]*)$/); + if (!match) return input; + const definition = lookup.get(match[2].toLowerCase()); + return definition ? `${match[1]}/${definition.command}${match[3]}` : input; +} + // src/services/telegramCommandDispatcher.ts var CATEGORY_LABELS = { main: "\u9996\u9875", @@ -15961,57 +17575,60 @@ function telegramSubscriptionPeerKey(peer) { // src/bot/presentation/subscription.ts function buildSubscriptionDisplayLines(row, index) { const status = row.enabled ? "\u2705" : "\u23F8\uFE0F"; - const sourceLine = row.source_original && row.source_original !== row.source ? ` ${row.source_original} \u2192 ${row.source} \xB7 last_id=${row.last_message_id || 0}` : ` ${row.source} \xB7 last_id=${row.last_message_id || 0}`; + const source = row.source_original && row.source_original !== row.source ? `${row.source_original} \u2192 ${row.source}` : row.source; + const target = row.target_mode === "fixed" ? `${row.target_account_name || row.target_provider || "\u6307\u5B9A\u5B58\u50A8"}` : "\u8DDF\u968F\u7CFB\u7EDF\u9ED8\u8BA4"; + const resultStatus = { + success: "\u6210\u529F", + completed: "\u5B8C\u6210", + failed: "\u5931\u8D25", + partial: "\u90E8\u5206\u5B8C\u6210", + running: "\u8FDB\u884C\u4E2D", + paused: "\u5DF2\u6682\u505C" + }; return [ `${index + 1}. ${status} ${row.title || row.source_original || row.source}`, - sourceLine, - row.folder_override ? ` \u{1F4C1} \u4E13\u5C5E\u76EE\u5F55\uFF1A${row.folder_override}` : " \u{1F4C1} \u4FDD\u5B58\u7B56\u7565\uFF1A\u9ED8\u8BA4\u81EA\u52A8\u5206\u7C7B", - ` \u{1F3AF} Target\uFF1A${row.target_mode === "fixed" ? `${row.target_provider} / ${row.target_account_id || "local"}\uFF08\u56FA\u5B9A\uFF09` : "\u8DDF\u968F\u7CFB\u7EDF\u9ED8\u8BA4\uFF08\u6BCF\u6B21 admission \u5FEB\u7167\uFF09"}`, + ` \u6765\u6E90\uFF1A${source}`, + ` \u540C\u6B65\u4F4D\u7F6E\uFF1A\u7B2C ${row.last_message_id || 0} \u6761\u6D88\u606F\u4E4B\u540E`, + row.folder_override ? ` \u{1F4C1} \u76EE\u5F55\uFF1A${row.folder_override}` : " \u{1F4C1} \u76EE\u5F55\uFF1A\u9ED8\u8BA4\u81EA\u52A8\u5206\u7C7B", + ` \u{1F3AF} \u5B58\u50A8\uFF1A${target}`, row.last_scan_at ? ` \u{1F50E} \u4E0A\u6B21\u626B\u63CF\uFF1A${new Date(row.last_scan_at).toLocaleString("zh-CN", { hour12: false })}` : " \u{1F50E} \u5C1A\u672A\u626B\u63CF", - row.last_success_at ? ` \u2705 \u4E0A\u6B21\u6210\u529F\uFF1A${new Date(row.last_success_at).toLocaleString("zh-CN", { hour12: false })}` : null, row.next_scan_at ? ` \u23ED\uFE0F \u4E0B\u6B21\u626B\u63CF\u7EA6\uFF1A${new Date(row.next_scan_at).toLocaleString("zh-CN", { hour12: false })}` : null, - row.last_result ? ` \u{1F4CA} \u6700\u8FD1\u7ED3\u679C\uFF1A${row.last_result.status || "unknown"}${row.last_result.found !== void 0 ? `\uFF0C\u53D1\u73B0 ${row.last_result.found}` : ""}${row.last_result.failed ? `\uFF0C\u5931\u8D25 ${row.last_result.failed}` : ""}` : null, - row.last_error ? ` \u26A0\uFE0F \u6700\u8FD1\u9519\u8BEF\uFF1A${row.last_error}` : null, - !row.enabled && row.disabled_reason ? ` \u26A0\uFE0F ${row.disabled_reason}` : null, - !row.enabled && row.disabled_at ? ` \u6682\u505C\u65F6\u95F4\uFF1A${new Date(row.disabled_at).toLocaleString("zh-CN", { hour12: false })}` : null + row.last_result ? ` \u{1F4CA} \u6700\u8FD1\u7ED3\u679C\uFF1A${resultStatus[row.last_result.status || ""] || "\u5DF2\u8BB0\u5F55"}${row.last_result.found !== void 0 ? `\uFF0C\u53D1\u73B0 ${row.last_result.found}` : ""}${row.last_result.failed ? `\uFF0C\u5931\u8D25 ${row.last_result.failed}` : ""}` : null, + row.last_error ? ` \u26A0\uFE0F ${row.last_error}` : null, + !row.enabled && row.disabled_reason ? ` \u26A0\uFE0F ${row.disabled_reason}` : null ].filter(Boolean).join("\n"); } function buildSubscriptionManagePanel(rows, page) { return [ - "\u{1F4E1} **\u9891\u9053\u8BA2\u9605\u7BA1\u7406**", - ...page.totalPages > 1 ? [`\u7B2C ${page.page + 1}/${page.totalPages} \u9875 \xB7 \u5171 ${rows.length} \u4E2A\u8BA2\u9605`] : [], + "\u{1F4E1} **\u9891\u9053\u8BA2\u9605**", + ...page.totalPages > 1 ? [`\u7B2C ${page.page + 1}/${page.totalPages} \u9875 \xB7 \u5171 ${rows.length} \u4E2A`] : [], "", - page.visibleRows.length > 0 ? page.visibleRows.map((row, index) => buildSubscriptionDisplayLines(row, page.startIndex + index)).join("\n") : "\u5F53\u524D\u6CA1\u6709\u8BA2\u9605\u3002", + page.visibleRows.length > 0 ? page.visibleRows.map((row, index) => buildSubscriptionDisplayLines(row, page.startIndex + index)).join("\n\n") : "\u5F53\u524D\u6CA1\u6709\u8BA2\u9605\u3002", "", - rows.length > 0 ? "\u53EF\u4F7F\u7528\u64CD\u4F5C\u6309\u94AE\u540C\u6B65\u3001\u6682\u505C\u3001\u4FEE\u6539\u76EE\u6807\u3001\u8865\u6293\u6216\u91CD\u8BD5\uFF1B\u5DF2\u4FDD\u5B58\u6587\u4EF6\u4E0D\u4F1A\u968F\u53D6\u6D88\u8BA2\u9605\u5220\u9664\u3002" : "\u56DE\u590D\u9891\u9053\u7528\u6237\u540D\u6216\u94FE\u63A5\u53EF\u65B0\u589E\u8BA2\u9605\u3002", - "\u56DE\u590D\u9891\u9053\u7528\u6237\u540D\u6216\u94FE\u63A5\u4E5F\u53EF\u65B0\u589E\u8BA2\u9605\u3002", - "\u4F8B\u5982\uFF1A`@channel_username`\u3001`https://t.me/channel_username` \u6216\u5DF2\u52A0\u5165\u7684 `https://t.me/+hash` \u79C1\u5BC6\u94FE\u63A5", - "", - "\u65B0\u589E\u8BA2\u9605\u65F6\u53EF\u6307\u5B9A\u72EC\u7ACB\u76EE\u5F55\uFF0C\u4E0D\u4F1A\u6539\u53D8\u5168\u5C40 /path_rules\u3002", - "\u53D1\u9001\u201C\u53D6\u6D88\u201D\u53EF\u9000\u51FA\u3002" + "\u{1F447} \u70B9\u51FB\u4E0B\u65B9\u6309\u94AE\u7BA1\u7406\u6216\u65B0\u589E\u8BA2\u9605\u3002" ].join("\n"); } // src/services/telegramBot.ts function buildBotStartKeyboard() { - return new Api8.ReplyInlineMarkup({ + return new Api9.ReplyInlineMarkup({ rows: [ - new Api8.KeyboardButtonRow({ buttons: [ - new Api8.KeyboardButtonCallback({ text: "\u{1F4E4} \u4E0A\u4F20\u8BF4\u660E", data: Buffer.from("home_upload") }), - new Api8.KeyboardButtonCallback({ text: "\u{1F527} \u4EFB\u52A1", data: Buffer.from("home_tasks") }) + new Api9.KeyboardButtonRow({ buttons: [ + new Api9.KeyboardButtonCallback({ text: "\u{1F4E4} \u4E0A\u4F20\u8BF4\u660E", data: Buffer.from("home_upload") }), + new Api9.KeyboardButtonCallback({ text: "\u{1F527} \u4EFB\u52A1", data: Buffer.from("home_tasks") }) ] }), - new Api8.KeyboardButtonRow({ buttons: [ - new Api8.KeyboardButtonCallback({ text: "\u{1F4CA} \u5B58\u50A8", data: Buffer.from("home_storage") }), - new Api8.KeyboardButtonCallback({ text: "\u2630 \u66F4\u591A", data: Buffer.from("home_more") }) + new Api9.KeyboardButtonRow({ buttons: [ + new Api9.KeyboardButtonCallback({ text: "\u{1F4CA} \u5B58\u50A8", data: Buffer.from("home_storage") }), + new Api9.KeyboardButtonCallback({ text: "\u2630 \u66F4\u591A", data: Buffer.from("home_more") }) ] }) ] }); } function homePageKeyboard(requestedPage) { const page = buildCommandHomePage(requestedPage); - return new Api8.ReplyInlineMarkup({ - rows: page.buttons.map((row) => new Api8.KeyboardButtonRow({ - buttons: row.map((button) => new Api8.KeyboardButtonCallback({ text: button.text, data: Buffer.from(button.data) })) + return new Api9.ReplyInlineMarkup({ + rows: page.buttons.map((row) => new Api9.KeyboardButtonRow({ + buttons: row.map((button) => new Api9.KeyboardButtonCallback({ text: button.text, data: Buffer.from(button.data) })) })) }); } @@ -16027,7 +17644,7 @@ async function handleBotHomeCallback(update, data) { if (data === "home_tasks" || data === "home_open_tasks") return handleTasks(await currentMessage()); if (data === "home_storage" || data === "home_open_storage") return handleStorage(await currentMessage()); if (data === "home_upload") { - await client.editMessage(update.peer, { message: Number(update.msgId), text: "\u{1F4E4} \u76F4\u63A5\u53D1\u9001\u6216\u8F6C\u53D1\u4EFB\u610F\u6587\u4EF6\u5373\u53EF\u4E0A\u4F20\u3002\n\n\u4F7F\u7528 /path_rules \u9009\u62E9\u4FDD\u5B58\u4F4D\u7F6E\uFF0C\u4F7F\u7528 /tasks \u67E5\u770B\u8FDB\u5EA6\u3002", buttons: buildBotStartKeyboard() }); + await client.editMessage(update.peer, { message: Number(update.msgId), text: "\u{1F4E4} \u76F4\u63A5\u53D1\u9001\u6216\u8F6C\u53D1\u6587\u4EF6\u5373\u53EF\u4E0A\u4F20\u3002\n\n\u4E0B\u65B9\u53EF\u6253\u5F00\u4EFB\u52A1\u6216\u66F4\u591A\u529F\u80FD\u3002", buttons: buildBotStartKeyboard() }); return; } const pageMatch = data.match(/^home_page_(\d+)$/); @@ -16047,7 +17664,7 @@ async function handleBotHomeCallback(update, data) { if (command === "tg_sub") return startTelegramWizard(message, userId, "tg_sub_manage"); if (command === "tg_subs") { const rows = await listManageableTelegramSubscriptions(userId); - await message.reply({ message: formatSubscriptionList(rows) }); + await message.reply({ message: buildSubscriptionManagePanel2(rows, 0), buttons: buildSubscriptionActionKeyboard(rows, 0) }); return; } if (command === "list") return handleList(message, []); @@ -16064,44 +17681,67 @@ async function handleBotHomeCallback(update, data) { if (command === "pc") return handlePathClear(message); if (command === "duplicate_mode") return handleDuplicateMode(message); if (command === "cleanup_settings") return handleCleanupSettings(message); - if (command === "help") return handleHelp(message); + if (command === "help") return handleHelp(message, homePageKeyboard(0)); if (command === "logout") { await client.sendMessage(update.peer, { message: "\u53D1\u9001 /logout \u53EF\u7ACB\u5373\u64A4\u9500\u5F53\u524D Bot \u8BA4\u8BC1\u3002" }); return; } + if (command === "ytdlp") { + await client.sendMessage(update.peer, { message: "\u8BF7\u76F4\u63A5\u53D1\u9001\u4E00\u4E2A\u89C6\u9891\u94FE\u63A5\u3002" }); + return; + } + if (command === "setup_2fa") { + await client.sendMessage(update.peer, { message: "\u8BF7\u4ECE Telegram \u547D\u4EE4\u83DC\u5355\u53D1\u9001\u201C\u914D\u7F6E\u53CC\u91CD\u9A8C\u8BC1\u201D\u3002" }); + return; + } + if (["p", "ps", "delete", "task_cancel", "tg_unsub"].includes(command)) { + const prompts = { + p: "\u8BF7\u53D1\u9001\u4E0B\u4E00\u6B21\u8981\u4F7F\u7528\u7684\u76EE\u5F55\u540D\u79F0\u3002", + ps: "\u8BF7\u53D1\u9001\u672C\u804A\u5929\u8981\u6301\u7EED\u4F7F\u7528\u7684\u76EE\u5F55\u540D\u79F0\u3002", + delete: "\u8BF7\u5728\u201C\u641C\u7D22\u548C\u64CD\u4F5C\u6587\u4EF6\u201D\u4E2D\u9009\u62E9\u6587\u4EF6\u5E76\u70B9\u51FB\u5220\u9664\u3002", + task_cancel: "\u8BF7\u5728\u4EFB\u52A1\u4E2D\u5FC3\u9009\u62E9\u8981\u53D6\u6D88\u7684\u4EFB\u52A1\u3002", + tg_unsub: "\u8BF7\u5728\u9891\u9053\u8BA2\u9605\u9762\u677F\u9009\u62E9\u8981\u53D6\u6D88\u7684\u8BA2\u9605\u3002" + }; + await client.sendMessage(update.peer, { message: prompts[command] }); + return; + } const definition = BOT_COMMANDS.find((item) => item.command === command); - await client.sendMessage(update.peer, { message: `/${command}${definition?.usage ? ` ${definition.usage}` : ""}` }); + await client.sendMessage(update.peer, { + message: definition ? `${definition.description} + +\u8BF7\u6309\u63D0\u793A\u8F93\u5165\uFF0C\u6216\u8FD4\u56DE\u201C\u66F4\u591A\u529F\u80FD\u201D\u9009\u62E9\u5176\u5B83\u5165\u53E3\u3002` : "\u8FD9\u4E2A\u5165\u53E3\u6682\u65F6\u4E0D\u53EF\u7528\u3002" + }); } var SESSION_FILE = process.env.TELEGRAM_SESSION_FILE || "./data/telegram_session.txt"; var client = null; var digestTimer = null; var botLifecycle = Promise.resolve(); function buildTelegramDownloadModeKeyboard() { - return new Api8.ReplyInlineMarkup({ + return new Api9.ReplyInlineMarkup({ rows: [ - new Api8.KeyboardButtonRow({ + new Api9.KeyboardButtonRow({ buttons: [ - new Api8.KeyboardButtonCallback({ text: "\u{1F5D3}\uFE0F \u6309\u65E5\u671F\u4E0B\u8F7D", data: Buffer.from("tgd_mode_date") }), - new Api8.KeyboardButtonCallback({ text: "\u{1F3F7}\uFE0F \u6309\u6807\u7B7E\u4E0B\u8F7D", data: Buffer.from("tgd_mode_tag") }) + new Api9.KeyboardButtonCallback({ text: "\u{1F5D3}\uFE0F \u6309\u65E5\u671F\u4E0B\u8F7D", data: Buffer.from("tgd_mode_date") }), + new Api9.KeyboardButtonCallback({ text: "\u{1F3F7}\uFE0F \u6309\u6807\u7B7E\u4E0B\u8F7D", data: Buffer.from("tgd_mode_tag") }) ] }), - new Api8.KeyboardButtonRow({ - buttons: [new Api8.KeyboardButtonCallback({ text: "\u53D6\u6D88", data: Buffer.from("tgd_cancel") })] + new Api9.KeyboardButtonRow({ + buttons: [new Api9.KeyboardButtonCallback({ text: "\u53D6\u6D88", data: Buffer.from("tgd_cancel") })] }) ] }); } function buildTelegramCommentsKeyboard() { - return new Api8.ReplyInlineMarkup({ + return new Api9.ReplyInlineMarkup({ rows: [ - new Api8.KeyboardButtonRow({ + new Api9.KeyboardButtonRow({ buttons: [ - new Api8.KeyboardButtonCallback({ text: "\u4EC5\u9891\u9053\u6B63\u6587", data: Buffer.from("tgd_comments_off") }), - new Api8.KeyboardButtonCallback({ text: "\u9891\u9053 + \u8BC4\u8BBA\u533A", data: Buffer.from("tgd_comments_on") }) + new Api9.KeyboardButtonCallback({ text: "\u4EC5\u9891\u9053\u6B63\u6587", data: Buffer.from("tgd_comments_off") }), + new Api9.KeyboardButtonCallback({ text: "\u9891\u9053 + \u8BC4\u8BBA\u533A", data: Buffer.from("tgd_comments_on") }) ] }), - new Api8.KeyboardButtonRow({ - buttons: [new Api8.KeyboardButtonCallback({ text: "\u53D6\u6D88", data: Buffer.from("tgd_cancel") })] + new Api9.KeyboardButtonRow({ + buttons: [new Api9.KeyboardButtonCallback({ text: "\u53D6\u6D88", data: Buffer.from("tgd_cancel") })] }) ] }); @@ -16210,21 +17850,18 @@ function buildTelegramWizardPrompt(state) { title, "", "\u8BF7\u9009\u62E9\u4E0B\u8F7D\u65B9\u5F0F\uFF1A", - "`\u65E5\u671F` \u2014 \u4E0B\u8F7D\u67D0\u4E2A\u65E5\u671F\u8303\u56F4\u5185\u7684\u9891\u9053\u5A92\u4F53", - "`\u6807\u7B7E` \u2014 \u4E0B\u8F7D\u5E26\u6307\u5B9A #\u6807\u7B7E \u7684\u9891\u9053\u5A92\u4F53", + "\u2022 \u6309\u65E5\u671F\uFF1A\u4E0B\u8F7D\u6307\u5B9A\u65E5\u671F\u8303\u56F4\u5185\u7684\u6587\u4EF6", + "\u2022 \u6309\u6807\u7B7E\uFF1A\u4E0B\u8F7D\u5E26\u6307\u5B9A\u6807\u7B7E\u7684\u6587\u4EF6", "", - "\u4E5F\u53EF\u4EE5\u76F4\u63A5\u53D1\u9001\uFF1A`date` / `tag`\u3002", - "\u53D1\u9001\u201C\u53D6\u6D88\u201D\u53EF\u9000\u51FA\u3002" + "\u{1F447} \u70B9\u51FB\u6309\u94AE\u7EE7\u7EED\u3002" ].join("\n"); } if (state.step === "source") { return [ title, "", - "\u8BF7\u53D1\u9001\u9891\u9053\u7528\u6237\u540D\u6216\u94FE\u63A5\uFF1A", - "\u4F8B\u5982\uFF1A`@channel_username` \u6216 `https://t.me/channel_username`", - "", - "\u4E5F\u53EF\u4EE5\u76F4\u63A5\u53D1\u9001\uFF1A`@\u9891\u9053 comments` \u6216 `@\u9891\u9053 no-comments`\u3002", + "\u8BF7\u53D1\u9001\u9891\u9053\u7528\u6237\u540D\u6216\u94FE\u63A5\u3002", + "\u652F\u6301\u516C\u5F00\u9891\u9053\u3001\u79C1\u5BC6\u9080\u8BF7\u94FE\u63A5\u548C\u5DF2\u52A0\u5165\u7684\u9891\u9053\u3002", "", "\u53D1\u9001\u201C\u53D6\u6D88\u201D\u53EF\u9000\u51FA\u3002" ].join("\n"); @@ -16255,8 +17892,7 @@ function buildTelegramWizardPrompt(state) { `\u9ED8\u8BA4\u5173\u95ED\uFF1B\u5F00\u542F\u540E\u6BCF\u4E2A\u9891\u9053\u5E16\u5B50\u6700\u591A\u626B\u63CF ${state.commentsMaxPerPost || TELEGRAM_COMMENTS_MAX_PER_POST} \u6761\u8BC4\u8BBA\u3002`, "\u6587\u5B57\u8BC4\u8BBA\u3001\u666E\u901A\u94FE\u63A5\u548C\u5176\u5B83\u65E0\u6587\u4EF6\u6D88\u606F\u4F1A\u81EA\u52A8\u5FFD\u7565\u3002", "", - "\u4E5F\u53EF\u4EE5\u53D1\u9001\uFF1A`\u5F00` / `\u5173` / `yes` / `no`\u3002", - "\u53D1\u9001\u201C\u53D6\u6D88\u201D\u53EF\u9000\u51FA\u3002" + "\u{1F447} \u70B9\u51FB\u6309\u94AE\u9009\u62E9\u662F\u5426\u626B\u63CF\u8BC4\u8BBA\u533A\u3002" ].join("\n"); } if (state.step === "confirm") { @@ -16276,8 +17912,8 @@ function buildTelegramWizardPrompt(state) { `\u{1F4C1} \u76EE\u5F55\uFF1A${state.customFolder || "\u9ED8\u8BA4\u81EA\u52A8\u5206\u7C7B"}`, `\u2601\uFE0F \u56FA\u5B9A\u5B58\u50A8\uFF1A${state.targetProvider || "\u5F53\u524D\u5B58\u50A8"} / ${state.targetAccountName || state.targetAccountId || "\u5F53\u524D\u8D26\u6237"}`, "", - "\u4F30\u8BA1\u91CF\u5C06\u5728\u626B\u63CF\u4E2D\u5B9E\u65F6\u66F4\u65B0\uFF1B\u8D85\u8FC7\u670D\u52A1\u7AEF\u626B\u63CF\u4E0A\u9650\u65F6\u4F1A\u5206\u6BB5\u7EE7\u7EED\uFF0C\u4EFB\u52A1\u53EF\u5728 /tasks \u53D6\u6D88\u3002", - "\u53D1\u9001 `\u786E\u8BA4` \u5F00\u59CB\uFF0C\u6216\u53D1\u9001 `\u53D6\u6D88` \u653E\u5F03\u3002" + "\u626B\u63CF\u8FDB\u5EA6\u4F1A\u5B9E\u65F6\u66F4\u65B0\uFF1B\u53EF\u5728\u4EFB\u52A1\u4E2D\u5FC3\u968F\u65F6\u53D6\u6D88\u3002", + "\u53D1\u9001\u201C\u786E\u8BA4\u201D\u5F00\u59CB\uFF0C\u6216\u53D1\u9001\u201C\u53D6\u6D88\u201D\u653E\u5F03\u3002" ].join("\n"); } if (state.step === "tag") { @@ -16325,7 +17961,7 @@ function buildLegacyJobProgressPresentation(summary) { const totalDone = summary.completed + summary.failed + summary.skipped; const cooldownIsFloodWait = summary.status === "cooling" && /floodwait/i.test(summary.error || ""); const title = summary.status === "paused" ? "\u23F8\uFE0F **\u9891\u9053\u4E0B\u8F7D\u5DF2\u6682\u505C**" : summary.status === "cooling" ? cooldownIsFloodWait ? "\u23F3 **Telegram FloodWait \u51B7\u5374\u4E2D**" : "\u23F8\uFE0F **\u5B58\u50A8\u670D\u52A1\u4FDD\u62A4\u51B7\u5374\u4E2D**" : summary.status === "cancelled" ? "\u{1F6D1} **\u9891\u9053\u4E0B\u8F7D\u5DF2\u53D6\u6D88**" : totalDone >= summary.totalMediaFound && summary.scanStatus === "done" ? "\u2705 **\u9891\u9053\u4EFB\u52A1\u5B8C\u6210**" : "\u{1F50E} **\u9891\u9053\u4EFB\u52A1\u8FD0\u884C\u4E2D**"; - const controls = summary.status === "paused" ? `\u63A7\u5236\uFF1A/task_resume ${summary.jobId.slice(0, 12)} \xB7 /task_cancel ${summary.jobId.slice(0, 12)}` : summary.status === "cooling" || summary.status === "cancelled" ? "" : `\u63A7\u5236\uFF1A/task_pause ${summary.jobId.slice(0, 12)} \xB7 /task_cancel ${summary.jobId.slice(0, 12)}`; + const controls = summary.status === "paused" ? "\u53EF\u5728\u4EFB\u52A1\u4E2D\u5FC3\u7EE7\u7EED\u6216\u53D6\u6D88\u3002" : summary.status === "cooling" || summary.status === "cancelled" ? "" : "\u53EF\u5728\u4EFB\u52A1\u4E2D\u5FC3\u6682\u505C\u6216\u53D6\u6D88\u3002"; return [ title, `\u{1F194} job: ${summary.jobId.slice(0, 12)}`, @@ -16621,47 +18257,41 @@ async function listManageableTelegramSubscriptions(userId) { } function buildSubscriptionActionKeyboard(rows, requestedPage = 0) { const page = buildTelegramSubscriptionPage(rows, requestedPage); - if (page.visibleRows.length === 0) return void 0; const actionRows = page.visibleRows.flatMap((row, localIndex) => [ - new Api8.KeyboardButtonRow({ - buttons: [new Api8.KeyboardButtonCallback({ text: `${page.startIndex + localIndex + 1}. ${row.title || row.source}`, data: Buffer.from(`tsub_view_${row.id}_${page.page}`) })] + new Api9.KeyboardButtonRow({ + buttons: [new Api9.KeyboardButtonCallback({ text: `${page.startIndex + localIndex + 1}. ${row.title || row.source}`, data: Buffer.from(`tsub_view_${row.id}_${page.page}`) })] }), - ...[0, 2, 5].map((start, index, starts) => new Api8.KeyboardButtonRow({ - buttons: buildSubscriptionOperations(row).slice(start, starts[index + 1]).map((operation) => new Api8.KeyboardButtonCallback({ + ...[0, 2, 5].map((start, index, starts) => new Api9.KeyboardButtonRow({ + buttons: buildSubscriptionOperations(row).slice(start, starts[index + 1]).map((operation) => new Api9.KeyboardButtonCallback({ text: operation.label, data: Buffer.from(`tsub_${operation.action}_${row.id}_${page.page}`) })) })), - new Api8.KeyboardButtonRow({ + new Api9.KeyboardButtonRow({ buttons: [ - new Api8.KeyboardButtonCallback({ text: "\u270F\uFE0F \u4FEE\u6539\u4E13\u5C5E\u76EE\u5F55", data: Buffer.from(`tsub_folder_${row.id}_${page.page}`) }), - new Api8.KeyboardButtonCallback({ text: "\u{1F9F9} \u6E05\u9664\u76EE\u5F55", data: Buffer.from(`tsub_clear_${row.id}_${page.page}`) }), - new Api8.KeyboardButtonCallback({ text: "\u53D6\u6D88\u8BA2\u9605", data: Buffer.from(`tsub_cancel_${row.id}_${page.page}`) }) + new Api9.KeyboardButtonCallback({ text: "\u270F\uFE0F \u4FEE\u6539\u4E13\u5C5E\u76EE\u5F55", data: Buffer.from(`tsub_folder_${row.id}_${page.page}`) }), + new Api9.KeyboardButtonCallback({ text: "\u{1F9F9} \u6E05\u9664\u76EE\u5F55", data: Buffer.from(`tsub_clear_${row.id}_${page.page}`) }), + new Api9.KeyboardButtonCallback({ text: "\u53D6\u6D88\u8BA2\u9605", data: Buffer.from(`tsub_cancel_${row.id}_${page.page}`) }) ] }) ]); const navigation = []; - if (page.page > 0) navigation.push(new Api8.KeyboardButtonCallback({ text: "\u25C0\uFE0F \u4E0A\u4E00\u9875", data: Buffer.from(`tsub_page_${page.page - 1}`) })); - navigation.push(new Api8.KeyboardButtonCallback({ text: "\u{1F504} \u5237\u65B0", data: Buffer.from(`tsub_page_${page.page}`) })); - if (page.page + 1 < page.totalPages) navigation.push(new Api8.KeyboardButtonCallback({ text: "\u4E0B\u4E00\u9875 \u25B6\uFE0F", data: Buffer.from(`tsub_page_${page.page + 1}`) })); - return new Api8.ReplyInlineMarkup({ - rows: [...actionRows, new Api8.KeyboardButtonRow({ buttons: navigation })] + if (page.page > 0) navigation.push(new Api9.KeyboardButtonCallback({ text: "\u25C0\uFE0F \u4E0A\u4E00\u9875", data: Buffer.from(`tsub_page_${page.page - 1}`) })); + navigation.push(new Api9.KeyboardButtonCallback({ text: "\u{1F504} \u5237\u65B0", data: Buffer.from(`tsub_page_${page.page}`) })); + if (page.page + 1 < page.totalPages) navigation.push(new Api9.KeyboardButtonCallback({ text: "\u4E0B\u4E00\u9875 \u25B6\uFE0F", data: Buffer.from(`tsub_page_${page.page + 1}`) })); + return new Api9.ReplyInlineMarkup({ + rows: [ + ...actionRows, + new Api9.KeyboardButtonRow({ + buttons: [new Api9.KeyboardButtonCallback({ text: "\u2795 \u65B0\u589E\u8BA2\u9605", data: Buffer.from("tsub_add") })] + }), + new Api9.KeyboardButtonRow({ buttons: navigation }) + ] }); } function buildSubscriptionManagePanel2(rows, requestedPage = 0) { return buildSubscriptionManagePanel(rows, buildTelegramSubscriptionPage(rows, requestedPage)); } -function formatSubscriptionList(rows) { - if (rows.length === 0) return "\u{1F4ED} \u6682\u65E0\u9891\u9053\u8BA2\u9605\u3002\n\n\u4F7F\u7528 `/tg_sub @\u9891\u9053` \u6DFB\u52A0\u8BA2\u9605\u3002"; - return [ - "\u{1F4E1} **\u9891\u9053\u8BA2\u9605**", - "", - ...rows.map((row, index) => [ - buildSubscriptionDisplayLines(row, index), - ` ID: ${String(row.id).slice(0, 8)}` - ].join("\n")) - ].join("\n"); -} function buildSubscriptionCancelConfirm(target, token) { return { text: [ @@ -16670,22 +18300,22 @@ function buildSubscriptionCancelConfirm(target, token) { `\u{1F4CC} ${target.title || target.source_original || target.source}`, `\u6765\u6E90\uFF1A${target.source_original || target.source}`, target.folder_override ? `\u4E13\u5C5E\u76EE\u5F55\uFF1A${target.folder_override}` : "\u4FDD\u5B58\u7B56\u7565\uFF1A\u9ED8\u8BA4\u81EA\u52A8\u5206\u7C7B", - `\u5F53\u524D\u6E38\u6807\uFF1Alast_id=${target.last_message_id || 0}`, + `\u540C\u6B65\u4F4D\u7F6E\uFF1A\u7B2C ${target.last_message_id || 0} \u6761\u6D88\u606F\u4E4B\u540E`, "", "\u786E\u8BA4\u540E\u4F1A\u505C\u6B62\u81EA\u52A8\u540C\u6B65\uFF0C\u5E76\u4ECE\u8BA2\u9605\u7BA1\u7406\u5217\u8868\u4E2D\u79FB\u9664\uFF1B\u5DF2\u4FDD\u5B58\u7684\u6587\u4EF6\u4E0D\u4F1A\u5220\u9664\u3002" ].join("\n"), - buttons: new Api8.ReplyInlineMarkup({ - rows: [new Api8.KeyboardButtonRow({ + buttons: new Api9.ReplyInlineMarkup({ + rows: [new Api9.KeyboardButtonRow({ buttons: [ - new Api8.KeyboardButtonCallback({ text: "\u26A0\uFE0F \u786E\u8BA4\u53D6\u6D88", data: Buffer.from(`tsub_confirm_${token}`) }), - new Api8.KeyboardButtonCallback({ text: "\u8FD4\u56DE\u8BA2\u9605\u5217\u8868", data: Buffer.from(`tsub_back_${token}`) }) + new Api9.KeyboardButtonCallback({ text: "\u26A0\uFE0F \u786E\u8BA4\u53D6\u6D88", data: Buffer.from(`tsub_confirm_${token}`) }), + new Api9.KeyboardButtonCallback({ text: "\u8FD4\u56DE\u8BA2\u9605\u5217\u8868", data: Buffer.from(`tsub_back_${token}`) }) ] })] }) }; } async function sendSubscriptionCancelConfirmation(message, userId, target, page) { - const token = crypto20.randomBytes(12).toString("base64url"); + const token = crypto21.randomBytes(12).toString("base64url"); const confirm = buildSubscriptionCancelConfirm(target, token); const sent = await message.reply({ message: confirm.text, buttons: confirm.buttons }); const messageId = Number(sent.id); @@ -16699,7 +18329,7 @@ async function sendSubscriptionCancelConfirmation(message, userId, target, page) }); } async function editSubscriptionCancelConfirmation(update, userId, target, page) { - const token = crypto20.randomBytes(12).toString("base64url"); + const token = crypto21.randomBytes(12).toString("base64url"); pendingSubscriptionCancels.set(token, { userId, peerKey: telegramSubscriptionPeerKey(update.peer), @@ -16724,39 +18354,39 @@ function getPendingSubscriptionCancel(update, token, userId) { function generatePasswordKeyboard(currentLength) { const display = "\u25CF".repeat(currentLength) + "-".repeat(Math.max(0, 4 - currentLength)); const displayWithSpaces = display.split("").join(" "); - return new Api8.ReplyInlineMarkup({ + return new Api9.ReplyInlineMarkup({ rows: [ - new Api8.KeyboardButtonRow({ + new Api9.KeyboardButtonRow({ buttons: [ - new Api8.KeyboardButtonCallback({ text: `\u{1F512} ${displayWithSpaces}`, data: Buffer.from("pwd_display") }) + new Api9.KeyboardButtonCallback({ text: `\u{1F512} ${displayWithSpaces}`, data: Buffer.from("pwd_display") }) ] }), - new Api8.KeyboardButtonRow({ + new Api9.KeyboardButtonRow({ buttons: [ - new Api8.KeyboardButtonCallback({ text: "1", data: Buffer.from("pwd_1") }), - new Api8.KeyboardButtonCallback({ text: "2", data: Buffer.from("pwd_2") }), - new Api8.KeyboardButtonCallback({ text: "3", data: Buffer.from("pwd_3") }) + new Api9.KeyboardButtonCallback({ text: "1", data: Buffer.from("pwd_1") }), + new Api9.KeyboardButtonCallback({ text: "2", data: Buffer.from("pwd_2") }), + new Api9.KeyboardButtonCallback({ text: "3", data: Buffer.from("pwd_3") }) ] }), - new Api8.KeyboardButtonRow({ + new Api9.KeyboardButtonRow({ buttons: [ - new Api8.KeyboardButtonCallback({ text: "4", data: Buffer.from("pwd_4") }), - new Api8.KeyboardButtonCallback({ text: "5", data: Buffer.from("pwd_5") }), - new Api8.KeyboardButtonCallback({ text: "6", data: Buffer.from("pwd_6") }) + new Api9.KeyboardButtonCallback({ text: "4", data: Buffer.from("pwd_4") }), + new Api9.KeyboardButtonCallback({ text: "5", data: Buffer.from("pwd_5") }), + new Api9.KeyboardButtonCallback({ text: "6", data: Buffer.from("pwd_6") }) ] }), - new Api8.KeyboardButtonRow({ + new Api9.KeyboardButtonRow({ buttons: [ - new Api8.KeyboardButtonCallback({ text: "7", data: Buffer.from("pwd_7") }), - new Api8.KeyboardButtonCallback({ text: "8", data: Buffer.from("pwd_8") }), - new Api8.KeyboardButtonCallback({ text: "9", data: Buffer.from("pwd_9") }) + new Api9.KeyboardButtonCallback({ text: "7", data: Buffer.from("pwd_7") }), + new Api9.KeyboardButtonCallback({ text: "8", data: Buffer.from("pwd_8") }), + new Api9.KeyboardButtonCallback({ text: "9", data: Buffer.from("pwd_9") }) ] }), - new Api8.KeyboardButtonRow({ + new Api9.KeyboardButtonRow({ buttons: [ - new Api8.KeyboardButtonCallback({ text: "\u53D6\u6D88", data: Buffer.from("pwd_clear") }), - new Api8.KeyboardButtonCallback({ text: "0", data: Buffer.from("pwd_0") }), - new Api8.KeyboardButtonCallback({ text: "\u232B", data: Buffer.from("pwd_backspace") }) + new Api9.KeyboardButtonCallback({ text: "\u53D6\u6D88", data: Buffer.from("pwd_clear") }), + new Api9.KeyboardButtonCallback({ text: "0", data: Buffer.from("pwd_0") }), + new Api9.KeyboardButtonCallback({ text: "\u232B", data: Buffer.from("pwd_backspace") }) ] }) ] @@ -16777,12 +18407,12 @@ function buildYtDlpPreviewText(metadata, target, folder) { ].join("\n"); } function buildYtDlpConfirmKeyboard(token) { - return new Api8.ReplyInlineMarkup({ rows: [ - new Api8.KeyboardButtonRow({ buttons: [ - new Api8.KeyboardButtonCallback({ text: "\u6700\u4F73\u89C6\u9891", data: Buffer.from(`yp_best_${token}`) }), - new Api8.KeyboardButtonCallback({ text: "\u4EC5\u97F3\u9891", data: Buffer.from(`yp_audio_${token}`) }) + return new Api9.ReplyInlineMarkup({ rows: [ + new Api9.KeyboardButtonRow({ buttons: [ + new Api9.KeyboardButtonCallback({ text: "\u6700\u4F73\u89C6\u9891", data: Buffer.from(`yp_best_${token}`) }), + new Api9.KeyboardButtonCallback({ text: "\u4EC5\u97F3\u9891", data: Buffer.from(`yp_audio_${token}`) }) ] }), - new Api8.KeyboardButtonRow({ buttons: [new Api8.KeyboardButtonCallback({ text: "\u53D6\u6D88", data: Buffer.from(`yp_cancel_${token}`) })] }) + new Api9.KeyboardButtonRow({ buttons: [new Api9.KeyboardButtonCallback({ text: "\u53D6\u6D88", data: Buffer.from(`yp_cancel_${token}`) })] }) ] }); } async function createYtDlpPreview(message, userId, url) { @@ -16805,12 +18435,12 @@ async function handleYtDlpPreviewCallback(update, data) { if (action === "cancel") { const cancelled = ytDlpConfirmations.cancel(token, binding); await client.editMessage(update.peer, { message: update.msgId, text: cancelled ? "\u5DF2\u53D6\u6D88\u94FE\u63A5\u4E0B\u8F7D\uFF0C\u672A\u521B\u5EFA\u4EFB\u52A1\u3002" : "\u786E\u8BA4\u5DF2\u5931\u6548\uFF0C\u8BF7\u91CD\u65B0\u53D1\u9001\u94FE\u63A5\u3002" }); - await client.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: cancelled ? "\u5DF2\u53D6\u6D88" : "\u5DF2\u5931\u6548", alert: !cancelled })); + await client.invoke(new Api9.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: cancelled ? "\u5DF2\u53D6\u6D88" : "\u5DF2\u5931\u6548", alert: !cancelled })); return; } const consumed = ytDlpConfirmations.consume(token, binding); if (consumed.status !== "ok") { - await client.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u786E\u8BA4\u5DF2\u5931\u6548\u6216\u4E0D\u5C5E\u4E8E\u5F53\u524D\u804A\u5929", alert: true })); + await client.invoke(new Api9.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u786E\u8BA4\u5DF2\u5931\u6548\u6216\u4E0D\u5C5E\u4E8E\u5F53\u524D\u804A\u5929", alert: true })); return; } const currentMessage = (await client.getMessages(update.peer, { ids: [Number(update.msgId)] }))[0]; @@ -16822,7 +18452,7 @@ async function handleYtDlpPreviewCallback(update, data) { await client.editMessage(update.peer, { message: update.msgId, text: `${buildYtDlpPreviewText(consumed.value.metadata, consumed.value.target, consumed.value.folder)} \u2705 \u5DF2\u786E\u8BA4\u5E76\u63D0\u4EA4\u3002` }); - await client.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u4EFB\u52A1\u5DF2\u63D0\u4EA4" })); + await client.invoke(new Api9.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u4EFB\u52A1\u5DF2\u63D0\u4EA4" })); } function canTelegramUserAuthenticate(userId, allowedUsers) { return allowedUsers.length > 0 && allowedUsers.includes(userId); @@ -16834,7 +18464,7 @@ async function handlePasswordCallback(update) { if (!data.startsWith("pwd_")) return; const lockSeconds = getPinLockSeconds(userId); if (lockSeconds > 0) { - await client.invoke(new Api8.messages.SetBotCallbackAnswer({ + await client.invoke(new Api9.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: `\u5BC6\u7801\u9519\u8BEF\u6B21\u6570\u8FC7\u591A\uFF0C\u8BF7 ${lockSeconds} \u79D2\u540E\u518D\u8BD5`, alert: true @@ -16848,7 +18478,7 @@ async function handlePasswordCallback(update) { } try { if (data === "pwd_display") { - await client.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId })); + await client.invoke(new Api9.messages.SetBotCallbackAnswer({ queryId: update.queryId })); return; } if (data === "pwd_backspace") { @@ -16860,7 +18490,7 @@ async function handlePasswordCallback(update) { message: update.msgId, text: MSG.AUTH_CANCELLED }); - await client.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId })); + await client.invoke(new Api9.messages.SetBotCallbackAnswer({ queryId: update.queryId })); return; } else { const digit = data.replace("pwd_", ""); @@ -16877,7 +18507,7 @@ async function handlePasswordCallback(update) { text, buttons: generatePasswordKeyboard(0) }); - await client.invoke(new Api8.messages.SetBotCallbackAnswer({ + await client.invoke(new Api9.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: failure.locked ? "\u5DF2\u4E34\u65F6\u9501\u5B9A" : "\u5BC6\u7801\u9519\u8BEF", alert: failure.locked @@ -16898,7 +18528,7 @@ async function handlePasswordCallback(update) { text: "\u26D4 \u5F53\u524D Telegram \u7528\u6237\u4E0D\u5728\u5141\u8BB8\u5217\u8868\u4E2D\uFF0C\u8BF7\u5728 TELEGRAM_ALLOWED_USER_IDS \u6216\u540E\u53F0\u5141\u8BB8\u5217\u8868\u4E2D\u52A0\u5165\u4F60\u7684 user id\u3002", buttons: generatePasswordKeyboard(0) }); - await client.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u672A\u5728\u5141\u8BB8\u5217\u8868\u4E2D", alert: true })); + await client.invoke(new Api9.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u672A\u5728\u5141\u8BB8\u5217\u8868\u4E2D", alert: true })); return; } clearPinFailures(userId); @@ -16912,7 +18542,7 @@ async function handlePasswordCallback(update) { message: update.msgId, text: MSG.AUTH_2FA_PROMPT }); - await client.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: MSG.AUTH_2FA_TOAST })); + await client.invoke(new Api9.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: MSG.AUTH_2FA_TOAST })); return; } await persistAuthenticatedUser(userId); @@ -16920,7 +18550,7 @@ async function handlePasswordCallback(update) { message: update.msgId, text: buildAuthSuccess() }); - await client.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: MSG.AUTH_SUCCESS })); + await client.invoke(new Api9.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: MSG.AUTH_SUCCESS })); return; } } @@ -16930,11 +18560,11 @@ async function handlePasswordCallback(update) { text: MSG.AUTH_INPUT_PROMPT, buttons: generatePasswordKeyboard(state.password.length) }); - await client.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId })); + await client.invoke(new Api9.messages.SetBotCallbackAnswer({ queryId: update.queryId })); } catch (error) { console.error("\u{1F916} \u5904\u7406\u5BC6\u7801\u56DE\u8C03\u5931\u8D25:", error); try { - await client.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId })); + await client.invoke(new Api9.messages.SetBotCallbackAnswer({ queryId: update.queryId })); } catch (e) { } } @@ -16943,7 +18573,7 @@ async function handleCleanupButtonCallback(update, cleanupId) { if (!client) return; const userId = update.userId.toJSNumber(); if (!await isAuthenticatedAsync(userId)) { - await client.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: MSG.AUTH_REQUIRED, alert: true })); + await client.invoke(new Api9.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: MSG.AUTH_REQUIRED, alert: true })); return; } try { @@ -16956,14 +18586,14 @@ async function handleCleanupButtonCallback(update, cleanupId) { } catch (e) { console.error("\u{1F916} \u66F4\u65B0\u6E05\u7406\u7ED3\u679C\u6D88\u606F\u5931\u8D25:", e); } - await client.invoke(new Api8.messages.SetBotCallbackAnswer({ + await client.invoke(new Api9.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: result.success ? "\u2705 \u6E05\u7406\u6210\u529F" : "\u274C \u6E05\u7406\u5931\u8D25" })); } catch (error) { console.error("\u{1F916} \u5904\u7406\u6E05\u7406\u56DE\u8C03\u5931\u8D25:", error); try { - await client.invoke(new Api8.messages.SetBotCallbackAnswer({ + await client.invoke(new Api9.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u274C \u6E05\u7406\u5931\u8D25" })); @@ -16975,7 +18605,7 @@ async function handleUploadReceiptCallback(update, data) { if (!client) return; const userId = update.userId.toJSNumber(); if (!await isAuthenticatedAsync(userId)) { - await client.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: MSG.AUTH_REQUIRED, alert: true })); + await client.invoke(new Api9.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: MSG.AUTH_REQUIRED, alert: true })); return; } const match = data.match(/^receipt_(retry|failures)_([A-Za-z0-9_-]+)$/); @@ -16984,24 +18614,24 @@ async function handleUploadReceiptCallback(update, data) { const chatId = resolveTaskChatIdForControl(taskId); const callbackChatId = callbackChatKey(update, userId); if (!chatId || callbackChatId !== messageChatKey({ chatId }, userId) || !canControlTask(taskId, chatId, userId)) { - await client.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u4EFB\u52A1\u5361\u5DF2\u5931\u6548\u6216\u4E0D\u5C5E\u4E8E\u5F53\u524D\u804A\u5929", alert: true })); + await client.invoke(new Api9.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u4EFB\u52A1\u5361\u5DF2\u5931\u6548\u6216\u4E0D\u5C5E\u4E8E\u5F53\u524D\u804A\u5929", alert: true })); return; } if (action === "retry") { const result = await retryFailedDownloadTasks(50, taskId, chatId, userId); await refreshSilentProgress(client, update.peer, userId); - await client.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: result.retried > 0 ? `\u5DF2\u91CD\u8BD5 ${result.retried} \u9879` : "\u6CA1\u6709\u53EF\u91CD\u8BD5\u5931\u8D25\u9879" })); + await client.invoke(new Api9.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: result.retried > 0 ? `\u5DF2\u91CD\u8BD5 ${result.retried} \u9879` : "\u6CA1\u6709\u53EF\u91CD\u8BD5\u5931\u8D25\u9879" })); return; } const details = listFailedDownloadTaskDetails(taskId, chatId, userId); await client.sendMessage(update.peer, { message: ["\u274C **\u5931\u8D25\u660E\u7EC6**", "", ...details.length ? details.slice(0, 30).map((item) => `\u2022 ${item}`) : ["\u5931\u8D25\u8BB0\u5F55\u5DF2\u6E05\u7406\u6216\u4EFB\u52A1\u5DF2\u91CD\u8BD5\u3002"]].join("\n") }); - await client.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5DF2\u53D1\u9001\u5931\u8D25\u660E\u7EC6" })); + await client.invoke(new Api9.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5DF2\u53D1\u9001\u5931\u8D25\u660E\u7EC6" })); } async function handleTaskQueueCallback(update, data) { if (!client) return; const userId = update.userId.toJSNumber(); if (!await isAuthenticatedAsync(userId)) { - await client.invoke(new Api8.messages.SetBotCallbackAnswer({ + await client.invoke(new Api9.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: MSG.AUTH_REQUIRED, alert: true @@ -17020,7 +18650,7 @@ async function handleTaskQueueCallback(update, data) { })(); const canonicalControlChatId = String(controlChatId || "").replace(/^-100/, "").replace(/^-/, ""); if (!controlChatId || callbackChatId !== canonicalControlChatId || !canControlTask(taskId, controlChatId, userId)) { - await client.invoke(new Api8.messages.SetBotCallbackAnswer({ + await client.invoke(new Api9.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u4EFB\u52A1\u5DF2\u5B8C\u6210\u3001\u5DF2\u5931\u6548\u6216\u4E0D\u5C5E\u4E8E\u5F53\u524D\u804A\u5929", alert: true @@ -17031,19 +18661,19 @@ async function handleTaskQueueCallback(update, data) { if (action === "pause") { const result = pauseDownloadTasks(taskId); await refreshSilentProgress(client, update.peer, userId); - await client.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: result.total > 0 ? "\u5DF2\u6682\u505C\u4E0B\u8F7D\u961F\u5217" : "\u5F53\u524D\u6CA1\u6709\u53EF\u6682\u505C\u7684\u4E0B\u8F7D\u4EFB\u52A1" })); + await client.invoke(new Api9.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: result.total > 0 ? "\u5DF2\u6682\u505C\u4E0B\u8F7D\u961F\u5217" : "\u5F53\u524D\u6CA1\u6709\u53EF\u6682\u505C\u7684\u4E0B\u8F7D\u4EFB\u52A1" })); return; } if (action === "resume") { const result = resumeDownloadTasks(taskId); await refreshSilentProgress(client, update.peer, userId); - await client.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: result.total > 0 ? "\u5DF2\u7EE7\u7EED\u4E0B\u8F7D\u961F\u5217" : "\u5F53\u524D\u6CA1\u6709\u7B49\u5F85\u4E2D\u7684\u4E0B\u8F7D\u4EFB\u52A1" })); + await client.invoke(new Api9.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: result.total > 0 ? "\u5DF2\u7EE7\u7EED\u4E0B\u8F7D\u961F\u5217" : "\u5F53\u524D\u6CA1\u6709\u7B49\u5F85\u4E2D\u7684\u4E0B\u8F7D\u4EFB\u52A1" })); return; } await cancelSilentTask(client, update.peer, taskId, update.msgId, userId); - await client.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5DF2\u53D6\u6D88\u540E\u53F0\u4EFB\u52A1", alert: true })); + await client.invoke(new Api9.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5DF2\u53D6\u6D88\u540E\u53F0\u4EFB\u52A1", alert: true })); } catch (error) { - await client.invoke(new Api8.messages.SetBotCallbackAnswer({ + await client.invoke(new Api9.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: `\u64CD\u4F5C\u5931\u8D25: ${error.message}`, alert: true @@ -17054,7 +18684,7 @@ async function handleTelegramDownloadModeCallback(update, data) { if (!client) return; const userId = update.userId.toJSNumber(); if (!await isAuthenticatedAsync(userId)) { - await client.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: MSG.AUTH_REQUIRED, alert: true })); + await client.invoke(new Api9.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: MSG.AUTH_REQUIRED, alert: true })); return; } const chatKey = callbackChatKey(update, userId); @@ -17067,7 +18697,7 @@ async function handleTelegramDownloadModeCallback(update, data) { allowedActions: ["cancel", "mode_date", "mode_tag", "comments_on", "comments_off"] }); if (!validation.ok) { - await client.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5411\u5BFC\u5DF2\u5931\u6548\uFF0C\u8BF7\u91CD\u65B0\u6253\u5F00", alert: true })); + await client.invoke(new Api9.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5411\u5BFC\u5DF2\u5931\u6548\uFF0C\u8BF7\u91CD\u65B0\u6253\u5F00", alert: true })); return; } const record = telegramWizardStates.get(userId, chatKey); @@ -17075,7 +18705,7 @@ async function handleTelegramDownloadModeCallback(update, data) { if (data === "tgd_cancel") { telegramWizardStates.delete(userId, chatKey); await client.editMessage(update.peer, { message: update.msgId, text: "\u5DF2\u53D6\u6D88\u9891\u9053\u6587\u4EF6\u4E0B\u8F7D\u5411\u5BFC\u3002" }); - await client.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5DF2\u53D6\u6D88" })); + await client.invoke(new Api9.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5DF2\u53D6\u6D88" })); return; } if (data === "tgd_mode_date") { @@ -17083,7 +18713,7 @@ async function handleTelegramDownloadModeCallback(update, data) { state.step = "source"; putTelegramWizardState(userId, chatKey, state, record.originMessageId); await client.editMessage(update.peer, { message: update.msgId, text: buildTelegramWizardPrompt(state) }); - await client.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u6309\u65E5\u671F\u4E0B\u8F7D" })); + await client.invoke(new Api9.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u6309\u65E5\u671F\u4E0B\u8F7D" })); return; } if (data === "tgd_mode_tag") { @@ -17091,7 +18721,7 @@ async function handleTelegramDownloadModeCallback(update, data) { state.step = "source"; putTelegramWizardState(userId, chatKey, state, record.originMessageId); await client.editMessage(update.peer, { message: update.msgId, text: buildTelegramWizardPrompt(state) }); - await client.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u6309\u6807\u7B7E\u4E0B\u8F7D" })); + await client.invoke(new Api9.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u6309\u6807\u7B7E\u4E0B\u8F7D" })); return; } if (data === "tgd_comments_on" || data === "tgd_comments_off") { @@ -17100,7 +18730,7 @@ async function handleTelegramDownloadModeCallback(update, data) { state.step = state.kind === "tg_tag" ? "tag" : "start_date"; putTelegramWizardState(userId, chatKey, state, record.originMessageId); await client.editMessage(update.peer, { message: update.msgId, text: buildTelegramWizardPrompt(state) }); - await client.invoke(new Api8.messages.SetBotCallbackAnswer({ + await client.invoke(new Api9.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: state.includeComments ? "\u5C06\u5305\u542B\u8BC4\u8BBA\u533A\u6587\u4EF6" : "\u4EC5\u4E0B\u8F7D\u9891\u9053\u6B63\u6587\u6587\u4EF6" })); @@ -17110,12 +18740,22 @@ async function handleTelegramSubscriptionCallback(update, data) { if (!client) return; const userId = update.userId.toJSNumber(); if (!await isAuthenticatedAsync(userId)) { - await client.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: MSG.AUTH_REQUIRED, alert: true })); + await client.invoke(new Api9.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: MSG.AUTH_REQUIRED, alert: true })); return; } const parsed = parseTelegramSubscriptionCallback(data); + if (data === "tsub_add") { + const state = { kind: "tg_sub_manage", step: "source" }; + putTelegramWizardState(userId, callbackChatKey(update, userId), state, Number(update.msgId)); + await client.editMessage(update.peer, { + message: update.msgId, + text: buildTelegramWizardPrompt(state) + }); + await client.invoke(new Api9.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u8BF7\u53D1\u9001\u9891\u9053" })); + return; + } if (!parsed) { - await client.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u8BA2\u9605\u6309\u94AE\u65E0\u6548\u6216\u5DF2\u8FC7\u671F", alert: true })); + await client.invoke(new Api9.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u8BA2\u9605\u6309\u94AE\u65E0\u6548\u6216\u5DF2\u8FC7\u671F", alert: true })); return; } const rows = await listManageableTelegramSubscriptions(userId); @@ -17126,13 +18766,13 @@ async function handleTelegramSubscriptionCallback(update, data) { text: buildSubscriptionManagePanel2(rows, page.page), buttons: buildSubscriptionActionKeyboard(rows, page.page) }); - await client.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u8BA2\u9605\u5217\u8868\u5DF2\u5237\u65B0" })); + await client.invoke(new Api9.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u8BA2\u9605\u5217\u8868\u5DF2\u5237\u65B0" })); return; } if (parsed.kind === "confirm" || parsed.kind === "back") { const pending = getPendingSubscriptionCancel(update, parsed.token, userId); if (!pending) { - await client.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u53D6\u6D88\u786E\u8BA4\u65E0\u6548\u6216\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u5237\u65B0\u8BA2\u9605\u5217\u8868", alert: true })); + await client.invoke(new Api9.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u53D6\u6D88\u786E\u8BA4\u65E0\u6548\u6216\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u5237\u65B0\u8BA2\u9605\u5217\u8868", alert: true })); return; } pendingSubscriptionCancels.delete(parsed.token); @@ -17150,7 +18790,7 @@ async function handleTelegramSubscriptionCallback(update, data) { ].join("\n"), buttons: buildSubscriptionActionKeyboard(rowsAfterCancel, page2.page) }); - await client.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: sub ? "\u5DF2\u53D6\u6D88\u8BA2\u9605" : "\u8BA2\u9605\u4E0D\u5B58\u5728\u6216\u5DF2\u7ECF\u53D6\u6D88", alert: true })); + await client.invoke(new Api9.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: sub ? "\u5DF2\u53D6\u6D88\u8BA2\u9605" : "\u8BA2\u9605\u4E0D\u5B58\u5728\u6216\u5DF2\u7ECF\u53D6\u6D88", alert: true })); return; } const page = buildTelegramSubscriptionPage(rows, pending.page); @@ -17159,33 +18799,33 @@ async function handleTelegramSubscriptionCallback(update, data) { text: buildSubscriptionManagePanel2(rows, page.page), buttons: buildSubscriptionActionKeyboard(rows, page.page) }); - await client.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5DF2\u8FD4\u56DE\u8BA2\u9605\u5217\u8868" })); + await client.invoke(new Api9.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5DF2\u8FD4\u56DE\u8BA2\u9605\u5217\u8868" })); return; } if (parsed.kind !== "action") { - await client.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u8BA2\u9605\u6309\u94AE\u65E0\u6548\u6216\u5DF2\u8FC7\u671F", alert: true })); + await client.invoke(new Api9.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u8BA2\u9605\u6309\u94AE\u65E0\u6548\u6216\u5DF2\u8FC7\u671F", alert: true })); return; } const target = rows.find((row) => String(row.id) === parsed.id); if (!target) { - await client.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u8BA2\u9605\u4E0D\u5B58\u5728\u6216\u5DF2\u53D6\u6D88", alert: true })); + await client.invoke(new Api9.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u8BA2\u9605\u4E0D\u5B58\u5728\u6216\u5DF2\u53D6\u6D88", alert: true })); return; } if (parsed.action === "sync") { await requestTelegramSubscriptionSync(userId, parsed.id); - await client.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5DF2\u8BF7\u6C42\u7ACB\u5373\u540C\u6B65" })); + await client.invoke(new Api9.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5DF2\u8BF7\u6C42\u7ACB\u5373\u540C\u6B65" })); return; } if (parsed.action === "pause" || parsed.action === "resume") { await setTelegramSubscriptionEnabled(userId, parsed.id, parsed.action === "resume"); const refreshed = await listManageableTelegramSubscriptions(userId); await client.editMessage(update.peer, { message: update.msgId, text: buildSubscriptionManagePanel2(refreshed, parsed.page), buttons: buildSubscriptionActionKeyboard(refreshed, parsed.page) }); - await client.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: parsed.action === "resume" ? "\u5DF2\u6062\u590D\u8BA2\u9605" : "\u5DF2\u6682\u505C\u8BA2\u9605" })); + await client.invoke(new Api9.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: parsed.action === "resume" ? "\u5DF2\u6062\u590D\u8BA2\u9605" : "\u5DF2\u6682\u505C\u8BA2\u9605" })); return; } if (parsed.action === "from_now") { await setTelegramSubscriptionFromNow(userId, parsed.id); - await client.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u6E38\u6807\u5DF2\u66F4\u65B0\u4E3A\u5F53\u524D\u6700\u65B0\u6D88\u606F" })); + await client.invoke(new Api9.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u6E38\u6807\u5DF2\u66F4\u65B0\u4E3A\u5F53\u524D\u6700\u65B0\u6D88\u606F" })); return; } if (parsed.action === "target") { @@ -17193,28 +18833,28 @@ async function handleTelegramSubscriptionCallback(update, data) { await updateTelegramSubscriptionTarget(userId, parsed.id, current3 ? { mode: "follow_global" } : { mode: "fixed", provider: storageManager.getActiveTarget().provider.name, accountId: storageManager.getActiveTarget().accountId }); const refreshed = await listManageableTelegramSubscriptions(userId); await client.editMessage(update.peer, { message: update.msgId, text: buildSubscriptionManagePanel2(refreshed, parsed.page), buttons: buildSubscriptionActionKeyboard(refreshed, parsed.page) }); - await client.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: current3 ? "\u5DF2\u6539\u4E3A\u8DDF\u968F\u5168\u5C40" : "\u5DF2\u56FA\u5B9A\u4E3A\u5F53\u524D\u76EE\u6807" })); + await client.invoke(new Api9.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: current3 ? "\u5DF2\u6539\u4E3A\u8DDF\u968F\u5168\u5C40" : "\u5DF2\u56FA\u5B9A\u4E3A\u5F53\u524D\u76EE\u6807" })); return; } if (parsed.action === "result") { - await client.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: target.last_result ? JSON.stringify(target.last_result).slice(0, 180) : "\u6682\u65E0\u8FD0\u884C\u7ED3\u679C", alert: true })); + await client.invoke(new Api9.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: target.last_result ? JSON.stringify(target.last_result).slice(0, 180) : "\u6682\u65E0\u8FD0\u884C\u7ED3\u679C", alert: true })); return; } if (parsed.action === "retry") { const failed = await query(`SELECT id FROM telegram_background_jobs WHERE kind = 'subscription_sync' AND params->>'subscriptionId' = $1 AND status IN ('failed','completed_with_errors') ORDER BY updated_at DESC LIMIT 1`, [String(target.id)]); const retried = failed.rows[0] ? await retryTelegramBackgroundJob(userId, String(failed.rows[0].id), target.chat_id?.toString()) : null; - await client.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: retried ? "\u5DF2\u91CD\u8BD5\u6700\u8FD1\u5931\u8D25\u9879" : "\u6CA1\u6709\u53EF\u91CD\u8BD5\u5931\u8D25\u9879" })); + await client.invoke(new Api9.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: retried ? "\u5DF2\u91CD\u8BD5\u6700\u8FD1\u5931\u8D25\u9879" : "\u6CA1\u6709\u53EF\u91CD\u8BD5\u5931\u8D25\u9879" })); return; } if (parsed.action === "backfill") { const state = { kind: "tg_date", step: "start_date", source: target.source }; const sent = await client.sendMessage(update.peer, { message: buildTelegramWizardPrompt(state) }); putTelegramWizardState(userId, callbackChatKey(update, userId), state, sent.id); - await client.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u8BF7\u8F93\u5165\u8865\u6293\u5F00\u59CB\u65E5\u671F" })); + await client.invoke(new Api9.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u8BF7\u8F93\u5165\u8865\u6293\u5F00\u59CB\u65E5\u671F" })); return; } if (parsed.action === "view") { - await client.invoke(new Api8.messages.SetBotCallbackAnswer({ + await client.invoke(new Api9.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: target.folder_override ? `\u4E13\u5C5E\u76EE\u5F55\uFF1A${target.folder_override}` : "\u5F53\u524D\u4F7F\u7528\u9ED8\u8BA4\u4FDD\u5B58\u8DEF\u5F84", alert: true @@ -17232,7 +18872,7 @@ async function handleTelegramSubscriptionCallback(update, data) { }; const sent = await client.sendMessage(update.peer, { message: buildTelegramWizardPrompt(state) }); putTelegramWizardState(userId, callbackChatKey(update, userId), state, sent.id); - await client.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u8BF7\u53D1\u9001\u65B0\u7684\u4E13\u5C5E\u76EE\u5F55" })); + await client.invoke(new Api9.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u8BF7\u53D1\u9001\u65B0\u7684\u4E13\u5C5E\u76EE\u5F55" })); return; } if (parsed.action === "clear") { @@ -17244,12 +18884,12 @@ async function handleTelegramSubscriptionCallback(update, data) { text: buildSubscriptionManagePanel2(rowsAfterClear, page.page), buttons: buildSubscriptionActionKeyboard(rowsAfterClear, page.page) }); - await client.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5DF2\u6E05\u9664\u4E13\u5C5E\u76EE\u5F55" })); + await client.invoke(new Api9.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5DF2\u6E05\u9664\u4E13\u5C5E\u76EE\u5F55" })); return; } if (parsed.action === "cancel") { await editSubscriptionCancelConfirmation(update, userId, target, parsed.page); - await client.invoke(new Api8.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u8BF7\u786E\u8BA4\u662F\u5426\u53D6\u6D88\u8BA2\u9605" })); + await client.invoke(new Api9.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u8BF7\u786E\u8BA4\u662F\u5426\u53D6\u6D88\u8BA2\u9605" })); } } async function initTelegramBot(credentialsOverride) { @@ -17274,15 +18914,15 @@ async function initTelegramBot(credentialsOverride) { } try { const sessionDir = path16.dirname(SESSION_FILE); - if (!fs12.existsSync(sessionDir)) { - fs12.mkdirSync(sessionDir, { recursive: true, mode: 448 }); + if (!fs13.existsSync(sessionDir)) { + fs13.mkdirSync(sessionDir, { recursive: true, mode: 448 }); } let sessionString = ""; - if (fs12.existsSync(SESSION_FILE)) { - sessionString = fs12.readFileSync(SESSION_FILE, "utf-8").trim(); + if (fs13.existsSync(SESSION_FILE)) { + sessionString = fs13.readFileSync(SESSION_FILE, "utf-8").trim(); } - const session = new StringSession3(sessionString); - client = new TelegramClient6(session, apiId, apiHash, { + const session = new StringSession5(sessionString); + client = new TelegramClient8(session, apiId, apiHash, { connectionRetries: 15, retryDelay: 2e3, useWSS: false, @@ -17296,9 +18936,9 @@ async function initTelegramBot(credentialsOverride) { botAuthToken: botToken }); const newSession = client.session.save(); - fs12.writeFileSync(SESSION_FILE, newSession, { mode: 384 }); + fs13.writeFileSync(SESSION_FILE, newSession, { mode: 384 }); try { - fs12.chmodSync(SESSION_FILE, 384); + fs13.chmodSync(SESSION_FILE, 384); } catch (e) { console.warn("\u{1F916} \u4FEE\u6B63 Telegram Bot session \u6587\u4EF6\u6743\u9650\u5931\u8D25:", e); } @@ -17336,10 +18976,10 @@ async function initTelegramBot(credentialsOverride) { console.error("\u{1F916} \u521D\u59CB\u5316 Telegram \u8BA4\u8BC1\u8868\u5931\u8D25:", e); } try { - await client.invoke(new Api8.bots.SetBotCommands({ - scope: new Api8.BotCommandScopeDefault(), + await client.invoke(new Api9.bots.SetBotCommands({ + scope: new Api9.BotCommandScopeDefault(), langCode: "zh", - commands: buildBotCommandMenu().map((command) => new Api8.BotCommand(command)) + commands: buildBotCommandMenu().map((command) => new Api9.BotCommand(command)) })); console.log("\u{1F916} Bot \u547D\u4EE4\u83DC\u5355\u5DF2\u66F4\u65B0"); } catch (e) { @@ -17423,12 +19063,17 @@ async function initTelegramBot(credentialsOverride) { await message.reply({ message: MSG.AUTH_REQUIRED }); return; } + let tempPath = null; try { + if (await is2FAEnabled()) { + await message.reply({ message: "\u{1F510} \u53CC\u91CD\u9A8C\u8BC1\u5DF2\u542F\u7528\u3002\u4E3A\u4FDD\u62A4\u73B0\u6709\u5BC6\u94A5\uFF0CBot \u4E0D\u4F1A\u518D\u6B21\u663E\u793A\u4E8C\u7EF4\u7801\u3002" }); + return; + } const qrDataUrl = await generateOTPAuthUrl(); const base64Data = qrDataUrl.replace(/^data:image\/png;base64,/, ""); const buffer = Buffer.from(base64Data, "base64"); - const tempPath = path16.join(process.cwd(), `temp_qr_${senderId}_${Date.now()}_${Math.random().toString(36).slice(2)}.png`); - fs12.writeFileSync(tempPath, buffer); + tempPath = path16.join(process.cwd(), `temp_qr_${senderId}_${Date.now()}_${crypto21.randomBytes(8).toString("hex")}.png`); + fs13.writeFileSync(tempPath, buffer, { mode: 384 }); const qrMessage = await client.sendFile(chatId, { file: tempPath, caption: build2FASetupCaption() @@ -17437,15 +19082,16 @@ async function initTelegramBot(credentialsOverride) { state: "WAITING_2FA_SETUP" /* WAITING_2FA_SETUP */, qrMessageId: qrMessage.id }); - fs12.unlinkSync(tempPath); } catch (e) { console.error("\u751F\u6210 2FA \u4E8C\u7EF4\u7801\u5931\u8D25:", e); await client.sendMessage(chatId, { message: MSG.AUTH_2FA_QR_FAIL }); + } finally { + if (tempPath && fs13.existsSync(tempPath)) fs13.unlinkSync(tempPath); } return; } if (text === "/help") { - await handleHelp(message); + await handleHelp(message, homePageKeyboard(0)); return; } if (text === "/logout") { @@ -17453,11 +19099,18 @@ async function initTelegramBot(credentialsOverride) { await message.reply({ message: MSG.AUTH_REQUIRED }); return; } - await revokeAuthenticatedUser(senderId); - passwordInputState.delete(senderId); - userStates.delete(senderId); - telegramWizardStates.delete(senderId, messageChatKey(message, senderId)); - await message.reply({ message: "\u2705 \u5F53\u524D Telegram \u7528\u6237\u7684 Bot \u8BA4\u8BC1\u5DF2\u64A4\u9500\u3002\u53D1\u9001 /start \u53EF\u91CD\u65B0\u8BA4\u8BC1\u3002" }); + try { + await revokeAuthenticatedUser(senderId); + passwordInputState.delete(senderId); + userStates.delete(senderId); + telegramWizardStates.delete(senderId, messageChatKey(message, senderId)); + await message.reply({ message: "\u2705 \u5F53\u524D Telegram \u7528\u6237\u7684 Bot \u8BA4\u8BC1\u5DF2\u64A4\u9500\u3002\u53D1\u9001 /start \u53EF\u91CD\u65B0\u8BA4\u8BC1\u3002" }); + } catch (error) { + const operationId = crypto21.randomUUID().slice(0, 8); + console.error(`\u{1F916} \u64A4\u9500 Telegram \u8BA4\u8BC1\u5931\u8D25 operationId=${operationId}:`, error); + await message.reply({ message: `\u274C \u9000\u51FA\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002 +\u64CD\u4F5C ID\uFF1A${operationId}` }); + } return; } { @@ -17470,12 +19123,12 @@ async function initTelegramBot(credentialsOverride) { } const argsText = (match[1] || "").trim(); if (!argsText) { - await message.reply({ message: "\u274C \u7528\u6CD5: /ytdlp " }); + await message.reply({ message: "\u8BF7\u53D1\u9001\u4E00\u4E2A\u89C6\u9891\u94FE\u63A5\uFF0C\u4E5F\u53EF\u4EE5\u76F4\u63A5\u7C98\u8D34\u94FE\u63A5\u3002" }); return; } const parts = argsText.split(/\s+/).filter(Boolean); if (parts.length !== 1) { - await message.reply({ message: "\u274C \u53EA\u5141\u8BB8\u4E00\u4E2A\u94FE\u63A5\n\n\u7528\u6CD5: /ytdlp " }); + await message.reply({ message: "\u274C \u4E00\u6B21\u53EA\u80FD\u5904\u7406\u4E00\u4E2A\u94FE\u63A5\uFF0C\u8BF7\u91CD\u65B0\u53D1\u9001\u3002" }); return; } const url = parts[0]; @@ -17576,7 +19229,10 @@ ${buildPathPreviewLine(appliedPath.folder)} return; } const rows = await listManageableTelegramSubscriptions(senderId); - await message.reply({ message: formatSubscriptionList(rows) }); + await message.reply({ + message: buildSubscriptionManagePanel2(rows, 0), + buttons: buildSubscriptionActionKeyboard(rows, 0) + }); return; } if (text.startsWith("/tg_sub ") || text.startsWith("/tg_subscribe ")) { @@ -17586,7 +19242,7 @@ ${buildPathPreviewLine(appliedPath.folder)} } const source = text.split(/\s+/).slice(1).join(" ").trim(); if (!source) { - await message.reply({ message: "\u274C \u7528\u6CD5\uFF1A/tg_sub @\u9891\u9053" }); + await startTelegramWizard(message, senderId, "tg_sub_manage"); return; } try { @@ -17606,7 +19262,7 @@ ${buildPathPreviewLine(appliedPath.folder)} } const selector = text.split(/\s+/).slice(1).join(" ").trim(); if (!selector) { - await message.reply({ message: "\u274C \u7528\u6CD5\uFF1A/tg_unsub @\u9891\u9053 \u6216 /tg_unsub <\u8BA2\u9605ID\u524D\u7F00>" }); + await message.reply({ message: "\u8BF7\u4ECE\u9891\u9053\u8BA2\u9605\u9762\u677F\u9009\u62E9\u8981\u53D6\u6D88\u7684\u8BA2\u9605\u3002" }); return; } const target = await findTelegramSubscription(senderId, selector); @@ -17626,7 +19282,7 @@ ${buildPathPreviewLine(appliedPath.folder)} const mode = (parts.shift() || "").toLowerCase(); if (mode === "date" || mode === "\u65E5\u671F") { if (parts.length !== 3) { - await message.reply({ message: "\u274C \u7528\u6CD5\uFF1A/tg_download date @\u9891\u9053 YYYY-MM-DD YYYY-MM-DD" }); + await startTelegramWizard(message, senderId, "tg_download"); return; } try { @@ -17640,7 +19296,7 @@ ${buildPathPreviewLine(appliedPath.folder)} } if (mode === "tag" || mode === "\u6807\u7B7E") { if (parts.length !== 2) { - await message.reply({ message: "\u274C \u7528\u6CD5\uFF1A/tg_download tag @\u9891\u9053 #\u6807\u7B7E" }); + await startTelegramWizard(message, senderId, "tg_download"); return; } try { @@ -17652,7 +19308,7 @@ ${buildPathPreviewLine(appliedPath.folder)} } return; } - await message.reply({ message: "\u274C \u7528\u6CD5\uFF1A/tg_download date @\u9891\u9053 YYYY-MM-DD YYYY-MM-DD\n\u6216\uFF1A/tg_download tag @\u9891\u9053 #\u6807\u7B7E" }); + await startTelegramWizard(message, senderId, "tg_download"); return; } if (text.startsWith("/tg_date ")) { @@ -17952,6 +19608,14 @@ ${buildPathPreviewLine(appliedPath.folder)} await handleDeleteConfirmCallback(activeClient, callbackUpdate, data); return; } + if (data.startsWith("target_")) { + await handleTargetCallback(activeClient, callbackUpdate, data); + return; + } + if (data.startsWith("nt_")) { + await handleNotificationsCallback(activeClient, callbackUpdate, data); + return; + } if (data.startsWith("pr_")) { await handlePathRulesCallback(activeClient, callbackUpdate, data); return; @@ -17993,7 +19657,7 @@ ${buildPathPreviewLine(appliedPath.folder)} return; } } - }, new Raw({})); + }, new Raw2({})); markTelegramBotReady(); const me = await client.getMe(); setTelegramBotIdentity({ @@ -18400,7 +20064,7 @@ function generateSignature(fileId, typeOrExpires, expires) { throw new Error("Missing signed URL expiration timestamp"); } const data = `${fileId}:${type}:${expiresTimestamp}`; - return crypto21.createHmac("sha256", SESSION_SECRET).update(data).digest("hex"); + return crypto22.createHmac("sha256", SESSION_SECRET).update(data).digest("hex"); } function getSignedUrl(fileId, type, expiresIn = 24 * 60 * 60) { const expires = Date.now() + expiresIn * 1e3; @@ -18428,7 +20092,7 @@ function verifySignedUrl(req) { try { const received = Buffer.from(sign, "hex"); const expected = Buffer.from(expectedSign, "hex"); - if (received.length !== expected.length || !crypto21.timingSafeEqual(received, expected)) { + if (received.length !== expected.length || !crypto22.timingSafeEqual(received, expected)) { console.log("[SignedURL] Signature mismatch:", { id, type }); return false; } @@ -18486,9 +20150,9 @@ function createFileDeletionService(dependencies) { } // src/services/webDestructiveConfirmation.ts -import crypto22 from "node:crypto"; +import crypto23 from "node:crypto"; function hash(value) { - return crypto22.createHash("sha256").update(value).digest("hex"); + return crypto23.createHash("sha256").update(value).digest("hex"); } var WebDestructiveConfirmationStore = class { constructor(ttlMs = 5 * 60 * 1e3, now = () => Date.now()) { @@ -18499,7 +20163,7 @@ var WebDestructiveConfirmationStore = class { now; values = /* @__PURE__ */ new Map(); issue(input) { - const token = crypto22.randomBytes(24).toString("base64url"); + const token = crypto23.randomBytes(24).toString("base64url"); const expiresAt = this.now() + this.ttlMs; this.values.set(token, { action: input.action, objectId: input.objectId, context: input.context ?? null, authTokenHash: hash(input.authToken), expiresAt }); return { confirmationToken: token, expiresAt }; @@ -18550,7 +20214,7 @@ function buildCloudMediaResponse(input) { import { pipeline } from "node:stream/promises"; var router2 = Router2(); var UPLOAD_DIR5 = path17.resolve(process.env.UPLOAD_DIR || "./data/uploads"); -var THUMBNAIL_DIR5 = path17.resolve(process.env.THUMBNAIL_DIR || "./data/thumbnails"); +var THUMBNAIL_DIR4 = path17.resolve(process.env.THUMBNAIL_DIR || "./data/thumbnails"); var PREVIEW_DIR3 = path17.resolve(process.env.PREVIEW_DIR || "./data/previews"); async function getSafeLocalFilePath(file) { const candidate = file.path || path17.join(UPLOAD_DIR5, file.stored_name); @@ -18558,17 +20222,17 @@ async function getSafeLocalFilePath(file) { if (!isPathInside(UPLOAD_DIR5, resolved)) { throw new Error("Unsafe local file path"); } - if (!fs13.existsSync(resolved)) { + if (!fs14.existsSync(resolved)) { return resolved; } - const real = await fs13.promises.realpath(resolved); + const real = await fs14.promises.realpath(resolved); if (!isPathInside(UPLOAD_DIR5, real)) { throw new Error("Unsafe local file path"); } return real; } async function serveLocalPathWithRange(req, res, filePath, mimeType, cacheControl, etag) { - const stat = fs13.statSync(filePath); + const stat = fs14.statSync(filePath); res.set({ "Content-Type": mimeType || "application/octet-stream", "Cache-Control": cacheControl, @@ -18594,13 +20258,13 @@ async function serveLocalPathWithRange(req, res, filePath, mimeType, cacheContro "Content-Range": `bytes ${start}-${end}/${stat.size}`, "Content-Length": String(chunkSize) }); - const stream2 = fs13.createReadStream(filePath, { start, end }); + const stream2 = fs14.createReadStream(filePath, { start, end }); req.once("aborted", () => stream2.destroy()); await pipeline(stream2, res); return; } res.set("Content-Length", String(stat.size)); - const stream = fs13.createReadStream(filePath); + const stream = fs14.createReadStream(filePath); req.once("aborted", () => stream.destroy()); await pipeline(stream, res); } @@ -18880,13 +20544,13 @@ router2.get("/:id([0-9a-fA-F-]{36})/media-status", async (req, res) => { if (!file) return res.status(404).json({ code: "FILE_NOT_FOUND", error: "\u6587\u4EF6\u8BB0\u5F55\u4E0D\u5B58\u5728" }); if (file.preview_path && (file.type === "image" || file.type === "video")) { const localPreviewPath = path17.join(PREVIEW_DIR3, path17.basename(file.preview_path)); - if (fs13.existsSync(localPreviewPath)) { + if (fs14.existsSync(localPreviewPath)) { return res.json({ available: true, source: "local_preview" }); } } if (file.source === "local" || file.source === "web") { const filePath = await getSafeLocalFilePath(file); - return fs13.existsSync(filePath) ? res.json({ available: true, source: "local" }) : res.status(410).json({ code: "MEDIA_SOURCE_MISSING", error: "\u670D\u52A1\u5668\u4E2D\u7684\u6E90\u6587\u4EF6\u5DF2\u4E0D\u5B58\u5728\u3002", reason: "not_found" }); + return fs14.existsSync(filePath) ? res.json({ available: true, source: "local" }) : res.status(410).json({ code: "MEDIA_SOURCE_MISSING", error: "\u670D\u52A1\u5668\u4E2D\u7684\u6E90\u6587\u4EF6\u5DF2\u4E0D\u5B58\u5728\u3002", reason: "not_found" }); } const { storageManager: storageManager2 } = await Promise.resolve().then(() => (init_storage(), storage_exports)); const provider = storageManager2.getProvider(`${file.source}:${file.storage_account_id}`); @@ -18912,7 +20576,7 @@ router2.get("/:id([0-9a-fA-F-]{36})/preview", async (req, res) => { return res.status(404).json({ error: "\u6587\u4EF6\u4E0D\u5B58\u5728" }); } const localPreviewPath = file.preview_path && (file.type === "image" || file.type === "video") ? path17.join(PREVIEW_DIR3, path17.basename(file.preview_path)) : null; - if (localPreviewPath && fs13.existsSync(localPreviewPath)) { + if (localPreviewPath && fs14.existsSync(localPreviewPath)) { await serveLocalPathWithRange( req, res, @@ -18948,12 +20612,12 @@ router2.get("/:id([0-9a-fA-F-]{36})/preview", async (req, res) => { } } const filePath = await getSafeLocalFilePath(file); - if (!fs13.existsSync(filePath)) { + if (!fs14.existsSync(filePath)) { return res.status(404).json({ error: "\u6587\u4EF6\u4E0D\u5B58\u5728\u4E8E\u670D\u52A1\u5668" }); } let previewPath = file.preview_path; let preferredPreviewPath = previewPath && (file.type === "image" || file.type === "video") ? path17.join(PREVIEW_DIR3, path17.basename(previewPath)) : null; - if (file.type === "image" && (!preferredPreviewPath || !fs13.existsSync(preferredPreviewPath))) { + if (file.type === "image" && (!preferredPreviewPath || !fs14.existsSync(preferredPreviewPath))) { try { const generatedPreview = await generateMediaPreview(filePath, file.stored_name || file.name, file.mime_type || "application/octet-stream"); if (generatedPreview) { @@ -18964,7 +20628,7 @@ router2.get("/:id([0-9a-fA-F-]{36})/preview", async (req, res) => { } catch (previewError) { console.error("\u61D2\u751F\u6210\u56FE\u7247\u9884\u89C8\u5931\u8D25:", previewError); } - } else if (file.type === "video" && (!preferredPreviewPath || !fs13.existsSync(preferredPreviewPath))) { + } else if (file.type === "video" && (!preferredPreviewPath || !fs14.existsSync(preferredPreviewPath))) { void generateMediaPreview(filePath, file.stored_name || file.name, file.mime_type || "application/octet-stream").then(async (generatedPreview) => { if (!generatedPreview) return; const generatedPreviewName = path17.basename(generatedPreview); @@ -18972,7 +20636,7 @@ router2.get("/:id([0-9a-fA-F-]{36})/preview", async (req, res) => { console.log(`[Preview] \u{1F39E}\uFE0F Lazy video preview cached for ${file.id}: ${generatedPreviewName}`); }).catch((previewError) => console.error("\u61D2\u751F\u6210\u89C6\u9891\u9884\u89C8\u5931\u8D25:", previewError)); } - const servedPath = preferredPreviewPath && fs13.existsSync(preferredPreviewPath) ? preferredPreviewPath : filePath; + const servedPath = preferredPreviewPath && fs14.existsSync(preferredPreviewPath) ? preferredPreviewPath : filePath; const servedMime = preferredPreviewPath && servedPath === preferredPreviewPath ? file.type === "video" ? "video/mp4" : "image/webp" : file.mime_type || "application/octet-stream"; await serveLocalPathWithRange( req, @@ -19017,7 +20681,7 @@ router2.get("/:id([0-9a-fA-F-]{36})/original", async (req, res) => { } } const filePath = await getSafeLocalFilePath(file); - if (!fs13.existsSync(filePath)) return res.status(404).json({ error: "\u6587\u4EF6\u4E0D\u5B58\u5728\u4E8E\u670D\u52A1\u5668" }); + if (!fs14.existsSync(filePath)) return res.status(404).json({ error: "\u6587\u4EF6\u4E0D\u5B58\u5728\u4E8E\u670D\u52A1\u5668" }); await serveLocalPathWithRange(req, res, filePath, file.mime_type || "application/octet-stream", "public, max-age=86400", `"${file.id}-${file.updated_at}-original"`); } catch (error) { console.error("\u83B7\u53D6\u539F\u59CB\u6587\u4EF6\u5931\u8D25:", error); @@ -19083,7 +20747,7 @@ router2.get("/:id([0-9a-fA-F-]{36})/download", async (req, res) => { } const filePath = await getSafeLocalFilePath(file); console.log(`[Download] Serving local file: ${filePath}`); - if (!fs13.existsSync(filePath)) { + if (!fs14.existsSync(filePath)) { console.log(`[Download] File system path not found: ${filePath}`); return res.status(404).json({ error: "\u6587\u4EF6\u4E0D\u5B58\u5728\u4E8E\u670D\u52A1\u5668" }); } @@ -19107,8 +20771,8 @@ router2.get("/:id([0-9a-fA-F-]{36})/thumbnail", async (req, res) => { if (!file.thumbnail_path) { return res.status(404).json({ error: "\u65E0\u7F29\u7565\u56FE" }); } - const thumbPath = path17.join(THUMBNAIL_DIR5, path17.basename(file.thumbnail_path)); - if (!fs13.existsSync(thumbPath)) { + const thumbPath = path17.join(THUMBNAIL_DIR4, path17.basename(file.thumbnail_path)); + if (!fs14.existsSync(thumbPath)) { return res.status(404).json({ error: "\u7F29\u7565\u56FE\u6587\u4EF6\u4E0D\u5B58\u5728" }); } await serveLocalPathWithRange(req, res, thumbPath, "image/webp", "public, max-age=604800"); @@ -19346,9 +21010,9 @@ function logOperationalEvent(event, requestId, data) { } // src/services/batchDeleteConfirmation.ts -import crypto23 from "node:crypto"; +import crypto24 from "node:crypto"; function hashAuthToken(token) { - return crypto23.createHash("sha256").update(token).digest("hex"); + return crypto24.createHash("sha256").update(token).digest("hex"); } function normalizeFileIds(fileIds) { return [...new Set(fileIds)].sort(); @@ -19361,7 +21025,7 @@ var BatchDeleteConfirmationStore = class { constructor(options = {}) { this.ttlMs = options.ttlMs ?? 5 * 60 * 1e3; this.now = options.now ?? (() => Date.now()); - this.tokenFactory = options.tokenFactory ?? (() => crypto23.randomBytes(24).toString("base64url")); + this.tokenFactory = options.tokenFactory ?? (() => crypto24.randomBytes(24).toString("base64url")); } issue(input) { const confirmationToken = this.tokenFactory(); @@ -19558,11 +21222,11 @@ router3.post("/batch-delete", requireAuth, async (req, res) => { } const fileIds = consumed.confirmation.fileIds; const storageScope = await getCurrentStorageScope(); - const selected = await query( + const selected3 = await query( `SELECT * FROM files WHERE ${storageScope.clause} AND id = ANY(${nextParam(storageScope, 1)}::uuid[])`, [...storageScope.params, fileIds] ); - const filesById = new Map(selected.rows.map((file) => [file.id, file])); + const filesById = new Map(selected3.rows.map((file) => [file.id, file])); const deletedIds = []; const failedFiles = []; for (const id of fileIds) { @@ -19704,8 +21368,8 @@ init_db(); import { Router as Router4 } from "express"; import multer from "multer"; import { v4 as uuidv4 } from "uuid"; -import path18 from "path"; -import fs14 from "fs"; +import path19 from "path"; +import fs16 from "fs"; // src/middleware/apiKey.ts init_db(); @@ -19798,6 +21462,141 @@ function buildUploadCapabilities(env = process.env) { }; } +// src/services/mediaDerivatives.ts +init_db(); +import fs15 from "node:fs/promises"; +import path18 from "node:path"; +var concurrency = Math.max(1, Math.min(4, Number.parseInt(process.env.MEDIA_DERIVATIVE_CONCURRENCY || "2", 10) || 2)); +var timeoutMs = Math.max(1e4, Number.parseInt(process.env.MEDIA_DERIVATIVE_TIMEOUT_MS || "120000", 10) || 12e4); +var queue = []; +var queuedIds = /* @__PURE__ */ new Set(); +var active = 0; +async function settleWithDeadline(promise, label) { + let timer; + let timedOut = false; + try { + const result = await Promise.race([ + promise.then((value) => ({ value })), + new Promise((resolve) => { + timer = setTimeout(() => { + timedOut = true; + resolve({ timeout: true }); + }, timeoutMs); + timer.unref?.(); + }) + ]); + if ("timeout" in result) { + await promise.catch(() => void 0); + throw new Error(`${label}\u8D85\u65F6`); + } + return result.value; + } finally { + if (timer) clearTimeout(timer); + if (timedOut) console.warn(`[MediaDerivatives] ${label} \u5DF2\u8D85\u8FC7 ${timeoutMs}ms\uFF0C\u7B49\u5F85\u5E95\u5C42\u8FDB\u7A0B\u9000\u51FA\u540E\u91CA\u653E\u961F\u5217\u69FD\u4F4D`); + } +} +function requiredResult(value, label) { + if (value == null) throw new Error(`${label}\u672A\u751F\u6210\u6709\u6548\u8F93\u51FA`); + return value; +} +async function processJob(job) { + const claimed = await query( + `UPDATE files + SET derivative_status = 'processing', derivative_error = NULL, + derivative_started_at = NOW(), derivative_attempts = derivative_attempts + 1, updated_at = NOW() + WHERE id = $1 AND derivative_status = 'queued' + RETURNING id`, + [job.fileId] + ); + if (!claimed.rowCount) return; + try { + const thumbnail = requiredResult( + await settleWithDeadline(generateThumbnail(job.sourcePath, job.storedName, job.mimeType), "\u7F29\u7565\u56FE\u751F\u6210"), + "\u7F29\u7565\u56FE" + ); + const dimensions = await settleWithDeadline(getImageDimensions(job.sourcePath, job.mimeType), "\u5A92\u4F53\u5C3A\u5BF8\u8BFB\u53D6"); + if (!(dimensions.width > 0 && dimensions.height > 0)) throw new Error("\u5A92\u4F53\u5C3A\u5BF8\u65E0\u6548"); + const preview = requiredResult( + await settleWithDeadline(generateMediaPreview(job.sourcePath, job.storedName, job.mimeType), "\u9884\u89C8\u751F\u6210"), + "\u9884\u89C8" + ); + await query( + `UPDATE files + SET thumbnail_path = $1, preview_path = $2, width = $3, height = $4, + derivative_status = 'ready', derivative_error = NULL, + derivative_source_path = NULL, derivative_cleanup_source = FALSE, + derivative_started_at = NULL, updated_at = NOW() + WHERE id = $5`, + [path18.basename(thumbnail), path18.basename(preview), dimensions.width, dimensions.height, job.fileId] + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + await query( + `UPDATE files + SET derivative_status = 'failed', derivative_error = $1, + derivative_started_at = NULL, updated_at = NOW() + WHERE id = $2`, + [message.slice(0, 1e3), job.fileId] + ).catch(() => void 0); + console.error(`[MediaDerivatives] ${job.fileId} \u5904\u7406\u5931\u8D25:`, error); + } finally { + if (job.cleanupSource) await fs15.rm(job.sourcePath, { force: true }).catch(() => void 0); + } +} +function drain() { + while (active < concurrency && queue.length > 0) { + const job = queue.shift(); + active += 1; + void processJob(job).finally(() => { + queuedIds.delete(job.fileId); + active -= 1; + drain(); + }); + } +} +function enqueueMediaDerivatives(job) { + if (queuedIds.has(job.fileId)) return; + queuedIds.add(job.fileId); + queue.push(job); + drain(); +} +function jobFromRow(row) { + const sourcePath = row.derivative_source_path || row.path; + if (!sourcePath) return null; + return { + fileId: String(row.id), + sourcePath: String(sourcePath), + storedName: String(row.stored_name), + mimeType: String(row.mime_type), + cleanupSource: row.derivative_cleanup_source === true + }; +} +async function recoverMediaDerivativeJobs() { + await query( + `UPDATE files + SET derivative_status = 'queued', derivative_error = '\u4E0A\u6B21\u5904\u7406\u88AB\u4E2D\u65AD\uFF0C\u5DF2\u91CD\u65B0\u6392\u961F', + derivative_started_at = NULL, updated_at = NOW() + WHERE derivative_status = 'processing'`, + [] + ); + const result = await query( + `SELECT id, stored_name, mime_type, path, derivative_source_path, derivative_cleanup_source + FROM files + WHERE derivative_status = 'queued' + ORDER BY created_at ASC, id ASC + LIMIT 1000` + ); + let recovered = 0; + for (const row of result.rows) { + const job = jobFromRow(row); + if (!job) continue; + enqueueMediaDerivatives(job); + recovered += 1; + } + if (recovered > 0) console.log(`[MediaDerivatives] \u5DF2\u6062\u590D ${recovered} \u4E2A\u6301\u4E45\u5316\u4EFB\u52A1`); + return recovered; +} + // src/routes/upload.ts var router4 = Router4(); var apiRouter = Router4(); @@ -19826,16 +21625,16 @@ function decodeFilename(filename) { } return filename; } -var TEMP_DIR = path18.join(process.cwd(), "data", "temp"); -if (!fs14.existsSync(TEMP_DIR)) { - fs14.mkdirSync(TEMP_DIR, { recursive: true }); +var TEMP_DIR = path19.join(process.cwd(), "data", "temp"); +if (!fs16.existsSync(TEMP_DIR)) { + fs16.mkdirSync(TEMP_DIR, { recursive: true }); } var storage = multer.diskStorage({ destination: (_req, _file, cb) => { cb(null, TEMP_DIR); }, filename: (_req, file, cb) => { - const ext = path18.extname(file.originalname); + const ext = path19.extname(file.originalname); const storedName = `${uuidv4()}${ext}`; cb(null, storedName); } @@ -19855,7 +21654,7 @@ var handleUpload = async (req, res, source = "web") => { const originalName = decodeFilename(file.originalname); const mimeType = file.mimetype; const size = file.size; - const tempPath = path18.resolve(file.path); + const tempPath = path19.resolve(file.path); let storageLease = null; let target; try { @@ -19881,7 +21680,7 @@ var handleUpload = async (req, res, source = "web") => { target = storageManager.getActiveTarget(); } } catch (error) { - if (fs14.existsSync(tempPath)) fs14.unlinkSync(tempPath); + if (fs16.existsSync(tempPath)) fs16.unlinkSync(tempPath); return res.status(409).json({ error: error instanceof Error ? error.message : "\u4E0A\u4F20\u76EE\u6807\u65E0\u6548" }); } const { provider, accountId: activeAccountId } = target; @@ -19889,7 +21688,7 @@ var handleUpload = async (req, res, source = "web") => { try { requestedFolder = folder ? normalizeFolderPath(folder) : null; } catch (error) { - if (fs14.existsSync(tempPath)) fs14.unlinkSync(tempPath); + if (fs16.existsSync(tempPath)) fs16.unlinkSync(tempPath); return res.status(400).json({ error: error instanceof Error ? error.message : "\u6587\u4EF6\u5939\u8DEF\u5F84\u65E0\u6548" }); } const storageRules = await getStoragePathRules(); @@ -19901,15 +21700,11 @@ var handleUpload = async (req, res, source = "web") => { storageLease = await acquireStorageAccountOperationLease(pool, activeAccountId, "web_upload"); await assertStorageTargetWritable(target); console.log(`[Upload] \u{1F6E0}\uFE0F Current storage provider: ${provider.name}, activeAccountId: ${activeAccountId || "none (local)"}`); - let thumbnailPath = null; - let previewPath = null; - let width = null; - let height = null; const duplicateMode = await getDuplicateMode(); if (duplicateMode === "skip") { const duplicate = await findDuplicateFile(originalName, storageFolder, size, activeAccountId); if (duplicate) { - if (fs14.existsSync(tempPath)) fs14.unlinkSync(tempPath); + if (fs16.existsSync(tempPath)) fs16.unlinkSync(tempPath); return res.json({ success: true, skipped: true, @@ -19924,61 +21719,43 @@ var handleUpload = async (req, res, source = "web") => { }); } } - if (mimeType.startsWith("image/") || mimeType.startsWith("video/")) { - try { - const thumbResult = await generateThumbnail(tempPath, storedName, mimeType); - if (thumbResult) { - thumbnailPath = path18.basename(thumbResult); - console.log(`[Upload] \u2728 Thumbnail generated: ${thumbnailPath}`); - const dims = await getImageDimensions(tempPath, mimeType); - width = dims.width; - height = dims.height; - } else { - console.log(`[Upload] \u26A0\uFE0F No thumbnail generated for: ${mimeType}`); - } - } catch (error) { - console.error("\u751F\u6210\u7F29\u7565\u56FE\u5931\u8D25:", error); - } - } - if (mimeType.startsWith("image/")) { - try { - const previewResult = await generateMediaPreview(tempPath, storedName, mimeType); - if (previewResult) { - previewPath = path18.basename(previewResult); - console.log(`[Upload] \u{1F39E}\uFE0F Image preview generated: ${previewPath}`); - } - } catch (error) { - console.error("\u751F\u6210\u56FE\u7247\u9884\u89C8\u5931\u8D25:", error); - } - } - let type = "other"; - if (mimeType.startsWith("image/")) type = "image"; - else if (mimeType.startsWith("video/")) type = "video"; - else if (mimeType.startsWith("audio/")) type = "audio"; - else if (mimeType.includes("pdf") || mimeType.includes("document") || mimeType.includes("text") || mimeType.includes("word") || mimeType.includes("excel") || mimeType.includes("spreadsheet") || mimeType.includes("powerpoint") || mimeType.includes("presentation") || mimeType.includes("markdown") || mimeType.includes("json") || mimeType.includes("xml") || mimeType.includes("sql")) type = "document"; + const type = getFileType(mimeType); + const derivativeStatus = type === "image" || type === "video" ? "queued" : "not_required"; let result; const storedPath = await saveAndIndexWithCompensation(provider, tempPath, storedName, mimeType, storageFolder, async (savedPath) => { result = await query( `INSERT INTO files - (name, stored_name, type, mime_type, size, path, thumbnail_path, preview_path, width, height, source, folder, storage_account_id) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) + (name, stored_name, type, mime_type, size, path, source, folder, storage_account_id, + derivative_status, derivative_source_path, derivative_cleanup_source) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id, created_at, name, type, size`, - [originalName, storedName, type, mimeType, size, savedPath, thumbnailPath, previewPath, width, height, provider.name, storageFolder, activeAccountId] + [ + originalName, + storedName, + type, + mimeType, + size, + savedPath, + provider.name, + storageFolder, + activeAccountId, + derivativeStatus, + derivativeStatus === "queued" ? provider.name === "local" ? savedPath : tempPath : null, + derivativeStatus === "queued" && provider.name !== "local" + ] ); }); const newFile = result.rows[0]; - if (type === "video") { - const previewSource = provider.name === "local" ? storedPath : tempPath; - void generateMediaPreview(previewSource, storedName, mimeType).then(async (previewResult) => { - if (!previewResult) return; - const generatedPreviewName = path18.basename(previewResult); - await query("UPDATE files SET preview_path = $1, updated_at = NOW() WHERE id = $2", [generatedPreviewName, newFile.id]); - console.log(`[Upload] \u{1F39E}\uFE0F Video preview generated async: ${generatedPreviewName}`); - }).catch((error) => console.error("\u5F02\u6B65\u751F\u6210\u89C6\u9891\u9884\u89C8\u5931\u8D25:", error)).finally(() => { - if (provider.name !== "local" && fs14.existsSync(tempPath)) fs14.unlinkSync(tempPath); + if (derivativeStatus === "queued") { + enqueueMediaDerivatives({ + fileId: String(newFile.id), + sourcePath: provider.name === "local" ? storedPath : tempPath, + storedName, + mimeType, + cleanupSource: provider.name !== "local" }); - } else if (fs14.existsSync(tempPath)) { - fs14.unlinkSync(tempPath); + } else if (fs16.existsSync(tempPath)) { + fs16.unlinkSync(tempPath); } res.json({ success: true, @@ -19987,7 +21764,7 @@ var handleUpload = async (req, res, source = "web") => { name: newFile.name, type: newFile.type, size: newFile.size, - thumbnailUrl: thumbnailPath ? getSignedUrl(newFile.id, "thumbnail") : void 0, + derivativeStatus, previewUrl: getSignedUrl(newFile.id, "preview"), date: newFile.created_at, source: provider.name, @@ -19998,7 +21775,7 @@ var handleUpload = async (req, res, source = "web") => { }); } catch (error) { console.error("\u4E0A\u4F20\u5904\u7406\u5931\u8D25:", error); - if (fs14.existsSync(tempPath)) fs14.unlinkSync(tempPath); + if (fs16.existsSync(tempPath)) fs16.unlinkSync(tempPath); if (isStorageCooldownError(error)) { return sendStorageCooldownHttpError(res, error); } @@ -20025,17 +21802,17 @@ import checkDiskSpaceModule2 from "check-disk-space"; init_settings(); init_authSettings(); import os5 from "os"; -import path19 from "path"; -import fs15 from "fs"; +import path20 from "path"; +import fs17 from "fs"; import axios3 from "axios"; -import crypto25 from "crypto"; +import crypto26 from "crypto"; import { rateLimit as rateLimit3 } from "express-rate-limit"; init_networkSecurity(); // src/services/oauthFlowStore.ts init_db(); init_credentialCrypto(); -import crypto24 from "node:crypto"; +import crypto25 from "node:crypto"; var OAuthFlowError = class extends Error { code = "OAUTH_FLOW_INVALID"; constructor() { @@ -20044,7 +21821,7 @@ var OAuthFlowError = class extends Error { } }; function sha256(value) { - return crypto24.createHash("sha256").update(value).digest("hex"); + return crypto25.createHash("sha256").update(value).digest("hex"); } function parsePendingConfig(value) { const parsed = typeof value === "string" ? JSON.parse(value) : value; @@ -20061,8 +21838,8 @@ var OAuthFlowStore = class { this.db = options.db ?? pool; this.ttlMs = options.ttlMs ?? 10 * 60 * 1e3; this.now = options.now ?? (() => Date.now()); - this.stateFactory = options.stateFactory ?? (() => crypto24.randomBytes(32).toString("base64url")); - this.nonceFactory = options.nonceFactory ?? (() => crypto24.randomBytes(24).toString("base64url")); + this.stateFactory = options.stateFactory ?? (() => crypto25.randomBytes(32).toString("base64url")); + this.nonceFactory = options.nonceFactory ?? (() => crypto25.randomBytes(24).toString("base64url")); } async ensureSchema() { if (!this.schemaPromise) { @@ -20393,10 +22170,11 @@ var telegramPinChangeLimiter = rateLimit3({ legacyHeaders: false }); var telegramUserLoginLimiter = rateLimit3({ windowMs: 15 * 60 * 1e3, max: 10, message: { error: "Telegram \u7528\u6237\u8D26\u53F7\u767B\u5F55\u8BF7\u6C42\u8FC7\u4E8E\u9891\u7E41\uFF0C\u8BF7\u7A0D\u540E\u518D\u8BD5" }, standardHeaders: true, legacyHeaders: false }); +var telegramUserLoginStatusLimiter = rateLimit3({ windowMs: 60 * 1e3, max: 60, message: { error: "Telegram \u767B\u5F55\u72B6\u6001\u67E5\u8BE2\u8FC7\u4E8E\u9891\u7E41\uFF0C\u8BF7\u7A0D\u540E\u518D\u8BD5" }, standardHeaders: true, legacyHeaders: false }); function getTelegramUserLoginSessionKey(req) { const token = getAuthToken(req); if (!token) throw new Error("UNAUTHORIZED"); - return crypto25.createHash("sha256").update(token).digest("base64url"); + return crypto26.createHash("sha256").update(token).digest("base64url"); } function sendTelegramUserLoginError(res, error) { const candidate = error; @@ -20430,7 +22208,7 @@ function sendStorageEndpointValidationError(res, error) { res.status(400).json({ error: safeMessages.includes(message) ? message : "\u65E0\u6CD5\u89E3\u6790\u5B58\u50A8\u7AEF\u70B9\u5730\u5740" }); } function sendOAuthSuccessPage(res, input) { - const nonce = crypto25.randomBytes(16).toString("base64"); + const nonce = crypto26.randomBytes(16).toString("base64"); res.setHeader("Content-Security-Policy", [ "default-src 'self'", "style-src 'unsafe-inline'", @@ -20443,7 +22221,7 @@ function sendOAuthSuccessPage(res, input) { res.type("html").send(renderOAuthSuccessPage({ ...input, scriptNonce: nonce })); } function sendOAuthFailurePage(res, input) { - const nonce = crypto25.randomBytes(16).toString("base64"); + const nonce = crypto26.randomBytes(16).toString("base64"); res.setHeader("Content-Security-Policy", [ "default-src 'self'", "style-src 'unsafe-inline'", @@ -20474,7 +22252,7 @@ router5.get("/stats", requireAuth, async (_req, res) => { const provider = target.provider; const activeAccountId = target.accountId; const scope = buildStorageScopeForTarget({ providerName: provider.name, accountId: activeAccountId }); - const diskPath = os5.platform() === "win32" ? "C:" : path19.resolve(UPLOAD_DIR6); + const diskPath = os5.platform() === "win32" ? "C:" : path20.resolve(UPLOAD_DIR6); const diskSpace = await checkDiskSpace2(diskPath); const result = await query(` SELECT COUNT(*) as file_count, COALESCE(SUM(size), 0) as total_size @@ -20513,16 +22291,16 @@ router5.get("/stats", requireAuth, async (_req, res) => { capabilities: buildStorageCapabilities(provider.name), // Transitional aliases for already-deployed clients. Indexed usage has no fake percentage. server: { - total: formatBytes3(payload.temporary.totalBytes), + total: formatBytes(payload.temporary.totalBytes), totalBytes: payload.temporary.totalBytes, - used: formatBytes3(payload.temporary.usedBytes), + used: formatBytes(payload.temporary.usedBytes), usedBytes: payload.temporary.usedBytes, - free: formatBytes3(payload.temporary.freeBytes), + free: formatBytes(payload.temporary.freeBytes), freeBytes: payload.temporary.freeBytes, usedPercent: payload.temporary.usedPercent }, tgvault: { - used: formatBytes3(payload.indexed.usedBytes), + used: formatBytes(payload.indexed.usedBytes), usedBytes: payload.indexed.usedBytes, fileCount: payload.indexed.fileCount } @@ -20548,7 +22326,7 @@ router5.get("/stats/types", requireAuth, async (_req, res) => { const stats = result.rows.map((row) => ({ type: row.type, count: parseInt(row.count), - size: formatBytes3(parseInt(row.total_size)), + size: formatBytes(parseInt(row.total_size)), sizeBytes: parseInt(row.total_size) })); res.json(stats); @@ -20557,13 +22335,6 @@ router5.get("/stats/types", requireAuth, async (_req, res) => { res.status(500).json({ error: "\u83B7\u53D6\u7C7B\u578B\u7EDF\u8BA1\u5931\u8D25" }); } }); -function formatBytes3(bytes) { - if (bytes === 0) return "0 B"; - const k = 1024; - const sizes = ["B", "KB", "MB", "GB", "TB"]; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; -} router5.get("/config", requireAuth, async (req, res) => { try { const { storageManager: storageManager2 } = await Promise.resolve().then(() => (init_storage(), storage_exports)); @@ -20701,6 +22472,7 @@ router5.put("/config/telegram-bot", requireAuth, async (req, res) => { try { if (enabled) await controls.restart(credentials); else await controls.stop(); + await initializeTelegramMultiAccountRuntime({ apiId: credentials.apiId, apiHash: credentials.apiHash }); } catch (activationError) { await restoreTelegramBotConfig(previous); const restored = await applyEffectiveTelegramBotConfig(); @@ -20727,6 +22499,7 @@ router5.post("/config/telegram-bot/migrate", requireAuth, async (req, res) => { setTelegramBotIdentity(bot); await applyEffectiveTelegramBotConfig(); await controls.restart(credentials); + await initializeTelegramMultiAccountRuntime({ apiId: credentials.apiId, apiHash: credentials.apiHash }); } catch (activationError) { await restoreTelegramBotConfig(previous); await applyEffectiveTelegramBotConfig(); @@ -20782,7 +22555,7 @@ router5.delete("/config/telegram-bot", requireAuth, async (req, res) => { await controls.stop(); await deleteTelegramBotConfig(); const sessionPath = process.env.TELEGRAM_SESSION_FILE || "./data/telegram_session.txt"; - if (fs15.existsSync(sessionPath)) fs15.rmSync(sessionPath, { force: true }); + if (fs17.existsSync(sessionPath)) fs17.rmSync(sessionPath, { force: true }); const effective = await applyEffectiveTelegramBotConfig(); if (effective.source === "environment" && effective.enabled && effective.credentials) await controls.restart(effective.credentials); }); @@ -20807,6 +22580,209 @@ router5.get("/config/telegram-user", requireAuth, async (_req, res) => { noStore(res); res.json(await getTelegramUserAccountStatus()); }); +router5.get("/config/telegram-user/accounts", requireAuth, async (_req, res) => { + noStore(res); + const accounts = await telegramAccountRepository.listAccounts(); + const publicAccounts = await Promise.all(accounts.map(async (account) => { + const permissions = await telegramAccountRepository.listAccessForAccount(account.id); + const permissionSummary = await telegramAccountRepository.getAccessSummaryForAccount(account.id); + const allowed = permissionSummary.allowed; + const denied = permissionSummary.denied; + const unknown = permissionSummary.unknown; + const lastCheckedAt = permissionSummary.lastCheckedAt?.toISOString?.() || null; + return { + id: account.id, + userId: account.telegramUserId, + username: account.username, + displayName: account.displayName, + enabled: account.enabled, + connected: Boolean(telegramUserClientPool.getAccountClient(account.id)?.connected), + health: !account.enabled ? "disabled" : account.healthState === "session_expired" ? "expired" : account.cooldownUntil && new Date(account.cooldownUntil).getTime() > Date.now() ? "cooldown" : telegramUserClientPool.getAccountClient(account.id) ? "ready" : "error", + checkedAt: lastCheckedAt, + lastError: account.lastError, + cooldownUntil: account.cooldownUntil ? new Date(account.cooldownUntil).toISOString() : null, + permissionSummary: { allowed, denied, unknown, total: permissionSummary.total, lastCheckedAt }, + permissions: permissions.map((row) => ({ sourceId: row.sourceKey, sourceName: row.sourceKey, status: row.accessState, checkedAt: row.checkedAt?.toISOString?.() || null, reason: row.lastError })), + scheduling: { weight: account.weight, activeDownloads: telegramUserClientPool.getActiveConnections(account.id), lastSelectedAt: null } + }; + })); + const aggregate = publicAccounts.reduce((sum, row) => ({ + allowed: sum.allowed + row.permissionSummary.allowed, + denied: sum.denied + row.permissionSummary.denied, + unknown: sum.unknown + row.permissionSummary.unknown, + total: sum.total + row.permissionSummary.total, + lastCheckedAt: !sum.lastCheckedAt || row.permissionSummary.lastCheckedAt && row.permissionSummary.lastCheckedAt > sum.lastCheckedAt ? row.permissionSummary.lastCheckedAt : sum.lastCheckedAt + }), { allowed: 0, denied: 0, unknown: 0, total: 0, lastCheckedAt: null }); + return res.json({ + accounts: publicAccounts, + summary: { total: publicAccounts.length, enabled: publicAccounts.filter((row) => row.enabled).length, ready: publicAccounts.filter((row) => row.health === "ready").length, coolingDown: publicAccounts.filter((row) => row.health === "cooldown").length, permissions: aggregate }, + scheduling: { strategy: "weighted_least_connections", description: "\u6743\u9650\u611F\u77E5\u7684\u52A0\u6743\u6700\u5C11\u8FDE\u63A5\uFF1B\u540C\u7B49\u8D1F\u8F7D\u81EA\u52A8\u8F6E\u6362\u8D26\u53F7" }, + accessSweep: getTelegramAccountAccessSweepSummary() + }); +}); +router5.post("/config/telegram-user/accounts/:accountId/enable", requireAuth, async (req, res) => { + noStore(res); + const ok = await telegramAccountRepository.setEnabled(req.params.accountId, true); + if (!ok) return res.status(404).json({ error: "\u8D26\u53F7\u4E0D\u5B58\u5728" }); + await telegramUserClientPool.refresh(); + return res.json({ success: true, enabled: true }); +}); +router5.post("/config/telegram-user/accounts/:accountId/disable", requireAuth, async (req, res) => { + noStore(res); + const ok = await telegramAccountRepository.setEnabled(req.params.accountId, false); + if (!ok) return res.status(404).json({ error: "\u8D26\u53F7\u4E0D\u5B58\u5728" }); + await telegramUserClientPool.refresh(); + return res.json({ success: true, enabled: false }); +}); +router5.delete("/config/telegram-user/accounts/:accountId", requireAuth, async (req, res) => { + noStore(res); + await telegramUserClientPool.expireAccount(req.params.accountId); + const ok = await telegramAccountRepository.deleteAccount(req.params.accountId); + return ok ? res.json({ success: true }) : res.status(404).json({ error: "\u8D26\u53F7\u4E0D\u5B58\u5728" }); +}); +router5.post("/config/telegram-user/accounts/:accountId/check", requireAuth, async (req, res) => { + noStore(res); + const summary = await triggerTelegramAccountAccessSweep({ accountIds: [req.params.accountId], reason: "manual" }); + return res.json({ success: true, summary }); +}); +router5.post("/config/telegram-user/accounts/:accountId/permissions/check", requireAuth, async (req, res) => { + noStore(res); + const summary = await triggerTelegramAccountAccessSweep({ accountIds: [req.params.accountId], reason: "manual" }); + return res.json({ success: true, summary }); +}); +router5.post("/config/telegram-user/access/check-all", requireAuth, async (_req, res) => { + noStore(res); + return res.json({ success: true, summary: await triggerTelegramAccountAccessSweep({ reason: "manual" }) }); +}); +router5.post("/config/telegram-user/accounts/permissions/check-all", requireAuth, async (_req, res) => { + noStore(res); + return res.json({ success: true, summary: await triggerTelegramAccountAccessSweep({ reason: "manual" }) }); +}); +function normalizeMultiAccountLoginPayload(payload) { + if (!payload || typeof payload !== "object") return payload; + const status = payload.status === "pending" ? "waiting_for_scan" : payload.status; + const stepStatus = payload.step === "password_required" ? "password_required" : payload.step === "complete" ? "complete" : void 0; + return { + ...payload, + status: stepStatus || status, + qrCode: payload.qrCode || payload.qrData || void 0, + qrExpiresAt: payload.qrExpiresAt || payload.expiresAt, + message: payload.message || payload.error || void 0 + }; +} +router5.post("/config/telegram-user/accounts/login/qr", requireAuth, telegramUserLoginLimiter, async (req, res) => { + noStore(res); + try { + return res.json(normalizeMultiAccountLoginPayload(await telegramMultiAccountLoginFlows.startQr(getTelegramUserLoginSessionKey(req)))); + } catch (error) { + return sendTelegramUserLoginError(res, error); + } +}); +router5.post("/config/telegram-user/login/qr", requireAuth, telegramUserLoginLimiter, async (req, res) => { + noStore(res); + try { + return res.json(normalizeMultiAccountLoginPayload(await telegramMultiAccountLoginFlows.startQr(getTelegramUserLoginSessionKey(req)))); + } catch (error) { + return sendTelegramUserLoginError(res, error); + } +}); +router5.get("/config/telegram-user/login/:flowId", requireAuth, telegramUserLoginStatusLimiter, async (req, res) => { + noStore(res); + try { + return res.json(normalizeMultiAccountLoginPayload(await telegramMultiAccountLoginFlows.getQrStatus(getTelegramUserLoginSessionKey(req), req.params.flowId))); + } catch (error) { + return sendTelegramUserLoginError(res, error); + } +}); +router5.delete("/config/telegram-user/login/:flowId", requireAuth, telegramUserLoginLimiter, async (req, res) => { + noStore(res); + try { + return res.json(await telegramMultiAccountLoginFlows.cancel(getTelegramUserLoginSessionKey(req), req.params.flowId)); + } catch (error) { + return sendTelegramUserLoginError(res, error); + } +}); +router5.post("/config/telegram-user/login/phone", requireAuth, telegramUserLoginLimiter, async (req, res) => { + noStore(res); + try { + const started = await telegramMultiAccountLoginFlows.startPhone(getTelegramUserLoginSessionKey(req), req.body?.phone); + return res.json({ ...started, status: "code_required", message: started.delivery === "app" ? "\u9A8C\u8BC1\u7801\u5DF2\u53D1\u9001\u5230 Telegram \u5E94\u7528" : "\u9A8C\u8BC1\u7801\u5DF2\u901A\u8FC7\u77ED\u4FE1\u53D1\u9001" }); + } catch (error) { + return sendTelegramUserLoginError(res, error); + } +}); +router5.post("/config/telegram-user/login/:flowId/code", requireAuth, telegramUserLoginLimiter, async (req, res) => { + noStore(res); + try { + return res.json(normalizeMultiAccountLoginPayload(await telegramMultiAccountLoginFlows.submitCode(getTelegramUserLoginSessionKey(req), req.params.flowId, req.body?.code))); + } catch (error) { + return sendTelegramUserLoginError(res, error); + } +}); +router5.post("/config/telegram-user/login/:flowId/password", requireAuth, telegramUserLoginLimiter, async (req, res) => { + noStore(res); + try { + return res.json(normalizeMultiAccountLoginPayload(await telegramMultiAccountLoginFlows.submitPassword(getTelegramUserLoginSessionKey(req), req.params.flowId, req.body?.password))); + } catch (error) { + return sendTelegramUserLoginError(res, error); + } +}); +router5.post("/config/telegram-accounts/login/phone", requireAuth, telegramUserLoginLimiter, async (req, res) => { + noStore(res); + try { + return res.json(await telegramMultiAccountLoginFlows.startPhone(getTelegramUserLoginSessionKey(req), req.body?.phone)); + } catch (error) { + return sendTelegramUserLoginError(res, error); + } +}); +router5.post("/config/telegram-accounts/login/code", requireAuth, telegramUserLoginLimiter, async (req, res) => { + noStore(res); + try { + return res.json(await telegramMultiAccountLoginFlows.submitCode(getTelegramUserLoginSessionKey(req), req.body?.flowId, req.body?.code)); + } catch (error) { + return sendTelegramUserLoginError(res, error); + } +}); +router5.post("/config/telegram-accounts/login/password", requireAuth, telegramUserLoginLimiter, async (req, res) => { + noStore(res); + try { + return res.json(await telegramMultiAccountLoginFlows.submitPassword(getTelegramUserLoginSessionKey(req), req.body?.flowId, req.body?.password)); + } catch (error) { + return sendTelegramUserLoginError(res, error); + } +}); +router5.post("/config/telegram-accounts/login/qr", requireAuth, telegramUserLoginLimiter, async (req, res) => { + noStore(res); + try { + return res.json(await telegramMultiAccountLoginFlows.startQr(getTelegramUserLoginSessionKey(req))); + } catch (error) { + return sendTelegramUserLoginError(res, error); + } +}); +router5.get("/config/telegram-accounts/login/qr/:flowId", requireAuth, telegramUserLoginStatusLimiter, async (req, res) => { + noStore(res); + try { + return res.json(await telegramMultiAccountLoginFlows.getQrStatus(getTelegramUserLoginSessionKey(req), req.params.flowId)); + } catch (error) { + return sendTelegramUserLoginError(res, error); + } +}); +router5.post("/config/telegram-accounts/login/qr/:flowId/refresh", requireAuth, telegramUserLoginLimiter, async (req, res) => { + noStore(res); + try { + return res.json(await telegramMultiAccountLoginFlows.refreshQr(getTelegramUserLoginSessionKey(req), req.params.flowId)); + } catch (error) { + return sendTelegramUserLoginError(res, error); + } +}); +router5.delete("/config/telegram-accounts/login/:flowId", requireAuth, telegramUserLoginLimiter, async (req, res) => { + noStore(res); + try { + return res.json(await telegramMultiAccountLoginFlows.cancel(getTelegramUserLoginSessionKey(req), req.params.flowId)); + } catch (error) { + return sendTelegramUserLoginError(res, error); + } +}); router5.post("/config/telegram-user/login/phone", requireAuth, telegramUserLoginLimiter, async (req, res) => { noStore(res); try { @@ -21348,10 +23324,10 @@ var storage_default = router5; // src/routes/chunkedUpload.ts init_db(); import { Router as Router6 } from "express"; -import crypto28 from "node:crypto"; -import fs17 from "node:fs"; +import crypto29 from "node:crypto"; +import fs19 from "node:fs"; import fsPromises2 from "node:fs/promises"; -import path21 from "node:path"; +import path22 from "node:path"; import { pipeline as pipeline3 } from "node:stream/promises"; import { rateLimit as rateLimit4 } from "express-rate-limit"; import checkDiskSpaceModule3 from "check-disk-space"; @@ -21359,7 +23335,7 @@ init_storage(); init_storageAccountLifecycle(); // src/services/chunkUploadReconciliation.ts -import crypto26 from "node:crypto"; +import crypto27 from "node:crypto"; async function ownsChunkReconciliationLease(db, operationId, leaseToken) { const result = await db.query( `UPDATE chunk_upload_reconciliations @@ -21458,7 +23434,7 @@ async function resolveClaimedChunkReconciliation(input) { return resolved ? "resolved" : "pending"; } async function beginChunkCompletionReconciliation(db, input) { - const operationId = crypto26.randomUUID(); + const operationId = crypto27.randomUUID(); const result = await db.query( `INSERT INTO chunk_upload_reconciliations (operation_id, upload_id, completion_token, provider, account_id, object_state, index_state, reason, status, created_at, updated_at) @@ -21548,10 +23524,10 @@ async function compensateChunkCompletionFailure(input) { } // src/services/chunkUploadSessions.ts -import crypto27 from "node:crypto"; -import fs16 from "node:fs"; +import crypto28 from "node:crypto"; +import fs18 from "node:fs"; import fsPromises from "node:fs/promises"; -import path20 from "node:path"; +import path21 from "node:path"; import { pipeline as pipeline2 } from "node:stream/promises"; var ChunkUploadProtocolError = class extends Error { constructor(name, message) { @@ -21560,8 +23536,8 @@ var ChunkUploadProtocolError = class extends Error { } }; async function writeChunkAtomically(input) { - await fsPromises.mkdir(path20.dirname(input.finalPath), { recursive: true }); - const committedPath = `${input.finalPath}.${crypto27.randomUUID()}.chunk`; + await fsPromises.mkdir(path21.dirname(input.finalPath), { recursive: true }); + const committedPath = `${input.finalPath}.${crypto28.randomUUID()}.chunk`; const temporaryPath = `${committedPath}.part`; const lockPath = `${input.finalPath}.lock`; let lockHandle; @@ -21571,7 +23547,7 @@ async function writeChunkAtomically(input) { if (error?.code === "EEXIST") throw new ChunkUploadProtocolError("ChunkWriteBusyError", "\u540C\u4E00\u5206\u5757\u6B63\u5728\u5199\u5165\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5"); throw error; } - const hash2 = crypto27.createHash("sha256"); + const hash2 = crypto28.createHash("sha256"); let size = 0; const counter = new (await import("node:stream")).Transform({ transform(chunk, _encoding, callback) { @@ -21582,7 +23558,7 @@ async function writeChunkAtomically(input) { } }); try { - await pipeline2(input.stream, counter, fs16.createWriteStream(temporaryPath, { flags: "wx" })); + await pipeline2(input.stream, counter, fs18.createWriteStream(temporaryPath, { flags: "wx" })); if (size !== input.expectedSize) throw new ChunkUploadProtocolError("ChunkSizeMismatchError", "\u5206\u5757\u5927\u5C0F\u4E0D\u5339\u914D"); const sha2562 = hash2.digest("hex"); if (sha2562 !== input.expectedSha256.toLowerCase()) throw new ChunkUploadProtocolError("ChunkHashMismatchError", "\u5206\u5757\u54C8\u5E0C\u4E0D\u5339\u914D"); @@ -21597,15 +23573,15 @@ async function writeChunkAtomically(input) { } } async function verifyChunkIntegrity(chunk, expectedDirectory, maxChunkBytes) { - const chunkPath = path20.resolve(chunk.path); - const directory = path20.resolve(expectedDirectory); - if (path20.dirname(chunkPath) !== directory) throw new ChunkUploadProtocolError("ChunkPathError", `\u5206\u5757 ${chunk.index} \u8DEF\u5F84\u65E0\u6548`); + const chunkPath = path21.resolve(chunk.path); + const directory = path21.resolve(expectedDirectory); + if (path21.dirname(chunkPath) !== directory) throw new ChunkUploadProtocolError("ChunkPathError", `\u5206\u5757 ${chunk.index} \u8DEF\u5F84\u65E0\u6548`); const stat = await fsPromises.stat(chunkPath); if (stat.size !== chunk.size || stat.size < 1 || stat.size > maxChunkBytes) { throw new ChunkUploadProtocolError("ChunkSizeMismatchError", `\u5206\u5757 ${chunk.index} \u5927\u5C0F\u65E0\u6548`); } - const hash2 = crypto27.createHash("sha256"); - await pipeline2(fs16.createReadStream(chunkPath), new (await import("node:stream")).Writable({ + const hash2 = crypto28.createHash("sha256"); + await pipeline2(fs18.createReadStream(chunkPath), new (await import("node:stream")).Writable({ write(buffer, _encoding, callback) { hash2.update(buffer); callback(); @@ -22092,7 +24068,7 @@ var PostgresChunkUploadSessionRepository = class { var router6 = Router6(); var checkDiskSpace3 = checkDiskSpaceModule3.default || checkDiskSpaceModule3; var UPLOAD_DIR7 = process.env.UPLOAD_DIR || "./data/uploads"; -var THUMBNAIL_DIR6 = process.env.THUMBNAIL_DIR || "./data/thumbnails"; +var THUMBNAIL_DIR5 = process.env.THUMBNAIL_DIR || "./data/thumbnails"; var CHUNK_DIR = process.env.CHUNK_DIR || "./data/chunks"; var MAX_CHUNK_BYTES = Math.max(1024 * 1024, (parseInt(process.env.MAX_UPLOAD_CHUNK_MB || "32", 10) || 32) * 1024 * 1024); var MAX_TOTAL_BYTES = Math.max(MAX_CHUNK_BYTES, (parseInt(process.env.MAX_CHUNK_UPLOAD_GB || "20", 10) || 20) * 1024 ** 3); @@ -22101,16 +24077,16 @@ var DISK_RESERVE_BYTES = Math.max(1024 ** 3, (parseInt(process.env.CHUNK_DISK_RE var MAX_TOTAL_CHUNKS = Math.max(1, parseInt(process.env.MAX_TOTAL_CHUNKS || "50000", 10) || 5e4); var SESSION_TTL_MS = Math.max(60 * 60 * 1e3, parseInt(process.env.CHUNK_SESSION_TTL_MS || String(24 * 60 * 60 * 1e3), 10)); var COMPLETION_LEASE_MS = Math.max(6e4, parseInt(process.env.CHUNK_COMPLETION_LEASE_MS || String(30 * 60 * 1e3), 10)); -[UPLOAD_DIR7, THUMBNAIL_DIR6, CHUNK_DIR].forEach((dir) => fs17.mkdirSync(dir, { recursive: true })); +[UPLOAD_DIR7, THUMBNAIL_DIR5, CHUNK_DIR].forEach((dir) => fs19.mkdirSync(dir, { recursive: true })); var chunkRepository = new PostgresChunkUploadSessionRepository(pool); var chunkStore = new ChunkUploadSessionStore(chunkRepository, { maxTotalBytes: MAX_TOTAL_BYTES, globalBudgetBytes: GLOBAL_BUDGET_BYTES, diskReserveBytes: DISK_RESERVE_BYTES, - getDiskFreeBytes: async () => (await checkDiskSpace3(path21.resolve(CHUNK_DIR))).free + getDiskFreeBytes: async () => (await checkDiskSpace3(path22.resolve(CHUNK_DIR))).free }); var runChunkMaintenance = async () => { - const reconciliationLease = crypto28.randomUUID(); + const reconciliationLease = crypto29.randomUUID(); const pending = await claimChunkReconciliations(pool, reconciliationLease, 100); for (const row of pending) { const target = storageManager.getTarget(row.provider, row.accountId); @@ -22123,7 +24099,7 @@ var runChunkMaintenance = async () => { } const expiredIds = await chunkRepository.deleteExpiredSessions(100); await Promise.all(expiredIds.map( - (uploadId) => fsPromises2.rm(path21.join(CHUNK_DIR, uploadId), { recursive: true, force: true }).catch((error) => console.error(`\u6E05\u7406\u8FC7\u671F\u5206\u5757\u76EE\u5F55\u5931\u8D25: ${uploadId}`, error)) + (uploadId) => fsPromises2.rm(path22.join(CHUNK_DIR, uploadId), { recursive: true, force: true }).catch((error) => console.error(`\u6E05\u7406\u8FC7\u671F\u5206\u5757\u76EE\u5F55\u5931\u8D25: ${uploadId}`, error)) )); await chunkRepository.recoverExpiredCompletions(100); }; @@ -22144,7 +24120,7 @@ function ownerId(req) { return stableWebAdminPrincipalId(); } function stableWebAdminPrincipalId() { - return crypto28.createHash("sha256").update("tg-vault:web-admin:v1").digest("hex"); + return crypto29.createHash("sha256").update("tg-vault:web-admin:v1").digest("hex"); } function decodeFilename2(filename) { try { @@ -22160,14 +24136,7 @@ function decodeFilename2(filename) { return filename; } function safeChunkPath(uploadId, chunkIndex) { - return path21.join(path21.resolve(CHUNK_DIR), uploadId, `chunk_${chunkIndex}`); -} -function getFileType2(mimeType) { - if (mimeType.startsWith("image/")) return "image"; - if (mimeType.startsWith("video/")) return "video"; - if (mimeType.startsWith("audio/")) return "audio"; - if (/pdf|document|text|word|excel|spreadsheet|powerpoint|presentation|markdown|json|xml|sql/i.test(mimeType)) return "document"; - return "other"; + return path22.join(path22.resolve(CHUNK_DIR), uploadId, `chunk_${chunkIndex}`); } function sendProtocolError(res, error) { if (error instanceof ChunkUploadProtocolError) { @@ -22201,14 +24170,14 @@ router6.post("/init", async (req, res) => { const client2 = await pool.connect(); try { await client2.query("BEGIN"); - const selected = await lockStorageTargetForUse( + const selected3 = await lockStorageTargetForUse( client2, String(targetProvider), targetAccountId ? String(targetAccountId) : null ); - admissionLease = await acquireStorageAccountOperationLease(pool, selected.accountId, "chunk_admission"); + admissionLease = await acquireStorageAccountOperationLease(pool, selected3.accountId, "chunk_admission"); await client2.query("COMMIT"); - target = storageManager.getTarget(selected.provider, selected.accountId); + target = storageManager.getTarget(selected3.provider, selected3.accountId); } catch (error) { await client2.query("ROLLBACK").catch(() => void 0); throw error; @@ -22220,7 +24189,7 @@ router6.post("/init", async (req, res) => { } const now = /* @__PURE__ */ new Date(); const session = { - uploadId: crypto28.randomUUID(), + uploadId: crypto29.randomUUID(), ownerId: ownerId(req), filename: decodeFilename2(filename).slice(0, 255), mimeType: mimeType.slice(0, 100), @@ -22239,7 +24208,7 @@ router6.post("/init", async (req, res) => { createdAt: now, updatedAt: now }; - uploadDirectory = path21.join(CHUNK_DIR, session.uploadId); + uploadDirectory = path22.join(CHUNK_DIR, session.uploadId); await fsPromises2.mkdir(uploadDirectory, { recursive: true }); await chunkStore.reserve(session); res.json({ @@ -22308,17 +24277,17 @@ router6.post("/chunk", async (req, res) => { } }); async function mergeChunks(uploadId, chunks, targetPath, expectedBytes) { - const temporary = `${targetPath}.${crypto28.randomUUID()}.part`; - await fsPromises2.mkdir(path21.dirname(targetPath), { recursive: true }); - const output = fs17.createWriteStream(temporary, { flags: "wx" }); + const temporary = `${targetPath}.${crypto29.randomUUID()}.part`; + await fsPromises2.mkdir(path22.dirname(targetPath), { recursive: true }); + const output = fs19.createWriteStream(temporary, { flags: "wx" }); try { if (chunks.length === 0) throw new Error("\u5206\u5757\u4E0D\u5B8C\u6574"); for (let index = 0; index < chunks.length; index++) { const chunk = chunks[index]; - const expectedDirectory = path21.dirname(path21.resolve(safeChunkPath(uploadId, index))); + const expectedDirectory = path22.dirname(path22.resolve(safeChunkPath(uploadId, index))); if (chunk.index !== index) throw new Error(`\u5206\u5757 ${index} \u5143\u6570\u636E\u65E0\u6548`); const verifiedPath = await verifyChunkIntegrity(chunk, expectedDirectory, MAX_CHUNK_BYTES); - await pipeline3(fs17.createReadStream(verifiedPath), output, { end: false }); + await pipeline3(fs19.createReadStream(verifiedPath), output, { end: false }); } await new Promise((resolve, reject) => { output.end(resolve); @@ -22367,7 +24336,7 @@ router6.post("/complete", async (req, res) => { if (current3.status === "failed") { return res.status(409).json({ error: "\u4E0A\u6B21\u5B8C\u6210\u5931\u8D25\uFF0C\u8BF7\u5148\u91CD\u8BD5\u4E0A\u4F20\u4F1A\u8BDD", retryable: true, lastError: current3.lastError }); } - token = crypto28.randomUUID(); + token = crypto29.randomUUID(); const claim = await chunkStore.claimCompletion(uploadId, owner, token, new Date(Date.now() + COMPLETION_LEASE_MS)); if (!claim) return res.status(409).json({ error: "\u4E0A\u4F20\u672A\u5B8C\u6574\u3001\u5DF2\u7531\u5176\u4ED6\u8BF7\u6C42\u5904\u7406\u6216\u72B6\u6001\u4E0D\u53EF\u5B8C\u6210" }); completionHeartbeat = setInterval(() => { @@ -22389,13 +24358,13 @@ router6.post("/complete", async (req, res) => { fileName: session.filename }, await getStoragePathRules()); const storedName = await getUniqueStoredName(session.filename, storageFolder, session.targetAccountId); - tempMergedPath = path21.join(path21.resolve(UPLOAD_DIR7), `${uploadId}-${storedName}`); + tempMergedPath = path22.join(path22.resolve(UPLOAD_DIR7), `${uploadId}-${storedName}`); await mergeChunks(uploadId, claim.chunks, tempMergedPath, session.totalSize); const duplicate = await getDuplicateMode() === "skip" ? await findDuplicateFile(session.filename, storageFolder, session.totalSize, session.targetAccountId) : null; if (duplicate) { await fsPromises2.rm(tempMergedPath, { force: true }); if (!await chunkStore.complete(uploadId, owner, token, duplicate.id)) throw new Error("\u5B8C\u6210\u79DF\u7EA6\u5DF2\u5931\u6548"); - await fsPromises2.rm(path21.join(CHUNK_DIR, uploadId), { recursive: true, force: true }).catch((error) => console.error("\u6E05\u7406\u5DF2\u5B8C\u6210\u91CD\u590D\u4E0A\u4F20\u7684\u5206\u5757\u5931\u8D25:", error)); + await fsPromises2.rm(path22.join(CHUNK_DIR, uploadId), { recursive: true, force: true }).catch((error) => console.error("\u6E05\u7406\u5DF2\u5B8C\u6210\u91CD\u590D\u4E0A\u4F20\u7684\u5206\u5757\u5931\u8D25:", error)); return res.json({ success: true, skipped: true, @@ -22409,22 +24378,8 @@ router6.post("/complete", async (req, res) => { } }); } - let thumbnailPath = null; - let previewPath = null; - let width = null; - let height = null; - if (session.mimeType.startsWith("image/") || session.mimeType.startsWith("video/")) { - const thumbnail = await generateThumbnail(tempMergedPath, storedName, session.mimeType).catch(() => null); - thumbnailPath = thumbnail ? path21.basename(thumbnail) : null; - const dimensions = await getImageDimensions(tempMergedPath, session.mimeType).catch(() => ({ width: null, height: null })); - width = dimensions.width; - height = dimensions.height; - } - if (session.mimeType.startsWith("image/")) { - const preview = await generateMediaPreview(tempMergedPath, storedName, session.mimeType).catch(() => null); - previewPath = preview ? path21.basename(preview) : null; - } - const type = getFileType2(session.mimeType); + const type = getFileType(session.mimeType); + const derivativeStatus = type === "image" || type === "video" ? "queued" : "not_required"; const operationId = await beginChunkCompletionReconciliation(pool, { uploadId, completionToken: token, @@ -22463,8 +24418,9 @@ router6.post("/complete", async (req, res) => { await markChunkReconciliationObjectPresent(pool, operationId, storedPath); const indexed = await query( `INSERT INTO files - (name, stored_name, type, mime_type, size, path, thumbnail_path, preview_path, width, height, source, folder, storage_account_id) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) + (name, stored_name, type, mime_type, size, path, source, folder, storage_account_id, + derivative_status, derivative_source_path, derivative_cleanup_source) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) RETURNING id, created_at, name, type, size, source`, [ session.filename, @@ -22473,13 +24429,12 @@ router6.post("/complete", async (req, res) => { session.mimeType, session.totalSize, storedPath, - thumbnailPath, - previewPath, - width, - height, target.provider.name, storageFolder, - session.targetAccountId + session.targetAccountId, + derivativeStatus, + derivativeStatus === "queued" ? target.provider.name === "local" ? storedPath : tempMergedPath : null, + derivativeStatus === "queued" && target.provider.name !== "local" ] ); file = indexed.rows[0]; @@ -22502,15 +24457,14 @@ router6.post("/complete", async (req, res) => { await compensateAfterCompletionFailure(completionError); throw completionError; } - await fsPromises2.rm(path21.join(CHUNK_DIR, uploadId), { recursive: true, force: true }).catch((error) => console.error("\u6E05\u7406\u5DF2\u5B8C\u6210\u4E0A\u4F20\u7684\u5206\u5757\u5931\u8D25:", error)); - if (type === "video") { - const previewSource = target.provider.name === "local" ? storedPath : tempMergedPath; - void generateMediaPreview(previewSource, storedName, session.mimeType).then(async (preview) => { - if (preview) await query("UPDATE files SET preview_path = $1 WHERE id = $2", [path21.basename(preview), file.id]); - }).catch((error) => console.error("\u5F02\u6B65\u751F\u6210\u89C6\u9891\u9884\u89C8\u5931\u8D25:", error)).finally(async () => { - if (target.provider.name !== "local") { - await fsPromises2.rm(tempMergedPath, { force: true }).catch((error) => console.error("\u6E05\u7406\u5408\u5E76\u4E34\u65F6\u6587\u4EF6\u5931\u8D25:", error)); - } + await fsPromises2.rm(path22.join(CHUNK_DIR, uploadId), { recursive: true, force: true }).catch((error) => console.error("\u6E05\u7406\u5DF2\u5B8C\u6210\u4E0A\u4F20\u7684\u5206\u5757\u5931\u8D25:", error)); + if (derivativeStatus === "queued") { + enqueueMediaDerivatives({ + fileId: String(file.id), + sourcePath: target.provider.name === "local" ? storedPath : tempMergedPath, + storedName, + mimeType: session.mimeType, + cleanupSource: target.provider.name !== "local" }); } else { await fsPromises2.rm(tempMergedPath, { force: true }).catch((error) => console.error("\u6E05\u7406\u5408\u5E76\u4E34\u65F6\u6587\u4EF6\u5931\u8D25:", error)); @@ -22522,7 +24476,7 @@ router6.post("/complete", async (req, res) => { name: file.name, type: file.type, size: file.size, - thumbnailUrl: thumbnailPath ? getSignedUrl(file.id, "thumbnail") : void 0, + derivativeStatus, previewUrl: getSignedUrl(file.id, "preview"), date: file.created_at, source: target.provider.name, @@ -22586,7 +24540,7 @@ router6.delete("/:uploadId", async (req, res) => { try { const result = await chunkStore.cancel(req.params.uploadId, ownerId(req)); if (result === "busy") return res.status(409).json({ error: "\u4E0A\u4F20\u6B63\u5728\u5B8C\u6210\uFF0C\u6682\u65F6\u4E0D\u80FD\u53D6\u6D88", status: result }); - if (result === "cancelled") await fsPromises2.rm(path21.join(CHUNK_DIR, req.params.uploadId), { recursive: true, force: true }); + if (result === "cancelled") await fsPromises2.rm(path22.join(CHUNK_DIR, req.params.uploadId), { recursive: true, force: true }); res.status(result === "not_found" ? 404 : 200).json({ success: result !== "not_found", status: result }); } catch (error) { sendProtocolError(res, error); @@ -22620,8 +24574,8 @@ var chunkedUpload_default = router6; // src/routes/tasks.ts init_db(); import { Router as Router7 } from "express"; -import fs18 from "node:fs/promises"; -import path22 from "node:path"; +import fs20 from "node:fs/promises"; +import path23 from "node:path"; // src/services/taskCenterDismissals.ts init_db(); @@ -22653,7 +24607,7 @@ async function saveTaskCenterDismissals(items) { } // src/routes/tasks.ts -import crypto29 from "node:crypto"; +import crypto30 from "node:crypto"; var router7 = Router7(); var CHUNK_DIR2 = process.env.CHUNK_DIR || "./data/chunks"; async function collectUnifiedTasks(limit, accountId) { @@ -22844,17 +24798,17 @@ router7.post("/dismissals/prepare", requireAuth, async (req, res) => { const requested = Array.isArray(req.body?.tasks) ? req.body.tasks : []; const requestedKeys = new Set(requested.map((item) => `${String(item.sourceType)}:${String(item.id)}`)); const all = filterDismissedTasks(await collectUnifiedTasks(500, accountId || void 0), await loadTaskCenterDismissals()); - const selected = all.filter((task) => isTaskDismissible(task)).filter((task) => requestedKeys.size > 0 ? requestedKeys.has(`${task.sourceType}:${task.id}`) : (!source || task.sourceType === source) && (!status || task.status === status)).map((task) => ({ sourceType: task.sourceType, id: task.id, status: task.status, title: task.title, updatedAt: task.updatedAt })); - if (selected.length === 0) return res.status(409).json({ error: "\u5F53\u524D\u8303\u56F4\u6CA1\u6709\u53EF\u5220\u9664\u7684\u7EC8\u6001\u8BB0\u5F55" }); - const context = JSON.stringify(selected); - const snapshotId = crypto29.createHash("sha256").update(context).digest("hex"); - const bySource = Object.fromEntries([...new Set(selected.map((item) => item.sourceType))].map((key) => [key, selected.filter((item) => item.sourceType === key).length])); - const byStatus = Object.fromEntries([...new Set(selected.map((item) => item.status))].map((key) => [key, selected.filter((item) => item.status === key).length])); + const selected3 = all.filter((task) => isTaskDismissible(task)).filter((task) => requestedKeys.size > 0 ? requestedKeys.has(`${task.sourceType}:${task.id}`) : (!source || task.sourceType === source) && (!status || task.status === status)).map((task) => ({ sourceType: task.sourceType, id: task.id, status: task.status, title: task.title, updatedAt: task.updatedAt })); + if (selected3.length === 0) return res.status(409).json({ error: "\u5F53\u524D\u8303\u56F4\u6CA1\u6709\u53EF\u5220\u9664\u7684\u7EC8\u6001\u8BB0\u5F55" }); + const context = JSON.stringify(selected3); + const snapshotId = crypto30.createHash("sha256").update(context).digest("hex"); + const bySource = Object.fromEntries([...new Set(selected3.map((item) => item.sourceType))].map((key) => [key, selected3.filter((item) => item.sourceType === key).length])); + const byStatus = Object.fromEntries([...new Set(selected3.map((item) => item.status))].map((key) => [key, selected3.filter((item) => item.status === key).length])); res.json({ ...webDestructiveConfirmationStore.issue({ authToken, action: "dismiss_tasks", objectId: snapshotId, context }), snapshotId, context, - impact: { count: selected.length, bySource, byStatus, filesDeleted: false, cloudObjectsDeleted: false, subscriptionsDeleted: false } + impact: { count: selected3.length, bySource, byStatus, filesDeleted: false, cloudObjectsDeleted: false, subscriptionsDeleted: false } }); } catch (error) { res.status(500).json({ error: error instanceof Error ? error.message : "\u521B\u5EFA\u5220\u9664\u9884\u89C8\u5931\u8D25" }); @@ -23002,7 +24956,7 @@ router7.post("/:sourceType/:id/:action", requireAuth, async (req, res) => { [id] ); if ((result.rowCount || 0) === 0) return res.status(409).json({ error: "\u4E0A\u4F20\u6B63\u5728\u5B8C\u6210\u6216\u5DF2\u7ED3\u675F\uFF0C\u4E0D\u80FD\u53D6\u6D88" }); - await fs18.rm(path22.join(CHUNK_DIR2, id), { recursive: true, force: true }); + await fs20.rm(path23.join(CHUNK_DIR2, id), { recursive: true, force: true }); return res.json({ success: true }); } return res.status(400).json({ error: "\u8BE5\u4EFB\u52A1\u7C7B\u578B\u6682\u4E0D\u652F\u6301\u63A7\u5236" }); @@ -23051,7 +25005,7 @@ function createSystemRouter(checker) { init_authSettings(); init_db(); import helmet from "helmet"; -import crypto30 from "node:crypto"; +import crypto31 from "node:crypto"; // src/utils/runtimeConfig.ts var NUMBER_SPECS = [ @@ -23213,7 +25167,7 @@ function logRuntimeConfigSummary(summary) { // package.json var package_default = { name: "tg-vault-backend", - version: "2.2.1", + version: "2.3.0", type: "module", scripts: { dev: "tsx watch src/index.ts", @@ -23506,24 +25460,44 @@ var updateChecker = createUpdateChecker({ sendBotMessage: sendUpdateNotificationToUser }); var updateCheckTimer = null; +var readinessProbeTimer = null; +var dependencyReadiness = { ready: false, checkedAt: null, error: "\u4F9D\u8D56\u5C1A\u672A\u68C0\u67E5" }; +async function refreshDependencyReadiness() { + try { + await ensureDatabaseInitialized(); + await pool.query("SELECT 1"); + const { storageManager: storageManager2 } = await Promise.resolve().then(() => (init_storage(), storage_exports)); + await storageManager2.assertReady(); + const twoFactor = await get2FAReadiness(); + if (!twoFactor.ready) throw new Error("2FA \u5BC6\u94A5\u4E0D\u53EF\u8BFB\u53D6"); + dependencyReadiness = { ready: true, checkedAt: (/* @__PURE__ */ new Date()).toISOString(), error: null }; + } catch (error) { + dependencyReadiness = { + ready: false, + checkedAt: (/* @__PURE__ */ new Date()).toISOString(), + error: error instanceof Error ? error.message : String(error) + }; + } + return dependencyReadiness; +} var UPLOAD_DIR8 = process.env.UPLOAD_DIR || "./data/uploads"; -var THUMBNAIL_DIR7 = process.env.THUMBNAIL_DIR || "./data/thumbnails"; +var THUMBNAIL_DIR6 = process.env.THUMBNAIL_DIR || "./data/thumbnails"; var PREVIEW_DIR4 = process.env.PREVIEW_DIR || "./data/previews"; var CHUNK_DIR3 = process.env.CHUNK_DIR || "./data/chunks"; -if (!fs19.existsSync(UPLOAD_DIR8)) { - fs19.mkdirSync(UPLOAD_DIR8, { recursive: true }); +if (!fs21.existsSync(UPLOAD_DIR8)) { + fs21.mkdirSync(UPLOAD_DIR8, { recursive: true }); console.log(`\u{1F4C1} \u521B\u5EFA\u4E0A\u4F20\u76EE\u5F55: ${UPLOAD_DIR8}`); } -if (!fs19.existsSync(THUMBNAIL_DIR7)) { - fs19.mkdirSync(THUMBNAIL_DIR7, { recursive: true }); - console.log(`\u{1F4C1} \u521B\u5EFA\u7F29\u7565\u56FE\u76EE\u5F55: ${THUMBNAIL_DIR7}`); +if (!fs21.existsSync(THUMBNAIL_DIR6)) { + fs21.mkdirSync(THUMBNAIL_DIR6, { recursive: true }); + console.log(`\u{1F4C1} \u521B\u5EFA\u7F29\u7565\u56FE\u76EE\u5F55: ${THUMBNAIL_DIR6}`); } -if (!fs19.existsSync(PREVIEW_DIR4)) { - fs19.mkdirSync(PREVIEW_DIR4, { recursive: true }); +if (!fs21.existsSync(PREVIEW_DIR4)) { + fs21.mkdirSync(PREVIEW_DIR4, { recursive: true }); console.log(`\u{1F39E}\uFE0F \u521B\u5EFA\u9884\u89C8\u76EE\u5F55: ${PREVIEW_DIR4}`); } -if (!fs19.existsSync(CHUNK_DIR3)) { - fs19.mkdirSync(CHUNK_DIR3, { recursive: true }); +if (!fs21.existsSync(CHUNK_DIR3)) { + fs21.mkdirSync(CHUNK_DIR3, { recursive: true }); console.log(`\u{1F4C1} \u521B\u5EFA\u5206\u5757\u76EE\u5F55: ${CHUNK_DIR3}`); } var configuredCorsOrigin = process.env.CORS_ORIGIN || ""; @@ -23543,7 +25517,7 @@ app.use(cors({ app.use(express.json({ limit: process.env.JSON_BODY_LIMIT || "2mb" })); app.use((req, res, next) => { const provided = normalizeRequestId(req.headers["x-request-id"]); - const requestId = provided || crypto30.randomUUID(); + const requestId = provided || crypto31.randomUUID(); res.locals.requestId = requestId; res.setHeader("X-Request-Id", requestId); next(); @@ -23577,7 +25551,7 @@ app.use("/uploads", requireAuth, express.static(UPLOAD_DIR8, { maxAge: "1d", etag: true })); -app.use("/thumbnails", requireAuth, express.static(THUMBNAIL_DIR7, { +app.use("/thumbnails", requireAuth, express.static(THUMBNAIL_DIR6, { maxAge: "7d", etag: true })); @@ -23594,15 +25568,10 @@ var readinessError = null; app.get("/livez", (_req, res) => { res.json({ status: "ok", timestamp: (/* @__PURE__ */ new Date()).toISOString() }); }); -app.get("/readyz", async (_req, res) => { +app.get("/readyz", (_req, res) => { try { if (!applicationReady) throw new Error(readinessError || "\u5E94\u7528\u4ECD\u5728\u521D\u59CB\u5316"); - await ensureDatabaseInitialized(); - await pool.query("SELECT 1"); - const { storageManager: storageManager2 } = await Promise.resolve().then(() => (init_storage(), storage_exports)); - await storageManager2.assertReady(); - const twoFactor = await get2FAReadiness(); - if (!twoFactor.ready) throw new Error("2FA \u5BC6\u94A5\u4E0D\u53EF\u8BFB\u53D6"); + if (!dependencyReadiness.ready) throw new Error(dependencyReadiness.error || "\u4F9D\u8D56\u672A\u5C31\u7EEA"); const bot = getTelegramBotStatus(); if (telegramBotBlocksReadiness(bot)) { throw new Error(`Telegram Bot \u672A\u5C31\u7EEA\uFF1A${bot.status}${bot.action ? `\uFF1B${bot.action}` : ""}`); @@ -23610,12 +25579,22 @@ app.get("/readyz", async (_req, res) => { res.json({ status: bot.degraded ? "degraded" : "ready", timestamp: (/* @__PURE__ */ new Date()).toISOString(), - components: { telegramBot: bot } + components: { dependencies: dependencyReadiness, telegramBot: bot } }); } catch (error) { res.status(503).json({ status: "not_ready", error: error instanceof Error ? error.message : String(error) }); } }); +app.get("/deepz", async (_req, res) => { + const dependencies = await refreshDependencyReadiness(); + const bot = getTelegramBotStatus(); + const ready = dependencies.ready && !telegramBotBlocksReadiness(bot); + res.status(ready ? 200 : 503).json({ + status: ready ? bot.degraded ? "degraded" : "ready" : "not_ready", + timestamp: (/* @__PURE__ */ new Date()).toISOString(), + components: { dependencies, telegramBot: bot } + }); +}); app.get("/health", (_req, res) => { res.json({ status: "ok", timestamp: (/* @__PURE__ */ new Date()).toISOString() }); }); @@ -23643,10 +25622,19 @@ async function initializeApplication() { markTelegramBotError("error", message, "\u68C0\u67E5 Telegram \u51ED\u8BC1/\u7F51\u7EDC\u5E76\u91CD\u542F\u540E\u7AEF"); console.warn("Telegram Bot \u53EF\u9009\u7EC4\u4EF6\u542F\u52A8\u5931\u8D25\uFF0C\u5E94\u7528\u4EE5 degraded \u72B6\u6001\u7EE7\u7EED:", message); } + } else if (telegramConfig.credentials) { + await initializeTelegramMultiAccountRuntime({ apiId: telegramConfig.credentials.apiId, apiHash: telegramConfig.credentials.apiHash }); } await initializeYtDlpQueue(); + await recoverMediaDerivativeJobs(); + const dependencies = await refreshDependencyReadiness(); + if (!dependencies.ready) throw new Error(dependencies.error || "\u4F9D\u8D56\u5C31\u7EEA\u68C0\u67E5\u5931\u8D25"); applicationReady = true; readinessError = null; + readinessProbeTimer = setInterval(() => { + void refreshDependencyReadiness(); + }, 6e4); + readinessProbeTimer.unref?.(); if (updateCheckEnabled) { const configuredInitialDelayMs = Number(process.env.UPDATE_CHECK_INITIAL_DELAY_MS || 3e4); const configuredIntervalMs = Number(process.env.UPDATE_CHECK_INTERVAL_MS || 6 * 60 * 60 * 1e3); @@ -23672,9 +25660,9 @@ async function startApplication() { \u{1F680} TG Vault \u540E\u7AEF\u670D\u52A1\u5DF2\u542F\u52A8 \u{1F3F7}\uFE0F \u7248\u672C: v${APP_VERSION} \u{1F4CD} \u7AEF\u53E3: ${PORT} -\u{1F4C1} \u4E0A\u4F20\u76EE\u5F55: ${path23.resolve(UPLOAD_DIR8)} -\u{1F5BC}\uFE0F \u7F29\u7565\u56FE\u76EE\u5F55: ${path23.resolve(THUMBNAIL_DIR7)} -\u{1F39E}\uFE0F \u9884\u89C8\u76EE\u5F55: ${path23.resolve(PREVIEW_DIR4)} +\u{1F4C1} \u4E0A\u4F20\u76EE\u5F55: ${path24.resolve(UPLOAD_DIR8)} +\u{1F5BC}\uFE0F \u7F29\u7565\u56FE\u76EE\u5F55: ${path24.resolve(THUMBNAIL_DIR6)} +\u{1F39E}\uFE0F \u9884\u89C8\u76EE\u5F55: ${path24.resolve(PREVIEW_DIR4)} \u{1F510} \u5BC6\u7801\u4FDD\u62A4: ${initialSetupRequired ? "\u5F85\u9996\u6B21\u521D\u59CB\u5316" : "\u5DF2\u542F\u7528"} \u{1F916} Telegram Bot: ${telegramEnabled ? "\u5DF2\u542F\u7528 (\u6700\u5927 2GB\uFF0C\u8D26\u53F7\u7EA7\u4E0B\u8F7D\u5668\u4E0D\u53D7\u6B64\u9650\u5236)" : "\u672A\u542F\u7528"} \u{1F464} Telegram User Download: ${isTelegramUserClientReady() ? "\u5DF2\u542F\u7528" : "\u672A\u542F\u7528"} @@ -23695,6 +25683,8 @@ async function shutdown(signal) { readinessError = `\u6B63\u5728\u56E0 ${signal} \u505C\u673A`; if (updateCheckTimer) clearInterval(updateCheckTimer); updateCheckTimer = null; + if (readinessProbeTimer) clearInterval(readinessProbeTimer); + readinessProbeTimer = null; const forceTimer = setTimeout(() => process.exit(1), 3e4); forceTimer.unref(); if (server) { diff --git a/backend/package-lock.json b/backend/package-lock.json index 1b3288a..77ca8a0 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "tg-vault-backend", - "version": "2.2.1", + "version": "2.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tg-vault-backend", - "version": "2.2.1", + "version": "2.3.0", "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 fbd4c8f..502586d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "tg-vault-backend", - "version": "2.2.1", + "version": "2.3.0", "type": "module", "scripts": { "dev": "tsx watch src/index.ts", diff --git a/backend/scripts/sync_dist_schema.mjs b/backend/scripts/sync_dist_schema.mjs index 35be75f..1047be3 100644 --- a/backend/scripts/sync_dist_schema.mjs +++ b/backend/scripts/sync_dist_schema.mjs @@ -1,11 +1,28 @@ -import { copyFile, mkdir, readFile } from 'node:fs/promises'; +import { copyFile, mkdir, readFile, readdir } from 'node:fs/promises'; await mkdir(new URL('../dist/', import.meta.url), { recursive: true }); const source = new URL('../src/db/schema.sql', import.meta.url); const destination = new URL('../dist/schema.sql', import.meta.url); +const migrationSource = new URL('../src/db/migrations/', import.meta.url); +const migrationDestination = new URL('../dist/migrations/', import.meta.url); +const migrationFiles = (await readdir(migrationSource)).filter(name => name.endsWith('.sql')).sort(); if (process.argv.includes('--check')) { - const [expected, actual] = await Promise.all([readFile(source), readFile(destination)]); + const [expected, actual, distMigrationFiles] = await Promise.all([ + readFile(source), + readFile(destination), + readdir(migrationDestination).then(files => files.filter(name => name.endsWith('.sql')).sort()), + ]); if (!expected.equals(actual)) throw new Error('dist/schema.sql is not synchronized with src/db/schema.sql'); + if (migrationFiles.join('\n') !== distMigrationFiles.join('\n')) throw new Error('dist/migrations is not synchronized with src/db/migrations'); + await Promise.all(migrationFiles.map(async name => { + const [migration, distMigration] = await Promise.all([ + readFile(new URL(name, migrationSource)), + readFile(new URL(name, migrationDestination)), + ]); + if (!migration.equals(distMigration)) throw new Error(`dist/migrations/${name} is not synchronized`); + })); } else { await copyFile(source, destination); + await mkdir(migrationDestination, { recursive: true }); + await Promise.all(migrationFiles.map(name => copyFile(new URL(name, migrationSource), new URL(name, migrationDestination)))); } diff --git a/backend/src/bot/presentation/subscription.ts b/backend/src/bot/presentation/subscription.ts index fb8985e..710ad5d 100644 --- a/backend/src/bot/presentation/subscription.ts +++ b/backend/src/bot/presentation/subscription.ts @@ -1,37 +1,54 @@ -export function buildSubscriptionDisplayLines(row: any, index: number): string { +export interface SubscriptionPresentationRow { + enabled: boolean; + source: string; + source_original?: string | null; + title?: string | null; + target_mode?: 'fixed' | string; + target_account_name?: string | null; + target_provider?: string | null; + last_message_id?: number | null; + folder_override?: string | null; + last_scan_at?: string | Date | null; + next_scan_at?: string | Date | null; + last_result?: { status?: string; found?: number; failed?: number } | null; + last_error?: string | null; + disabled_reason?: string | null; +} + +export function buildSubscriptionDisplayLines(row: SubscriptionPresentationRow, index: number): string { const status = row.enabled ? '✅' : '⏸️'; - const sourceLine = row.source_original && row.source_original !== row.source - ? ` ${row.source_original} → ${row.source} · last_id=${row.last_message_id || 0}` - : ` ${row.source} · last_id=${row.last_message_id || 0}`; + const source = row.source_original && row.source_original !== row.source + ? `${row.source_original} → ${row.source}` + : row.source; + const target = row.target_mode === 'fixed' + ? `${row.target_account_name || row.target_provider || '指定存储'}` + : '跟随系统默认'; + const resultStatus: Record = { + success: '成功', completed: '完成', failed: '失败', partial: '部分完成', running: '进行中', paused: '已暂停', + }; return [ `${index + 1}. ${status} ${row.title || row.source_original || row.source}`, - sourceLine, - row.folder_override ? ` 📁 专属目录:${row.folder_override}` : ' 📁 保存策略:默认自动分类', - ` 🎯 Target:${row.target_mode === 'fixed' ? `${row.target_provider} / ${row.target_account_id || 'local'}(固定)` : '跟随系统默认(每次 admission 快照)'}`, + ` 来源:${source}`, + ` 同步位置:第 ${row.last_message_id || 0} 条消息之后`, + row.folder_override ? ` 📁 目录:${row.folder_override}` : ' 📁 目录:默认自动分类', + ` 🎯 存储:${target}`, row.last_scan_at ? ` 🔎 上次扫描:${new Date(row.last_scan_at).toLocaleString('zh-CN', { hour12: false })}` : ' 🔎 尚未扫描', - row.last_success_at ? ` ✅ 上次成功:${new Date(row.last_success_at).toLocaleString('zh-CN', { hour12: false })}` : null, row.next_scan_at ? ` ⏭️ 下次扫描约:${new Date(row.next_scan_at).toLocaleString('zh-CN', { hour12: false })}` : null, - row.last_result ? ` 📊 最近结果:${row.last_result.status || 'unknown'}${row.last_result.found !== undefined ? `,发现 ${row.last_result.found}` : ''}${row.last_result.failed ? `,失败 ${row.last_result.failed}` : ''}` : null, - row.last_error ? ` ⚠️ 最近错误:${row.last_error}` : null, + row.last_result ? ` 📊 最近结果:${resultStatus[row.last_result.status || ''] || '已记录'}${row.last_result.found !== undefined ? `,发现 ${row.last_result.found}` : ''}${row.last_result.failed ? `,失败 ${row.last_result.failed}` : ''}` : null, + row.last_error ? ` ⚠️ ${row.last_error}` : null, !row.enabled && row.disabled_reason ? ` ⚠️ ${row.disabled_reason}` : null, - !row.enabled && row.disabled_at ? ` 暂停时间:${new Date(row.disabled_at).toLocaleString('zh-CN', { hour12: false })}` : null, ].filter(Boolean).join('\n'); } -export function buildSubscriptionManagePanel(rows: any[], page: { page: number; totalPages: number; startIndex: number; visibleRows: any[] }): string { +export function buildSubscriptionManagePanel(rows: SubscriptionPresentationRow[], page: { page: number; totalPages: number; startIndex: number; visibleRows: SubscriptionPresentationRow[] }): string { return [ - '📡 **频道订阅管理**', - ...(page.totalPages > 1 ? [`第 ${page.page + 1}/${page.totalPages} 页 · 共 ${rows.length} 个订阅`] : []), + '📡 **频道订阅**', + ...(page.totalPages > 1 ? [`第 ${page.page + 1}/${page.totalPages} 页 · 共 ${rows.length} 个`] : []), '', page.visibleRows.length > 0 - ? page.visibleRows.map((row, index) => buildSubscriptionDisplayLines(row, page.startIndex + index)).join('\n') + ? page.visibleRows.map((row, index) => buildSubscriptionDisplayLines(row, page.startIndex + index)).join('\n\n') : '当前没有订阅。', '', - rows.length > 0 ? '可使用操作按钮同步、暂停、修改目标、补抓或重试;已保存文件不会随取消订阅删除。' : '回复频道用户名或链接可新增订阅。', - '回复频道用户名或链接也可新增订阅。', - '例如:`@channel_username`、`https://t.me/channel_username` 或已加入的 `https://t.me/+hash` 私密链接', - '', - '新增订阅时可指定独立目录,不会改变全局 /path_rules。', - '发送“取消”可退出。', + '👇 点击下方按钮管理或新增订阅。', ].join('\n'); } diff --git a/backend/src/db/index.ts b/backend/src/db/index.ts index 013307b..90201c3 100644 --- a/backend/src/db/index.ts +++ b/backend/src/db/index.ts @@ -1,93 +1,127 @@ import pg from 'pg'; -import fs from 'fs/promises'; -import path from 'path'; +import fs from 'node:fs/promises'; +import path from 'node:path'; import dotenv from 'dotenv'; -import { fileURLToPath } from 'url'; -import { buildSqlLogEvent, parseSqlLoggingConfig, shouldLogSqlQuery, sqlOperation } from '../utils/dbLogging.js'; +import { fileURLToPath } from 'node:url'; import crypto from 'node:crypto'; +import { buildSqlLogEvent, parseSqlLoggingConfig, shouldLogSqlQuery, sqlOperation } from '../utils/dbLogging.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); +const MIGRATIONS_DIR = path.join(__dirname, 'migrations'); +const MIGRATION_LOCK_KEY = 0x54475641; dotenv.config(); const { Pool } = pg; +type DatabaseClient = pg.PoolClient; export const pool = new Pool({ - connectionString: process.env.DATABASE_URL || 'postgresql://tgvault:password@localhost:5432/tgvault', + connectionString: process.env.DATABASE_URL || 'postgresql://tgvault:***@localhost:5432/tgvault', }); let initializationPromise: Promise | null = null; -const CANONICAL_SCHEMA_VERSION = 2026082401; -async function applyCanonicalSchemaMigration(schemaSql: string): Promise { - const checksum = crypto.createHash('sha256').update(schemaSql).digest('hex'); - await pool.query( - `INSERT INTO schema_migrations (version, name, checksum) - VALUES ($1, $2, $3) - ON CONFLICT (version) DO UPDATE SET checksum = EXCLUDED.checksum`, - [CANONICAL_SCHEMA_VERSION, 'canonical-expand-schema', checksum], - ); +interface MigrationFile { + version: number; + name: string; + checksum: string; + sql: string; } -// 自动初始化数据库表结构 -async function initializeDatabase() { +async function listMigrations(): Promise { + let entries: string[]; try { - const schemaPath = path.join(__dirname, 'schema.sql'); - const schemaSql = await fs.readFile(schemaPath, 'utf-8'); + entries = await fs.readdir(MIGRATIONS_DIR); + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') return []; + throw error; + } + return Promise.all(entries + .filter(name => /^\d+_[a-z0-9_-]+\.sql$/i.test(name)) + .sort() + .map(async fileName => { + const separator = fileName.indexOf('_'); + const version = Number(fileName.slice(0, separator)); + if (!Number.isSafeInteger(version)) throw new Error(`invalid migration version: ${fileName}`); + const sql = await fs.readFile(path.join(MIGRATIONS_DIR, fileName), 'utf8'); + return { + version, + name: fileName.slice(separator + 1, -4), + checksum: crypto.createHash('sha256').update(sql).digest('hex'), + sql, + }; + })); +} - // 智能分割 SQL 语句(处理 PL/pgSQL 的 $$ 块) - const statements: string[] = []; - let current = ''; - let inDollarQuote = false; +async function ensureMigrationLedger(client: DatabaseClient): Promise { + await client.query(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + version BIGINT PRIMARY KEY, + name TEXT NOT NULL, + checksum VARCHAR(64) NOT NULL, + applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `); +} - for (let i = 0; i < schemaSql.length; i++) { - const char = schemaSql[i]; - current += char; - - // 检测 $$ 块的开始和结束 - if (char === '$' && schemaSql[i + 1] === '$') { - inDollarQuote = !inDollarQuote; - current += '$'; - i++; // 跳过下一个 $ - } else if (char === ';' && !inDollarQuote) { - const stmt = current.trim(); - if (stmt.length > 1) { - const withoutLeadingLineComments = stmt.replace(/^\s*(--[^\n]*\n\s*)+/g, '').trim(); - if (withoutLeadingLineComments.length > 0) { - statements.push(withoutLeadingLineComments.slice(0, -1)); // 移除末尾的分号 - } - } - current = ''; +async function applyMigrations(client: DatabaseClient): Promise { + for (const migration of await listMigrations()) { + const existing = await client.query( + 'SELECT name, checksum FROM schema_migrations WHERE version = $1', + [migration.version], + ); + if (existing.rowCount) { + if (existing.rows[0].name !== migration.name || existing.rows[0].checksum !== migration.checksum) { + throw new Error(`migration ${migration.version} checksum/name mismatch`); } + continue; } - // 添加最后一条语句(如果没有以分号结尾) - const lastStmt = current.trim(); - if (lastStmt.length > 0) { - const withoutLeadingLineComments = lastStmt.replace(/^\s*(--[^\n]*\n\s*)+/g, '').trim(); - if (withoutLeadingLineComments.length > 0) { - statements.push(withoutLeadingLineComments); - } + await client.query('BEGIN'); + try { + await client.query(migration.sql); + await client.query( + 'INSERT INTO schema_migrations (version, name, checksum) VALUES ($1, $2, $3)', + [migration.version, migration.name, migration.checksum], + ); + await client.query('COMMIT'); + console.log(`✅ 数据库迁移 ${migration.version}_${migration.name} 已应用`); + } catch (error) { + await client.query('ROLLBACK').catch(() => undefined); + throw error; } + } +} - for (const statement of statements) { - try { - await pool.query(statement); - } catch (err: any) { - // 如果是表已存在的错误,忽略 - if (err.message?.includes('already exists')) { - continue; - } - throw err; - } - } - - await applyCanonicalSchemaMigration(schemaSql); +async function bootstrapNewDatabase(client: DatabaseClient): Promise { + const schemaPath = path.join(__dirname, 'schema.sql'); + const schemaSql = await fs.readFile(schemaPath, 'utf8'); + await client.query('BEGIN'); + try { + // PostgreSQL receives the canonical snapshot as one script; no custom semicolon parser is involved. + await client.query(schemaSql); + await client.query('COMMIT'); + } catch (error) { + await client.query('ROLLBACK').catch(() => undefined); + throw error; + } +} +async function initializeDatabase(): Promise { + const client = await pool.connect(); + try { + await client.query('SELECT pg_advisory_lock($1)', [MIGRATION_LOCK_KEY]); + const exists = await client.query("SELECT to_regclass('public.schema_migrations') AS ledger"); + if (!exists.rows[0]?.ledger) await bootstrapNewDatabase(client); + await ensureMigrationLedger(client); + await applyMigrations(client); console.log('✅ 数据库表结构初始化完成'); - } catch (err: any) { - console.error('❌ 数据库初始化失败:', err); - throw err; + } catch (error) { + console.error('❌ 数据库初始化失败:', error); + throw error; + } finally { + await client.query('SELECT pg_advisory_unlock($1)', [MIGRATION_LOCK_KEY]).catch(() => undefined); + client.release(); } } @@ -103,7 +137,7 @@ pool.on('connect', () => { }); pool.on('error', (err) => { - console.error(JSON.stringify({ event: 'db.pool_error', code: (err as any)?.code || 'UNKNOWN' })); + console.error(JSON.stringify({ event: 'db.pool_error', code: (err as { code?: string })?.code || 'UNKNOWN' })); }); export const query = async (text: string, params?: unknown[]) => { diff --git a/backend/src/db/migrations/2026082901_file_search_trigram.sql b/backend/src/db/migrations/2026082901_file_search_trigram.sql new file mode 100644 index 0000000..f79c536 --- /dev/null +++ b/backend/src/db/migrations/2026082901_file_search_trigram.sql @@ -0,0 +1,8 @@ +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +CREATE INDEX IF NOT EXISTS idx_files_name_trgm + ON files USING GIN (name gin_trgm_ops); + +CREATE INDEX IF NOT EXISTS idx_files_folder_trgm + ON files USING GIN (folder gin_trgm_ops) + WHERE folder IS NOT NULL; diff --git a/backend/src/db/migrations/2026082902_media_derivatives.sql b/backend/src/db/migrations/2026082902_media_derivatives.sql new file mode 100644 index 0000000..f7889b8 --- /dev/null +++ b/backend/src/db/migrations/2026082902_media_derivatives.sql @@ -0,0 +1,13 @@ +ALTER TABLE files ADD COLUMN IF NOT EXISTS derivative_status VARCHAR(20) NOT NULL DEFAULT 'not_required'; +ALTER TABLE files ADD COLUMN IF NOT EXISTS derivative_error TEXT; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'files_derivative_status_check' + ) THEN + ALTER TABLE files ADD CONSTRAINT files_derivative_status_check + CHECK (derivative_status IN ('queued', 'processing', 'ready', 'failed', 'not_required')); + END IF; +END +$$; diff --git a/backend/src/db/migrations/2026082903_durable_media_derivatives.sql b/backend/src/db/migrations/2026082903_durable_media_derivatives.sql new file mode 100644 index 0000000..c77461f --- /dev/null +++ b/backend/src/db/migrations/2026082903_durable_media_derivatives.sql @@ -0,0 +1,8 @@ +ALTER TABLE files ADD COLUMN IF NOT EXISTS derivative_source_path VARCHAR(1000); +ALTER TABLE files ADD COLUMN IF NOT EXISTS derivative_cleanup_source BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE files ADD COLUMN IF NOT EXISTS derivative_attempts INT NOT NULL DEFAULT 0; +ALTER TABLE files ADD COLUMN IF NOT EXISTS derivative_started_at TIMESTAMPTZ; + +CREATE INDEX IF NOT EXISTS idx_files_derivative_pending + ON files (derivative_status, created_at, id) + WHERE derivative_status IN ('queued', 'processing'); diff --git a/backend/src/db/migrations/2026082904_telegram_multi_accounts.sql b/backend/src/db/migrations/2026082904_telegram_multi_accounts.sql new file mode 100644 index 0000000..248802d --- /dev/null +++ b/backend/src/db/migrations/2026082904_telegram_multi_accounts.sql @@ -0,0 +1,76 @@ +CREATE TABLE IF NOT EXISTS telegram_user_accounts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + telegram_user_id TEXT NOT NULL UNIQUE, + username TEXT, + display_name TEXT, + session_ciphertext TEXT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + health_state VARCHAR(24) NOT NULL DEFAULT 'healthy' + CHECK (health_state IN ('healthy', 'degraded', 'session_expired')), + cooldown_until TIMESTAMPTZ, + weight NUMERIC(8, 3) NOT NULL DEFAULT 1 CHECK (weight > 0), + priority INT NOT NULL DEFAULT 0, + max_connections INT NOT NULL DEFAULT 4 CHECK (max_connections > 0), + last_error TEXT, + last_connected_at TIMESTAMPTZ, + last_failure_at TIMESTAMPTZ, + session_expired_at TIMESTAMPTZ, + is_legacy BOOLEAN NOT NULL DEFAULT FALSE, + deleted_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_telegram_user_accounts_schedulable + ON telegram_user_accounts(enabled, health_state, cooldown_until, priority DESC); +CREATE INDEX IF NOT EXISTS idx_telegram_user_accounts_visible + ON telegram_user_accounts(created_at, id) WHERE deleted_at IS NULL; +CREATE OR REPLACE TRIGGER telegram_user_accounts_updated_at + BEFORE UPDATE ON telegram_user_accounts + FOR EACH ROW EXECUTE FUNCTION update_updated_at(); + +CREATE TABLE IF NOT EXISTS telegram_account_source_access ( + account_id UUID NOT NULL REFERENCES telegram_user_accounts(id) ON DELETE CASCADE, + source_key TEXT NOT NULL, + scope VARCHAR(20) NOT NULL DEFAULT 'download' + CHECK (scope IN ('download', 'scan', 'metadata')), + access_state VARCHAR(20) NOT NULL DEFAULT 'unknown' + CHECK (access_state IN ('unknown', 'allowed', 'denied')), + last_error TEXT, + checked_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (account_id, source_key, scope) +); +CREATE INDEX IF NOT EXISTS idx_telegram_account_source_access_lookup + ON telegram_account_source_access(source_key, scope, access_state, checked_at DESC); +CREATE OR REPLACE TRIGGER telegram_account_source_access_updated_at + BEFORE UPDATE ON telegram_account_source_access + FOR EACH ROW EXECUTE FUNCTION update_updated_at(); + +ALTER TABLE telegram_background_jobs + ADD COLUMN IF NOT EXISTS assigned_account_id UUID REFERENCES telegram_user_accounts(id) ON DELETE SET NULL; +CREATE INDEX IF NOT EXISTS idx_tg_background_jobs_assigned_account + ON telegram_background_jobs(assigned_account_id, status); + +CREATE TABLE IF NOT EXISTS telegram_download_attempts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + account_id UUID NOT NULL REFERENCES telegram_user_accounts(id) ON DELETE RESTRICT, + source_key TEXT NOT NULL, + scope VARCHAR(20) NOT NULL DEFAULT 'download' + CHECK (scope IN ('download', 'scan', 'metadata')), + job_id UUID REFERENCES telegram_background_jobs(id) ON DELETE SET NULL, + item_id UUID REFERENCES telegram_download_items(id) ON DELETE SET NULL, + lease_token UUID, + status VARCHAR(20) NOT NULL DEFAULT 'running' + CHECK (status IN ('running', 'succeeded', 'failed', 'cancelled')), + error TEXT, + started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + finished_at TIMESTAMPTZ, + CHECK ((status = 'running' AND finished_at IS NULL) OR (status <> 'running' AND finished_at IS NOT NULL)) +); +CREATE INDEX IF NOT EXISTS idx_telegram_download_attempts_account_started + ON telegram_download_attempts(account_id, started_at DESC); +CREATE INDEX IF NOT EXISTS idx_telegram_download_attempts_job_item + ON telegram_download_attempts(job_id, item_id, started_at DESC); +CREATE INDEX IF NOT EXISTS idx_telegram_download_attempts_running + ON telegram_download_attempts(account_id, source_key) WHERE status = 'running'; diff --git a/backend/src/db/schema.sql b/backend/src/db/schema.sql index ba89a98..854c1be 100644 --- a/backend/src/db/schema.sql +++ b/backend/src/db/schema.sql @@ -104,6 +104,12 @@ CREATE TABLE IF NOT EXISTS files ( path VARCHAR(500) NOT NULL, thumbnail_path VARCHAR(500), preview_path VARCHAR(500), + derivative_status VARCHAR(20) NOT NULL DEFAULT 'not_required' CHECK (derivative_status IN ('queued', 'processing', 'ready', 'failed', 'not_required')), + derivative_error TEXT, + derivative_source_path VARCHAR(1000), + derivative_cleanup_source BOOLEAN NOT NULL DEFAULT FALSE, + derivative_attempts INT NOT NULL DEFAULT 0, + derivative_started_at TIMESTAMPTZ, width INT, height INT, source VARCHAR(50) DEFAULT 'web', @@ -126,6 +132,13 @@ CREATE INDEX IF NOT EXISTS idx_files_source_fav_created ON files(source, is_favo CREATE INDEX IF NOT EXISTS idx_files_account_folder_created ON files(storage_account_id, folder, created_at DESC, id DESC); CREATE INDEX IF NOT EXISTS idx_files_source_folder_created ON files(source, folder, created_at DESC, id DESC); ALTER TABLE files ADD COLUMN IF NOT EXISTS preview_path VARCHAR(500); +ALTER TABLE files ADD COLUMN IF NOT EXISTS derivative_status VARCHAR(20) NOT NULL DEFAULT 'not_required'; +ALTER TABLE files ADD COLUMN IF NOT EXISTS derivative_error TEXT; +ALTER TABLE files ADD COLUMN IF NOT EXISTS derivative_source_path VARCHAR(1000); +ALTER TABLE files ADD COLUMN IF NOT EXISTS derivative_cleanup_source BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE files ADD COLUMN IF NOT EXISTS derivative_attempts INT NOT NULL DEFAULT 0; +ALTER TABLE files ADD COLUMN IF NOT EXISTS derivative_started_at TIMESTAMPTZ; +CREATE INDEX IF NOT EXISTS idx_files_derivative_pending ON files(derivative_status, created_at, id) WHERE derivative_status IN ('queued', 'processing'); CREATE OR REPLACE TRIGGER files_updated_at BEFORE UPDATE ON files @@ -593,3 +606,81 @@ CREATE OR REPLACE TRIGGER telegram_download_items_updated_at BEFORE UPDATE ON telegram_download_items FOR EACH ROW EXECUTE FUNCTION update_updated_at(); + +-- 多 Telegram 用户账号、来源权限和账号级下载尝试。 +CREATE TABLE IF NOT EXISTS telegram_user_accounts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + telegram_user_id TEXT NOT NULL UNIQUE, + username TEXT, + display_name TEXT, + session_ciphertext TEXT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + health_state VARCHAR(24) NOT NULL DEFAULT 'healthy' + CHECK (health_state IN ('healthy', 'degraded', 'session_expired')), + cooldown_until TIMESTAMPTZ, + weight NUMERIC(8, 3) NOT NULL DEFAULT 1 CHECK (weight > 0), + priority INT NOT NULL DEFAULT 0, + max_connections INT NOT NULL DEFAULT 4 CHECK (max_connections > 0), + last_error TEXT, + last_connected_at TIMESTAMPTZ, + last_failure_at TIMESTAMPTZ, + session_expired_at TIMESTAMPTZ, + is_legacy BOOLEAN NOT NULL DEFAULT FALSE, + deleted_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_telegram_user_accounts_schedulable + ON telegram_user_accounts(enabled, health_state, cooldown_until, priority DESC); +CREATE INDEX IF NOT EXISTS idx_telegram_user_accounts_visible + ON telegram_user_accounts(created_at, id) WHERE deleted_at IS NULL; +CREATE OR REPLACE TRIGGER telegram_user_accounts_updated_at + BEFORE UPDATE ON telegram_user_accounts + FOR EACH ROW EXECUTE FUNCTION update_updated_at(); + +CREATE TABLE IF NOT EXISTS telegram_account_source_access ( + account_id UUID NOT NULL REFERENCES telegram_user_accounts(id) ON DELETE CASCADE, + source_key TEXT NOT NULL, + scope VARCHAR(20) NOT NULL DEFAULT 'download' + CHECK (scope IN ('download', 'scan', 'metadata')), + access_state VARCHAR(20) NOT NULL DEFAULT 'unknown' + CHECK (access_state IN ('unknown', 'allowed', 'denied')), + last_error TEXT, + checked_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (account_id, source_key, scope) +); +CREATE INDEX IF NOT EXISTS idx_telegram_account_source_access_lookup + ON telegram_account_source_access(source_key, scope, access_state, checked_at DESC); +CREATE OR REPLACE TRIGGER telegram_account_source_access_updated_at + BEFORE UPDATE ON telegram_account_source_access + FOR EACH ROW EXECUTE FUNCTION update_updated_at(); + +ALTER TABLE telegram_background_jobs + ADD COLUMN IF NOT EXISTS assigned_account_id UUID REFERENCES telegram_user_accounts(id) ON DELETE SET NULL; +CREATE INDEX IF NOT EXISTS idx_tg_background_jobs_assigned_account + ON telegram_background_jobs(assigned_account_id, status); + +CREATE TABLE IF NOT EXISTS telegram_download_attempts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + account_id UUID NOT NULL REFERENCES telegram_user_accounts(id) ON DELETE RESTRICT, + source_key TEXT NOT NULL, + scope VARCHAR(20) NOT NULL DEFAULT 'download' + CHECK (scope IN ('download', 'scan', 'metadata')), + job_id UUID REFERENCES telegram_background_jobs(id) ON DELETE SET NULL, + item_id UUID REFERENCES telegram_download_items(id) ON DELETE SET NULL, + lease_token UUID, + status VARCHAR(20) NOT NULL DEFAULT 'running' + CHECK (status IN ('running', 'succeeded', 'failed', 'cancelled')), + error TEXT, + started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + finished_at TIMESTAMPTZ, + CHECK ((status = 'running' AND finished_at IS NULL) OR (status <> 'running' AND finished_at IS NOT NULL)) +); +CREATE INDEX IF NOT EXISTS idx_telegram_download_attempts_account_started + ON telegram_download_attempts(account_id, started_at DESC); +CREATE INDEX IF NOT EXISTS idx_telegram_download_attempts_job_item + ON telegram_download_attempts(job_id, item_id, started_at DESC); +CREATE INDEX IF NOT EXISTS idx_telegram_download_attempts_running + ON telegram_download_attempts(account_id, source_key) WHERE status = 'running'; diff --git a/backend/src/index.ts b/backend/src/index.ts index ba01cd4..d4a9894 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -17,6 +17,7 @@ import { initTelegramBot, sendUpdateNotificationToUser } from './services/telegr import { applyEffectiveTelegramBotConfig } from './services/telegramBotConfig.js'; import { getTelegramBotStatus, markTelegramBotError, telegramBotBlocksReadiness } from './services/telegramBotStatus.js'; import { initTelegramUserClient, isTelegramUserClientReady } from './services/telegramUserClient.js'; +import { initializeTelegramMultiAccountRuntime } from './services/telegramMultiAccountRuntime.js'; import { isInitialSetupRequired } from './utils/authSettings.js'; import { get2FAReadiness } from './utils/security.js'; import { ensureDatabaseInitialized, pool } from './db/index.js'; @@ -25,6 +26,7 @@ import crypto from 'node:crypto'; import { normalizeRequestId } from './services/operationalEvents.js'; import { markTransferTasksAfterRestart } from './services/transferTasks.js'; import { initializeYtDlpQueue } from './services/ytDlpDownload.js'; +import { recoverMediaDerivativeJobs } from './services/mediaDerivatives.js'; import { logRuntimeConfigSummary, validateRuntimeConfig } from './utils/runtimeConfig.js'; import { APP_VERSION } from './services/appVersion.js'; import { createUpdateChecker } from './services/updateChecker.js'; @@ -44,6 +46,34 @@ const updateChecker = createUpdateChecker({ sendBotMessage: sendUpdateNotificationToUser, }); let updateCheckTimer: ReturnType | null = null; +let readinessProbeTimer: ReturnType | null = null; + +interface DependencyReadinessSnapshot { + ready: boolean; + checkedAt: string | null; + error: string | null; +} + +let dependencyReadiness: DependencyReadinessSnapshot = { ready: false, checkedAt: null, error: '依赖尚未检查' }; + +async function refreshDependencyReadiness(): Promise { + try { + await ensureDatabaseInitialized(); + await pool.query('SELECT 1'); + const { storageManager } = await import('./services/storage.js'); + await storageManager.assertReady(); + const twoFactor = await get2FAReadiness(); + if (!twoFactor.ready) throw new Error('2FA 密钥不可读取'); + dependencyReadiness = { ready: true, checkedAt: new Date().toISOString(), error: null }; + } catch (error) { + dependencyReadiness = { + ready: false, + checkedAt: new Date().toISOString(), + error: error instanceof Error ? error.message : String(error), + }; + } + return dependencyReadiness; +} // 确保上传目录存在 const UPLOAD_DIR = process.env.UPLOAD_DIR || './data/uploads'; @@ -163,15 +193,10 @@ let readinessError: string | null = null; app.get('/livez', (_req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString() }); }); -app.get('/readyz', async (_req, res) => { +app.get('/readyz', (_req, res) => { try { if (!applicationReady) throw new Error(readinessError || '应用仍在初始化'); - await ensureDatabaseInitialized(); - await pool.query('SELECT 1'); - const { storageManager } = await import('./services/storage.js'); - await storageManager.assertReady(); - const twoFactor = await get2FAReadiness(); - if (!twoFactor.ready) throw new Error('2FA 密钥不可读取'); + if (!dependencyReadiness.ready) throw new Error(dependencyReadiness.error || '依赖未就绪'); const bot = getTelegramBotStatus(); if (telegramBotBlocksReadiness(bot)) { throw new Error(`Telegram Bot 未就绪:${bot.status}${bot.action ? `;${bot.action}` : ''}`); @@ -179,12 +204,22 @@ app.get('/readyz', async (_req, res) => { res.json({ status: bot.degraded ? 'degraded' : 'ready', timestamp: new Date().toISOString(), - components: { telegramBot: bot }, + components: { dependencies: dependencyReadiness, telegramBot: bot }, }); } catch (error) { res.status(503).json({ status: 'not_ready', error: error instanceof Error ? error.message : String(error) }); } }); +app.get('/deepz', async (_req, res) => { + const dependencies = await refreshDependencyReadiness(); + const bot = getTelegramBotStatus(); + const ready = dependencies.ready && !telegramBotBlocksReadiness(bot); + res.status(ready ? 200 : 503).json({ + status: ready ? (bot.degraded ? 'degraded' : 'ready') : 'not_ready', + timestamp: new Date().toISOString(), + components: { dependencies, telegramBot: bot }, + }); +}); // Backward-compatible liveness alias. Deployments should route traffic based on /readyz. app.get('/health', (_req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString() }); @@ -217,10 +252,17 @@ async function initializeApplication(): Promise { markTelegramBotError('error', message, '检查 Telegram 凭证/网络并重启后端'); console.warn('Telegram Bot 可选组件启动失败,应用以 degraded 状态继续:', message); } + } else if (telegramConfig.credentials) { + await initializeTelegramMultiAccountRuntime({ apiId: telegramConfig.credentials.apiId, apiHash: telegramConfig.credentials.apiHash }); } await initializeYtDlpQueue(); + await recoverMediaDerivativeJobs(); + const dependencies = await refreshDependencyReadiness(); + if (!dependencies.ready) throw new Error(dependencies.error || '依赖就绪检查失败'); applicationReady = true; readinessError = null; + readinessProbeTimer = setInterval(() => { void refreshDependencyReadiness(); }, 60_000); + readinessProbeTimer.unref?.(); if (updateCheckEnabled) { const configuredInitialDelayMs = Number(process.env.UPDATE_CHECK_INITIAL_DELAY_MS || 30_000); const configuredIntervalMs = Number(process.env.UPDATE_CHECK_INTERVAL_MS || 6 * 60 * 60 * 1000); @@ -272,6 +314,8 @@ async function shutdown(signal: string) { readinessError = `正在因 ${signal} 停机`; if (updateCheckTimer) clearInterval(updateCheckTimer); updateCheckTimer = null; + if (readinessProbeTimer) clearInterval(readinessProbeTimer); + readinessProbeTimer = null; const forceTimer = setTimeout(() => process.exit(1), 30_000); forceTimer.unref(); if (server) { diff --git a/backend/src/routes/chunkedCloudMediaPreviewUpload.test.ts b/backend/src/routes/chunkedCloudMediaPreviewUpload.test.ts index c01f6c6..94a64e0 100644 --- a/backend/src/routes/chunkedCloudMediaPreviewUpload.test.ts +++ b/backend/src/routes/chunkedCloudMediaPreviewUpload.test.ts @@ -2,18 +2,16 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import test from 'node:test'; -const source = fs.readFileSync(new URL('./chunkedUpload.ts', import.meta.url), 'utf8'); +const route = fs.readFileSync(new URL('./chunkedUpload.ts', import.meta.url), 'utf8'); +const worker = fs.readFileSync(new URL('../services/mediaDerivatives.ts', import.meta.url), 'utf8'); -test('chunked cloud media generates local thumbnail and image preview before remote save', () => { - assert.match(source, /if \(session\.mimeType\.startsWith\('image\/'\) \|\| session\.mimeType\.startsWith\('video\/'\)\)/); - assert.match(source, /if \(session\.mimeType\.startsWith\('image\/'\)\)[\s\S]*generateMediaPreview\(tempMergedPath/); +test('chunked cloud media enqueues local thumbnail and preview work after index completion', () => { + assert.match(route, /markChunkReconciliationIndexPresent[\s\S]*enqueueMediaDerivatives/); + assert.match(worker, /generateThumbnail\(job\.sourcePath/); + assert.match(worker, /generateMediaPreview\(job\.sourcePath/); }); -test('chunked cloud video keeps merged source until preview generation settles', () => { - assert.match(source, /const previewSource = target\.provider\.name === 'local' \? storedPath : tempMergedPath/); - assert.match(source, /generateMediaPreview\(previewSource[\s\S]*\.finally\(async \(\) => \{[\s\S]*fsPromises\.rm\(tempMergedPath/); - const completedAt = source.indexOf('if (!completed)'); - const videoAt = source.indexOf("if (type === 'video')", completedAt); - assert.ok(completedAt >= 0 && videoAt > completedAt); - assert.doesNotMatch(source.slice(completedAt, videoAt), /fsPromises\.rm\(tempMergedPath/); +test('chunked cloud media source is retained until the derivative worker settles', () => { + assert.match(route, /cleanupSource: target\.provider\.name !== 'local'/); + assert.match(worker, /finally \{[\s\S]*if \(job\.cleanupSource\) await fs\.rm\(job\.sourcePath/); }); diff --git a/backend/src/routes/chunkedUpload.ts b/backend/src/routes/chunkedUpload.ts index caa579b..de383fa 100644 --- a/backend/src/routes/chunkedUpload.ts +++ b/backend/src/routes/chunkedUpload.ts @@ -10,9 +10,10 @@ import { pool, query } from '../db/index.js'; import { getAuthToken } from './auth.js'; import { storageManager } from '../services/storage.js'; import { assertStorageTargetWritable, isStorageCooldownError, sendStorageCooldownHttpError } from '../services/storageCooldownGuard.js'; -import { generateThumbnail, getImageDimensions, generateMediaPreview } from '../utils/thumbnail.js'; + import { getSignedUrl } from '../middleware/signedUrl.js'; import { getUniqueStoredName } from '../utils/fileUtils.js'; +import { getFileType } from '../utils/fileMetadata.js'; import { buildStorageFolderWithRules, getStoragePathRules } from '../utils/storagePath.js'; import { findDuplicateFile, getDuplicateMode } from '../utils/duplicatePolicy.js'; import { acquireStorageAccountOperationLease, type StorageAccountOperationLease } from '../services/storageAccountOperation.js'; @@ -32,6 +33,7 @@ import { type ChunkUploadSession, } from '../services/chunkUploadSessions.js'; import { normalizeFolderPath } from '../utils/folderPath.js'; +import { enqueueMediaDerivatives } from '../services/mediaDerivatives.js'; const router = Router(); const checkDiskSpace = (checkDiskSpaceModule as any).default || checkDiskSpaceModule; @@ -113,13 +115,6 @@ function safeChunkPath(uploadId: string, chunkIndex: number): string { return path.join(path.resolve(CHUNK_DIR), uploadId, `chunk_${chunkIndex}`); } -function getFileType(mimeType: string): string { - if (mimeType.startsWith('image/')) return 'image'; - if (mimeType.startsWith('video/')) return 'video'; - if (mimeType.startsWith('audio/')) return 'audio'; - if (/pdf|document|text|word|excel|spreadsheet|powerpoint|presentation|markdown|json|xml|sql/i.test(mimeType)) return 'document'; - return 'other'; -} function sendProtocolError(res: Response, error: unknown): Response { if (error instanceof ChunkUploadProtocolError) { @@ -361,23 +356,8 @@ router.post('/complete', async (req: Request, res: Response) => { }); } - let thumbnailPath: string | null = null; - let previewPath: string | null = null; - let width: number | null = null; - let height: number | null = null; - if (session.mimeType.startsWith('image/') || session.mimeType.startsWith('video/')) { - const thumbnail = await generateThumbnail(tempMergedPath, storedName, session.mimeType).catch(() => null); - thumbnailPath = thumbnail ? path.basename(thumbnail) : null; - const dimensions = await getImageDimensions(tempMergedPath, session.mimeType).catch(() => ({ width: null, height: null })); - width = dimensions.width; - height = dimensions.height; - } - if (session.mimeType.startsWith('image/')) { - const preview = await generateMediaPreview(tempMergedPath, storedName, session.mimeType).catch(() => null); - previewPath = preview ? path.basename(preview) : null; - } - const type = getFileType(session.mimeType); + const derivativeStatus = type === 'image' || type === 'video' ? 'queued' : 'not_required'; const operationId = await beginChunkCompletionReconciliation(pool, { uploadId, completionToken: token, @@ -415,11 +395,14 @@ router.post('/complete', async (req: Request, res: Response) => { await markChunkReconciliationObjectPresent(pool, operationId, storedPath); const indexed = await query( `INSERT INTO files - (name, stored_name, type, mime_type, size, path, thumbnail_path, preview_path, width, height, source, folder, storage_account_id) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) + (name, stored_name, type, mime_type, size, path, source, folder, storage_account_id, + derivative_status, derivative_source_path, derivative_cleanup_source) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) RETURNING id, created_at, name, type, size, source`, - [session.filename, storedName, type, session.mimeType, session.totalSize, storedPath, thumbnailPath, previewPath, - width, height, target.provider.name, storageFolder, session.targetAccountId], + [session.filename, storedName, type, session.mimeType, session.totalSize, storedPath, + target.provider.name, storageFolder, session.targetAccountId, derivativeStatus, + derivativeStatus === 'queued' ? (target.provider.name === 'local' ? storedPath : tempMergedPath) : null, + derivativeStatus === 'queued' && target.provider.name !== 'local'], ); file = indexed.rows[0]; await markChunkReconciliationIndexPresent(pool, operationId, String(file.id)); @@ -443,17 +426,14 @@ router.post('/complete', async (req: Request, res: Response) => { } await fsPromises.rm(path.join(CHUNK_DIR, uploadId), { recursive: true, force: true }) .catch(error => console.error('清理已完成上传的分块失败:', error)); - if (type === 'video') { - const previewSource = target.provider.name === 'local' ? storedPath : tempMergedPath; - void generateMediaPreview(previewSource, storedName, session.mimeType) - .then(async preview => { if (preview) await query('UPDATE files SET preview_path = $1 WHERE id = $2', [path.basename(preview), file.id]); }) - .catch(error => console.error('异步生成视频预览失败:', error)) - .finally(async () => { - if (target.provider.name !== 'local') { - await fsPromises.rm(tempMergedPath, { force: true }) - .catch(error => console.error('清理合并临时文件失败:', error)); - } - }); + if (derivativeStatus === 'queued') { + enqueueMediaDerivatives({ + fileId: String(file.id), + sourcePath: target.provider.name === 'local' ? storedPath : tempMergedPath, + storedName, + mimeType: session.mimeType, + cleanupSource: target.provider.name !== 'local', + }); } else { await fsPromises.rm(tempMergedPath, { force: true }).catch(error => console.error('清理合并临时文件失败:', error)); } @@ -464,7 +444,7 @@ router.post('/complete', async (req: Request, res: Response) => { name: file.name, type: file.type, size: file.size, - thumbnailUrl: thumbnailPath ? getSignedUrl(file.id, 'thumbnail') : undefined, + derivativeStatus, previewUrl: getSignedUrl(file.id, 'preview'), date: file.created_at, source: target.provider.name, diff --git a/backend/src/routes/cloudMediaPreviewUpload.test.ts b/backend/src/routes/cloudMediaPreviewUpload.test.ts index 847def3..d1c030b 100644 --- a/backend/src/routes/cloudMediaPreviewUpload.test.ts +++ b/backend/src/routes/cloudMediaPreviewUpload.test.ts @@ -2,18 +2,16 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import test from 'node:test'; -const source = fs.readFileSync(new URL('./upload.ts', import.meta.url), 'utf8'); +const route = fs.readFileSync(new URL('./upload.ts', import.meta.url), 'utf8'); +const worker = fs.readFileSync(new URL('../services/mediaDerivatives.ts', import.meta.url), 'utf8'); -test('cloud image uploads generate local thumbnails and previews from the temporary source', () => { - assert.match(source, /if \(mimeType\.startsWith\('image\/'\) \|\| mimeType\.startsWith\('video\/'\)\)/); - assert.match(source, /if \(mimeType\.startsWith\('image\/'\)\)[\s\S]*generateMediaPreview\(tempPath/); +test('cloud image uploads enqueue local thumbnails and previews from the temporary source', () => { + assert.match(route, /saveAndIndexWithCompensation[\s\S]*enqueueMediaDerivatives/); + assert.match(worker, /generateThumbnail\(job\.sourcePath/); + assert.match(worker, /generateMediaPreview\(job\.sourcePath/); }); test('cloud video temporary source survives until asynchronous preview generation settles', () => { - assert.match(source, /const previewSource = provider\.name === 'local' \? storedPath : tempPath/); - assert.match(source, /generateMediaPreview\(previewSource[\s\S]*\.finally\(\(\) => \{[\s\S]*fs\.unlinkSync\(tempPath\)/); - const indexedAt = source.indexOf('const storedPath = await saveAndIndexWithCompensation'); - const videoAt = source.indexOf("if (type === 'video')", indexedAt); - assert.ok(indexedAt >= 0 && videoAt > indexedAt); - assert.doesNotMatch(source.slice(indexedAt, videoAt), /fs\.unlinkSync\(tempPath\)/); + assert.match(route, /cleanupSource: provider\.name !== 'local'/); + assert.match(worker, /finally \{[\s\S]*if \(job\.cleanupSource\) await fs\.rm\(job\.sourcePath/); }); diff --git a/backend/src/routes/storage.ts b/backend/src/routes/storage.ts index 4f7838b..267aa5b 100644 --- a/backend/src/routes/storage.ts +++ b/backend/src/routes/storage.ts @@ -11,6 +11,10 @@ import { rateLimit } from 'express-rate-limit'; import { getSetting, setSetting } from '../utils/settings.js'; import { changeTelegramPin, ensureTelegramPinConfigured, getConfiguredTelegramAllowedUsers, parseTelegramAllowedUserIds, setTelegramAllowedUsersAndReconcile, TelegramPinChangeError } from '../utils/authSettings.js'; import { disableTelegramUserAccount, enableTelegramUserAccount, getTelegramUserAccountStatus, getTelegramUserSessionFilePath, isTelegramUserClientReady, telegramUserWebLogin, unlinkTelegramUserAccount } from '../services/telegramUserClient.js'; +import { telegramAccountRepository } from '../services/telegramAccountRepository.js'; +import { initializeTelegramMultiAccountRuntime, telegramUserClientPool } from '../services/telegramMultiAccountRuntime.js'; +import { triggerTelegramAccountAccessSweep, getTelegramAccountAccessSweepSummary } from '../services/telegramAccountAccessSweep.js'; +import { telegramMultiAccountLoginFlows } from '../services/telegramMultiAccountLogin.js'; import { assertPublicStorageEndpoint, assertStorageEndpoint } from '../utils/networkSecurity.js'; import { getCurrentStorageScope } from '../utils/fileScope.js'; import { getAuthToken } from './auth.js'; @@ -23,6 +27,7 @@ import { buildStorageDeletePreviewQueries } from '../services/storageDeletePrevi import { StorageProbeError } from '../services/storage.js'; import { getTelegramUserClientStatus } from '../services/telegramUserClientStatus.js'; import { getTelegramBotStatus } from '../services/telegramBotStatus.js'; +import { formatBytes } from '../utils/fileMetadata.js'; import { applyEffectiveTelegramBotConfig, deleteTelegramBotConfig, @@ -66,6 +71,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' }); } @@ -241,13 +247,7 @@ router.get('/stats/types', requireAuth, async (_req: Request, res: Response) => } }); -function formatBytes(bytes: number): string { - if (bytes === 0) return '0 B'; - const k = 1024; - const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; -} + // 获取存储配置 @@ -396,6 +396,7 @@ router.put('/config/telegram-bot', requireAuth, async (req: Request, res: Respon await applyEffectiveTelegramBotConfig(); try { if (enabled) await controls.restart(credentials); else await controls.stop(); + await initializeTelegramMultiAccountRuntime({ apiId: credentials.apiId, apiHash: credentials.apiHash }); } catch (activationError) { await restoreTelegramBotConfig(previous); const restored = await applyEffectiveTelegramBotConfig(); @@ -423,6 +424,7 @@ router.post('/config/telegram-bot/migrate', requireAuth, async (req: Request, re setTelegramBotIdentity(bot); await applyEffectiveTelegramBotConfig(); await controls.restart(credentials); + await initializeTelegramMultiAccountRuntime({ apiId: credentials.apiId, apiHash: credentials.apiHash }); } catch (activationError) { await restoreTelegramBotConfig(previous); await applyEffectiveTelegramBotConfig(); @@ -506,6 +508,137 @@ router.post('/config/telegram-user-download', requireAuth, async (req: Request, }); router.get('/config/telegram-user', requireAuth, async (_req: Request, res: Response) => { noStore(res); res.json(await getTelegramUserAccountStatus()); }); + +// Multi-account login API. Successful authorization is delegated to the +// registered repository/pool adapter, which upserts by Telegram user id. +router.get('/config/telegram-user/accounts', requireAuth, async (_req: Request, res: Response) => { + noStore(res); + const accounts = await telegramAccountRepository.listAccounts(); + const publicAccounts = await Promise.all(accounts.map(async account => { + const permissions = await telegramAccountRepository.listAccessForAccount(account.id); + const permissionSummary = await telegramAccountRepository.getAccessSummaryForAccount(account.id); + const allowed = permissionSummary.allowed; + const denied = permissionSummary.denied; + const unknown = permissionSummary.unknown; + const lastCheckedAt = permissionSummary.lastCheckedAt?.toISOString?.() || null; + return { + id: account.id, + userId: account.telegramUserId, + username: account.username, + displayName: account.displayName, + enabled: account.enabled, + connected: Boolean(telegramUserClientPool.getAccountClient(account.id)?.connected), + health: !account.enabled ? 'disabled' : account.healthState === 'session_expired' ? 'expired' : account.cooldownUntil && new Date(account.cooldownUntil).getTime() > Date.now() ? 'cooldown' : telegramUserClientPool.getAccountClient(account.id) ? 'ready' : 'error', + checkedAt: lastCheckedAt, + lastError: account.lastError, + cooldownUntil: account.cooldownUntil ? new Date(account.cooldownUntil).toISOString() : null, + permissionSummary: { allowed, denied, unknown, total: permissionSummary.total, lastCheckedAt }, + permissions: permissions.map(row => ({ sourceId: row.sourceKey, sourceName: row.sourceKey, status: row.accessState, checkedAt: row.checkedAt?.toISOString?.() || null, reason: row.lastError })), + scheduling: { weight: account.weight, activeDownloads: telegramUserClientPool.getActiveConnections(account.id), lastSelectedAt: null }, + }; + })); + const aggregate = publicAccounts.reduce((sum, row) => ({ + allowed: sum.allowed + row.permissionSummary.allowed, + denied: sum.denied + row.permissionSummary.denied, + unknown: sum.unknown + row.permissionSummary.unknown, + total: sum.total + row.permissionSummary.total, + lastCheckedAt: !sum.lastCheckedAt || (row.permissionSummary.lastCheckedAt && row.permissionSummary.lastCheckedAt > sum.lastCheckedAt) ? row.permissionSummary.lastCheckedAt : sum.lastCheckedAt, + }), { allowed: 0, denied: 0, unknown: 0, total: 0, lastCheckedAt: null as string | null }); + return res.json({ + accounts: publicAccounts, + summary: { total: publicAccounts.length, enabled: publicAccounts.filter(row => row.enabled).length, ready: publicAccounts.filter(row => row.health === 'ready').length, coolingDown: publicAccounts.filter(row => row.health === 'cooldown').length, permissions: aggregate }, + scheduling: { strategy: 'weighted_least_connections', description: '权限感知的加权最少连接;同等负载自动轮换账号' }, + accessSweep: getTelegramAccountAccessSweepSummary(), + }); +}); +router.post('/config/telegram-user/accounts/:accountId/enable', requireAuth, async (req: Request, res: Response) => { noStore(res); const ok = await telegramAccountRepository.setEnabled(req.params.accountId, true); if (!ok) return res.status(404).json({ error: '账号不存在' }); await telegramUserClientPool.refresh(); return res.json({ success: true, enabled: true }); }); +router.post('/config/telegram-user/accounts/:accountId/disable', requireAuth, async (req: Request, res: Response) => { noStore(res); const ok = await telegramAccountRepository.setEnabled(req.params.accountId, false); if (!ok) return res.status(404).json({ error: '账号不存在' }); await telegramUserClientPool.refresh(); return res.json({ success: true, enabled: false }); }); +router.delete('/config/telegram-user/accounts/:accountId', requireAuth, async (req: Request, res: Response) => { noStore(res); await telegramUserClientPool.expireAccount(req.params.accountId); const ok = await telegramAccountRepository.deleteAccount(req.params.accountId); return ok ? res.json({ success: true }) : res.status(404).json({ error: '账号不存在' }); }); +router.post('/config/telegram-user/accounts/:accountId/check', requireAuth, async (req: Request, res: Response) => { noStore(res); const summary = await triggerTelegramAccountAccessSweep({ accountIds: [req.params.accountId], reason: 'manual' }); return res.json({ success: true, summary }); }); +router.post('/config/telegram-user/accounts/:accountId/permissions/check', requireAuth, async (req: Request, res: Response) => { noStore(res); const summary = await triggerTelegramAccountAccessSweep({ accountIds: [req.params.accountId], reason: 'manual' }); return res.json({ success: true, summary }); }); +router.post('/config/telegram-user/access/check-all', requireAuth, async (_req: Request, res: Response) => { noStore(res); return res.json({ success: true, summary: await triggerTelegramAccountAccessSweep({ reason: 'manual' }) }); }); +router.post('/config/telegram-user/accounts/permissions/check-all', requireAuth, async (_req: Request, res: Response) => { noStore(res); return res.json({ success: true, summary: await triggerTelegramAccountAccessSweep({ reason: 'manual' }) }); }); + +function normalizeMultiAccountLoginPayload(payload: any): any { + if (!payload || typeof payload !== 'object') return payload; + const status = payload.status === 'pending' ? 'waiting_for_scan' : payload.status; + const stepStatus = payload.step === 'password_required' ? 'password_required' : payload.step === 'complete' ? 'complete' : undefined; + return { + ...payload, + status: stepStatus || status, + qrCode: payload.qrCode || payload.qrData || undefined, + qrExpiresAt: payload.qrExpiresAt || payload.expiresAt, + message: payload.message || payload.error || undefined, + }; +} + +// 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.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/: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)); + } catch (error) { + return sendTelegramUserLoginError(res, error); + } +}); +router.post('/config/telegram-accounts/login/code', requireAuth, telegramUserLoginLimiter, async (req: Request, res: Response) => { + noStore(res); + try { + return res.json(await telegramMultiAccountLoginFlows.submitCode(getTelegramUserLoginSessionKey(req), req.body?.flowId, req.body?.code)); + } catch (error) { + return sendTelegramUserLoginError(res, error); + } +}); +router.post('/config/telegram-accounts/login/password', requireAuth, telegramUserLoginLimiter, async (req: Request, res: Response) => { + noStore(res); + try { + return res.json(await telegramMultiAccountLoginFlows.submitPassword(getTelegramUserLoginSessionKey(req), req.body?.flowId, req.body?.password)); + } catch (error) { + return sendTelegramUserLoginError(res, error); + } +}); +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))); + } catch (error) { + return sendTelegramUserLoginError(res, error); + } +}); +router.get('/config/telegram-accounts/login/qr/:flowId', requireAuth, telegramUserLoginStatusLimiter, async (req: Request, res: Response) => { + noStore(res); + try { + return res.json(await telegramMultiAccountLoginFlows.getQrStatus(getTelegramUserLoginSessionKey(req), req.params.flowId)); + } catch (error) { + return sendTelegramUserLoginError(res, error); + } +}); +router.post('/config/telegram-accounts/login/qr/:flowId/refresh', requireAuth, telegramUserLoginLimiter, async (req: Request, res: Response) => { + noStore(res); + try { + return res.json(await telegramMultiAccountLoginFlows.refreshQr(getTelegramUserLoginSessionKey(req), req.params.flowId)); + } catch (error) { + return sendTelegramUserLoginError(res, error); + } +}); +router.delete('/config/telegram-accounts/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); + } +}); + +// Legacy single-account login API remains available for deployed clients. router.post('/config/telegram-user/login/phone', requireAuth, telegramUserLoginLimiter, async (req: Request, res: Response) => { noStore(res); try { diff --git a/backend/src/routes/upload.ts b/backend/src/routes/upload.ts index 2fafdb5..df4823a 100644 --- a/backend/src/routes/upload.ts +++ b/backend/src/routes/upload.ts @@ -6,7 +6,7 @@ import path from 'path'; import fs from 'fs'; import { pool, query } from '../db/index.js'; import { validateApiKey, requireApiKeyPermission } from '../middleware/apiKey.js'; -import { generateThumbnail, getImageDimensions, generateMediaPreview } from '../utils/thumbnail.js'; + import { storageManager } from '../services/storage.js'; import { assertStorageTargetWritable, isStorageCooldownError, sendStorageCooldownHttpError } from '../services/storageCooldownGuard.js'; import { getSignedUrl } from '../middleware/signedUrl.js'; @@ -19,6 +19,8 @@ import { acquireStorageAccountOperationLease, type StorageAccountOperationLease import { lockStorageAccountForUse } from '../services/storageAccountLifecycle.js'; import { normalizeFolderPath } from '../utils/folderPath.js'; import { buildUploadCapabilities, SIMPLE_UPLOAD_MAX_BYTES } from '../utils/uploadCapabilities.js'; +import { getFileType } from '../utils/fileMetadata.js'; +import { enqueueMediaDerivatives } from '../services/mediaDerivatives.js'; const router = Router(); export const apiRouter = Router(); @@ -142,12 +144,6 @@ const handleUpload = async (req: Request, res: Response, source: string = 'web') await assertStorageTargetWritable(target); console.log(`[Upload] 🛠️ Current storage provider: ${provider.name}, activeAccountId: ${activeAccountId || 'none (local)'}`); - // Media derivatives are generated from the upload source before cloud providers consume it. - // This keeps gallery thumbnails and previews independent from remote download quotas. - let thumbnailPath = null; - let previewPath = null; - let width = null; - let height = null; const duplicateMode = await getDuplicateMode(); if (duplicateMode === 'skip') { const duplicate = await findDuplicateFile(originalName, storageFolder, size, activeAccountId); @@ -168,70 +164,32 @@ const handleUpload = async (req: Request, res: Response, source: string = 'web') } } - if (mimeType.startsWith('image/') || mimeType.startsWith('video/')) { - try { - const thumbResult = await generateThumbnail(tempPath, storedName, mimeType); - if (thumbResult) { - thumbnailPath = path.basename(thumbResult); - console.log(`[Upload] ✨ Thumbnail generated: ${thumbnailPath}`); - const dims = await getImageDimensions(tempPath, mimeType); - width = dims.width; - height = dims.height; - } else { - console.log(`[Upload] ⚠️ No thumbnail generated for: ${mimeType}`); - } - } catch (error) { - console.error('生成缩略图失败:', error); - } - } - - if (mimeType.startsWith('image/')) { - try { - const previewResult = await generateMediaPreview(tempPath, storedName, mimeType); - if (previewResult) { - previewPath = path.basename(previewResult); - console.log(`[Upload] 🎞️ Image preview generated: ${previewPath}`); - } - } catch (error) { - console.error('生成图片预览失败:', error); - } - } - - let type = 'other'; - if (mimeType.startsWith('image/')) type = 'image'; - else if (mimeType.startsWith('video/')) type = 'video'; - else if (mimeType.startsWith('audio/')) type = 'audio'; - else if (mimeType.includes('pdf') || mimeType.includes('document') || mimeType.includes('text') || - mimeType.includes('word') || mimeType.includes('excel') || mimeType.includes('spreadsheet') || - mimeType.includes('powerpoint') || mimeType.includes('presentation') || - mimeType.includes('markdown') || mimeType.includes('json') || mimeType.includes('xml') || - mimeType.includes('sql')) type = 'document'; + const type = getFileType(mimeType); + const derivativeStatus = type === 'image' || type === 'video' ? 'queued' : 'not_required'; let result: Awaited>; const storedPath = await saveAndIndexWithCompensation(provider, tempPath, storedName, mimeType, storageFolder, async savedPath => { result = await query( `INSERT INTO files - (name, stored_name, type, mime_type, size, path, thumbnail_path, preview_path, width, height, source, folder, storage_account_id) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) + (name, stored_name, type, mime_type, size, path, source, folder, storage_account_id, + derivative_status, derivative_source_path, derivative_cleanup_source) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id, created_at, name, type, size`, - [originalName, storedName, type, mimeType, size, savedPath, thumbnailPath, previewPath, width, height, provider.name, storageFolder, activeAccountId] + [originalName, storedName, type, mimeType, size, savedPath, provider.name, storageFolder, activeAccountId, + derivativeStatus, derivativeStatus === 'queued' ? (provider.name === 'local' ? savedPath : tempPath) : null, + derivativeStatus === 'queued' && provider.name !== 'local'] ); }); const newFile = result!.rows[0]; - if (type === 'video') { - const previewSource = provider.name === 'local' ? storedPath : tempPath; - void generateMediaPreview(previewSource, storedName, mimeType) - .then(async (previewResult) => { - if (!previewResult) return; - const generatedPreviewName = path.basename(previewResult); - await query('UPDATE files SET preview_path = $1, updated_at = NOW() WHERE id = $2', [generatedPreviewName, newFile.id]); - console.log(`[Upload] 🎞️ Video preview generated async: ${generatedPreviewName}`); - }) - .catch((error) => console.error('异步生成视频预览失败:', error)) - .finally(() => { - if (provider.name !== 'local' && fs.existsSync(tempPath)) fs.unlinkSync(tempPath); - }); + if (derivativeStatus === 'queued') { + enqueueMediaDerivatives({ + fileId: String(newFile.id), + sourcePath: provider.name === 'local' ? storedPath : tempPath, + storedName, + mimeType, + cleanupSource: provider.name !== 'local', + }); } else if (fs.existsSync(tempPath)) { fs.unlinkSync(tempPath); } @@ -243,7 +201,7 @@ const handleUpload = async (req: Request, res: Response, source: string = 'web') name: newFile.name, type: newFile.type, size: newFile.size, - thumbnailUrl: thumbnailPath ? getSignedUrl(newFile.id, 'thumbnail') : undefined, + derivativeStatus, previewUrl: getSignedUrl(newFile.id, 'preview'), date: newFile.created_at, source: provider.name, diff --git a/backend/src/services/databaseReadinessRegression.test.ts b/backend/src/services/databaseReadinessRegression.test.ts index 6c5f887..0a80a24 100644 --- a/backend/src/services/databaseReadinessRegression.test.ts +++ b/backend/src/services/databaseReadinessRegression.test.ts @@ -20,12 +20,14 @@ test('local storage fails closed when a cloud account is still marked active', ( assert.throws(() => validateConfiguredStorageTarget('local', [{ id: 'drive-id', type: 'google_drive' }]), /local/); }); -test('application explicitly awaits schema initialization before storage init and readiness revalidates it', () => { +test('application initializes dependencies once and readiness serves the refreshed memory snapshot', () => { const db = fs.readFileSync(new URL('../db/index.ts', import.meta.url), 'utf8'); const app = fs.readFileSync(new URL('../index.ts', import.meta.url), 'utf8'); assert.match(db, /export function ensureDatabaseInitialized/); assert.doesNotMatch(db, /pool\.on\('connect', async/); assert.match(app, /await ensureDatabaseInitialized\(\)[\s\S]*await storageManager\.init\(\)/); - assert.match(app, /\/readyz[\s\S]*await ensureDatabaseInitialized\(\)/); - assert.match(app, /\/readyz[\s\S]*await storageManager\.assertReady\(\)/); + const readyRoute = app.slice(app.indexOf("app.get('/readyz'"), app.indexOf("app.get('/deepz'")); + assert.match(readyRoute, /dependencyReadiness/); + assert.doesNotMatch(readyRoute, /pool\.query|assertReady|get2FAReadiness/); + assert.match(app, /app\.get\('\/deepz'[\s\S]*refreshDependencyReadiness/); }); diff --git a/backend/src/services/mediaDerivatives.test.ts b/backend/src/services/mediaDerivatives.test.ts new file mode 100644 index 0000000..f386944 --- /dev/null +++ b/backend/src/services/mediaDerivatives.test.ts @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import test from 'node:test'; + +const upload = fs.readFileSync(new URL('../routes/upload.ts', import.meta.url), 'utf8'); +const chunked = fs.readFileSync(new URL('../routes/chunkedUpload.ts', import.meta.url), 'utf8'); +const worker = fs.readFileSync(new URL('./mediaDerivatives.ts', import.meta.url), 'utf8'); +const schema = fs.readFileSync(new URL('../db/schema.sql', import.meta.url), 'utf8'); + +test('web and chunk completion enqueue media derivatives after save and index', () => { + assert.doesNotMatch(upload, /await generateThumbnail|await generateMediaPreview|await getImageDimensions/); + assert.doesNotMatch(chunked, /await generateThumbnail|await generateMediaPreview|await getImageDimensions/); + assert.match(upload, /saveAndIndexWithCompensation[\s\S]*enqueueMediaDerivatives/); + assert.match(chunked, /markChunkReconciliationIndexPresent[\s\S]*enqueueMediaDerivatives/); +}); + +test('bounded derivative worker persists processing lifecycle and has timeouts', () => { + assert.match(worker, /MEDIA_DERIVATIVE_CONCURRENCY/); + assert.match(worker, /MEDIA_DERIVATIVE_TIMEOUT_MS/); + assert.match(worker, /derivative_status = 'processing'/); + assert.match(worker, /derivative_status = 'ready'/); + assert.match(worker, /derivative_status = 'failed'/); + assert.match(schema, /derivative_status/); +}); + +test('derivative jobs are durable, recovered at startup, and do not release slots before timed-out work settles', () => { + assert.match(worker, /recoverMediaDerivativeJobs/); + assert.match(worker, /derivative_source_path/); + assert.match(worker, /await promise\.catch/); + assert.match(worker, /requiredResult/); + assert.match(schema, /idx_files_derivative_pending/); +}); diff --git a/backend/src/services/mediaDerivatives.ts b/backend/src/services/mediaDerivatives.ts new file mode 100644 index 0000000..b3314eb --- /dev/null +++ b/backend/src/services/mediaDerivatives.ts @@ -0,0 +1,170 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { query } from '../db/index.js'; +import { generateMediaPreview, generateThumbnail, getImageDimensions } from '../utils/thumbnail.js'; + +export type MediaDerivativeStatus = 'queued' | 'processing' | 'ready' | 'failed' | 'not_required'; + +interface MediaDerivativeJob { + fileId: string; + sourcePath: string; + storedName: string; + mimeType: string; + cleanupSource?: boolean; +} + +interface MediaDerivativeRow { + id: string; + stored_name: string; + mime_type: string; + path: string; + derivative_source_path: string | null; + derivative_cleanup_source: boolean; +} + +const concurrency = Math.max(1, Math.min(4, Number.parseInt(process.env.MEDIA_DERIVATIVE_CONCURRENCY || '2', 10) || 2)); +const timeoutMs = Math.max(10_000, Number.parseInt(process.env.MEDIA_DERIVATIVE_TIMEOUT_MS || '120000', 10) || 120_000); +const queue: MediaDerivativeJob[] = []; +const queuedIds = new Set(); +let active = 0; + +/** + * A deadline records failure without releasing the concurrency slot early. + * The underlying Sharp/FFmpeg operation is awaited to settlement before cleanup + * and before another job starts, so timed-out work cannot escape the bound. + */ +async function settleWithDeadline(promise: Promise, label: string): Promise { + let timer: NodeJS.Timeout | undefined; + let timedOut = false; + try { + const result = await Promise.race([ + promise.then(value => ({ value })), + new Promise<{ timeout: true }>(resolve => { + timer = setTimeout(() => { timedOut = true; resolve({ timeout: true }); }, timeoutMs); + timer.unref?.(); + }), + ]); + if ('timeout' in result) { + await promise.catch(() => undefined); + throw new Error(`${label}超时`); + } + return result.value; + } finally { + if (timer) clearTimeout(timer); + if (timedOut) console.warn(`[MediaDerivatives] ${label} 已超过 ${timeoutMs}ms,等待底层进程退出后释放队列槽位`); + } +} + +function requiredResult(value: T | null | undefined, label: string): T { + if (value == null) throw new Error(`${label}未生成有效输出`); + return value; +} + +async function processJob(job: MediaDerivativeJob): Promise { + const claimed = await query( + `UPDATE files + SET derivative_status = 'processing', derivative_error = NULL, + derivative_started_at = NOW(), derivative_attempts = derivative_attempts + 1, updated_at = NOW() + WHERE id = $1 AND derivative_status = 'queued' + RETURNING id`, + [job.fileId], + ); + if (!claimed.rowCount) return; + + try { + const thumbnail = requiredResult( + await settleWithDeadline(generateThumbnail(job.sourcePath, job.storedName, job.mimeType), '缩略图生成'), + '缩略图', + ); + const dimensions = await settleWithDeadline(getImageDimensions(job.sourcePath, job.mimeType), '媒体尺寸读取'); + if (!(dimensions.width > 0 && dimensions.height > 0)) throw new Error('媒体尺寸无效'); + const preview = requiredResult( + await settleWithDeadline(generateMediaPreview(job.sourcePath, job.storedName, job.mimeType), '预览生成'), + '预览', + ); + + await query( + `UPDATE files + SET thumbnail_path = $1, preview_path = $2, width = $3, height = $4, + derivative_status = 'ready', derivative_error = NULL, + derivative_source_path = NULL, derivative_cleanup_source = FALSE, + derivative_started_at = NULL, updated_at = NOW() + WHERE id = $5`, + [path.basename(thumbnail), path.basename(preview), dimensions.width, dimensions.height, job.fileId], + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + await query( + `UPDATE files + SET derivative_status = 'failed', derivative_error = $1, + derivative_started_at = NULL, updated_at = NOW() + WHERE id = $2`, + [message.slice(0, 1000), job.fileId], + ).catch(() => undefined); + console.error(`[MediaDerivatives] ${job.fileId} 处理失败:`, error); + } finally { + if (job.cleanupSource) await fs.rm(job.sourcePath, { force: true }).catch(() => undefined); + } +} + +function drain(): void { + while (active < concurrency && queue.length > 0) { + const job = queue.shift()!; + active += 1; + void processJob(job).finally(() => { + queuedIds.delete(job.fileId); + active -= 1; + drain(); + }); + } +} + +export function enqueueMediaDerivatives(job: MediaDerivativeJob): void { + if (queuedIds.has(job.fileId)) return; + queuedIds.add(job.fileId); + queue.push(job); + drain(); +} + +function jobFromRow(row: MediaDerivativeRow): MediaDerivativeJob | null { + const sourcePath = row.derivative_source_path || row.path; + if (!sourcePath) return null; + return { + fileId: String(row.id), + sourcePath: String(sourcePath), + storedName: String(row.stored_name), + mimeType: String(row.mime_type), + cleanupSource: row.derivative_cleanup_source === true, + }; +} + +/** Recover persisted queued jobs and stale processing jobs after a restart. */ +export async function recoverMediaDerivativeJobs(): Promise { + await query( + `UPDATE files + SET derivative_status = 'queued', derivative_error = '上次处理被中断,已重新排队', + derivative_started_at = NULL, updated_at = NOW() + WHERE derivative_status = 'processing'`, + [], + ); + const result = await query( + `SELECT id, stored_name, mime_type, path, derivative_source_path, derivative_cleanup_source + FROM files + WHERE derivative_status = 'queued' + ORDER BY created_at ASC, id ASC + LIMIT 1000`, + ); + let recovered = 0; + for (const row of result.rows as MediaDerivativeRow[]) { + const job = jobFromRow(row); + if (!job) continue; + enqueueMediaDerivatives(job); + recovered += 1; + } + if (recovered > 0) console.log(`[MediaDerivatives] 已恢复 ${recovered} 个持久化任务`); + return recovered; +} + +export function mediaDerivativeQueueSnapshot(): { active: number; queued: number; concurrency: number } { + return { active, queued: queue.length, concurrency }; +} diff --git a/backend/src/services/migrationLedger.test.ts b/backend/src/services/migrationLedger.test.ts index 0abf97a..8f0bd46 100644 --- a/backend/src/services/migrationLedger.test.ts +++ b/backend/src/services/migrationLedger.test.ts @@ -5,22 +5,45 @@ import test from 'node:test'; const db = fs.readFileSync(new URL('../db/index.ts', import.meta.url), 'utf8'); const schema = fs.readFileSync(new URL('../db/schema.sql', import.meta.url), 'utf8'); const init = fs.readFileSync(new URL('../../../init.sql', import.meta.url), 'utf8'); +const trigram = fs.readFileSync(new URL('../db/migrations/2026082901_file_search_trigram.sql', import.meta.url), 'utf8'); +const dockerfile = fs.readFileSync(new URL('../../Dockerfile', import.meta.url), 'utf8'); +const durableDerivatives = fs.readFileSync(new URL('../db/migrations/2026082903_durable_media_derivatives.sql', import.meta.url), 'utf8'); -test('database startup records a canonical schema migration ledger', () => { +test('database startup uses immutable incremental migrations under an advisory lock', () => { assert.match(schema, /CREATE TABLE IF NOT EXISTS schema_migrations/); - assert.match(db, /applyCanonicalSchemaMigration/); + assert.match(db, /MIGRATIONS_DIR/); + assert.match(db, /pg_advisory_lock/); + assert.match(db, /checksum\/name mismatch/); assert.match(db, /INSERT INTO schema_migrations/); - assert.match(db, /CANONICAL_SCHEMA_VERSION/); + assert.doesNotMatch(db, /inDollarQuote|split SQL|CANONICAL_SCHEMA_VERSION/); }); -test('first-install init is generated from the same canonical schema source', () => { +test('each pending migration runs in its own transaction without rewriting applied checksums', () => { + assert.match(db, /for \(const migration of await listMigrations\(\)\)/); + assert.match(db, /await client\.query\('BEGIN'\)/); + assert.match(db, /await client\.query\('COMMIT'\)/); + assert.doesNotMatch(db, /ON CONFLICT \(version\) DO UPDATE/); +}); + +test('first-install init remains generated from the canonical schema snapshot', () => { assert.match(init, /GENERATED FROM backend\/src\/db\/schema\.sql/); assert.match(init, /schema_migrations/); assert.match(init, /telegram_notification_preferences/); }); -test('legacy ad-hoc startup migrations no longer duplicate schema ownership', () => { - const initializer = db.slice(db.indexOf('async function initializeDatabase'), db.indexOf('export function ensureDatabaseInitialized')); - assert.doesNotMatch(initializer, /ALTER TABLE files ADD COLUMN/); - assert.doesNotMatch(initializer, /CREATE TABLE IF NOT EXISTS web_sessions/); +test('file search migration enables trigram indexes for infix search', () => { + assert.match(trigram, /CREATE EXTENSION IF NOT EXISTS pg_trgm/); + assert.match(trigram, /name gin_trgm_ops/); + assert.match(trigram, /folder gin_trgm_ops/); +}); + +test('production build ships immutable migration files beside the bundled entrypoint', () => { + assert.match(db, /MIGRATIONS_DIR = path\.join\(__dirname, 'migrations'\)/); + assert.match(dockerfile, /COPY --from=builder \/app\/src\/db\/migrations \.\/dist\/migrations/); +}); + +test('durable media derivative migration persists recovery metadata and a pending-work index', () => { + assert.match(durableDerivatives, /derivative_source_path/); + assert.match(durableDerivatives, /derivative_cleanup_source/); + assert.match(durableDerivatives, /idx_files_derivative_pending/); }); diff --git a/backend/src/services/orphanCleanup.test.ts b/backend/src/services/orphanCleanup.test.ts index e776758..4336b24 100644 --- a/backend/src/services/orphanCleanup.test.ts +++ b/backend/src/services/orphanCleanup.test.ts @@ -3,7 +3,13 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; -import { getAllFiles, isReservedTransientUploadPath } from './orphanCleanup.js'; +import { getAllFiles, isReservedTransientUploadPath, walkFiles } from './orphanCleanup.js'; + +const source = fs.readFileSync(new URL('./orphanCleanup.ts', import.meta.url), 'utf8'); + +test('cleanup protects every local-volume index regardless of historical source label', () => { + assert.match(source, /WHERE storage_account_id IS NULL/); +}); test('orphan cleanup excludes the yt-dlp workspace while still allowing adjacent orphan cleanup', () => { const uploadDir = path.resolve('/srv/tg-vault/uploads'); @@ -38,7 +44,7 @@ test('orphan file enumeration never descends into an active yt-dlp task director await fs.promises.writeFile(activeFile, 'active'); try { - const files = getAllFiles(root, [], [ytDlpDir]).map(file => file.path); + const files = (await getAllFiles(root, [ytDlpDir])).map(file => file.path); assert.deepEqual(files, [ordinaryFile]); assert.equal(fs.existsSync(activeFile), true); } finally { @@ -53,7 +59,7 @@ test('orphan enumeration fails safe when the configured yt-dlp workspace equals await fs.promises.writeFile(activeFile, 'active'); try { - assert.deepEqual(getAllFiles(root, [], [root]), []); + assert.deepEqual(await getAllFiles(root, [root]), []); assert.equal(fs.existsSync(activeFile), true); } finally { await fs.promises.rm(root, { recursive: true, force: true }); @@ -71,7 +77,7 @@ test('orphan enumeration never follows a symlink into the reserved yt-dlp worksp await fs.promises.symlink(ytDlpDir, alias, 'dir'); try { - assert.deepEqual(getAllFiles(root, [], [ytDlpDir]), []); + assert.deepEqual(await getAllFiles(root, [ytDlpDir]), []); assert.equal(fs.existsSync(activeFile), true); } finally { await fs.promises.rm(root, { recursive: true, force: true }); diff --git a/backend/src/services/orphanCleanup.ts b/backend/src/services/orphanCleanup.ts index be3e3d5..0f81d77 100644 --- a/backend/src/services/orphanCleanup.ts +++ b/backend/src/services/orphanCleanup.ts @@ -1,19 +1,26 @@ /** * 孤儿文件清理服务 - * - * 扫描 uploads 目录,删除不在数据库中的"孤儿文件" - * 解决进程崩溃/重启导致的文件残留问题 + * + * 以异步流式方式扫描 uploads,删除未被数据库索引且超过保护期的文件。 */ -import fs from 'fs'; -import path from 'path'; +import fs from 'node:fs/promises'; +import path from 'node:path'; import { query } from '../db/index.js'; import { getRelativeStoragePath, safeUnlink } from '../utils/localPath.js'; +import { formatBytes } from '../utils/fileMetadata.js'; const UPLOAD_DIR = path.resolve(process.env.UPLOAD_DIR || './data/uploads'); -const THUMBNAIL_DIR = path.resolve(process.env.THUMBNAIL_DIR || './data/thumbnails'); const YTDLP_WORK_DIR = path.resolve(process.env.YTDLP_WORK_DIR || path.join(UPLOAD_DIR, 'ytdlp')); const ORPHAN_MIN_AGE_MS = Math.max(60_000, parseInt(process.env.ORPHAN_CLEANUP_MIN_AGE_MS || '600000', 10) || 600_000); +const YIELD_EVERY = Math.max(25, parseInt(process.env.ORPHAN_CLEANUP_YIELD_EVERY || '250', 10) || 250); + +export interface ScannedFile { + name: string; + path: string; + size: number; + mtimeMs: number; +} export function isReservedTransientUploadPath(filePath: string, reservedDirs: string[] = [YTDLP_WORK_DIR]): boolean { const resolvedPath = path.resolve(filePath); @@ -27,216 +34,178 @@ export function isAutoCleanupEnabled(): boolean { return ['1', 'true', 'yes', 'on'].includes((process.env.AUTO_CLEANUP_ORPHANS || 'true').toLowerCase()); } -// 清理统计结果 export interface CleanupStats { deletedCount: number; freedBytes: number; - freedSpace: string; // 人类可读格式 - deletedFiles: string[]; // 删除的文件列表 + freedSpace: string; + deletedFiles: string[]; } -// 格式化字节数为人类可读格式 -function formatBytes(bytes: number): string { - if (bytes === 0) return '0 B'; - const k = 1024; - const sizes = ['B', 'KB', 'MB', 'GB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; -} - -// 递归获取目录下所有文件 -export function getAllFiles( - dirPath: string, - arrayOfFiles: { name: string; path: string; size: number }[] = [], - reservedDirs: string[] = [YTDLP_WORK_DIR], -): { name: string; path: string; size: number }[] { - if (isReservedTransientUploadPath(dirPath, reservedDirs)) { - return arrayOfFiles; - } - if (!fs.existsSync(dirPath)) { - return arrayOfFiles; - } - - try { - const files = fs.readdirSync(dirPath); - - for (const file of files) { - const fullPath = path.join(dirPath, file); - try { - const stat = fs.lstatSync(fullPath); - if (stat.isSymbolicLink()) { - continue; - } - if (stat.isDirectory()) { - if (!isReservedTransientUploadPath(fullPath, reservedDirs)) { - getAllFiles(fullPath, arrayOfFiles, reservedDirs); - } - } else if (!isReservedTransientUploadPath(fullPath, reservedDirs)) { - arrayOfFiles.push({ - name: file, - path: fullPath, - size: stat.size - }); - } - } catch (e) { - console.warn(`🧹 无法读取文件状态: ${fullPath}`, e); - } - } - } catch (e) { - console.error(`🧹 无法读取目录: ${dirPath}`, e); - } - - return arrayOfFiles; -} - -// 删除空文件夹(递归) -function removeEmptyDirectories(dirPath: string): void { - if (!fs.existsSync(dirPath)) return; - - try { - const files = fs.readdirSync(dirPath); - - // 先递归处理子目录 - for (const file of files) { - const fullPath = path.join(dirPath, file); - try { - if (isReservedTransientUploadPath(fullPath)) continue; - const stat = fs.lstatSync(fullPath); - if (stat.isSymbolicLink()) continue; - if (stat.isDirectory()) { - removeEmptyDirectories(fullPath); - } - } catch (e) { /* ignore */ } - } - - // 再检查当前目录是否为空 - const remainingFiles = fs.readdirSync(dirPath); - if (remainingFiles.length === 0 && dirPath !== UPLOAD_DIR) { - fs.rmdirSync(dirPath); - console.log(`🧹 删除空文件夹: ${dirPath}`); - } - } catch (e) { - console.warn(`🧹 删除空文件夹失败: ${dirPath}`, e); - } +async function yieldToEventLoop(): Promise { + await new Promise(resolve => setImmediate(resolve)); } /** - * 清理孤儿文件 - * 扫描 uploads 目录,删除不在数据库中的文件 + * Streams files without following symlinks or entering reserved transient workspaces. + * Read/stat races are logged and skipped rather than aborting the maintenance run. */ -export async function cleanupOrphanFiles(): Promise { - const stats: CleanupStats = { - deletedCount: 0, - freedBytes: 0, - freedSpace: '0 B', - deletedFiles: [] - }; - - console.log('🧹 开始扫描孤儿文件...'); +export async function* walkFiles( + dirPath: string, + reservedDirs: string[] = [YTDLP_WORK_DIR], + state: { visited: number } = { visited: 0 }, +): AsyncGenerator { + if (isReservedTransientUploadPath(dirPath, reservedDirs)) return; + let directory; try { - // 1. 从数据库获取所有本地已注册文件的相对存储路径 - const dbResult = await query(` - SELECT stored_name, folder, path - FROM files - WHERE source = 'local' - AND mime_type IS DISTINCT FROM 'application/x-directory' - `); - const dbFileSet = new Set(); - for (const row of dbResult.rows) { - if (row.path) { - const relativePath = getRelativeStoragePath(UPLOAD_DIR, row.path); - if (relativePath) dbFileSet.add(relativePath); - } - - if (row.stored_name) { - const key = [row.folder, row.stored_name].filter(Boolean).join('/'); - if (key) dbFileSet.add(key); - } - } - - console.log(`🧹 数据库中已注册文件数: ${dbFileSet.size}`); - - // 2. 扫描 uploads 目录下所有文件 - const diskFiles = getAllFiles(UPLOAD_DIR); - console.log(`🧹 磁盘上文件数: ${diskFiles.length}`); - - // 3. 找出孤儿文件并删除 - for (const file of diskFiles) { - const relativePath = getRelativeStoragePath(UPLOAD_DIR, file.path); - if (relativePath && !dbFileSet.has(relativePath)) { - const ageMs = Date.now() - fs.statSync(file.path).mtimeMs; - if (ageMs < ORPHAN_MIN_AGE_MS) continue; - try { - await safeUnlink(file.path, UPLOAD_DIR); - stats.deletedCount++; - stats.freedBytes += file.size; - stats.deletedFiles.push(relativePath); - console.log(`🧹 删除孤儿文件: ${file.path} (${formatBytes(file.size)})`); - } catch (e) { - console.error(`🧹 删除文件失败: ${file.path}`, e); - } - } - } - - // 4. 删除空文件夹 - removeEmptyDirectories(UPLOAD_DIR); - - stats.freedSpace = formatBytes(stats.freedBytes); - - if (stats.deletedCount > 0) { - console.log(`🧹 清理完成: 删除 ${stats.deletedCount} 个孤儿文件,释放 ${stats.freedSpace}`); - } else { - console.log('🧹 扫描完成: 没有发现孤儿文件'); - } - - } catch (error) { - console.error('🧹 孤儿文件清理失败:', error); - throw error; + directory = await fs.opendir(dirPath); + } catch (error: any) { + if (error?.code !== 'ENOENT') console.warn(`🧹 无法读取目录: ${dirPath}`, error); + return; } + try { + for await (const entry of directory) { + const fullPath = path.join(dirPath, entry.name); + if (isReservedTransientUploadPath(fullPath, reservedDirs)) continue; + try { + const stat = await fs.lstat(fullPath); + if (stat.isSymbolicLink()) continue; + if (stat.isDirectory()) { + yield* walkFiles(fullPath, reservedDirs, state); + } else if (stat.isFile()) { + yield { name: entry.name, path: fullPath, size: stat.size, mtimeMs: stat.mtimeMs }; + } + } catch (error: any) { + if (error?.code !== 'ENOENT') console.warn(`🧹 无法读取文件状态: ${fullPath}`, error); + } + state.visited += 1; + if (state.visited % YIELD_EVERY === 0) await yieldToEventLoop(); + } + } catch (error: any) { + if (error?.code !== 'ENOENT') console.warn(`🧹 扫描目录失败: ${dirPath}`, error); + } +} + +/** Compatibility helper for callers/tests that need a materialized snapshot. */ +export async function getAllFiles(dirPath: string, reservedDirs: string[] = [YTDLP_WORK_DIR]): Promise { + const files: ScannedFile[] = []; + for await (const file of walkFiles(dirPath, reservedDirs)) files.push(file); + return files; +} + +async function removeEmptyDirectories(dirPath: string, reservedDirs: string[] = [YTDLP_WORK_DIR]): Promise { + if (isReservedTransientUploadPath(dirPath, reservedDirs)) return; + let entries; + try { + entries = await fs.readdir(dirPath, { withFileTypes: true }); + } catch (error: any) { + if (error?.code !== 'ENOENT') console.warn(`🧹 无法读取待清理目录: ${dirPath}`, error); + return; + } + + for (const entry of entries) { + const fullPath = path.join(dirPath, entry.name); + if (isReservedTransientUploadPath(fullPath, reservedDirs) || entry.isSymbolicLink()) continue; + if (entry.isDirectory()) await removeEmptyDirectories(fullPath, reservedDirs); + } + + if (path.resolve(dirPath) === UPLOAD_DIR) return; + try { + const remaining = await fs.readdir(dirPath); + if (remaining.length === 0) { + await fs.rmdir(dirPath); + console.log(`🧹 删除空文件夹: ${dirPath}`); + } + } catch (error: any) { + if (!['ENOENT', 'ENOTEMPTY'].includes(error?.code)) console.warn(`🧹 删除空文件夹失败: ${dirPath}`, error); + } +} + +async function runCleanup(): Promise { + const stats: CleanupStats = { deletedCount: 0, freedBytes: 0, freedSpace: '0 B', deletedFiles: [] }; + console.log('🧹 开始扫描孤儿文件...'); + + const dbResult = await query(` + SELECT stored_name, folder, path + FROM files + WHERE storage_account_id IS NULL + AND mime_type IS DISTINCT FROM 'application/x-directory' + `); + const dbFileSet = new Set(); + for (const row of dbResult.rows) { + if (row.path) { + const relativePath = getRelativeStoragePath(UPLOAD_DIR, row.path); + if (relativePath) dbFileSet.add(relativePath); + } + if (row.stored_name) { + const key = [row.folder, row.stored_name].filter(Boolean).join('/'); + if (key) dbFileSet.add(key); + } + } + console.log(`🧹 数据库中已注册文件数: ${dbFileSet.size}`); + + let scannedCount = 0; + const now = Date.now(); + 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; + try { + await safeUnlink(file.path, UPLOAD_DIR); + stats.deletedCount += 1; + stats.freedBytes += file.size; + stats.deletedFiles.push(relativePath); + console.log(`🧹 删除孤儿文件: ${file.path} (${formatBytes(file.size)})`); + } catch (error) { + console.error(`🧹 删除文件失败: ${file.path}`, error); + } + } + console.log(`🧹 磁盘上文件数: ${scannedCount}`); + + await removeEmptyDirectories(UPLOAD_DIR); + stats.freedSpace = formatBytes(stats.freedBytes); + console.log(stats.deletedCount > 0 + ? `🧹 清理完成: 删除 ${stats.deletedCount} 个孤儿文件,释放 ${stats.freedSpace}` + : '🧹 扫描完成: 没有发现孤儿文件'); return stats; } -// 定期清理定时器 +let cleanupInFlight: Promise | null = null; + +/** Concurrent triggers share one run so periodic/manual cleanup never overlaps. */ +export function cleanupOrphanFiles(): Promise { + if (cleanupInFlight) return cleanupInFlight; + cleanupInFlight = runCleanup() + .catch(error => { + console.error('🧹 孤儿文件清理失败:', error); + throw error; + }) + .finally(() => { cleanupInFlight = null; }); + return cleanupInFlight; +} + let cleanupInterval: NodeJS.Timeout | null = null; -/** - * 启动定期清理任务 - * 默认每小时执行一次 - */ export function startPeriodicCleanup(intervalMs: number = 60 * 60 * 1000): void { if (!isAutoCleanupEnabled()) { console.log('🧹 自动孤儿文件清理已关闭 (AUTO_CLEANUP_ORPHANS=false)'); return; } - - if (cleanupInterval) { - clearInterval(cleanupInterval); - } - - cleanupInterval = setInterval(async () => { + if (cleanupInterval) clearInterval(cleanupInterval); + cleanupInterval = setInterval(() => { console.log('🧹 执行定期孤儿文件清理...'); - try { - const stats = await cleanupOrphanFiles(); - if (stats.deletedCount > 0) { - console.log(`🧹 定期清理完成: 删除 ${stats.deletedCount} 个文件,释放 ${stats.freedSpace}`); - } - } catch (e) { - console.error('🧹 定期清理失败:', e); - } + void cleanupOrphanFiles().then(stats => { + if (stats.deletedCount > 0) console.log(`🧹 定期清理完成: 删除 ${stats.deletedCount} 个文件,释放 ${stats.freedSpace}`); + }).catch(error => console.error('🧹 定期清理失败:', error)); }, intervalMs); - + cleanupInterval.unref?.(); console.log(`🧹 已启动定期清理任务 (间隔: ${intervalMs / 1000 / 60} 分钟)`); } -/** - * 停止定期清理任务 - */ export function stopPeriodicCleanup(): void { - if (cleanupInterval) { - clearInterval(cleanupInterval); - cleanupInterval = null; - console.log('🧹 已停止定期清理任务'); - } + if (!cleanupInterval) return; + clearInterval(cleanupInterval); + cleanupInterval = null; + console.log('🧹 已停止定期清理任务'); } diff --git a/backend/src/services/reproducibleBuildContract.test.ts b/backend/src/services/reproducibleBuildContract.test.ts index 5c53d1f..b6057c4 100644 --- a/backend/src/services/reproducibleBuildContract.test.ts +++ b/backend/src/services/reproducibleBuildContract.test.ts @@ -26,8 +26,8 @@ function assertBeginnerFriendlyInstall(source: string): void { } test('release images use locked dependencies, pinned bases, verified yt-dlp and source labels', () => { - assert.equal(backendPackage.version, '2.2.1'); - assert.equal(frontendPackage.version, '2.2.1'); + assert.equal(backendPackage.version, '2.3.0'); + assert.equal(frontendPackage.version, '2.3.0'); assert.equal((backend.match(/npm ci/g) || []).length, 2); assert.doesNotMatch(backend, /npm install/); assert.match(backend, /node@sha256:/); diff --git a/backend/src/services/storage.ts b/backend/src/services/storage.ts index 2dad01e..b2156a8 100644 --- a/backend/src/services/storage.ts +++ b/backend/src/services/storage.ts @@ -1,5 +1,4 @@ import fs from 'fs'; -import path from 'path'; import axios from 'axios'; import OSS from 'ali-oss'; import { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand, HeadObjectCommand, ListObjectsV2Command } from '@aws-sdk/client-s3'; @@ -7,9 +6,12 @@ import { getSignedUrl as getS3SignedUrl } from '@aws-sdk/s3-request-presigner'; import { createClient, WebDAVClient } from 'webdav'; import { google } from 'googleapis'; import { query, pool } from '../db/index.js'; -import { safeJoin } from '../utils/localPath.js'; import { summarizeStorageProbeFailure } from '../utils/storageProbe.js'; import { MediaSourceMissingError } from './mediaProxyError.js'; +import { type IStorageProvider, type StorageTargetSnapshot, StorageProbeError } from './storage/contracts.js'; +import { LocalStorageProvider } from './storage/localStorageProvider.js'; +export { type IStorageProvider, type StorageTargetSnapshot, StorageProbeError } from './storage/contracts.js'; +export { LocalStorageProvider } from './storage/localStorageProvider.js'; import { decryptSettingValue, decryptStorageConfig, @@ -67,76 +69,6 @@ function isGoogleDriveDailyUploadLimitError(error: any): boolean { return /Drive upload limit|exceeded their Drive upload limit|exceeded.*upload limit|upload limit exceeded|upload limit/i.test(text); } -// 接口定义 -export interface IStorageProvider { - id?: string; // 对于云盘,这是账户 ID - name: string; - /** Performs a read-only connectivity and authorization check. */ - probe(): Promise; - /** - * 保存文件 - * @param tempPath 临时文件路径 - * @param fileName 目标文件名 - * @param mimeType 文件类型 - * @returns 存储后的路径或标识符 - */ - saveFile(tempPath: string, fileName: string, mimeType: string, folder?: string | null): Promise; - - /** - * 获取文件流(用于下载) - * @param storedPath 存储路径或标识符 - */ - getFileStream(storedPath: string, options?: { range?: string }): Promise; - - /** Checks whether a remote media source still exists without downloading its bytes. */ - getFileAvailability?(storedPath: string): Promise<{ available: true }>; - - /** Probes whether the source bytes can be read without downloading the whole object. */ - probeFileReadable?(storedPath: string): Promise<{ available: true }>; - - /** - * 获取预览URL(可能是临时的) - * @param storedPath 存储路径或标识符 - */ - getPreviewUrl(storedPath: string): Promise; - - /** - * 删除文件 - * @param storedPath 存储路径或标识符 - */ - deleteFile(storedPath: string): Promise; - - /** - * 获取文件大小(可选) - */ - getFileSize?(storedPath: string): Promise; - - /** Returns remote account quota when the provider exposes it. */ - getQuota?(): Promise<{ totalBytes: number; usedBytes: number } | null>; - - - /** - * 创建分享链接 - * @param storedPath 存储路径或标识符 - * @param password 访问密码(可选) - * @param expiration 过期时间 ISO 字符串(可选) - */ - createShareLink?(storedPath: string, password?: string, expiration?: string): Promise<{ link: string; error?: string }>; -} - -export interface StorageTargetSnapshot { - provider: IStorageProvider; - accountId: string | null; - providerKey: string; -} - -export class StorageProbeError extends Error { - constructor(public readonly provider: string, message: string, public readonly causeCode?: string) { - super(message); - this.name = 'StorageProbeError'; - } -} - function storageProbeError(provider: string, error: unknown): StorageProbeError { if (error instanceof StorageProbeError) return error; const { reason, code } = summarizeStorageProbeFailure(error); @@ -160,80 +92,6 @@ async function withStorageProbeDeadline(operation: Promise, provider: stri } } -// 本地存储实现 -export class LocalStorageProvider implements IStorageProvider { - name = 'local'; - private uploadDir: string; - - constructor(uploadDir: string = process.env.UPLOAD_DIR || './data/uploads') { - this.uploadDir = path.resolve(uploadDir); - if (!fs.existsSync(this.uploadDir)) { - fs.mkdirSync(this.uploadDir, { recursive: true }); - } - } - - async probe(): Promise { - const stats = await fs.promises.stat(this.uploadDir); - if (!stats.isDirectory()) throw new StorageProbeError(this.name, '本地存储路径不是目录'); - await fs.promises.access(this.uploadDir, fs.constants.R_OK | fs.constants.W_OK); - } - - async saveFile(tempPath: string, fileName: string, _mimeType?: string, folder?: string | null): Promise { - const destDir = folder ? safeJoin(this.uploadDir, folder) : this.uploadDir; - if (!fs.existsSync(destDir)) { - fs.mkdirSync(destDir, { recursive: true }); - } - const destPath = safeJoin(destDir, fileName); - try { - await fs.promises.rename(tempPath, destPath); - } catch (error: any) { - // 如果是跨设备移动 (EXDEV),则使用复制+删除 - if (error.code === 'EXDEV') { - await fs.promises.copyFile(tempPath, destPath); - await fs.promises.unlink(tempPath); - } else { - throw error; - } - } - return destPath; // 返回绝对路径 - } - - async getFileStream(storedPath: string): Promise { - const safePath = safeJoin(this.uploadDir, path.relative(this.uploadDir, storedPath)); - if (safePath !== path.resolve(storedPath)) { - throw new Error('Unsafe local file path'); - } - if (!fs.existsSync(safePath)) { - throw new Error(`File not found: ${safePath}`); - } - return fs.createReadStream(safePath); - } - - async getPreviewUrl(storedPath: string): Promise { - // 本地文件通过现有的 serve-static 或 API 路由提供服务 - // 这里我们返回文件名,让上层路由处理 - // 注意:目前架构是 controller 层组装 URL,这里其实可能不需要具体 URL - // 或者我们可以返回 null,让上层使用默认逻辑 - return ''; - } - - async deleteFile(storedPath: string): Promise { - const safePath = safeJoin(this.uploadDir, path.relative(this.uploadDir, storedPath)); - if (safePath !== path.resolve(storedPath)) { - throw new Error('Unsafe local file path'); - } - if (fs.existsSync(safePath)) { - await fs.promises.unlink(safePath); - } - } - - async createShareLink(storedPath: string, password?: string, expiration?: string): Promise<{ link: string; error?: string }> { - // 本地存储暂不支持生成外部访问链接,除非我们自己实现一个分享页面 - // 这里返回错误提示 - return { link: '', error: '本地存储暂不支持生成分享链接,请使用 OneDrive 存储。' }; - } -} - // Aliyun OSS 存储实现 export class AliyunOSSStorageProvider implements IStorageProvider { name = 'aliyun_oss'; diff --git a/backend/src/services/storage/contracts.ts b/backend/src/services/storage/contracts.ts new file mode 100644 index 0000000..0c2fec2 --- /dev/null +++ b/backend/src/services/storage/contracts.ts @@ -0,0 +1,68 @@ +export interface IStorageProvider { + id?: string; // 对于云盘,这是账户 ID + name: string; + /** Performs a read-only connectivity and authorization check. */ + probe(): Promise; + /** + * 保存文件 + * @param tempPath 临时文件路径 + * @param fileName 目标文件名 + * @param mimeType 文件类型 + * @returns 存储后的路径或标识符 + */ + saveFile(tempPath: string, fileName: string, mimeType: string, folder?: string | null): Promise; + + /** + * 获取文件流(用于下载) + * @param storedPath 存储路径或标识符 + */ + getFileStream(storedPath: string, options?: { range?: string }): Promise; + + /** Checks whether a remote media source still exists without downloading its bytes. */ + getFileAvailability?(storedPath: string): Promise<{ available: true }>; + + /** Probes whether the source bytes can be read without downloading the whole object. */ + probeFileReadable?(storedPath: string): Promise<{ available: true }>; + + /** + * 获取预览URL(可能是临时的) + * @param storedPath 存储路径或标识符 + */ + getPreviewUrl(storedPath: string): Promise; + + /** + * 删除文件 + * @param storedPath 存储路径或标识符 + */ + deleteFile(storedPath: string): Promise; + + /** + * 获取文件大小(可选) + */ + getFileSize?(storedPath: string): Promise; + + /** Returns remote account quota when the provider exposes it. */ + getQuota?(): Promise<{ totalBytes: number; usedBytes: number } | null>; + + + /** + * 创建分享链接 + * @param storedPath 存储路径或标识符 + * @param password 访问密码(可选) + * @param expiration 过期时间 ISO 字符串(可选) + */ + createShareLink?(storedPath: string, password?: string, expiration?: string): Promise<{ link: string; error?: string }>; +} + +export interface StorageTargetSnapshot { + provider: IStorageProvider; + accountId: string | null; + providerKey: string; +} + +export class StorageProbeError extends Error { + constructor(public readonly provider: string, message: string, public readonly causeCode?: string) { + super(message); + this.name = 'StorageProbeError'; + } +} diff --git a/backend/src/services/storage/localStorageProvider.test.ts b/backend/src/services/storage/localStorageProvider.test.ts new file mode 100644 index 0000000..7b9a82a --- /dev/null +++ b/backend/src/services/storage/localStorageProvider.test.ts @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { + LocalStorageProvider as PublicLocalStorageProvider, + StorageProbeError as PublicStorageProbeError, +} from '../storage.js'; +import { StorageProbeError } from './contracts.js'; +import { LocalStorageProvider } from './localStorageProvider.js'; + +async function readStream(stream: NodeJS.ReadableStream): Promise { + const chunks: Buffer[] = []; + for await (const chunk of stream as NodeJS.ReadableStream & AsyncIterable) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks).toString('utf8'); +} + +test('storage.ts preserves the extracted local provider and error exports', () => { + assert.equal(PublicLocalStorageProvider, LocalStorageProvider); + assert.equal(PublicStorageProbeError, StorageProbeError); +}); + +test('extracted local provider preserves save, read, delete, and share behavior', async () => { + const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'tg-vault-local-storage-')); + const uploadDir = path.join(root, 'uploads'); + const tempPath = path.join(root, 'incoming.txt'); + + try { + await fs.promises.writeFile(tempPath, 'stored locally'); + const provider = new LocalStorageProvider(uploadDir); + + await provider.probe(); + const storedPath = await provider.saveFile(tempPath, 'saved.txt', 'text/plain', 'nested'); + assert.equal(storedPath, path.join(uploadDir, 'nested', 'saved.txt')); + assert.equal(await readStream(await provider.getFileStream(storedPath)), 'stored locally'); + assert.equal(await provider.getPreviewUrl(storedPath), ''); + assert.deepEqual(await provider.createShareLink(storedPath), { + link: '', + error: '本地存储暂不支持生成分享链接,请使用 OneDrive 存储。', + }); + + await provider.deleteFile(storedPath); + assert.equal(fs.existsSync(storedPath), false); + } finally { + await fs.promises.rm(root, { recursive: true, force: true }); + } +}); + +test('extracted local provider still rejects paths outside its upload directory', async () => { + const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'tg-vault-local-storage-')); + const uploadDir = path.join(root, 'uploads'); + const outsidePath = path.join(root, 'outside.txt'); + + try { + await fs.promises.writeFile(outsidePath, 'do not expose'); + const provider = new LocalStorageProvider(uploadDir); + + await assert.rejects(() => provider.getFileStream(outsidePath), /Unsafe path outside storage directory/); + await assert.rejects(() => provider.deleteFile(outsidePath), /Unsafe path outside storage directory/); + assert.equal(await fs.promises.readFile(outsidePath, 'utf8'), 'do not expose'); + } finally { + await fs.promises.rm(root, { recursive: true, force: true }); + } +}); diff --git a/backend/src/services/storage/localStorageProvider.ts b/backend/src/services/storage/localStorageProvider.ts new file mode 100644 index 0000000..e33fbf3 --- /dev/null +++ b/backend/src/services/storage/localStorageProvider.ts @@ -0,0 +1,77 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { safeJoin } from '../../utils/localPath.js'; +import { type IStorageProvider, StorageProbeError } from './contracts.js'; + +export class LocalStorageProvider implements IStorageProvider { + name = 'local'; + private uploadDir: string; + + constructor(uploadDir: string = process.env.UPLOAD_DIR || './data/uploads') { + this.uploadDir = path.resolve(uploadDir); + if (!fs.existsSync(this.uploadDir)) { + fs.mkdirSync(this.uploadDir, { recursive: true }); + } + } + + async probe(): Promise { + const stats = await fs.promises.stat(this.uploadDir); + if (!stats.isDirectory()) throw new StorageProbeError(this.name, '本地存储路径不是目录'); + await fs.promises.access(this.uploadDir, fs.constants.R_OK | fs.constants.W_OK); + } + + async saveFile(tempPath: string, fileName: string, _mimeType?: string, folder?: string | null): Promise { + const destDir = folder ? safeJoin(this.uploadDir, folder) : this.uploadDir; + if (!fs.existsSync(destDir)) { + fs.mkdirSync(destDir, { recursive: true }); + } + const destPath = safeJoin(destDir, fileName); + try { + await fs.promises.rename(tempPath, destPath); + } catch (error: any) { + // 如果是跨设备移动 (EXDEV),则使用复制+删除 + if (error.code === 'EXDEV') { + await fs.promises.copyFile(tempPath, destPath); + await fs.promises.unlink(tempPath); + } else { + throw error; + } + } + return destPath; // 返回绝对路径 + } + + async getFileStream(storedPath: string): Promise { + const safePath = safeJoin(this.uploadDir, path.relative(this.uploadDir, storedPath)); + if (safePath !== path.resolve(storedPath)) { + throw new Error('Unsafe local file path'); + } + if (!fs.existsSync(safePath)) { + throw new Error(`File not found: ${safePath}`); + } + return fs.createReadStream(safePath); + } + + async getPreviewUrl(storedPath: string): Promise { + // 本地文件通过现有的 serve-static 或 API 路由提供服务 + // 这里我们返回文件名,让上层路由处理 + // 注意:目前架构是 controller 层组装 URL,这里其实可能不需要具体 URL + // 或者我们可以返回 null,让上层使用默认逻辑 + return ''; + } + + async deleteFile(storedPath: string): Promise { + const safePath = safeJoin(this.uploadDir, path.relative(this.uploadDir, storedPath)); + if (safePath !== path.resolve(storedPath)) { + throw new Error('Unsafe local file path'); + } + if (fs.existsSync(safePath)) { + await fs.promises.unlink(safePath); + } + } + + async createShareLink(storedPath: string, password?: string, expiration?: string): Promise<{ link: string; error?: string }> { + // 本地存储暂不支持生成外部访问链接,除非我们自己实现一个分享页面 + // 这里返回错误提示 + return { link: '', error: '本地存储暂不支持生成分享链接,请使用 OneDrive 存储。' }; + } +} diff --git a/backend/src/services/telegram2FASetupSafety.test.ts b/backend/src/services/telegram2FASetupSafety.test.ts new file mode 100644 index 0000000..f87b80d --- /dev/null +++ b/backend/src/services/telegram2FASetupSafety.test.ts @@ -0,0 +1,14 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import fs from 'node:fs'; + +const bot = fs.readFileSync(new URL('./telegramBot.ts', import.meta.url), 'utf8'); + +test('2FA setup refuses to redisplay an enabled secret and always removes QR temp files', () => { + const block = bot.slice(bot.indexOf("if (text === '/setup_2fa'"), bot.indexOf("if (text === '/help'")); + assert.match(block, /await is2FAEnabled\(\)/); + assert.match(block, /双重验证已启用/); + assert.ok(block.indexOf('await is2FAEnabled()') < block.indexOf('generateOTPAuthUrl()')); + assert.match(block, /finally/); + assert.match(block, /unlinkSync/); +}); diff --git a/backend/src/services/telegramAccountAccessSweep.test.ts b/backend/src/services/telegramAccountAccessSweep.test.ts new file mode 100644 index 0000000..9538d4f --- /dev/null +++ b/backend/src/services/telegramAccountAccessSweep.test.ts @@ -0,0 +1,257 @@ +import assert from 'node:assert/strict'; +import test, { afterEach } from 'node:test'; +import { + configureTelegramAccountAccessSweep, + getTelegramAccountAccessSweepSummary, + probeTelegramAccountSource, + resetTelegramAccountAccessSweepForTests, + runTelegramAccountAccessSweep, + triggerTelegramAccountAccessSweep, + type TelegramAccessClient, + type TelegramAccountAccessSweepDependencies, +} from './telegramAccountAccessSweep.js'; + +const NOW = new Date('2026-08-29T12:00:00.000Z'); + +afterEach(() => resetTelegramAccountAccessSweepForTests()); + +function rpcError(code: string, message = code): Error & { errorMessage: string } { + return Object.assign(new Error(message), { errorMessage: code }); +} + +test('channel probe resolves the entity and reads exactly one recent message without joining', async () => { + const calls: Array<{ method: string; value: unknown }> = []; + const entity = { id: 'peer-1', title: 'source' }; + const client: TelegramAccessClient = { + async getEntity(source) { + calls.push({ method: 'getEntity', value: source }); + return entity; + }, + async getMessages(peer, options) { + calls.push({ method: 'getMessages', value: { peer, options } }); + return [{ id: 42 }]; + }, + }; + + const result = await probeTelegramAccountSource({ + accountId: 'account-1', + sourceId: 'subscription-1', + source: '@source', + scope: 'channel', + client, + now: () => NOW, + }); + + assert.equal(result.state, 'allowed'); + assert.equal(result.latestMessageId, 42); + assert.equal(result.checkedAt, NOW.toISOString()); + assert.deepEqual(calls, [ + { method: 'getEntity', value: '@source' }, + { method: 'getMessages', value: { peer: entity, options: { limit: 1 } } }, + ]); + assert.equal('invoke' in client, false, 'the probe must not join/import an invite'); +}); + +test('comments probe tests the most recent post discussion and treats an empty source as readable', async () => { + const optionsSeen: Array> = []; + const peersSeen: unknown[] = []; + const client: TelegramAccessClient = { + async getEntity() { return { id: 'peer' }; }, + async getMessages(peer: any, options: Record) { + peersSeen.push(peer); + optionsSeen.push(options); + if ('replyTo' in options) return []; + return [{ id: 99 }]; + }, + }; + + const result = await probeTelegramAccountSource({ + accountId: 'account-1', sourceId: 'subscription-1', source: '@source', scope: 'comments', client, + }); + assert.equal(result.state, 'allowed'); + assert.equal(result.latestMessageId, 99); + assert.deepEqual(optionsSeen, [{ limit: 1 }, { limit: 1, replyTo: 99 }]); + assert.deepEqual(peersSeen, [{ id: 'peer' }, '@source']); + + const empty = await probeTelegramAccountSource({ + accountId: 'account-1', sourceId: 'empty', source: '@empty', scope: 'comments', + client: { + async getEntity() { return { id: 'empty-peer' }; }, + async getMessages() { return []; }, + }, + }); + assert.equal(empty.state, 'allowed'); + assert.equal(empty.latestMessageId, null); +}); + +test('probe classifies Telegram permission failures as denied and operational failures as error', async () => { + for (const code of ['CHANNEL_PRIVATE', 'CHAT_FORBIDDEN', 'USER_NOT_PARTICIPANT', 'PEER_ID_INVALID']) { + const result = await probeTelegramAccountSource({ + accountId: 'account-1', sourceId: code, source: '@source', scope: 'channel', + client: { + async getEntity() { throw rpcError(code); }, + async getMessages() { return []; }, + }, + }); + assert.equal(result.state, 'denied', code); + assert.equal(result.errorCode, code); + } + + const transient = await probeTelegramAccountSource({ + accountId: 'account-1', sourceId: 'network', source: '@source', scope: 'channel', + client: { + async getEntity() { return { id: 'peer' }; }, + async getMessages() { throw rpcError('FLOOD_WAIT_12'); }, + }, + }); + assert.equal(transient.state, 'error'); + assert.equal(transient.errorCode, 'FLOOD_WAIT_12'); +}); + +test('sweep probes the enabled account x subscription scopes with bounded concurrency and persists every result', async () => { + let active = 0; + let maxActive = 0; + const persisted: Array<{ accountId: string; sourceId: string; scope: string; state: string }> = []; + const runtimeLookups: string[] = []; + const client: TelegramAccessClient = { + async getEntity(source) { + active += 1; + maxActive = Math.max(maxActive, active); + await new Promise(resolve => setTimeout(resolve, 4)); + active -= 1; + return { source }; + }, + async getMessages() { return [{ id: 1 }]; }, + }; + const dependencies: TelegramAccountAccessSweepDependencies = { + async listTelegramAccounts() { + return [ + { accountId: 'a', enabled: true }, + { accountId: 'disabled', enabled: false }, + { accountId: 'b', enabled: true }, + ]; + }, + async listTelegramChannelSubscriptions() { + return [ + { sourceId: 'channel-only', source: '@one', enabled: true, scopes: ['channel'] }, + { sourceId: 'with-comments', source: '@two', enabled: true, scopes: ['channel', 'comments'] }, + { sourceId: 'paused', source: '@three', enabled: false, scopes: ['channel'] }, + ]; + }, + async getTelegramAccountRuntime(accountId) { + runtimeLookups.push(accountId); + return { client }; + }, + async markTelegramAccountSourceAccess(result) { + persisted.push(result); + }, + now: () => NOW, + }; + + const summary = await runTelegramAccountAccessSweep(dependencies, { concurrency: 2, reason: 'automatic' }); + + assert.equal(summary.status, 'completed'); + assert.deepEqual(summary.counts, { accounts: 2, sources: 2, probes: 6, allowed: 6, denied: 0, error: 0 }); + assert.equal(maxActive, 2); + assert.deepEqual(runtimeLookups.sort(), ['a', 'b']); + assert.equal(persisted.length, 6); + assert.deepEqual( + persisted.map(result => `${result.accountId}:${result.sourceId}:${result.scope}`).sort(), + [ + 'a:channel-only:channel', 'a:with-comments:channel', 'a:with-comments:comments', + 'b:channel-only:channel', 'b:with-comments:channel', 'b:with-comments:comments', + ], + ); +}); + +test('runtime lookup failures become structured per-source errors instead of aborting the sweep', async () => { + const persisted: Array<{ state: string; errorCode?: string }> = []; + const dependencies: TelegramAccountAccessSweepDependencies = { + async listTelegramAccounts() { return [{ accountId: 'expired', enabled: true }]; }, + async listTelegramChannelSubscriptions() { + return [{ sourceId: 'source', source: '@source', enabled: true, scopes: ['channel', 'comments'] }]; + }, + async getTelegramAccountRuntime() { throw rpcError('AUTH_KEY_UNREGISTERED'); }, + async markTelegramAccountSourceAccess(result) { persisted.push(result); }, + now: () => NOW, + }; + + const summary = await runTelegramAccountAccessSweep(dependencies); + assert.deepEqual(summary.counts, { accounts: 1, sources: 1, probes: 2, allowed: 0, denied: 0, error: 2 }); + assert.deepEqual(persisted.map(result => ({ state: result.state, errorCode: result.errorCode })), [ + { state: 'error', errorCode: 'AUTH_KEY_UNREGISTERED' }, + { state: 'error', errorCode: 'AUTH_KEY_UNREGISTERED' }, + ]); +}); + +test('configured trigger supports targeted new-account/new-subscription sweeps and exposes live summary', async () => { + const persisted: string[] = []; + const dependencies: TelegramAccountAccessSweepDependencies = { + async listTelegramAccounts() { return [{ accountId: 'new', enabled: true }, { accountId: 'old', enabled: true }]; }, + async listTelegramChannelSubscriptions() { + return [ + { sourceId: 'new-source', source: '@new', enabled: true, scopes: ['channel'] }, + { sourceId: 'old-source', source: '@old', enabled: true, scopes: ['channel'] }, + ]; + }, + async getTelegramAccountRuntime() { + return { + client: { + async getEntity() { return { id: 'peer' }; }, + async getMessages() { return []; }, + }, + }; + }, + async markTelegramAccountSourceAccess(result) { + persisted.push(`${result.accountId}:${result.sourceId}`); + }, + now: () => NOW, + }; + configureTelegramAccountAccessSweep(dependencies); + + const run = triggerTelegramAccountAccessSweep({ + reason: 'account_created', accountIds: ['new'], sourceIds: ['new-source'], + }); + assert.match(getTelegramAccountAccessSweepSummary().status, /queued|running/); + const summary = await run; + + assert.equal(summary.reason, 'account_created'); + assert.deepEqual(persisted, ['new:new-source']); + assert.equal(getTelegramAccountAccessSweepSummary().status, 'completed'); + assert.equal(getTelegramAccountAccessSweepSummary().counts.probes, 1); +}); + +test('triggers received during a run are queued rather than losing a new account or subscription sweep', async () => { + const visited: string[] = []; + let releaseFirst!: () => void; + const firstGate = new Promise(resolve => { releaseFirst = resolve; }); + const dependencies: TelegramAccountAccessSweepDependencies = { + async listTelegramAccounts() { return [{ accountId: 'a', enabled: true }, { accountId: 'b', enabled: true }]; }, + async listTelegramChannelSubscriptions() { + return [{ sourceId: 'one', source: '@one', enabled: true, scopes: ['channel'] }]; + }, + async getTelegramAccountRuntime(accountId) { + return { + client: { + async getEntity(source: string) { + visited.push(`${accountId}:${source}`); + if (accountId === 'a') await firstGate; + return source; + }, + async getMessages() { return []; }, + }, + }; + }, + async markTelegramAccountSourceAccess() {}, + }; + configureTelegramAccountAccessSweep(dependencies); + + const first = triggerTelegramAccountAccessSweep({ reason: 'account_created', accountIds: ['a'] }); + await new Promise(resolve => setImmediate(resolve)); + const second = triggerTelegramAccountAccessSweep({ reason: 'account_created', accountIds: ['b'] }); + releaseFirst(); + + assert.equal((await first).reason, 'account_created'); + assert.equal((await second).reason, 'account_created'); + assert.deepEqual(visited, ['a:@one', 'b:@one']); +}); diff --git a/backend/src/services/telegramAccountAccessSweep.ts b/backend/src/services/telegramAccountAccessSweep.ts new file mode 100644 index 0000000..fd3d64a --- /dev/null +++ b/backend/src/services/telegramAccountAccessSweep.ts @@ -0,0 +1,367 @@ +export type TelegramAccessScope = 'channel' | 'comments'; +export type TelegramAccessState = 'allowed' | 'denied' | 'error'; +export type TelegramAccessSweepReason = 'automatic' | 'manual' | 'account_created' | 'subscription_created' | string; + +export interface TelegramAccessClient { + getEntity(source: any): Promise; + getMessages(peer: any, options: Record): Promise>; +} + +export interface TelegramAccessSweepAccount { + accountId: string; + enabled: boolean; +} + +export interface TelegramAccessSweepSource { + sourceId: string; + source: string; + enabled: boolean; + scopes?: readonly TelegramAccessScope[]; +} + +export interface TelegramAccountRuntime { + client: TelegramAccessClient; +} + +export interface TelegramAccountSourceAccessResult { + accountId: string; + sourceId: string; + source: string; + scope: TelegramAccessScope; + state: TelegramAccessState; + checkedAt: string; + latestMessageId: number | null; + errorCode?: string; + errorMessage?: string; +} + +export interface TelegramAccountAccessSweepDependencies { + listTelegramAccounts(): Promise; + listTelegramChannelSubscriptions(): Promise; + getTelegramAccountRuntime(accountId: string): Promise; + markTelegramAccountSourceAccess(result: TelegramAccountSourceAccessResult): Promise; + now?: () => Date; +} + +export interface TelegramAccountAccessProbeInput { + accountId: string; + sourceId: string; + source: string; + scope: TelegramAccessScope; + client: TelegramAccessClient; + now?: () => Date; +} + +export interface TelegramAccountAccessSweepCounts { + accounts: number; + sources: number; + probes: number; + allowed: number; + denied: number; + error: number; +} + +export interface TelegramAccountAccessSweepSummary { + runId: string | null; + status: 'idle' | 'queued' | 'running' | 'completed' | 'failed'; + reason: TelegramAccessSweepReason | null; + startedAt: string | null; + completedAt: string | null; + counts: TelegramAccountAccessSweepCounts; + lastError: string | null; +} + +export interface TelegramAccountAccessSweepOptions { + concurrency?: number; + reason?: TelegramAccessSweepReason; + accountIds?: Iterable; + sourceIds?: Iterable; +} + +const DENIED_ERROR_CODES = new Set([ + 'CHANNEL_INVALID', + 'CHANNEL_PRIVATE', + 'CHAT_ADMIN_REQUIRED', + 'CHAT_FORBIDDEN', + 'CHAT_RESTRICTED', + 'GROUP_PRIVATE', + 'INVITE_HASH_EXPIRED', + 'INVITE_HASH_INVALID', + 'MESSAGE_ID_INVALID', + 'PEER_ID_INVALID', + 'USER_BANNED_IN_CHANNEL', + 'USER_NOT_PARTICIPANT', +]); + +const EMPTY_COUNTS = (): TelegramAccountAccessSweepCounts => ({ + accounts: 0, + sources: 0, + probes: 0, + allowed: 0, + denied: 0, + error: 0, +}); + +let configuredDependencies: TelegramAccountAccessSweepDependencies | null = null; +let currentSummary: TelegramAccountAccessSweepSummary = { + runId: null, + status: 'idle', + reason: null, + startedAt: null, + completedAt: null, + counts: EMPTY_COUNTS(), + lastError: null, +}; +let triggerTail: Promise = Promise.resolve(); +let nextRunSequence = 0; + +function errorText(error: unknown): string { + if (error instanceof Error) return error.message; + if (typeof error === 'string') return error; + try { return JSON.stringify(error); } catch { return String(error); } +} + +export function getTelegramAccessErrorCode(error: unknown): string { + const candidate = error as { errorMessage?: unknown; code?: unknown; message?: unknown } | null; + const raw = [candidate?.errorMessage, candidate?.code, candidate?.message, error] + .find(value => typeof value === 'string' && value.trim()) as string | undefined; + if (!raw) return 'UNKNOWN_ERROR'; + const normalized = raw.toUpperCase().trim(); + if (/^[A-Z][A-Z0-9_]*$/.test(normalized)) return normalized; + const tokens = normalized.match(/[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+/g); + return tokens?.at(-1) || 'UNKNOWN_ERROR'; +} + +export function classifyTelegramAccessError(error: unknown): 'denied' | 'error' { + const code = getTelegramAccessErrorCode(error); + return DENIED_ERROR_CODES.has(code) || /(?:PRIVATE|FORBIDDEN|NOT_PARTICIPANT|BANNED)/.test(code) + ? 'denied' + : 'error'; +} + +function failureResult( + input: Pick, + error: unknown, + now: () => Date, +): TelegramAccountSourceAccessResult { + return { + accountId: input.accountId, + sourceId: input.sourceId, + source: input.source, + scope: input.scope, + state: classifyTelegramAccessError(error), + checkedAt: now().toISOString(), + latestMessageId: null, + errorCode: getTelegramAccessErrorCode(error), + errorMessage: errorText(error), + }; +} + +/** + * Read-only permission probe. It deliberately uses only entity resolution and + * message reads; it never invokes ImportChatInvite/JoinChannel or similar RPCs. + */ +export async function probeTelegramAccountSource( + input: TelegramAccountAccessProbeInput, +): Promise { + const now = input.now || (() => new Date()); + try { + 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) { + // 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 }); + } + return { + accountId: input.accountId, + sourceId: input.sourceId, + source: input.source, + scope: input.scope, + state: 'allowed', + checkedAt: now().toISOString(), + latestMessageId, + }; + } catch (error) { + return failureResult(input, error, now); + } +} + +async function mapWithConcurrency( + values: readonly T[], + concurrency: number, + worker: (value: T) => Promise, +): Promise { + let cursor = 0; + const workerCount = Math.min(values.length, Math.max(1, Math.floor(concurrency))); + await Promise.all(Array.from({ length: workerCount }, async () => { + while (cursor < values.length) { + const index = cursor; + cursor += 1; + await worker(values[index]); + } + })); +} + +function normalizeScopes(source: TelegramAccessSweepSource): TelegramAccessScope[] { + const requested = source.scopes?.length ? source.scopes : ['channel']; + return Array.from(new Set(requested.filter(scope => scope === 'channel' || scope === 'comments'))); +} + +function selected( + values: readonly T[], + key: 'accountId' | 'sourceId', + identifiers: Iterable | undefined, +): T[] { + if (!identifiers) return [...values]; + const wanted = new Set(identifiers); + return values.filter(value => wanted.has(String(value[key]))); +} + +function runId(now: Date): string { + nextRunSequence += 1; + return `${now.getTime()}-${nextRunSequence}`; +} + +/** + * Probe enabled account x enabled subscription scopes at bounded concurrency. + * Runtime lookup is once per account; persistence is once per structured probe + * result, including denied/error outcomes. + */ +export async function runTelegramAccountAccessSweep( + dependencies: TelegramAccountAccessSweepDependencies, + options: TelegramAccountAccessSweepOptions = {}, +): Promise { + const now = dependencies.now || (() => new Date()); + const startedAt = now(); + const reason = options.reason || 'automatic'; + const summary: TelegramAccountAccessSweepSummary = { + runId: runId(startedAt), + status: 'running', + reason, + startedAt: startedAt.toISOString(), + completedAt: null, + counts: EMPTY_COUNTS(), + lastError: null, + }; + currentSummary = summary; + + try { + const [accountRows, sourceRows] = await Promise.all([ + dependencies.listTelegramAccounts(), + dependencies.listTelegramChannelSubscriptions(), + ]); + const accounts = selected(accountRows.filter(account => account.enabled), 'accountId', options.accountIds); + const sources = selected(sourceRows.filter(source => source.enabled), 'sourceId', options.sourceIds); + summary.counts.accounts = accounts.length; + summary.counts.sources = sources.length; + + const work: Array<{ + account: TelegramAccessSweepAccount; + source: TelegramAccessSweepSource; + scope: TelegramAccessScope; + }> = []; + for (const account of accounts) { + for (const source of sources) { + for (const scope of normalizeScopes(source)) work.push({ account, source, scope }); + } + } + summary.counts.probes = work.length; + + const runtimePromises = new Map>(); + const getRuntime = (accountId: string): Promise => { + let runtime = runtimePromises.get(accountId); + if (!runtime) { + runtime = dependencies.getTelegramAccountRuntime(accountId); + runtimePromises.set(accountId, runtime); + } + return runtime; + }; + + await mapWithConcurrency(work, options.concurrency ?? 2, async item => { + let result: TelegramAccountSourceAccessResult; + try { + const runtime = await getRuntime(item.account.accountId); + if (!runtime?.client) throw new Error('TELEGRAM_ACCOUNT_RUNTIME_UNAVAILABLE'); + result = await probeTelegramAccountSource({ + accountId: item.account.accountId, + sourceId: item.source.sourceId, + source: item.source.source, + scope: item.scope, + client: runtime.client, + now, + }); + } catch (error) { + result = failureResult({ + accountId: item.account.accountId, + sourceId: item.source.sourceId, + source: item.source.source, + scope: item.scope, + }, error, now); + } + summary.counts[result.state] += 1; + await dependencies.markTelegramAccountSourceAccess(result); + }); + + summary.status = 'completed'; + summary.completedAt = now().toISOString(); + currentSummary = summary; + return summary; + } catch (error) { + summary.status = 'failed'; + summary.completedAt = now().toISOString(); + summary.lastError = errorText(error); + currentSummary = summary; + throw error; + } +} + +export function configureTelegramAccountAccessSweep(dependencies: TelegramAccountAccessSweepDependencies): void { + configuredDependencies = dependencies; +} + +/** Queue a manual/new-account/new-subscription trigger to avoid overlapping sweeps. */ +export function triggerTelegramAccountAccessSweep( + options: TelegramAccountAccessSweepOptions = {}, +): Promise { + if (!configuredDependencies) { + return Promise.reject(new Error('TELEGRAM_ACCOUNT_ACCESS_SWEEP_NOT_CONFIGURED')); + } + const dependencies = configuredDependencies; + currentSummary = { + ...currentSummary, + status: 'queued', + reason: options.reason || 'manual', + completedAt: null, + lastError: null, + }; + const run = triggerTail.then(() => runTelegramAccountAccessSweep(dependencies, { + ...options, + reason: options.reason || 'manual', + })); + triggerTail = run.then(() => undefined, () => undefined); + return run; +} + +export function getTelegramAccountAccessSweepSummary(): TelegramAccountAccessSweepSummary { + return { + ...currentSummary, + counts: { ...currentSummary.counts }, + }; +} + +export function resetTelegramAccountAccessSweepForTests(): void { + configuredDependencies = null; + triggerTail = Promise.resolve(); + nextRunSequence = 0; + currentSummary = { + runId: null, + status: 'idle', + reason: null, + startedAt: null, + completedAt: null, + counts: EMPTY_COUNTS(), + lastError: null, + }; +} diff --git a/backend/src/services/telegramAccountAccessSweepAdapter.test.ts b/backend/src/services/telegramAccountAccessSweepAdapter.test.ts new file mode 100644 index 0000000..6bb7e08 --- /dev/null +++ b/backend/src/services/telegramAccountAccessSweepAdapter.test.ts @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createTelegramAccountAccessSweepDependencies } from './telegramAccountAccessSweepAdapter.js'; + +test('repository adapter lists enabled subscriptions, uses account runtimes and maps access scopes', async () => { + const queries: string[] = []; + const marks: unknown[][] = []; + const client = { + async getEntity(source: string) { return { source }; }, + async getMessages() { return []; }, + }; + const dependencies = createTelegramAccountAccessSweepDependencies({ + repository: { + async listEnabledAccounts() { return [{ id: 'account', enabled: true }]; }, + async markSourceAccess(...args: unknown[]) { marks.push(args); }, + }, + clientPool: { + getAccountClient(accountId: string) { + assert.equal(accountId, 'account'); + return client; + }, + }, + async querySubscriptions(text: string) { + queries.push(text); + return { rows: [{ id: 'subscription', source: '@news', enabled: true }] }; + }, + }); + + assert.deepEqual(await dependencies.listTelegramAccounts(), [{ accountId: 'account', enabled: true }]); + assert.deepEqual(await dependencies.listTelegramChannelSubscriptions(), [{ + sourceId: 'subscription', source: '@news', enabled: true, scopes: ['channel', 'comments'], + }]); + assert.equal((await dependencies.getTelegramAccountRuntime('account'))?.client, client); + assert.match(queries[0], /telegram_channel_subscriptions[\s\S]*enabled = TRUE/); + + await dependencies.markTelegramAccountSourceAccess({ + accountId: 'account', sourceId: 'subscription', source: '@news', scope: 'channel', + state: 'allowed', checkedAt: '2026-08-29T08:00:00.000Z', latestMessageId: null, + }); + await dependencies.markTelegramAccountSourceAccess({ + accountId: 'account', sourceId: 'subscription', source: '@news', scope: 'comments', + state: 'error', checkedAt: '2026-08-29T08:00:00.000Z', latestMessageId: null, + errorCode: 'TIMEOUT', + }); + assert.deepEqual(marks, [ + ['account', '@news', 'scan', 'allowed', null], + ['account', '@news', 'download', 'allowed', null], + ['account', '@news', 'metadata', 'unknown', 'TIMEOUT'], + ]); +}); diff --git a/backend/src/services/telegramAccountAccessSweepAdapter.ts b/backend/src/services/telegramAccountAccessSweepAdapter.ts new file mode 100644 index 0000000..64114c5 --- /dev/null +++ b/backend/src/services/telegramAccountAccessSweepAdapter.ts @@ -0,0 +1,89 @@ +import { query } from '../db/index.js'; +import { telegramAccountRepository } from './telegramAccountRepository.js'; +import { + configureTelegramAccountAccessSweep, + type TelegramAccessClient, + type TelegramAccountAccessSweepDependencies, + type TelegramAccountSourceAccessResult, +} from './telegramAccountAccessSweep.js'; + +interface SweepAccountRepository { + listEnabledAccounts(): Promise>; + markSourceAccess( + accountId: string, + sourceKey: string, + scope: 'download' | 'scan' | 'metadata', + accessState: 'unknown' | 'allowed' | 'denied', + error?: string | null, + ): Promise; +} + +interface SweepAccountClientPool { + getAccountClient(accountId: string): TelegramAccessClient | null; +} + +interface SweepQueryResult { + rows: Array<{ id: unknown; source: unknown; enabled: unknown }>; +} + +export interface TelegramAccountAccessSweepAdapterOptions { + repository?: SweepAccountRepository; + clientPool: SweepAccountClientPool; + querySubscriptions?: (text: string, params?: readonly unknown[]) => Promise; + now?: () => Date; +} + +/** + * Bridges the pure sweep to the current repository/pool. The existing access + * schema calls channel reads `scan` and discussion reads `metadata`; the public + * sweep API keeps the more explicit channel/comments vocabulary. + */ +export function createTelegramAccountAccessSweepDependencies( + options: TelegramAccountAccessSweepAdapterOptions, +): TelegramAccountAccessSweepDependencies { + const repository = options.repository || telegramAccountRepository; + const querySubscriptions = options.querySubscriptions || query; + return { + async listTelegramAccounts() { + const accounts = await repository.listEnabledAccounts(); + return accounts.map(account => ({ accountId: account.id, enabled: account.enabled })); + }, + async listTelegramChannelSubscriptions() { + const result = await querySubscriptions( + `SELECT id, source, enabled + FROM telegram_channel_subscriptions + WHERE enabled = TRUE + ORDER BY created_at, id`, + ); + return result.rows.map(row => ({ + sourceId: String(row.id), + source: String(row.source), + enabled: Boolean(row.enabled), + scopes: ['channel', 'comments'] as const, + })); + }, + async getTelegramAccountRuntime(accountId) { + const client = options.clientPool.getAccountClient(accountId); + return client ? { client } : null; + }, + async markTelegramAccountSourceAccess(result: TelegramAccountSourceAccessResult) { + const state = result.state === 'error' ? 'unknown' : result.state; + const error = result.errorCode || result.errorMessage || null; + const scope = result.scope === 'channel' ? 'scan' : 'metadata'; + await repository.markSourceAccess(result.accountId, result.source, scope, state, error); + if (result.scope === 'channel') { + await repository.markSourceAccess(result.accountId, result.source, 'download', state, error); + } + }, + now: options.now, + }; +} + +/** Register the production adapters once the multi-account client pool exists. */ +export function installTelegramAccountAccessSweep( + options: TelegramAccountAccessSweepAdapterOptions, +): TelegramAccountAccessSweepDependencies { + const dependencies = createTelegramAccountAccessSweepDependencies(options); + configureTelegramAccountAccessSweep(dependencies); + return dependencies; +} diff --git a/backend/src/services/telegramAccountRepository.test.ts b/backend/src/services/telegramAccountRepository.test.ts new file mode 100644 index 0000000..1a0bc4c --- /dev/null +++ b/backend/src/services/telegramAccountRepository.test.ts @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + TelegramAccountRepository, + type TelegramAccountQueryClient, +} from './telegramAccountRepository.js'; + +class ScriptedDb implements TelegramAccountQueryClient { + calls: Array<{ text: string; params: readonly unknown[] }> = []; + constructor(private replies: Array<{ rows?: any[]; rowCount?: number }> = []) {} + async query(text: string, params: readonly unknown[] = []) { + this.calls.push({ text, params }); + const reply = this.replies.shift() || {}; + return { rows: reply.rows || [], rowCount: reply.rowCount ?? reply.rows?.length ?? 0 }; + } +} + +test('legacy single-account migration is an idempotent insert-select and keeps the encrypted session', async () => { + const db = new ScriptedDb([{ rows: [{ id: 'legacy-account' }], rowCount: 1 }]); + const repo = new TelegramAccountRepository(db); + assert.equal(await repo.migrateLegacySystemSettings(), 'legacy-account'); + assert.match(db.calls[0].text, /INSERT INTO telegram_user_accounts/); + assert.match(db.calls[0].text, /system_settings/); + assert.match(db.calls[0].text, /NOT EXISTS/); + assert.match(db.calls[0].text, /telegram_user_session/); +}); + +test('source probe and attempt lifecycle persist account-scoped state', async () => { + const db = new ScriptedDb([ + { rows: [{ account_id: 'a', source_key: '@news', scope: 'download', access_state: 'allowed' }] }, + { rows: [{ id: 'attempt-1' }] }, + { rowCount: 1 }, + ]); + const repo = new TelegramAccountRepository(db); + const access = await repo.markSourceAccess('a', '@news', 'download', 'allowed'); + const attempt = await repo.startDownloadAttempt({ accountId: 'a', sourceKey: '@news', scope: 'download' }); + assert.equal(access.accessState, 'allowed'); + assert.equal(attempt, 'attempt-1'); + assert.equal(await repo.finishDownloadAttempt(attempt, 'succeeded'), true); + assert.match(db.calls[0].text, /ON CONFLICT \(account_id, source_key, scope\)/); + assert.match(db.calls[1].text, /INSERT INTO telegram_download_attempts/); + assert.match(db.calls[2].text, /finished_at = NOW\(\)/); +}); + +test('cooldown and session expiry are account-local health updates', async () => { + const db = new ScriptedDb([{ rowCount: 1 }, { rowCount: 1 }]); + const repo = new TelegramAccountRepository(db); + assert.equal(await repo.markCooldown('a', 42, 'FLOOD_WAIT_42'), true); + assert.equal(await repo.markSessionExpired('b', 'AUTH_KEY_UNREGISTERED'), true); + assert.match(db.calls[0].text, /cooldown_until/); + assert.deepEqual(db.calls[0].params, ['a', 42, 'FLOOD_WAIT_42']); + assert.match(db.calls[1].text, /session_expired/); +}); diff --git a/backend/src/services/telegramAccountRepository.ts b/backend/src/services/telegramAccountRepository.ts new file mode 100644 index 0000000..d213b26 --- /dev/null +++ b/backend/src/services/telegramAccountRepository.ts @@ -0,0 +1,317 @@ +import { query } from '../db/index.js'; +import { encryptCredential } from '../utils/credentialCrypto.js'; +import type { TelegramAccountHealthState, TelegramSourceAccessState } from './telegramAccountScheduler.js'; + +export type TelegramAccountSourceScope = 'download' | 'scan' | 'metadata'; +export type TelegramDownloadAttemptStatus = 'running' | 'succeeded' | 'failed' | 'cancelled'; + +export interface TelegramAccountQueryResult { + rows: any[]; + rowCount?: number | null; +} + +export interface TelegramAccountQueryClient { + query(text: string, params?: readonly unknown[]): Promise; +} + +export interface TelegramUserAccountRecord { + id: string; + telegramUserId: string; + username: string | null; + displayName: string | null; + session: string; + enabled: boolean; + healthState: TelegramAccountHealthState; + cooldownUntil: Date | null; + weight: number; + priority: number; + maxConnections: number; + lastError: string | null; + isLegacy: boolean; +} + +export interface TelegramAccountSourceAccessRecord { + accountId: string; + sourceKey: string; + scope: TelegramAccountSourceScope; + accessState: TelegramSourceAccessState; + lastError: string | null; + checkedAt: Date | null; +} + +function mapAccount(row: any): TelegramUserAccountRecord { + return { + id: String(row.id), + telegramUserId: String(row.telegram_user_id || ''), + username: row.username || null, + displayName: row.display_name || null, + session: String(row.session_ciphertext || ''), + enabled: Boolean(row.enabled), + healthState: row.health_state, + cooldownUntil: row.cooldown_until || null, + weight: Number(row.weight || 1), + priority: Number(row.priority || 0), + maxConnections: Number(row.max_connections || 1), + lastError: row.last_error || null, + isLegacy: Boolean(row.is_legacy), + }; +} + +function mapAccess(row: any): TelegramAccountSourceAccessRecord { + return { + accountId: String(row.account_id), + sourceKey: String(row.source_key), + scope: row.scope, + accessState: row.access_state, + lastError: row.last_error || null, + checkedAt: row.checked_at || null, + }; +} + +export class TelegramAccountRepository { + constructor(private readonly db: TelegramAccountQueryClient = { query }) {} + + async migrateLegacySystemSettings(): Promise { + const result = await this.db.query(` + INSERT INTO telegram_user_accounts + (telegram_user_id, username, session_ciphertext, enabled, health_state, is_legacy) + SELECT + COALESCE(NULLIF(user_id.value, ''), 'legacy'), + NULLIF(username.value, ''), + session.value, + COALESCE(enabled.value, 'false') = 'true', + CASE WHEN COALESCE(enabled.value, 'false') = 'true' THEN 'degraded' ELSE 'healthy' END, + TRUE + FROM system_settings session + LEFT JOIN system_settings user_id ON user_id.key = 'telegram_user_id' + LEFT JOIN system_settings username ON username.key = 'telegram_user_username' + LEFT JOIN system_settings enabled ON enabled.key = 'telegram_user_download_enabled' + WHERE session.key = 'telegram_user_session' AND session.value <> '' + AND NOT EXISTS ( + SELECT 1 FROM telegram_user_accounts + WHERE is_legacy = TRUE AND deleted_at IS NULL + ) + ON CONFLICT (telegram_user_id) DO NOTHING + RETURNING id + `); + return result.rows[0]?.id ? String(result.rows[0].id) : null; + } + + async upsertAccount(input: { + telegramUserId: string; + username?: string | null; + displayName?: string | null; + session: string; + enabled?: boolean; + weight?: number; + priority?: number; + maxConnections?: number; + isLegacy?: boolean; + }): Promise { + const result = await this.db.query(` + INSERT INTO telegram_user_accounts + (telegram_user_id, username, display_name, session_ciphertext, enabled, health_state, weight, priority, max_connections, is_legacy, last_error, session_expired_at) + VALUES ($1, $2, $3, $4, $5, 'healthy', $6, $7, $8, $9, NULL, NULL) + ON CONFLICT (telegram_user_id) DO UPDATE SET + username = EXCLUDED.username, display_name = EXCLUDED.display_name, + session_ciphertext = EXCLUDED.session_ciphertext, enabled = EXCLUDED.enabled, + health_state = 'healthy', weight = EXCLUDED.weight, priority = EXCLUDED.priority, + max_connections = EXCLUDED.max_connections, is_legacy = EXCLUDED.is_legacy, last_error = NULL, + session_expired_at = NULL, deleted_at = NULL, updated_at = NOW() + RETURNING * + `, [ + input.telegramUserId, input.username || null, input.displayName || null, + 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, + ]); + return mapAccount(result.rows[0]); + } + + async listEnabledAccounts(): Promise { + const result = await this.db.query(` + SELECT * FROM telegram_user_accounts + WHERE enabled = TRUE AND deleted_at IS NULL + ORDER BY priority DESC, created_at, id + `); + return result.rows.map(mapAccount); + } + + async listAccounts(): Promise { + const result = await this.db.query(` + SELECT * FROM telegram_user_accounts WHERE deleted_at IS NULL + ORDER BY priority DESC, created_at, id + `); + return result.rows.map(mapAccount); + } + + async getAccount(accountId: string): Promise { + const result = await this.db.query('SELECT * FROM telegram_user_accounts WHERE id = $1 AND deleted_at IS NULL', [accountId]); + return result.rows[0] ? mapAccount(result.rows[0]) : null; + } + + async setEnabled(accountId: string, enabled: boolean): Promise { + const result = await this.db.query( + `UPDATE telegram_user_accounts SET enabled = $2, updated_at = NOW() + WHERE id = $1 AND deleted_at IS NULL`, [accountId, enabled], + ); + return result.rowCount === 1; + } + + async deleteAccount(accountId: string): Promise { + await this.db.query('DELETE FROM telegram_account_source_access WHERE account_id = $1', [accountId]); + const result = await this.db.query( + `UPDATE telegram_user_accounts + SET enabled = FALSE, session_ciphertext = '', health_state = 'session_expired', + cooldown_until = NULL, deleted_at = NOW(), updated_at = NOW() + WHERE id = $1 AND deleted_at IS NULL`, + [accountId], + ); + return result.rowCount === 1; + } + + async listAccessForAccount(accountId: string): Promise { + const result = await this.db.query(` + SELECT * FROM telegram_account_source_access + WHERE account_id = $1 ORDER BY checked_at DESC NULLS LAST, source_key, scope + `, [accountId]); + return result.rows.map(mapAccess); + } + + async getAccessSummaryForAccount(accountId: string): Promise<{ allowed: number; denied: number; unknown: number; total: number; lastCheckedAt: Date | null }> { + const result = await this.db.query(` + SELECT COUNT(*)::int AS total, + COUNT(*) FILTER (WHERE access_state = 'allowed')::int AS allowed, + COUNT(*) FILTER (WHERE access_state = 'denied')::int AS denied, + COUNT(*) FILTER (WHERE access_state = 'unknown')::int AS unknown, + MAX(checked_at) AS last_checked_at + FROM telegram_account_source_access + WHERE account_id = $1 AND scope = 'download' + `, [accountId]); + const row = result.rows[0] || {}; + const total = Number(row.total || 0); + return { allowed: Number(row.allowed || 0), denied: Number(row.denied || 0), unknown: Number(row.unknown || 0), total, lastCheckedAt: row.last_checked_at || 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() + WHERE id = $1 AND deleted_at IS NULL + `, [accountId, encryptCredential(session)]); + return result.rowCount === 1; + } + + async recordHealthy(accountId: string): Promise { + const result = await this.db.query(` + UPDATE telegram_user_accounts SET health_state = 'healthy', last_error = NULL, + last_connected_at = NOW(), updated_at = NOW() WHERE id = $1 AND deleted_at IS NULL + `, [accountId]); + return result.rowCount === 1; + } + + async recordFailure(accountId: string, error: string): Promise { + const result = await this.db.query(` + UPDATE telegram_user_accounts SET health_state = 'degraded', last_error = $2, + last_failure_at = NOW(), updated_at = NOW() WHERE id = $1 AND deleted_at IS NULL + `, [accountId, error]); + return result.rowCount === 1; + } + + async markCooldown(accountId: string, seconds: number, error: string | null = null): 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'), + health_state = 'degraded', last_error = $3, last_failure_at = NOW(), updated_at = NOW() + WHERE id = $1 AND deleted_at IS NULL + `, [accountId, cooldownSeconds, error]); + return result.rowCount === 1; + } + + async markSessionExpired(accountId: string, error: string | null = null): Promise { + const result = await this.db.query(` + UPDATE telegram_user_accounts SET health_state = 'session_expired', session_expired_at = NOW(), + cooldown_until = NULL, last_error = $2, last_failure_at = NOW(), updated_at = NOW() + WHERE id = $1 AND deleted_at IS NULL + `, [accountId, error]); + return result.rowCount === 1; + } + + async markSourceAccess( + accountId: string, + sourceKey: string, + scope: TelegramAccountSourceScope, + accessState: TelegramSourceAccessState, + error: string | null = null, + ): Promise { + const result = await this.db.query(` + INSERT INTO telegram_account_source_access + (account_id, source_key, scope, access_state, last_error, checked_at) + VALUES ($1, $2, $3, $4, $5, NOW()) + ON CONFLICT (account_id, source_key, scope) DO UPDATE SET + access_state = EXCLUDED.access_state, last_error = EXCLUDED.last_error, + checked_at = NOW(), updated_at = NOW() + RETURNING * + `, [accountId, sourceKey, scope, accessState, error]); + return mapAccess(result.rows[0]); + } + + async probeSourceAccess( + accountId: string, + sourceKey: string, + scope: TelegramAccountSourceScope, + accessState: TelegramSourceAccessState, + error: string | null = null, + ): Promise { + return this.markSourceAccess(accountId, sourceKey, scope, accessState, error); + } + + async getSourceAccess(accountId: string, sourceKey: string, scope: TelegramAccountSourceScope): Promise { + const result = await this.db.query(` + SELECT * FROM telegram_account_source_access + WHERE account_id = $1 AND source_key = $2 AND scope = $3 + `, [accountId, sourceKey, scope]); + return result.rows[0] ? mapAccess(result.rows[0]) : null; + } + + async listSourceAccess(sourceKey: string, scope: TelegramAccountSourceScope): Promise { + const result = await this.db.query(` + SELECT * FROM telegram_account_source_access + WHERE source_key = $1 AND scope = $2 ORDER BY checked_at DESC NULLS LAST, account_id + `, [sourceKey, scope]); + return result.rows.map(mapAccess); + } + + async getLatestAccessCheckForAccount(accountId: string): Promise { + const result = await this.db.query( + 'SELECT MAX(checked_at) AS checked_at FROM telegram_account_source_access WHERE account_id = $1', + [accountId], + ); + return result.rows[0]?.checked_at || null; + } + + async startDownloadAttempt(input: { + accountId: string; + sourceKey: string; + scope?: TelegramAccountSourceScope; + jobId?: string | null; + itemId?: string | null; + leaseToken?: string | null; + }): Promise { + const result = await this.db.query(` + INSERT INTO telegram_download_attempts + (account_id, source_key, scope, job_id, item_id, lease_token, status) + VALUES ($1, $2, $3, $4, $5, $6, 'running') RETURNING id + `, [input.accountId, input.sourceKey, input.scope || 'download', input.jobId || null, input.itemId || null, input.leaseToken || null]); + return String(result.rows[0].id); + } + + async finishDownloadAttempt(attemptId: string, status: Exclude, error: string | null = null): Promise { + const result = await this.db.query(` + UPDATE telegram_download_attempts SET status = $2, error = $3, finished_at = NOW() + WHERE id = $1 AND status = 'running' + `, [attemptId, status, error]); + return result.rowCount === 1; + } +} + +export const telegramAccountRepository = new TelegramAccountRepository(); diff --git a/backend/src/services/telegramAccountScheduler.test.ts b/backend/src/services/telegramAccountScheduler.test.ts new file mode 100644 index 0000000..1359449 --- /dev/null +++ b/backend/src/services/telegramAccountScheduler.test.ts @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { selectWeightedLeastConnectedTelegramAccount } from './telegramAccountScheduler.js'; + +const NOW = new Date('2026-08-29T08:00:00.000Z'); + +function candidate(accountId: string, overrides: Record = {}) { + return { + accountId, + enabled: true, + healthState: 'healthy' as const, + cooldownUntil: null, + weight: 1, + activeConnections: 0, + maxConnections: 4, + sourceAccessState: 'unknown' as const, + ...overrides, + }; +} + +test('scheduler excludes disabled, expired, cooling, denied, saturated and explicitly excluded accounts', () => { + const selected = selectWeightedLeastConnectedTelegramAccount([ + candidate('disabled', { enabled: false }), + candidate('expired', { healthState: 'session_expired' }), + candidate('cooling', { cooldownUntil: '2026-08-29T08:00:01.000Z' }), + candidate('denied', { sourceAccessState: 'denied' }), + candidate('saturated', { activeConnections: 4 }), + candidate('excluded'), + candidate('available', { activeConnections: 2 }), + ], { now: NOW, excludeAccountIds: ['excluded'] }); + + assert.equal(selected?.accountId, 'available'); +}); + +test('scheduler prefers known source access then applies weighted least connections', () => { + const selected = selectWeightedLeastConnectedTelegramAccount([ + candidate('unknown-idle', { activeConnections: 0, sourceAccessState: 'unknown' }), + candidate('allowed-heavy', { activeConnections: 3, weight: 2, sourceAccessState: 'allowed' }), + candidate('allowed-light', { activeConnections: 2, weight: 2, sourceAccessState: 'allowed' }), + ], { now: NOW }); + + assert.equal(selected?.accountId, 'allowed-light'); +}); + +test('scheduler uses deterministic priority and account id tie breakers and treats elapsed cooldown as runnable', () => { + const selected = selectWeightedLeastConnectedTelegramAccount([ + candidate('z-account', { priority: 5, cooldownUntil: '2026-08-29T07:59:59.000Z' }), + candidate('b-account', { priority: 10 }), + candidate('a-account', { priority: 10 }), + ], { now: NOW }); + + assert.equal(selected?.accountId, 'a-account'); +}); + +test('an idle account with a larger weight receives the first otherwise-equal connection', () => { + assert.equal(selectWeightedLeastConnectedTelegramAccount([ + candidate('light', { weight: 1 }), + candidate('heavy', { weight: 3 }), + ], { now: NOW })?.accountId, 'heavy'); +}); diff --git a/backend/src/services/telegramAccountScheduler.ts b/backend/src/services/telegramAccountScheduler.ts new file mode 100644 index 0000000..270a6f6 --- /dev/null +++ b/backend/src/services/telegramAccountScheduler.ts @@ -0,0 +1,54 @@ +export type TelegramAccountHealthState = 'healthy' | 'degraded' | 'session_expired'; +export type TelegramSourceAccessState = 'unknown' | 'allowed' | 'denied'; + +export interface TelegramAccountSchedulingCandidate { + accountId: string; + enabled: boolean; + healthState: TelegramAccountHealthState; + cooldownUntil: Date | string | null; + weight: number; + priority?: number; + activeConnections: number; + maxConnections: number; + sourceAccessState: TelegramSourceAccessState; +} + +export interface TelegramAccountSchedulingOptions { + now?: Date; + excludeAccountIds?: Iterable; +} + +/** + * Pure permission-aware weighted least-connections selection. + * Known source access is preferred over an unprobed account. Within the same + * access class, the lowest active/weight score wins. Priority and account id + * make ties stable, which keeps retries and tests deterministic. + */ +export function selectWeightedLeastConnectedTelegramAccount( + candidates: readonly T[], + options: TelegramAccountSchedulingOptions = {}, +): T | null { + const now = (options.now || new Date()).getTime(); + const excluded = new Set(options.excludeAccountIds || []); + const runnable = candidates.filter(candidate => { + const cooldownUntil = candidate.cooldownUntil ? new Date(candidate.cooldownUntil).getTime() : 0; + return candidate.enabled + && candidate.healthState !== 'session_expired' + && candidate.sourceAccessState !== 'denied' + && !excluded.has(candidate.accountId) + && candidate.activeConnections < Math.max(1, candidate.maxConnections) + && (!Number.isFinite(cooldownUntil) || cooldownUntil <= now); + }); + + runnable.sort((left, right) => { + const accessDifference = Number(right.sourceAccessState === 'allowed') - Number(left.sourceAccessState === 'allowed'); + if (accessDifference) return accessDifference; + const leftLoad = (left.activeConnections + 1) / Math.max(Number.EPSILON, left.weight); + const rightLoad = (right.activeConnections + 1) / Math.max(Number.EPSILON, right.weight); + if (leftLoad !== rightLoad) return leftLoad - rightLoad; + const priorityDifference = (right.priority || 0) - (left.priority || 0); + if (priorityDifference) return priorityDifference; + return left.accountId.localeCompare(right.accountId); + }); + return runnable[0] || null; +} diff --git a/backend/src/services/telegramBatchConfirmation.test.ts b/backend/src/services/telegramBatchConfirmation.test.ts index 6ccc63c..a077f89 100644 --- a/backend/src/services/telegramBatchConfirmation.test.ts +++ b/backend/src/services/telegramBatchConfirmation.test.ts @@ -9,11 +9,11 @@ test('date and tag wizards require a final scope and target confirmation', () => assert.match(bot, /step === 'confirm'/); assert.match(bot, /请确认任务范围/); assert.match(bot, /固定存储/); - assert.match(bot, /估计量将在扫描中实时更新/); + assert.match(bot, /扫描进度会实时更新/); assert.match(bot, /共 \$\{state\.dayCount/); assert.match(bot, /范围较大/); assert.match(bot, /parseTelegramDateRange\(state\.startDate!, input\)/); - assert.match(bot, /发送 `确认` 开始/); + assert.match(bot, /发送“确认”开始/); }); test('confirmed channel jobs pass an immutable target snapshot to admission', () => { diff --git a/backend/src/services/telegramBot.ts b/backend/src/services/telegramBot.ts index 233d0e6..d3515e7 100644 --- a/backend/src/services/telegramBot.ts +++ b/backend/src/services/telegramBot.ts @@ -8,7 +8,7 @@ import crypto from 'crypto'; import { storageManager, type StorageTargetSnapshot } from '../services/storage.js'; import { passwordInputState, isAuthenticatedAsync, loadAuthenticatedUsers, persistAuthenticatedUser, revokeAuthenticatedUser, reconcileTelegramAllowedUsers, userStates, TelegramUserState } from './telegramState.js'; import { is2FAEnabled, generateOTPAuthUrl, verifyTOTP, activate2FA } from '../utils/security.js'; -import { handleStart, handleHelp, handleNotifications, handleStatus, handleStorage, handleStorageSwitch, handleStorageSwitchCallback, handleTarget, handleFind, handleList, handleDelete, handleDeleteConfirmCallback, handleTelegramFileBrowserCallback, applyPendingTelegramFileMutation, handleTasks, handleTaskCenterCallback, handleBulkTaskCancelCallback, handleStopTasks, handlePauseTasks, handleResumeTasks, handleCancelTask, handleChannelTaskQueueCallback, handleRetryFailedTasks, handleDownloadWorkers, handleDownloadWorkersCallback, handleFileConcurrency, handleFileConcurrencyCallback, handleStorageCleanupCallback, handlePathRules, handlePathOnce, handlePathSession, handlePathClear, handlePathRulesCallback, handleDuplicateMode, handleDuplicateModeCallback, handleCleanupSettings, handleCleanupSettingsCallback } from './telegramCommands.js'; +import { handleStart, handleHelp, handleNotifications, handleNotificationsCallback, handleStatus, handleStorage, handleStorageSwitch, handleStorageSwitchCallback, handleTarget, handleTargetCallback, handleFind, handleList, handleDelete, handleDeleteConfirmCallback, handleTelegramFileBrowserCallback, applyPendingTelegramFileMutation, handleTasks, handleTaskCenterCallback, handleBulkTaskCancelCallback, handleStopTasks, handlePauseTasks, handleResumeTasks, handleCancelTask, handleChannelTaskQueueCallback, handleRetryFailedTasks, handleDownloadWorkers, handleDownloadWorkersCallback, handleFileConcurrency, handleFileConcurrencyCallback, handleStorageCleanupCallback, handlePathRules, handlePathOnce, handlePathSession, handlePathClear, handlePathRulesCallback, handleDuplicateMode, handleDuplicateModeCallback, handleCleanupSettings, handleCleanupSettingsCallback } from './telegramCommands.js'; import { handleFileUpload, handleCleanupCallback, pauseDownloadTasks, resumeDownloadTasks, resolveTaskChatIdForControl, refreshSilentProgress, cancelSilentTask, canControlTask, listFailedDownloadTaskDetails, retryFailedDownloadTasks, loadFileDownloadConcurrencySetting } from './telegramUpload.js'; import { handleYtDlpCommand, setYtDlpNotifier } from './ytDlpDownload.js'; import { runYtDlpProbe, type YtDlpProbeResult } from './ytDlpProbe.js'; @@ -96,7 +96,7 @@ async function handleBotHomeCallback(update: Api.UpdateBotCallbackQuery, data: s if (data === 'home_tasks' || data === 'home_open_tasks') return handleTasks(await currentMessage()); if (data === 'home_storage' || data === 'home_open_storage') return handleStorage(await currentMessage()); if (data === 'home_upload') { - await client!.editMessage(update.peer, { message: Number(update.msgId), text: '📤 直接发送或转发任意文件即可上传。\n\n使用 /path_rules 选择保存位置,使用 /tasks 查看进度。', buttons: buildBotStartKeyboard() }); + await client!.editMessage(update.peer, { message: Number(update.msgId), text: '📤 直接发送或转发文件即可上传。\n\n下方可打开任务或更多功能。', buttons: buildBotStartKeyboard() }); return; } const pageMatch = data.match(/^home_page_(\d+)$/); @@ -116,7 +116,7 @@ async function handleBotHomeCallback(update: Api.UpdateBotCallbackQuery, data: s if (command === 'tg_sub') return startTelegramWizard(message, userId, 'tg_sub_manage'); if (command === 'tg_subs') { const rows = await listManageableTelegramSubscriptions(userId); - await message.reply({ message: formatSubscriptionList(rows) }); + await message.reply({ message: buildSubscriptionManagePanel(rows, 0), buttons: buildSubscriptionActionKeyboard(rows, 0) }); return; } if (command === 'list') return handleList(message, []); @@ -133,13 +133,34 @@ async function handleBotHomeCallback(update: Api.UpdateBotCallbackQuery, data: s if (command === 'pc') return handlePathClear(message); if (command === 'duplicate_mode') return handleDuplicateMode(message); if (command === 'cleanup_settings') return handleCleanupSettings(message); - if (command === 'help') return handleHelp(message); + if (command === 'help') return handleHelp(message, homePageKeyboard(0)); if (command === 'logout') { await client!.sendMessage(update.peer, { message: '发送 /logout 可立即撤销当前 Bot 认证。' }); return; } + if (command === 'ytdlp') { + await client!.sendMessage(update.peer, { message: '请直接发送一个视频链接。' }); + return; + } + if (command === 'setup_2fa') { + await client!.sendMessage(update.peer, { message: '请从 Telegram 命令菜单发送“配置双重验证”。' }); + return; + } + if (['p', 'ps', 'delete', 'task_cancel', 'tg_unsub'].includes(command)) { + const prompts: Record = { + p: '请发送下一次要使用的目录名称。', + ps: '请发送本聊天要持续使用的目录名称。', + delete: '请在“搜索和操作文件”中选择文件并点击删除。', + task_cancel: '请在任务中心选择要取消的任务。', + tg_unsub: '请在频道订阅面板选择要取消的订阅。', + }; + await client!.sendMessage(update.peer, { message: prompts[command] }); + return; + } const definition = BOT_COMMANDS.find(item => item.command === command); - await client!.sendMessage(update.peer, { message: `/${command}${definition?.usage ? ` ${definition.usage}` : ''}` }); + await client!.sendMessage(update.peer, { + message: definition ? `${definition.description}\n\n请按提示输入,或返回“更多功能”选择其它入口。` : '这个入口暂时不可用。', + }); } // Session File Path @@ -363,11 +384,10 @@ function buildTelegramWizardPrompt(state: TelegramWizardState): string { title, '', '请选择下载方式:', - '`日期` — 下载某个日期范围内的频道媒体', - '`标签` — 下载带指定 #标签 的频道媒体', + '• 按日期:下载指定日期范围内的文件', + '• 按标签:下载带指定标签的文件', '', - '也可以直接发送:`date` / `tag`。', - '发送“取消”可退出。', + '👇 点击按钮继续。', ].join('\n'); } @@ -375,10 +395,8 @@ function buildTelegramWizardPrompt(state: TelegramWizardState): string { return [ title, '', - '请发送频道用户名或链接:', - '例如:`@channel_username` 或 `https://t.me/channel_username`', - '', - '也可以直接发送:`@频道 comments` 或 `@频道 no-comments`。', + '请发送频道用户名或链接。', + '支持公开频道、私密邀请链接和已加入的频道。', '', '发送“取消”可退出。', ].join('\n'); @@ -411,8 +429,7 @@ function buildTelegramWizardPrompt(state: TelegramWizardState): string { `默认关闭;开启后每个频道帖子最多扫描 ${state.commentsMaxPerPost || TELEGRAM_COMMENTS_MAX_PER_POST} 条评论。`, '文字评论、普通链接和其它无文件消息会自动忽略。', '', - '也可以发送:`开` / `关` / `yes` / `no`。', - '发送“取消”可退出。', + '👇 点击按钮选择是否扫描评论区。', ].join('\n'); } @@ -429,8 +446,8 @@ function buildTelegramWizardPrompt(state: TelegramWizardState): string { `💬 评论区:${state.includeComments ? `包含(每帖最多 ${state.commentsMaxPerPost} 条)` : '不包含'}`, `📁 目录:${state.customFolder || '默认自动分类'}`, `☁️ 固定存储:${state.targetProvider || '当前存储'} / ${state.targetAccountName || state.targetAccountId || '当前账户'}`, - '', '估计量将在扫描中实时更新;超过服务端扫描上限时会分段继续,任务可在 /tasks 取消。', - '发送 `确认` 开始,或发送 `取消` 放弃。'].join('\n'); + '', '扫描进度会实时更新;可在任务中心随时取消。', + '发送“确认”开始,或发送“取消”放弃。'].join('\n'); } if (state.step === 'tag') { @@ -504,10 +521,10 @@ export function buildLegacyJobProgressPresentation(summary: TelegramJobProgressS ? '✅ **频道任务完成**' : '🔎 **频道任务运行中**'; const controls = summary.status === 'paused' - ? `控制:/task_resume ${summary.jobId.slice(0, 12)} · /task_cancel ${summary.jobId.slice(0, 12)}` + ? '可在任务中心继续或取消。' : summary.status === 'cooling' || summary.status === 'cancelled' ? '' - : `控制:/task_pause ${summary.jobId.slice(0, 12)} · /task_cancel ${summary.jobId.slice(0, 12)}`; + : '可在任务中心暂停或取消。'; return [ title, `🆔 job: ${summary.jobId.slice(0, 12)}`, @@ -817,9 +834,8 @@ async function listManageableTelegramSubscriptions(userId: number): Promise [ new Api.KeyboardButtonRow({ buttons: [new Api.KeyboardButtonCallback({ text: `${page.startIndex + localIndex + 1}. ${row.title || row.source}`, data: Buffer.from(`tsub_view_${row.id}_${page.page}`) })], @@ -843,7 +859,13 @@ function buildSubscriptionActionKeyboard(rows: any[], requestedPage = 0): Api.Re navigation.push(new Api.KeyboardButtonCallback({ text: '🔄 刷新', data: Buffer.from(`tsub_page_${page.page}`) })); if (page.page + 1 < page.totalPages) navigation.push(new Api.KeyboardButtonCallback({ text: '下一页 ▶️', data: Buffer.from(`tsub_page_${page.page + 1}`) })); return new Api.ReplyInlineMarkup({ - rows: [...actionRows, new Api.KeyboardButtonRow({ buttons: navigation })], + rows: [ + ...actionRows, + new Api.KeyboardButtonRow({ + buttons: [new Api.KeyboardButtonCallback({ text: '➕ 新增订阅', data: Buffer.from('tsub_add') })], + }), + new Api.KeyboardButtonRow({ buttons: navigation }), + ], }); } @@ -851,18 +873,6 @@ function buildSubscriptionManagePanel(rows: any[], requestedPage = 0): string { return buildSubscriptionManagePanelText(rows, buildTelegramSubscriptionPage(rows, requestedPage)); } -function formatSubscriptionList(rows: any[]): string { - if (rows.length === 0) return '📭 暂无频道订阅。\n\n使用 `/tg_sub @频道` 添加订阅。'; - return [ - '📡 **频道订阅**', - '', - ...rows.map((row, index) => [ - buildSubscriptionDisplayLines(row, index), - ` ID: ${String(row.id).slice(0, 8)}`, - ].join('\n')), - ].join('\n'); -} - function buildSubscriptionCancelConfirm(target: any, token: string): { text: string; buttons: Api.ReplyInlineMarkup } { return { text: [ @@ -871,7 +881,7 @@ function buildSubscriptionCancelConfirm(target: any, token: string): { text: str `📌 ${target.title || target.source_original || target.source}`, `来源:${target.source_original || target.source}`, target.folder_override ? `专属目录:${target.folder_override}` : '保存策略:默认自动分类', - `当前游标:last_id=${target.last_message_id || 0}`, + `同步位置:第 ${target.last_message_id || 0} 条消息之后`, '', '确认后会停止自动同步,并从订阅管理列表中移除;已保存的文件不会删除。', ].join('\n'), @@ -1349,6 +1359,16 @@ async function handleTelegramSubscriptionCallback(update: Api.UpdateBotCallbackQ return; } const parsed = parseTelegramSubscriptionCallback(data); + if (data === 'tsub_add') { + const state: TelegramWizardState = { kind: 'tg_sub_manage', step: 'source' }; + putTelegramWizardState(userId, callbackChatKey(update, userId), state, Number(update.msgId)); + await client.editMessage(update.peer, { + message: update.msgId, + text: buildTelegramWizardPrompt(state), + }); + await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: '请发送频道' })); + return; + } if (!parsed) { await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: '订阅按钮无效或已过期', alert: true })); return; @@ -1701,12 +1721,17 @@ export async function initTelegramBot(credentialsOverride?: TelegramBotCredentia await message.reply({ message: MSG.AUTH_REQUIRED }); return; } + let tempPath: string | null = null; try { + if (await is2FAEnabled()) { + await message.reply({ message: '🔐 双重验证已启用。为保护现有密钥,Bot 不会再次显示二维码。' }); + return; + } const qrDataUrl = await generateOTPAuthUrl(); const base64Data = qrDataUrl.replace(/^data:image\/png;base64,/, ""); const buffer = Buffer.from(base64Data, 'base64'); - const tempPath = path.join(process.cwd(), `temp_qr_${senderId}_${Date.now()}_${Math.random().toString(36).slice(2)}.png`); - fs.writeFileSync(tempPath, buffer); + tempPath = path.join(process.cwd(), `temp_qr_${senderId}_${Date.now()}_${crypto.randomBytes(8).toString('hex')}.png`); + fs.writeFileSync(tempPath, buffer, { mode: 0o600 }); const qrMessage = await client.sendFile(chatId, { file: tempPath, @@ -1717,17 +1742,17 @@ export async function initTelegramBot(credentialsOverride?: TelegramBotCredentia state: TelegramUserState.WAITING_2FA_SETUP, qrMessageId: qrMessage.id }); - - fs.unlinkSync(tempPath); } catch (e) { console.error('生成 2FA 二维码失败:', e); await client.sendMessage(chatId, { message: MSG.AUTH_2FA_QR_FAIL }); + } finally { + if (tempPath && fs.existsSync(tempPath)) fs.unlinkSync(tempPath); } return; } if (text === '/help') { - await handleHelp(message); + await handleHelp(message, homePageKeyboard(0)); return; } @@ -1736,11 +1761,17 @@ export async function initTelegramBot(credentialsOverride?: TelegramBotCredentia await message.reply({ message: MSG.AUTH_REQUIRED }); return; } - await revokeAuthenticatedUser(senderId); - passwordInputState.delete(senderId); - userStates.delete(senderId); - telegramWizardStates.delete(senderId, messageChatKey(message, senderId)); - await message.reply({ message: '✅ 当前 Telegram 用户的 Bot 认证已撤销。发送 /start 可重新认证。' }); + try { + await revokeAuthenticatedUser(senderId); + passwordInputState.delete(senderId); + userStates.delete(senderId); + telegramWizardStates.delete(senderId, messageChatKey(message, senderId)); + await message.reply({ message: '✅ 当前 Telegram 用户的 Bot 认证已撤销。发送 /start 可重新认证。' }); + } catch (error) { + const operationId = crypto.randomUUID().slice(0, 8); + console.error(`🤖 撤销 Telegram 认证失败 operationId=${operationId}:`, error); + await message.reply({ message: `❌ 退出失败,请稍后重试。\n操作 ID:${operationId}` }); + } return; } @@ -1756,13 +1787,13 @@ export async function initTelegramBot(credentialsOverride?: TelegramBotCredentia const argsText = (match[1] || '').trim(); if (!argsText) { - await message.reply({ message: '❌ 用法: /ytdlp ' }); + await message.reply({ message: '请发送一个视频链接,也可以直接粘贴链接。' }); return; } const parts = argsText.split(/\s+/).filter(Boolean); if (parts.length !== 1) { - await message.reply({ message: '❌ 只允许一个链接\n\n用法: /ytdlp ' }); + await message.reply({ message: '❌ 一次只能处理一个链接,请重新发送。' }); return; } @@ -1866,7 +1897,10 @@ export async function initTelegramBot(credentialsOverride?: TelegramBotCredentia return; } const rows = await listManageableTelegramSubscriptions(senderId); - await message.reply({ message: formatSubscriptionList(rows) }); + await message.reply({ + message: buildSubscriptionManagePanel(rows, 0), + buttons: buildSubscriptionActionKeyboard(rows, 0), + }); return; } @@ -1877,7 +1911,7 @@ export async function initTelegramBot(credentialsOverride?: TelegramBotCredentia } const source = text.split(/\s+/).slice(1).join(' ').trim(); if (!source) { - await message.reply({ message: '❌ 用法:/tg_sub @频道' }); + await startTelegramWizard(message, senderId, 'tg_sub_manage'); return; } try { @@ -1896,7 +1930,7 @@ export async function initTelegramBot(credentialsOverride?: TelegramBotCredentia } const selector = text.split(/\s+/).slice(1).join(' ').trim(); if (!selector) { - await message.reply({ message: '❌ 用法:/tg_unsub @频道 或 /tg_unsub <订阅ID前缀>' }); + await message.reply({ message: '请从频道订阅面板选择要取消的订阅。' }); return; } const target = await findTelegramSubscription(senderId, selector); @@ -1917,7 +1951,7 @@ export async function initTelegramBot(credentialsOverride?: TelegramBotCredentia const mode = (parts.shift() || '').toLowerCase(); if (mode === 'date' || mode === '日期') { if (parts.length !== 3) { - await message.reply({ message: '❌ 用法:/tg_download date @频道 YYYY-MM-DD YYYY-MM-DD' }); + await startTelegramWizard(message, senderId, 'tg_download'); return; } try { @@ -1931,7 +1965,7 @@ export async function initTelegramBot(credentialsOverride?: TelegramBotCredentia } if (mode === 'tag' || mode === '标签') { if (parts.length !== 2) { - await message.reply({ message: '❌ 用法:/tg_download tag @频道 #标签' }); + await startTelegramWizard(message, senderId, 'tg_download'); return; } try { @@ -1943,7 +1977,7 @@ export async function initTelegramBot(credentialsOverride?: TelegramBotCredentia } return; } - await message.reply({ message: '❌ 用法:/tg_download date @频道 YYYY-MM-DD YYYY-MM-DD\n或:/tg_download tag @频道 #标签' }); + await startTelegramWizard(message, senderId, 'tg_download'); return; } @@ -2301,6 +2335,18 @@ export async function initTelegramBot(credentialsOverride?: TelegramBotCredentia } + // 处理当前聊天存储目标快捷按钮 + if (data.startsWith('target_')) { + await handleTargetCallback(activeClient, callbackUpdate, data); + return; + } + + // 处理通知偏好快捷按钮 + if (data.startsWith('nt_')) { + await handleNotificationsCallback(activeClient, callbackUpdate, data); + return; + } + // 处理保存路径规则回调 if (data.startsWith('pr_')) { await handlePathRulesCallback(activeClient, callbackUpdate, data); diff --git a/backend/src/services/telegramBotHealthWiring.test.ts b/backend/src/services/telegramBotHealthWiring.test.ts index f74d1f6..c73d46d 100644 --- a/backend/src/services/telegramBotHealthWiring.test.ts +++ b/backend/src/services/telegramBotHealthWiring.test.ts @@ -15,7 +15,7 @@ test('Bot startup errors are observable and required mode blocks readiness', () assert.match(bot, /throw error/); assert.match(app, /telegramConfig\.required/); assert.match(app, /telegramBotBlocksReadiness\(bot\)/); - assert.match(app, /components: \{ telegramBot: bot \}/); + assert.match(app, /components: \{ dependencies, telegramBot: bot \}/); }); test('optional Bot failure remains visible as degraded in Web settings and readiness', () => { diff --git a/backend/src/services/telegramChannelJobs.ts b/backend/src/services/telegramChannelJobs.ts index beed77a..36b5f82 100644 --- a/backend/src/services/telegramChannelJobs.ts +++ b/backend/src/services/telegramChannelJobs.ts @@ -14,7 +14,8 @@ import { type StorageAccountCooldown, } from './storageCooldown.js'; import { getTelegramUserClient, isTelegramUserClientReady } from './telegramUserClient.js'; -import { recordTelegramUserClientFailure } from './telegramUserClientStatus.js'; +import { classifyTelegramDownloadAccountError } from './telegramMultiAccountRuntime.js'; +import { triggerTelegramAccountAccessSweep } from './telegramAccountAccessSweep.js'; import { abortChannelExecutionForLeaseLoss, downloadTelegramChannelRange, getTelegramDownloadPreview, getChannelTaskAbortSignal, releaseChannelTaskAbortSignal, type TelegramDownloadMessageRef } from './telegramUpload.js'; import { getSetting } from '../utils/settings.js'; import { extractFileInfo, getEstimatedFileSize, type TelegramFileInfo } from '../utils/telegramMedia.js'; @@ -766,7 +767,12 @@ export async function subscribeTelegramChannel(userId: number, chatId: string | RETURNING id, source, source_original, source_type, title, last_message_id, folder_override, enabled, disabled_reason, disabled_at`, [userId, chatId || null, resolved.source, resolved.originalSource, resolved.sourceType, title, latestMessageId, folderOverride || null] ); - return result.rows[0]; + const subscription = result.rows[0]; + if (subscription?.id) { + void triggerTelegramAccountAccessSweep({ sourceIds: [String(subscription.id)], reason: 'subscription_created' }) + .catch(error => console.error('Telegram 新订阅权限检测失败:', error)); + } + return subscription; } export async function listTelegramSubscriptions(userId: number, includeDisabled = false) { @@ -1330,6 +1336,11 @@ async function downloadClaimedRefs(botClient: TelegramClient, requestMessage: Ap await restoreUnfinishedClaimedRefs(jobId, refs, error instanceof Error ? error.message : String(error)); return { found: 0, skipped: 0, failed: 0, successful: 0, successfulMessageIds: [], failedMessageIds: [], skippedMessageIds: [] }; } + const accountFailure = classifyTelegramDownloadAccountError(error); + if (accountFailure !== 'retryable') { + await restoreUnfinishedClaimedRefs(jobId, refs, error instanceof Error ? error.message : String(error)); + return { found: 0, skipped: 0, failed: 0, successful: 0, successfulMessageIds: [], failedMessageIds: [], skippedMessageIds: [] }; + } for (const ref of refs) await markDownloadRefStatus(jobId, ref, 'failed', error instanceof Error ? error.message : String(error)); throw error; } finally { @@ -2025,7 +2036,8 @@ async function runSubscriptionScan(botClient: TelegramClient) { last_result = jsonb_build_object('status', 'failed') WHERE id = $1`, [row.id, safeError]).catch(() => undefined); if (isTelegramSourceInaccessibleError(error)) { - recordTelegramUserClientFailure('permission_denied', '当前 Telegram 用户账号无法访问订阅来源'); + // Account-scoped permission failures are persisted by the selected download/probe account. + // Only pause here after the subscription worker cannot find any usable account. const reason = subscriptionDisabledReason(error); await pauseTelegramSubscriptionForError(row.id, reason).catch(updateError => console.error('🤖 暂停不可访问的 Telegram 订阅失败:', updateError)); const targetChat = row.chat_id || row.user_id; diff --git a/backend/src/services/telegramCommands.ts b/backend/src/services/telegramCommands.ts index a64ac60..f600892 100644 --- a/backend/src/services/telegramCommands.ts +++ b/backend/src/services/telegramCommands.ts @@ -52,6 +52,12 @@ import { import { buildTelegramStatusPanel } from './telegramStatusPanel.js'; import { getTelegramBotStatus } from './telegramBotStatus.js'; import { getTelegramNotificationPreferences, setTelegramNotificationPreferences } from './telegramNotificationPreferences.js'; +import { + buildNotificationSettingsButtonRows, + buildNotificationSettingsText, + notificationCallbackArgs, + updateNotificationPreference, +} from './telegramNotificationSettings.js'; import { getTelegramUserClientStatus } from './telegramUserClientStatus.js'; import crypto from 'crypto'; import { buildStorageCapabilities } from '../utils/storageProductContract.js'; @@ -364,15 +370,11 @@ function buildDownloadWorkersText(current: number): string { return [ '⚙️ **Telegram 分片并发设置**', '', - `当前 worker 数:**${current}**`, + `当前分片数:**${current}**`, '', - '说明:Telegram 单次请求上限仍是 512KB,这里调整的是单个文件内部的并发分片请求数。', - '如果要调整“一次同时下载几个文件”,请使用 /file_concurrency。', + '这里控制单个文件同时下载多少个分片;数值越高越快,也越容易触发限流。', '', - '建议:', - '- `4`:稳定优先', - '- `8`:速度/稳定平衡', - '- `12` / `16`:激进模式,可能触发风控、断流、限速,甚至账号风险,需要二次确认', + '建议:4 稳定优先,8 速度与稳定平衡;12 或 16 属于激进模式,需要二次确认。', ].join('\n'); } @@ -426,16 +428,11 @@ function buildFileConcurrencyText(current: number): string { `当前同时下载文件数:**${current}**`, `当前队列:进行中 ${stats.active},等待中 ${stats.pending}`, '', - '说明:这里控制“一次同时下载几个文件”。', - '它不同于 /download_workers:后者控制单个文件内部的 512KB 分片并发。', + '这里控制一次同时下载多少个文件。', '', - '建议:', - '- `1`:最稳,适合风控/限速时使用', - '- `2`:默认推荐,速度与稳定平衡', - '- `3`:速度优先,适合线路稳定时使用', - '- `4`:激进模式,可能触发 Telegram 限流或云盘上传限速,需要二次确认', + '建议:1 最稳定,2 默认推荐,3 速度优先;4 属于激进模式,需要二次确认。', '', - '修改后会立即影响队列中新启动的文件下载;已在进行中的文件不会被中断。', + '修改后只影响新开始的文件,正在下载的文件不会中断。', ].join('\n'); } @@ -467,10 +464,10 @@ function buildDuplicateModeText(mode: DuplicateMode): string { '', `当前模式:${mode === 'skip' ? '跳过重复' : '生成副本'}`, '', - '- 跳过重复:同名 + 同目录 + 同大小时不再保存', - '- 生成副本:自动改名为 `文件 (1).ext` 保留副本', + '• 跳过重复:名称、目录和大小都相同时不再保存', + '• 生成副本:自动改名并保留一份副本', '', - '说明:修改后只影响后续新上传/转存文件。', + '只影响之后保存的文件。', ].join('\n'); } @@ -498,10 +495,10 @@ function buildCleanupSettingsText(enabled: boolean): string { '', `当前状态:${enabled ? '✅ 开启' : '⬜ 关闭'}`, '', - '开启后会自动删除本地 uploads 中未登记到文件索引、且超过保护期的临时文件。', - '这不会删除任务历史、已登记文件或第三方云端实体。', + '开启后每小时检查服务器下载目录,只删除超过 10 分钟且未出现在文件列表中的临时文件。', + '不会删除任务记录、已登记文件或云端文件。', '', - '如果本地 uploads 中有绕过 TG Vault 写入的文件,请保持关闭,避免其被识别为未索引临时文件。', + '如果你会绕过 TG Vault 直接写入服务器下载目录,请保持关闭。', ].join('\n'); } @@ -521,8 +518,19 @@ export async function handleStart(message: Api.Message, senderId: number, button } } -export async function handleHelp(message: Api.Message): Promise { - await message.reply({ message: buildHelp() }); +export async function handleHelp(message: Api.Message, buttons?: Api.TypeReplyMarkup): Promise { + await message.reply({ message: buildHelp(), buttons }); +} + +function buildNotificationSettingsKeyboard(current: Awaited>): Api.ReplyInlineMarkup { + return new Api.ReplyInlineMarkup({ + rows: buildNotificationSettingsButtonRows(current).map(row => new Api.KeyboardButtonRow({ + buttons: row.map(button => new Api.KeyboardButtonCallback({ + text: button.text, + data: Buffer.from(button.data), + })), + })), + }); } export async function handleNotifications(message: Api.Message, args: string[] = []): Promise { @@ -534,32 +542,79 @@ export async function handleNotifications(message: Api.Message, args: string[] = const chatId = canonicalTelegramChatKey(message.chatId?.toString() || userId); const current = await getTelegramNotificationPreferences(userId, chatId); if (args.length === 0) { - await message.reply({ message: [ - '🔔 **通知偏好**', - `失败:${current.failureImmediate ? '即时' : '摘要'}`, - `成功:${current.successMode}`, - `安全:始终即时(不可关闭)`, - `订阅:${current.subscriptionDigest ? '摘要' : '即时'}`, - `时区:${current.timezone}`, - `安静时段:${current.quietStart && current.quietEnd ? `${current.quietStart}-${current.quietEnd}` : '关闭'}`, - '', - '用法:/notifications timezone Asia/Shanghai;/notifications quiet 22:00-07:00;/notifications success digest|immediate|off;/notifications failure immediate|digest;/notifications subscription digest|immediate', - ].join('\n') }); + await message.reply({ + message: buildNotificationSettingsText(current), + buttons: buildNotificationSettingsKeyboard(current), + }); return; } - const [key, value = ''] = args; - const update: Record = { ...current }; - if (key === 'timezone') update.timezone = value; - else if (key === 'quiet') { - const match = value.match(/^([0-2]\d:[0-5]\d)-([0-2]\d:[0-5]\d)$/); - if (!match) throw new Error('安静时段格式必须是 HH:MM-HH:MM'); - update.quietStart = match[1]; update.quietEnd = match[2]; - } else if (key === 'success') update.successMode = value; - else if (key === 'failure') update.failureImmediate = value === 'immediate'; - else if (key === 'subscription') update.subscriptionDigest = value !== 'immediate'; - else throw new Error('未知通知设置'); - const saved = await setTelegramNotificationPreferences(userId, chatId, update); - await message.reply({ message: `✅ 通知偏好已更新。安全告警仍始终即时。\n时区:${saved.timezone};安静时段:${saved.quietStart || '-'}-${saved.quietEnd || '-'}` }); + + try { + const saved = await setTelegramNotificationPreferences( + userId, + chatId, + updateNotificationPreference(current, args), + ); + await message.reply({ + message: `${buildNotificationSettingsText(saved)}\n\n✅ 设置已保存。`, + buttons: buildNotificationSettingsKeyboard(saved), + }); + } catch (error) { + await message.reply({ + message: `❌ ${(error as Error).message}\n\n发送 /notifications 可重新查看说明和快捷按钮。`, + }); + } +} + +export async function handleNotificationsCallback( + client: TelegramClient, + update: Api.UpdateBotCallbackQuery, + data: string, +): Promise { + const userId = update.userId.toJSNumber(); + if (!(await isAuthenticatedAsync(userId))) { + await client.invoke(new Api.messages.SetBotCallbackAnswer({ + queryId: update.queryId, + message: MSG.AUTH_REQUIRED, + alert: true, + })); + return; + } + + try { + const args = notificationCallbackArgs(data); + if (!args) return; + const chatId = getCallbackChatKey(update); + const current = await getTelegramNotificationPreferences(userId, chatId); + const next = updateNotificationPreference(current, args); + if (JSON.stringify(next) === JSON.stringify(current)) { + await client.invoke(new Api.messages.SetBotCallbackAnswer({ + queryId: update.queryId, + message: '已是当前设置', + })); + return; + } + const saved = await setTelegramNotificationPreferences( + userId, + chatId, + next, + ); + await client.editMessage(update.peer, { + message: Number(update.msgId), + text: buildNotificationSettingsText(saved), + buttons: buildNotificationSettingsKeyboard(saved), + }); + await client.invoke(new Api.messages.SetBotCallbackAnswer({ + queryId: update.queryId, + message: '通知设置已更新', + })); + } catch (error) { + await client.invoke(new Api.messages.SetBotCallbackAnswer({ + queryId: update.queryId, + message: `设置失败:${(error as Error).message}`, + alert: true, + })); + } } export async function handleStatus(message: Api.Message): Promise { @@ -653,6 +708,20 @@ export async function handleStorage(message: Api.Message): Promise { } } +function buildTargetKeyboard(): Api.ReplyInlineMarkup { + return new Api.ReplyInlineMarkup({ + rows: [ + new Api.KeyboardButtonRow({ buttons: [ + new Api.KeyboardButtonCallback({ text: '📌 下一次使用当前存储', data: Buffer.from('target_once_active') }), + new Api.KeyboardButtonCallback({ text: '📍 本聊天使用当前存储', data: Buffer.from('target_session_active') }), + ] }), + new Api.KeyboardButtonRow({ buttons: [ + new Api.KeyboardButtonCallback({ text: '🧹 恢复系统默认', data: Buffer.from('target_clear') }), + ] }), + ], + }); +} + export async function handleTarget(message: Api.Message, args: string[] = []): Promise { const senderId = message.senderId?.toJSNumber(); if (!senderId || !(await isAuthenticatedAsync(senderId))) { @@ -673,17 +742,21 @@ export async function handleTarget(message: Api.Message, args: string[] = []): P await message.reply({ message: [ '🎯 **当前聊天存储目标**', - `下一次:${once ? `${once.provider} / ${once.accountId || 'local'}` : '未设置'}`, - `会话:${session ? `${session.provider} / ${session.accountId || 'local'}` : '未设置'}`, - `系统默认:${active.provider.name} / ${active.accountId || 'local'}`, + `下一次:${once ? `${getProviderDisplayName(once.provider)}(已设置)` : '系统默认'}`, + `本聊天:${session ? `${getProviderDisplayName(session.provider)}(已设置)` : '系统默认'}`, + `系统默认:${getProviderDisplayName(active.provider.name)}`, '', - '用法:`/target once `、`/target session `、`/target clear`', + '👇 点击按钮,用当前系统存储设置临时目标。', ].join('\n'), + buttons: buildTargetKeyboard(), }); return; } if (!['once', 'session'].includes(modeInput) || !args[1]) { - await message.reply({ message: '❌ 用法:/target once 、/target session 、/target clear' }); + await message.reply({ + message: '❌ 无法识别这个存储目标。请使用下方按钮。', + buttons: buildTargetKeyboard(), + }); return; } const mode = modeInput as TelegramTargetMode; @@ -708,6 +781,49 @@ export async function handleTarget(message: Api.Message, args: string[] = []): P await message.reply({ message: `✅ 已设置${mode === 'once' ? '下一次' : '当前聊天会话'}目标:${provider} / ${accountName}\n不会修改系统全局默认。` }); } +export async function handleTargetCallback( + client: TelegramClient, + update: Api.UpdateBotCallbackQuery, + data: string, +): Promise { + const userId = update.userId.toJSNumber(); + if (!(await isAuthenticatedAsync(userId))) { + await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: MSG.AUTH_REQUIRED, alert: true })); + return; + } + const chatId = getCallbackChatKey(update); + if (data === 'target_clear') { + await clearTelegramTargetState(chatId); + await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: '已恢复系统默认' })); + } else { + const mode: TelegramTargetMode = data === 'target_once_active' ? 'once' : 'session'; + const active = storageManager.getActiveTarget(); + const expiresAt = new Date(Date.now() + (mode === 'once' ? 24 * 60 * 60 * 1000 : 7 * 24 * 60 * 60 * 1000)); + await setTelegramTargetState(undefined, chatId, mode, active.provider.name, active.accountId, expiresAt); + await client.invoke(new Api.messages.SetBotCallbackAnswer({ + queryId: update.queryId, + message: mode === 'once' ? '已设置下一次存储' : '已设置本聊天存储', + })); + } + const once = await getTelegramTargetState(undefined, chatId, 'once'); + const session = await getTelegramTargetState(undefined, chatId, 'session'); + const active = storageManager.getActiveTarget(); + await client.editMessage(update.peer, { + message: Number(update.msgId), + text: [ + '🎯 **当前聊天存储目标**', + `下一次:${once ? `${getProviderDisplayName(once.provider)}(已设置)` : '系统默认'}`, + `本聊天:${session ? `${getProviderDisplayName(session.provider)}(已设置)` : '系统默认'}`, + `系统默认:${getProviderDisplayName(active.provider.name)}`, + '', + '👇 点击按钮,用当前系统存储设置临时目标。', + ].join('\n'), + buttons: buildTargetKeyboard(), + }).catch(error => { + if (!isTelegramMessageNotModified(error)) throw error; + }); +} + export async function handleStorageSwitch(message: Api.Message): Promise { try { const view = await buildStorageSwitchView(); @@ -926,8 +1042,8 @@ export async function handleList(message: Api.Message, args: string[]): Promise< let page = 1; if (args.length > 0) { const parsed = parseInt(args[0]); - if (!isNaN(parsed) && parsed > 0 && parsed <= 50) { - limit = parsed; + if (!isNaN(parsed) && parsed > 0) { + limit = Math.min(parsed, 12); } } if (args.length > 1) { @@ -1059,9 +1175,7 @@ export async function applyPendingTelegramFileMutation(message: Api.Message, act export async function handleDelete(message: Api.Message, args: string[]): Promise { if (args.length === 0) { - await message.reply({ - message: '❌ 请提供要删除的文件 ID 前缀\n\n用法:/delete <至少 8 位 ID 前缀>\n提示:发送 /list 可查看最近文件及 ID,也可从 Web 文件预览复制完整 ID。' - }); + await message.reply({ message: '请从“搜索和操作文件”中选择文件,然后点击“删除”。' }); return; } @@ -1888,7 +2002,7 @@ export async function handleDownloadWorkers(message: Api.Message): Promise }); } catch (error) { console.error('🤖 获取分片并发设置失败:', error); - await message.reply({ message: `❌ 获取分片并发设置失败: ${(error as Error).message}` }); + await message.reply({ message: '❌ 暂时无法读取单文件分片并发,请稍后重试。' }); } } @@ -1902,7 +2016,7 @@ export async function handleFileConcurrency(message: Api.Message): Promise }); } catch (error) { console.error('🤖 获取文件级并发设置失败:', error); - await message.reply({ message: `❌ 获取文件级并发设置失败: ${(error as Error).message}` }); + await message.reply({ message: '❌ 暂时无法读取文件并发设置,请稍后重试。' }); } } @@ -1917,7 +2031,7 @@ export async function handlePathRules(message: Api.Message): Promise { export async function handlePathOnce(message: Api.Message, args: string[]): Promise { const folder = args.join(' ').trim(); if (!folder) { - await message.reply({ message: '❌ 用法:/p <目录>\n例如:/p PIXIV/每日Top50' }); + await message.reply({ message: '请直接发送下一次要使用的目录名称。' }); return; } try { @@ -1931,12 +2045,12 @@ export async function handlePathOnce(message: Api.Message, args: string[]): Prom export async function handlePathSession(message: Api.Message, args: string[]): Promise { const folder = args.join(' ').trim(); if (!folder) { - await message.reply({ message: '❌ 用法:/ps <目录>\n例如:/ps 相册/2026-07' }); + await message.reply({ message: '请直接发送本聊天要持续使用的目录名称。' }); return; } try { const normalized = await setSessionTelegramPathPersistent(message.chatId?.toString() || 'unknown', folder); - await message.reply({ message: `📍 已设置本会话下载目录:\`${normalized}\`\n${buildPathPreviewLine(normalized)}\n\n后续此聊天中的下载会优先保存到该目录,发送 /pc 可清除。` }); + await message.reply({ message: `📍 已设置本会话下载目录:\`${normalized}\`\n${buildPathPreviewLine(normalized)}\n\n后续此聊天中的下载会优先保存到该目录;可在“保存位置”中清除。` }); } catch (error) { await message.reply({ message: `❌ 路径无效:${(error as Error).message}` }); } @@ -1963,8 +2077,8 @@ export async function handlePathRulesCallback(client: TelegramClient, update: Ap const recent = await getRecentTelegramPathsPersistent(chatKey); await client.sendMessage(update.peer, { message: recent.length > 0 - ? ['🕘 **最近使用目录**', '', ...recent.map((item, index) => `${index + 1}. ${item}`), '', '要使用其中一个目录,请直接复制发送,或发送 `/p <目录>` / `/ps <目录>`。'].join('\n') - : '🕘 暂无最近使用目录。设置过 `/p`、`/ps`、订阅专属目录或下载任务专属目录后会自动记录。' + ? ['🕘 **最近使用目录**', '', ...recent.map((item, index) => `${index + 1}. ${item}`), '', '如需使用某个目录,请在“保存位置”中选择下一次或本聊天目录,然后发送目录名称。'].join('\n') + : '🕘 暂无最近使用目录。设置目录后会自动记录。' }); await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: '已发送最近目录' })); return; @@ -1984,7 +2098,7 @@ export async function handlePathRulesCallback(client: TelegramClient, update: Ap await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: '保存位置已更新' })); } catch (error) { console.error('🤖 设置保存位置失败:', error); - await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: `设置失败: ${(error as Error).message}`, alert: true })); + await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: '设置失败,请稍后重试', alert: true })); } } @@ -2016,7 +2130,7 @@ export async function handleDuplicateModeCallback(client: TelegramClient, update await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: `已设置为${mode === 'skip' ? '跳过重复' : '生成副本'}` })); } catch (error) { console.error('🤖 设置重复文件处理失败:', error); - await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: `设置失败: ${(error as Error).message}`, alert: true })); + await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: '设置失败,请稍后重试', alert: true })); } } @@ -2052,7 +2166,7 @@ export async function handleCleanupSettingsCallback(client: TelegramClient, upda await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: enabled ? '已开启自动清理' : '已关闭自动清理' })); } catch (error) { console.error('🤖 设置自动清理失败:', error); - await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: `设置失败: ${(error as Error).message}`, alert: true })); + await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: '设置失败,请稍后重试', alert: true })); } } @@ -2086,12 +2200,12 @@ export async function handleDownloadWorkersCallback(client: TelegramClient, upda await client.editMessage(update.peer, { message: Number(update.msgId), text: [ - `⚠️ **确认使用 ${workers} workers?**`, + `⚠️ **确认使用 ${workers} 个分片?**`, '', '这是激进分片并发模式,可能出现:', '- Telegram 风控或限流', '- 下载断流 / 重试增多', - '- user session 账号风险,极端情况下可能影响账号', + '- Telegram 用户账号可能被限流,极端情况下会影响账号', '', '如果只是日常下载,建议使用 4 或 8。', ].join('\n'), @@ -2104,7 +2218,7 @@ export async function handleDownloadWorkersCallback(client: TelegramClient, upda await setSetting('telegram_download_workers', String(workers)); await client.editMessage(update.peer, { message: Number(update.msgId), - text: `${buildDownloadWorkersText(workers)}\n\n✅ 已切换为 ${workers} workers,后续新下载任务立即生效。`, + text: `${buildDownloadWorkersText(workers)}\n\n✅ 已切换为 ${workers} 个分片,后续新下载任务立即生效。`, buttons: buildDownloadWorkersKeyboard(workers), }); await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: `已设置为 ${workers}` })); @@ -2117,7 +2231,7 @@ export async function handleDownloadWorkersCallback(client: TelegramClient, upda await setSetting('telegram_download_workers', String(workers)); await client.editMessage(update.peer, { message: Number(update.msgId), - text: `${buildDownloadWorkersText(workers)}\n\n⚠️ 已确认并切换为 ${workers} workers。若出现断流、限速、风控提示,请立即降回 4 或 8。`, + text: `${buildDownloadWorkersText(workers)}\n\n⚠️ 已确认并切换为 ${workers} 个分片。若出现断流、限速、风控提示,请立即降回 4 或 8。`, buttons: buildDownloadWorkersKeyboard(workers), }); await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: `已确认 ${workers} workers`, alert: true })); @@ -2126,7 +2240,7 @@ export async function handleDownloadWorkersCallback(client: TelegramClient, upda console.error('🤖 设置并发下载 worker 失败:', error); await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, - message: `设置失败: ${(error as Error).message}`, + message: '设置失败,请稍后重试', alert: true, })); } @@ -2204,7 +2318,7 @@ export async function handleFileConcurrencyCallback(client: TelegramClient, upda console.error('🤖 设置文件级并发失败:', error); await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, - message: `设置失败: ${(error as Error).message}`, + message: '设置失败,请稍后重试', alert: true, })); } diff --git a/backend/src/services/telegramFileBrowser.test.ts b/backend/src/services/telegramFileBrowser.test.ts index 6ce1693..a38d2d9 100644 --- a/backend/src/services/telegramFileBrowser.test.ts +++ b/backend/src/services/telegramFileBrowser.test.ts @@ -51,3 +51,16 @@ test('file browser card includes detail and actionable-operation guidance', asyn assert.match(text, /移动\/重命名/); assert.match(text, /删除确认/); }); + +test('file browser text truncates long dynamic fields and formats sizes for mobile', () => { + const hostile = '[name]_*'.repeat(100); + const text = buildTelegramFileBrowserText({ + files: rows(8).map(file => ({ ...file, name: hostile, folder: hostile, size: 5 * 1024 * 1024 })), + nextCursor: null, + hasMore: false, + }, hostile); + assert.ok(text.length < 3900, `length=${text.length}`); + assert.match(text, /5 MB/); + assert.match(text, /…/); + assert.doesNotMatch(text, /\*\*\*/); +}); diff --git a/backend/src/services/telegramFileBrowser.ts b/backend/src/services/telegramFileBrowser.ts index 7905238..da123d5 100644 --- a/backend/src/services/telegramFileBrowser.ts +++ b/backend/src/services/telegramFileBrowser.ts @@ -1,3 +1,4 @@ +import { formatBytes } from '../utils/telegramUtils.js'; import { query } from '../db/index.js'; import { buildFilePageQuery, @@ -30,6 +31,11 @@ export function parseTelegramFileCallback(data: string): TelegramFileBrowserCall return match ? { action: match[1].toLowerCase() as TelegramFileBrowserAction, fileId: match[2].toLowerCase() } : null; } +function compactText(value: unknown, maxLength: number): string { + const text = String(value || '').replace(/[\r\n\t]+/g, ' ').replace(/[*_`[\]\\]/g, '').trim(); + return text.length > maxLength ? `${text.slice(0, Math.max(1, maxLength - 1))}…` : text; +} + export function buildTelegramFileActionRows(file: any): Array> { const id = String(file.id); const rows: Array> = [ @@ -44,11 +50,11 @@ export function buildTelegramFileActionRows(file: any): Array 0 ? page.files.map(buildTelegramFileCard) : ['没有匹配文件。']), '', diff --git a/backend/src/services/telegramHumanFriendlyOutput.test.ts b/backend/src/services/telegramHumanFriendlyOutput.test.ts new file mode 100644 index 0000000..2eb047c --- /dev/null +++ b/backend/src/services/telegramHumanFriendlyOutput.test.ts @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import fs from 'node:fs'; +import { buildSubscriptionDisplayLines, buildSubscriptionManagePanel } from '../bot/presentation/subscription.js'; + +const botSource = fs.readFileSync(new URL('./telegramBot.ts', import.meta.url), 'utf8'); +const commandSource = fs.readFileSync(new URL('./telegramCommands.ts', import.meta.url), 'utf8'); + +test('button-driven panels do not print command syntax as primary instructions', () => { + const targetPanel = commandSource.slice( + commandSource.indexOf("export async function handleTarget"), + commandSource.indexOf("export async function handleStorageSwitch"), + ); + assert.doesNotMatch(targetPanel, /用法:/); + assert.match(targetPanel, /buttons:/); + + const wizardPrompt = botSource.slice( + botSource.indexOf('function buildTelegramWizardPrompt'), + botSource.indexOf('function isDateOnly'), + ); + assert.doesNotMatch(wizardPrompt, /date` \/ `tag|comments`|no-comments|yes` \/ `no/); + + const progress = botSource.slice( + botSource.indexOf('export function buildLegacyJobProgressPresentation'), + botSource.indexOf('async function updateJobProgressMessage'), + ); + assert.doesNotMatch(progress, /\/task_pause|\/task_resume|\/task_cancel/); +}); + +test('subscription panel uses user language and stays compact', () => { + const row = { + id: 'sub-1', + enabled: true, + title: '壁纸频道', + source: '@wallpaper', + source_original: '@wallpaper', + last_message_id: 42, + target_mode: 'follow_global', + last_scan_at: '2026-08-28T00:00:00.000Z', + next_scan_at: '2026-08-28T01:00:00.000Z', + }; + const detail = buildSubscriptionDisplayLines(row, 0); + const panel = buildSubscriptionManagePanel([row], { page: 0, totalPages: 1, startIndex: 0, visibleRows: [row] }); + + assert.doesNotMatch(detail, /last_id=|Target:|admission|unknown/); + assert.match(detail, /同步位置:第 42 条消息之后/); + assert.match(detail, /存储:跟随系统默认/); + assert.doesNotMatch(panel, /回复频道用户名|https:\/\/t\.me\/\+hash|\/path_rules/); + assert.match(panel, /点击下方按钮管理或新增订阅/); +}); diff --git a/backend/src/services/telegramMultiAccountContract.test.ts b/backend/src/services/telegramMultiAccountContract.test.ts new file mode 100644 index 0000000..b75090e --- /dev/null +++ b/backend/src/services/telegramMultiAccountContract.test.ts @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import test from 'node:test'; + +const route = fs.readFileSync(new URL('../routes/storage.ts', import.meta.url), 'utf8'); +const schema = fs.readFileSync(new URL('../db/schema.sql', import.meta.url), 'utf8'); +const index = fs.readFileSync(new URL('../index.ts', import.meta.url), 'utf8'); +const frontendApi = fs.readFileSync(new URL('../../../frontend/src/services/api.ts', import.meta.url), 'utf8'); +const settings = fs.readFileSync(new URL('../../../frontend/src/components/pages/SettingsPage.tsx', import.meta.url), 'utf8'); + +test('multi-account API exposes account lifecycle, access checks and QR login behind admin auth', () => { + for (const marker of [ + "/config/telegram-user/accounts'", + "/config/telegram-user/accounts/:accountId'", + "/config/telegram-user/accounts/:accountId/check'", + "/config/telegram-user/access/check-all'", + '/config/telegram-user/accounts/login/qr', + ]) assert.match(route, new RegExp(marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); + assert.match(route, /requireAuth/); + assert.match(route, /telegramUserLoginLimiter/); + assert.match(route, /noStore\(res\)/); +}); + +test('account sessions and QR tokens never appear in public API payload contracts', () => { + const publicSlice = route.slice(route.indexOf("/config/telegram-user/accounts"), route.indexOf("/config/telegram-allowed-users")); + assert.doesNotMatch(publicSlice, /session_ciphertext\s*:/i); + assert.doesNotMatch(frontendApi, /sessionCiphertext|StringSession|phoneCodeHash/); + assert.match(schema, /session_ciphertext TEXT NOT NULL/); +}); + +test('application initializes the account pool independently of Telegram Bot enabled state', () => { + assert.match(index, /initializeTelegramMultiAccountRuntime|initTelegramUserClientPool|initTelegramUserAccounts/); + const botGuard = index.slice(index.indexOf('if (telegramEnabled)'), index.indexOf('await initializeYtDlpQueue')); + assert.doesNotMatch(botGuard, /initTelegramUserClientPool|initTelegramUserAccounts/); +}); + +test('Web settings provide QR-first multi-account controls and permission summaries', () => { + const ui = settings + fs.readFileSync(new URL('../../../frontend/src/components/pages/TelegramUserAccountsPanel.tsx', import.meta.url), 'utf8'); + for (const copy of ['添加账号', '二维码登录', '手机号登录', '权限检测']) assert.match(ui, new RegExp(copy)); + assert.match(ui, /智能调度|智能均衡|智能负载均衡/); + assert.match(ui, /可访问/); + assert.match(ui, /不可访问|无权限/); + for (const method of ['getTelegramUserAccounts', 'startTelegramUserQrLogin', 'getTelegramUserLoginStatus', 'setTelegramUserAccountEnabled', 'checkTelegramUserAccountPermissions']) assert.match(frontendApi, new RegExp(method)); +}); diff --git a/backend/src/services/telegramMultiAccountDownload.test.ts b/backend/src/services/telegramMultiAccountDownload.test.ts new file mode 100644 index 0000000..8e9f1a5 --- /dev/null +++ b/backend/src/services/telegramMultiAccountDownload.test.ts @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import test from 'node:test'; + +const upload = fs.readFileSync(new URL('./telegramUpload.ts', import.meta.url), 'utf8'); +const jobs = fs.readFileSync(new URL('./telegramChannelJobs.ts', import.meta.url), 'utf8'); +const schema = fs.readFileSync(new URL('../db/schema.sql', import.meta.url), 'utf8'); + +test('channel downloads select an account for the source and keep the selected client for a claimed batch', () => { + assert.match(upload, /selectTelegramDownloadAccount\(String\(source\)\)/); + assert.match(upload, /selectedDownloadAccount\?\.client/); + assert.match(upload, /finally[\s\S]*selectedDownloadAccount\?\.release\(\)/); + assert.doesNotMatch(upload, /Promise\.all\([^)]*selectTelegramDownloadAccount/); +}); + +test('download jobs persist account assignment and attempt audit tables without changing child lease ownership', () => { + assert.match(schema, /assigned_account_id/); + assert.match(schema, /CREATE TABLE IF NOT EXISTS telegram_download_attempts/); + assert.match(schema, /lease_token UUID/); + assert.match(jobs, /lease_token|withTelegramDownloadRefLease/); +}); + +test('FloodWait and inaccessible sources update the selected account instead of global user state', () => { + assert.match(upload, /markTelegramAccountCooldown/); + assert.match(upload, /markTelegramAccountSourceAccess/); + assert.match(upload, /markTelegramAccountSessionExpired/); + assert.doesNotMatch(jobs, /recordTelegramUserClientFailure\('permission_denied'/); +}); diff --git a/backend/src/services/telegramMultiAccountLogin.ts b/backend/src/services/telegramMultiAccountLogin.ts new file mode 100644 index 0000000..192bfce --- /dev/null +++ b/backend/src/services/telegramMultiAccountLogin.ts @@ -0,0 +1,151 @@ +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 { + TelegramMultiAccountLoginFlows, + type TelegramLoginCredentials, + type TelegramMultiAccountLoginClient, + type TelegramQrExportResult, + type TelegramUserLoginAccount, +} from './telegramMultiAccountLoginFlows.js'; + +export interface TelegramAuthorizedAccountAdapter { + upsertByTelegramUserId(input: { + session: string; + account: TelegramUserLoginAccount; + }): 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. + */ +export function registerTelegramMultiAccountAuthorizedAdapter(next: TelegramAuthorizedAccountAdapter | null): void { + adapter = next; +} + +async function getCredentials(): Promise { + const effective = await getEffectiveTelegramBotConfig(); + if (effective.credentials) { + return { apiId: effective.credentials.apiId, apiHash: effective.credentials.apiHash }; + } + const apiId = Number.parseInt(process.env.TELEGRAM_API_ID || '0', 10); + const apiHash = process.env.TELEGRAM_API_HASH || ''; + return apiId && apiHash ? { apiId, apiHash } : null; +} + +function makeClient(credentials: TelegramLoginCredentials): TelegramClient { + return new TelegramClient(new StringSession(''), credentials.apiId, credentials.apiHash, { + connectionRetries: 15, + retryDelay: 2000, + useWSS: false, + deviceModel: 'TG Vault Multi-Account Login', + systemVersion: '1.0.0', + appVersion: '1.0.0', + floodSleepThreshold: 120, + }); +} + +class GramJsMultiAccountLoginClient implements TelegramMultiAccountLoginClient { + private qrHandler: (() => void | Promise) | null = null; + private readonly qrEvent = new Raw({ types: [Api.UpdateLoginToken] }); + + constructor( + private readonly client: TelegramClient, + private readonly credentials: TelegramLoginCredentials, + ) {} + + async connect(): Promise { await this.client.connect(); } + + sendCode(credentials: TelegramLoginCredentials, phone: string) { + return this.client.sendCode(credentials, phone); + } + + async signInCode(phone: string, phoneCodeHash: string, code: string): Promise<'authorized' | 'password_needed'> { + try { + await this.client.invoke(new Api.auth.SignIn({ phoneNumber: phone, phoneCodeHash, phoneCode: code })); + return 'authorized'; + } catch (error) { + if (this.errorName(error).includes('SESSION_PASSWORD_NEEDED')) return 'password_needed'; + throw error; + } + } + + async signInPassword(password: string): Promise { + let captured: unknown; + await this.client.signInWithPassword(this.credentials, { + password: async () => password, + onError: async error => { captured = error; return true; }, + }).catch(error => { throw captured || error; }); + } + + setQrLoginTokenHandler(handler: (() => void | Promise) | null): void { + if (this.qrHandler) this.client.removeEventHandler(this.qrHandler, this.qrEvent); + this.qrHandler = handler; + if (handler) this.client.addEventHandler(handler, this.qrEvent); + } + + async exportQrLoginToken(): Promise { + let result: Api.auth.TypeLoginToken; + try { + result = await this.client.invoke(new Api.auth.ExportLoginToken({ + apiId: this.credentials.apiId, + apiHash: this.credentials.apiHash, + exceptIds: [], + })); + } catch (error) { + if (this.errorName(error).includes('SESSION_PASSWORD_NEEDED')) { + return { kind: 'password_required' }; + } + throw error; + } + if (result instanceof Api.auth.LoginToken) { + return { + kind: 'token', + token: Buffer.from(result.token), + expiresAt: Number(result.expires) * 1000, + }; + } + let imported: Api.auth.TypeLoginToken = result; + if (result instanceof Api.auth.LoginTokenMigrateTo) { + await this.client._switchDC(result.dcId); + imported = await this.client.invoke(new Api.auth.ImportLoginToken({ token: result.token })) as Api.auth.TypeLoginToken; + } + if (imported instanceof Api.auth.LoginTokenSuccess) return { kind: 'authorized' }; + if (imported instanceof Api.auth.LoginToken) { + return { + kind: 'token', + token: Buffer.from(imported.token), + expiresAt: Number(imported.expires) * 1000, + }; + } + throw new Error('TELEGRAM_QR_LOGIN_UNEXPECTED_RESPONSE'); + } + + async getMe() { return await this.client.getMe() as any; } + saveSession(): string { return this.client.session.save() as unknown as string; } + disconnect(): Promise { return this.client.disconnect(); } + destroy(): Promise { return this.client.destroy(); } + + private errorName(error: unknown): string { + if (!error || typeof error !== 'object') return ''; + return String((error as { errorMessage?: unknown }).errorMessage || (error as Error).message || ''); + } +} + +async function onAuthorized(input: { session: string; account: TelegramUserLoginAccount }): Promise { + if (!adapter) { + throw new Error('Telegram 多账号仓库尚未注册,无法保存登录账号'); + } + await adapter.upsertByTelegramUserId(input); +} + +export const telegramMultiAccountLoginFlows = new TelegramMultiAccountLoginFlows({ + credentials: getCredentials, + createClient: credentials => new GramJsMultiAccountLoginClient(makeClient(credentials), credentials), + onAuthorized, +}); diff --git a/backend/src/services/telegramMultiAccountLoginAdapter.test.ts b/backend/src/services/telegramMultiAccountLoginAdapter.test.ts new file mode 100644 index 0000000..1d4a61e --- /dev/null +++ b/backend/src/services/telegramMultiAccountLoginAdapter.test.ts @@ -0,0 +1,21 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createTelegramMultiAccountAuthorizedAdapter } from './telegramMultiAccountLoginAdapter.js'; + +test('authorized adapter upserts by Telegram user id then refreshes the runtime pool', async () => { + const calls: string[] = []; + const adapter = createTelegramMultiAccountAuthorizedAdapter({ + repository: { + async upsertAccount(input) { + calls.push(`upsert:${input.telegramUserId}:${input.enabled}:${input.session}`); + return { id: 'account-42' }; + }, + }, + pool: { async refresh() { calls.push('refresh'); } }, + }); + await adapter.upsertByTelegramUserId({ + session: 'secret-session', + account: { userId: '42', username: 'owner', displayName: 'Vault Owner' }, + }); + assert.deepEqual(calls, ['upsert:42:true:secret-session', 'refresh']); +}); \ No newline at end of file diff --git a/backend/src/services/telegramMultiAccountLoginAdapter.ts b/backend/src/services/telegramMultiAccountLoginAdapter.ts new file mode 100644 index 0000000..e61271b --- /dev/null +++ b/backend/src/services/telegramMultiAccountLoginAdapter.ts @@ -0,0 +1,47 @@ +import type { TelegramAuthorizedAccountAdapter } from './telegramMultiAccountLogin.js'; + +export interface TelegramLoginAccountRepository { + upsertAccount(input: { + telegramUserId: string; + username?: string | null; + displayName?: string | null; + session: string; + enabled?: boolean; + }): Promise; +} + +export interface TelegramLoginAccountPool { + refresh(): Promise; +} + +export interface TelegramLoginAccountAccessSweep { + trigger(input: { accountIds: string[]; reason: 'account_created' }): Promise; +} + +/** + * Production seam between ephemeral authentication and persistent account + * runtime state. The repository owns encryption and userId upsert semantics; + * the pool is reloaded only after the database write succeeds. + */ +export function createTelegramMultiAccountAuthorizedAdapter(deps: { + repository: TelegramLoginAccountRepository; + pool: TelegramLoginAccountPool; + accessSweep?: TelegramLoginAccountAccessSweep; +}): TelegramAuthorizedAccountAdapter { + return { + async upsertByTelegramUserId({ session, account }): Promise { + const persisted = await deps.repository.upsertAccount({ + telegramUserId: account.userId, + username: account.username, + displayName: account.displayName, + session, + enabled: true, + }); + await deps.pool.refresh(); + const accountId = String((persisted as { id?: unknown } | null)?.id || ''); + if (accountId && deps.accessSweep) { + await deps.accessSweep.trigger({ accountIds: [accountId], reason: 'account_created' }); + } + }, + }; +} diff --git a/backend/src/services/telegramMultiAccountLoginContract.test.ts b/backend/src/services/telegramMultiAccountLoginContract.test.ts new file mode 100644 index 0000000..7e90465 --- /dev/null +++ b/backend/src/services/telegramMultiAccountLoginContract.test.ts @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import test from 'node:test'; + +const route = fs.readFileSync(new URL('../routes/storage.ts', import.meta.url), 'utf8'); +const flows = fs.readFileSync(new URL('./telegramMultiAccountLoginFlows.ts', import.meta.url), 'utf8'); +const gramJs = fs.readFileSync(new URL('./telegramMultiAccountLogin.ts', import.meta.url), 'utf8'); + +test('multi-account login routes are admin-only, rate-limited, no-store, session-bound, and cancellable', () => { + assert.match(route, /telegramUserLoginLimiter\s*=\s*rateLimit/); + const contracts: Array<[string, string, string]> = [ + ['post', '/config/telegram-accounts/login/phone', 'telegramUserLoginLimiter'], + ['post', '/config/telegram-accounts/login/code', 'telegramUserLoginLimiter'], + ['post', '/config/telegram-accounts/login/password', 'telegramUserLoginLimiter'], + ['post', '/config/telegram-accounts/login/qr', 'telegramUserLoginLimiter'], + ['get', '/config/telegram-accounts/login/qr/:flowId', 'telegramUserLoginStatusLimiter'], + ['post', '/config/telegram-accounts/login/qr/:flowId/refresh', 'telegramUserLoginLimiter'], + ['delete', '/config/telegram-accounts/login/:flowId', 'telegramUserLoginLimiter'], + ]; + for (const [method, path, limiter] of contracts) { + const escaped = path.replace(/[/:]/g, match => match === ':' ? '\\:' : `\\${match}`); + assert.match(route, new RegExp(`router\\.${method}\\('${escaped}',\\s*requireAuth,\\s*${limiter}`)); + } + const section = route.slice(route.indexOf("router.post('/config/telegram-accounts/login/phone'"), route.indexOf('// Legacy single-account login API')); + assert.match(section, /getTelegramUserLoginSessionKey\(req\)/); + assert.match(section, /noStore\(res\)/); + assert.doesNotMatch(section, /ACCOUNT_ALREADY_BOUND|getTelegramUserAccountStatus/); +}); + +test('QR contract is ephemeral and never exposes raw token, code hash, or StringSession', () => { + assert.match(flows, /tg:\/\/login\?token=\$\{result\.token\.toString\('base64url'\)\}/); + assert.match(flows, /flowId: string;\s*status: TelegramQrLoginStatus;\s*qrData: string \| null;\s*expiresAt: string;\s*version: number;/s); + assert.match(flows, /owner: string/); + assert.match(flows, /scheduleCleanup/); + assert.match(flows, /setQrLoginTokenHandler\(null\)/); + assert.doesNotMatch(route, /phoneCodeHash|saveSession\(\)|token\.toString\('base64url'\)/); +}); + +test('login completion integrates through a userId-upsert adapter rather than a missing pool export', () => { + assert.match(gramJs, /upsertByTelegramUserId/); + assert.match(gramJs, /registerTelegramMultiAccountAuthorizedAdapter/); + assert.match(gramJs, /await adapter\.upsertByTelegramUserId\(input\)/); + assert.match(gramJs, /Api\.auth\.ExportLoginToken/); + assert.match(gramJs, /Api\.UpdateLoginToken/); + assert.match(gramJs, /SESSION_PASSWORD_NEEDED[\s\S]*password_required/); +}); + +test('legacy single-account phone/code/password routes remain available', () => { + for (const suffix of ['phone', 'code', 'password']) { + assert.match(route, new RegExp(`router\\.post\\('\\/config\\/telegram-user\\/login\\/${suffix}'`)); + } +}); diff --git a/backend/src/services/telegramMultiAccountLoginFlows.test.ts b/backend/src/services/telegramMultiAccountLoginFlows.test.ts new file mode 100644 index 0000000..73abac9 --- /dev/null +++ b/backend/src/services/telegramMultiAccountLoginFlows.test.ts @@ -0,0 +1,163 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + TelegramMultiAccountLoginFlows, + TelegramUserLoginFlowError, + type TelegramMultiAccountLoginClient, + type TelegramQrExportResult, +} from './telegramMultiAccountLoginFlows.js'; + +type QrHandler = () => void | Promise; + +class FakeClient implements TelegramMultiAccountLoginClient { + disconnects = 0; + destroys = 0; + qrHandler: QrHandler | null = null; + qrResults: TelegramQrExportResult[] = []; + phoneCodeResult: 'authorized' | 'password_needed' = 'authorized'; + passwordError: Error | null = null; + me = { id: '42', username: 'vault_owner', firstName: 'Vault', lastName: 'Owner' }; + + async connect(): Promise {} + async sendCode(_credentials: unknown, phone: string) { + assert.equal(phone, '+8613800138000'); + return { phoneCodeHash: 'server-only-hash', isCodeViaApp: true }; + } + async signInCode(_phone: string, hash: string, code: string) { + assert.equal(hash, 'server-only-hash'); + if (code === '00000') throw new Error('PHONE_CODE_INVALID'); + return this.phoneCodeResult; + } + async signInPassword(_password: string): Promise { + if (this.passwordError) throw this.passwordError; + } + setQrLoginTokenHandler(handler: QrHandler | null): void { this.qrHandler = handler; } + async exportQrLoginToken(): Promise { + const next = this.qrResults.shift(); + if (!next) throw new Error('NO_QR_RESULT'); + return next; + } + async emitQrUpdate(): Promise { await this.qrHandler?.(); } + async getMe() { return this.me; } + saveSession(): string { return `SECRET_SESSION_${this.me.id}`; } + async disconnect(): Promise { this.disconnects += 1; } + async destroy(): Promise { this.destroys += 1; } +} + +function fixture() { + let now = Date.parse('2026-08-29T00:00:00.000Z'); + const clients: FakeClient[] = []; + const authorized: Array<{ session: string; userId: string }> = []; + const flows = new TelegramMultiAccountLoginFlows({ + now: () => now, + ttlMs: 5 * 60_000, + maxErrors: 3, + credentials: async () => ({ apiId: 123, apiHash: 'a'.repeat(32) }), + createClient: () => { + const client = new FakeClient(); + client.qrResults.push({ + kind: 'token', + token: Buffer.from('initial-token'), + expiresAt: now + 60_000, + }); + clients.push(client); + return client; + }, + onAuthorized: async ({ session, account }) => { + // A repository/pool adapter upserts by Telegram identity; repeat login is valid. + const existing = authorized.find(item => item.userId === account.userId); + if (existing) existing.session = session; + else authorized.push({ session, userId: account.userId }); + }, + }); + return { flows, clients, authorized, advance(ms: number) { now += ms; } }; +} + +test('phone login is session-bound and authorizes through an injected userId upsert callback', async () => { + const fx = fixture(); + const first = await fx.flows.startPhone('admin-session-a', '+86 138 0013 8000'); + assert.deepEqual(Object.keys(first).sort(), ['delivery', 'expiresAt', 'flowId']); + assert.equal(JSON.stringify(first).includes('server-only-hash'), false); + + await assert.rejects( + fx.flows.submitCode('admin-session-b', first.flowId, '12345'), + (error: unknown) => error instanceof TelegramUserLoginFlowError && error.code === 'FLOW_NOT_FOUND', + ); + const completed = await fx.flows.submitCode('admin-session-a', first.flowId, '12345'); + assert.deepEqual(completed, { + step: 'complete', + account: { userId: '42', username: 'vault_owner', displayName: 'Vault Owner' }, + }); + assert.equal(JSON.stringify(completed).includes('SECRET_SESSION'), false); + + const repeated = await fx.flows.startPhone('admin-session-a', '+8613800138000'); + await fx.flows.submitCode('admin-session-a', repeated.flowId, '12345'); + assert.deepEqual(fx.authorized, [{ userId: '42', session: 'SECRET_SESSION_42' }]); +}); + +test('QR start/refresh returns only a base64url tg login URI and increments version', async () => { + const fx = fixture(); + const started = await fx.flows.startQr('admin-session-a'); + const client = fx.clients[0]; + assert.deepEqual(started, { + flowId: started.flowId, + status: 'pending', + qrData: `tg://login?token=${Buffer.from('initial-token').toString('base64url')}`, + expiresAt: '2026-08-29T00:01:00.000Z', + version: 1, + }); + assert.equal(JSON.stringify(started).includes('SECRET_SESSION'), false); + + client.qrResults.push({ kind: 'token', token: Buffer.from('new-token'), expiresAt: Date.parse('2026-08-29T00:02:00.000Z') }); + const refreshed = await fx.flows.refreshQr('admin-session-a', started.flowId); + assert.equal(refreshed.version, 2); + assert.equal(refreshed.qrData, `tg://login?token=${Buffer.from('new-token').toString('base64url')}`); + await assert.rejects( + fx.flows.getQrStatus('admin-session-b', started.flowId), + (error: unknown) => error instanceof TelegramUserLoginFlowError && error.code === 'FLOW_NOT_FOUND', + ); +}); + +test('QR scan can require 2FA, then upserts the authorized account and cleans the temporary client', async () => { + const fx = fixture(); + const started = await fx.flows.startQr('admin-session-a'); + const client = fx.clients[0]; + + client.qrResults.push({ kind: 'password_required', hint: 'pet' }); + await client.emitQrUpdate(); + assert.deepEqual(await fx.flows.getQrStatus('admin-session-a', started.flowId), { + flowId: started.flowId, + status: 'password_required', + qrData: null, + expiresAt: '2026-08-29T00:05:00.000Z', + version: 1, + passwordHint: 'pet', + }); + + const completed = await fx.flows.submitPassword('admin-session-a', started.flowId, 'correct horse'); + assert.equal(completed.step, 'complete'); + const status = await fx.flows.getQrStatus('admin-session-a', started.flowId); + assert.equal(status.status, 'complete'); + assert.deepEqual(status.account, { userId: '42', username: 'vault_owner', displayName: 'Vault Owner' }); + assert.deepEqual(fx.authorized, [{ userId: '42', session: 'SECRET_SESSION_42' }]); + assert.equal(client.qrHandler, null); + assert.equal(client.disconnects, 1); + assert.equal(client.destroys, 1); +}); + +test('expired and cancelled QR flows cannot be reused and always close temporary clients', async () => { + const fx = fixture(); + const first = await fx.flows.startQr('admin-session-a'); + fx.advance(5 * 60_000 + 1); + await assert.rejects( + fx.flows.getQrStatus('admin-session-a', first.flowId), + (error: unknown) => error instanceof TelegramUserLoginFlowError && error.code === 'FLOW_EXPIRED', + ); + assert.equal(fx.clients[0].disconnects, 1); + + const second = await fx.flows.startQr('admin-session-a'); + assert.deepEqual(await fx.flows.cancel('admin-session-a', second.flowId), { success: true }); + assert.equal(fx.clients[1].qrHandler, null); + assert.equal(fx.clients[1].disconnects, 1); + await assert.rejects(fx.flows.getQrStatus('admin-session-a', second.flowId)); +}); diff --git a/backend/src/services/telegramMultiAccountLoginFlows.ts b/backend/src/services/telegramMultiAccountLoginFlows.ts new file mode 100644 index 0000000..becb442 --- /dev/null +++ b/backend/src/services/telegramMultiAccountLoginFlows.ts @@ -0,0 +1,484 @@ +import crypto from 'node:crypto'; + +export interface TelegramUserLoginAccount { + userId: string; + username: string | null; + displayName: string | null; +} + +export interface TelegramLoginCredentials { + apiId: number; + apiHash: string; +} + +export type TelegramQrExportResult = + | { kind: 'token'; token: Buffer; expiresAt: number } + | { kind: 'authorized' } + | { kind: 'password_required'; hint?: string | null }; + +export interface TelegramMultiAccountLoginClient { + connect(): Promise; + sendCode(credentials: TelegramLoginCredentials, phone: string): Promise<{ phoneCodeHash: string; isCodeViaApp: boolean }>; + signInCode(phone: string, phoneCodeHash: string, code: string): Promise<'authorized' | 'password_needed'>; + signInPassword(password: string): Promise; + setQrLoginTokenHandler(handler: (() => void | Promise) | null): void; + exportQrLoginToken(): Promise; + getMe(): Promise<{ id: unknown; username?: string; firstName?: string; lastName?: string }>; + saveSession(): string; + disconnect(): Promise; + destroy(): Promise; +} + +export type TelegramUserLoginFlowErrorCode = + | 'INVALID_PHONE' + | 'INVALID_CODE' + | 'INVALID_PASSWORD' + | 'FLOW_NOT_FOUND' + | 'FLOW_EXPIRED' + | 'TOO_MANY_ERRORS' + | 'API_NOT_CONFIGURED' + | 'TELEGRAM_ERROR'; + +export class TelegramUserLoginFlowError extends Error { + constructor(public readonly code: TelegramUserLoginFlowErrorCode, message: string) { + super(message); + this.name = 'TelegramUserLoginFlowError'; + } +} + +interface BaseFlow { + id: string; + owner: string; + expiresAt: number; + errors: number; + client: C | null; + cleanupTimer: ReturnType; +} + +interface PhoneFlow extends BaseFlow { + kind: 'phone'; + phone: string; + phoneCodeHash: string; + step: 'code' | 'password'; +} + +export type TelegramQrLoginStatus = 'pending' | 'password_required' | 'complete' | 'error'; + +export interface TelegramQrLoginStatusResponse { + flowId: string; + status: TelegramQrLoginStatus; + qrData: string | null; + expiresAt: string; + version: number; + passwordHint?: string | null; + account?: TelegramUserLoginAccount; + error?: string; +} + +interface QrFlow extends BaseFlow { + kind: 'qr'; + status: TelegramQrLoginStatus; + qrData: string | null; + tokenExpiresAt: number | null; + version: number; + passwordHint: string | null; + account: TelegramUserLoginAccount | null; + error: string | null; + operation: Promise; +} + +type LoginFlow = PhoneFlow | QrFlow; + +type AuthorizedCallback = (input: { + session: string; + account: TelegramUserLoginAccount; +}) => Promise; + +function telegramErrorName(error: unknown): string { + if (!error || typeof error !== 'object') return ''; + return String((error as { errorMessage?: unknown }).errorMessage || (error as Error).message || ''); +} + +function normalizeAccount(me: { id: unknown; username?: string; firstName?: string; lastName?: string }): TelegramUserLoginAccount { + const displayName = [me.firstName, me.lastName].filter(Boolean).join(' ').trim(); + return { + userId: String(me.id ?? ''), + username: me.username || null, + displayName: displayName || null, + }; +} + +/** + * 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. + */ +export class TelegramMultiAccountLoginFlows { + private readonly flows = new Map>(); + private readonly ttlMs: number; + private readonly maxErrors: number; + private readonly now: () => number; + + constructor(private readonly deps: { + credentials(): Promise; + createClient(credentials: TelegramLoginCredentials): C; + onAuthorized: AuthorizedCallback; + now?: () => number; + ttlMs?: number; + maxErrors?: number; + }) { + this.now = deps.now || Date.now; + this.ttlMs = deps.ttlMs ?? 5 * 60_000; + this.maxErrors = deps.maxErrors ?? 3; + } + + /** Backward-compatible alias for the existing phone-login service API. */ + start(owner: string, rawPhone: string) { + return this.startPhone(owner, rawPhone); + } + + async startPhone(owner: string, rawPhone: string): 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', '请输入含国家区号的有效手机号'); + } + await this.removeOwnerFlows(owner); + const credentials = await this.requireCredentials(); + const client = this.deps.createClient(credentials); + try { + await client.connect(); + const sent = await client.sendCode(credentials, phone); + const id = this.newFlowId(); + const expiresAt = this.now() + this.ttlMs; + const flow: PhoneFlow = { + id, + owner, + expiresAt, + errors: 0, + client, + cleanupTimer: this.scheduleCleanup(id, expiresAt), + kind: 'phone', + phone, + phoneCodeHash: sent.phoneCodeHash, + step: 'code', + }; + this.flows.set(id, flow); + return { + flowId: id, + delivery: sent.isCodeViaApp ? 'app' : 'sms', + expiresAt: new Date(expiresAt).toISOString(), + }; + } catch (error) { + await this.closeClient(client); + throw this.publicError(error); + } + } + + async startQr(owner: string): Promise { + await this.removeOwnerFlows(owner); + const credentials = await this.requireCredentials(); + const client = this.deps.createClient(credentials); + const id = this.newFlowId(); + const expiresAt = this.now() + this.ttlMs; + const flow: QrFlow = { + id, + owner, + expiresAt, + errors: 0, + client, + cleanupTimer: this.scheduleCleanup(id, expiresAt), + kind: 'qr', + status: 'pending', + qrData: null, + tokenExpiresAt: null, + version: 0, + passwordHint: null, + account: null, + error: null, + operation: Promise.resolve(), + }; + try { + await client.connect(); + client.setQrLoginTokenHandler(async () => { + try { + await this.advanceQr(flow); + } catch (error) { + await this.failQr(flow, error); + } + }); + this.flows.set(id, flow); + await this.advanceQr(flow); + return this.qrResponse(flow); + } catch (error) { + this.flows.delete(id); + clearTimeout(flow.cleanupTimer); + client.setQrLoginTokenHandler(null); + await this.closeClient(client); + throw this.publicError(error); + } + } + + async refreshQr(owner: string, flowId: string): Promise { + const flow = await this.requireQrFlow(owner, flowId); + if (flow.status !== 'pending') return this.qrResponse(flow); + try { + await this.advanceQr(flow); + } catch (error) { + await this.failQr(flow, error); + throw this.publicError(error); + } + return this.qrResponse(flow); + } + + async getQrStatus(owner: string, flowId: string): Promise { + return this.qrResponse(await this.requireQrFlow(owner, flowId)); + } + + async submitCode(owner: string, flowId: string, rawCode: string): Promise< + { step: 'password_required' } | { step: 'complete'; account: TelegramUserLoginAccount } + > { + const flow = await this.requirePhoneFlow(owner, flowId, 'code'); + const code = String(rawCode || '').replace(/\s/g, ''); + if (!/^\d{5,6}$/.test(code)) { + throw new TelegramUserLoginFlowError('INVALID_CODE', '请输入有效验证码'); + } + try { + const result = await this.requireClient(flow).signInCode(flow.phone, flow.phoneCodeHash, code); + if (result === 'password_needed') { + flow.step = 'password'; + return { step: 'password_required' }; + } + return await this.completePhone(flow); + } catch (error) { + if (telegramErrorName(error).includes('SESSION_PASSWORD_NEEDED')) { + flow.step = 'password'; + return { step: 'password_required' }; + } + await this.recordError(flow); + throw this.publicError(error, 'INVALID_CODE'); + } + } + + async submitPassword(owner: string, flowId: string, password: string): Promise< + { step: 'complete'; account: TelegramUserLoginAccount } + > { + const flow = await this.requireFlow(owner, flowId); + if (!password) throw new TelegramUserLoginFlowError('INVALID_PASSWORD', '请输入两步验证密码'); + if (flow.kind === 'phone' && flow.step !== 'password') { + throw new TelegramUserLoginFlowError('FLOW_NOT_FOUND', '登录步骤无效,请重新开始登录'); + } + if (flow.kind === 'qr' && flow.status !== 'password_required') { + throw new TelegramUserLoginFlowError('FLOW_NOT_FOUND', '登录步骤无效,请重新开始登录'); + } + try { + await this.requireClient(flow).signInPassword(password); + if (flow.kind === 'phone') return await this.completePhone(flow); + const account = await this.authorizeAndClose(flow); + flow.status = 'complete'; + flow.account = account; + flow.qrData = null; + flow.tokenExpiresAt = null; + flow.passwordHint = null; + return { step: 'complete', account }; + } catch (error) { + await this.recordError(flow); + throw this.publicError(error, 'INVALID_PASSWORD'); + } + } + + async cancel(owner: string, flowId: string): Promise<{ success: true }> { + const flow = await this.requireFlow(owner, flowId); + await this.deleteAndClose(flow); + return { success: true }; + } + + private async advanceQr(flow: QrFlow): Promise { + const previous = flow.operation; + let release!: () => void; + flow.operation = new Promise(resolve => { release = resolve; }); + await previous; + try { + if (flow.status !== 'pending' || !this.flows.has(flow.id)) return; + const result = await this.requireClient(flow).exportQrLoginToken(); + if (result.kind === 'token') { + flow.qrData = `tg://login?token=${result.token.toString('base64url')}`; + flow.tokenExpiresAt = Math.min(result.expiresAt, flow.expiresAt); + flow.version += 1; + return; + } + flow.qrData = null; + flow.tokenExpiresAt = null; + if (result.kind === 'password_required') { + flow.status = 'password_required'; + flow.passwordHint = result.hint || null; + return; + } + flow.account = await this.authorizeAndClose(flow); + flow.status = 'complete'; + } finally { + release(); + } + } + + private async failQr(flow: QrFlow, error: unknown): Promise { + if (!this.flows.has(flow.id) || flow.status === 'complete') return; + flow.status = 'error'; + flow.qrData = null; + flow.tokenExpiresAt = null; + flow.error = this.publicError(error).message; + await this.detachAndClose(flow); + } + + private async completePhone(flow: PhoneFlow): Promise<{ step: 'complete'; account: TelegramUserLoginAccount }> { + try { + const account = await this.authorize(flow); + this.flows.delete(flow.id); + clearTimeout(flow.cleanupTimer); + return { step: 'complete', account }; + } finally { + await this.detachAndClose(flow); + } + } + + private async authorizeAndClose(flow: LoginFlow): Promise { + try { + return await this.authorize(flow); + } finally { + await this.detachAndClose(flow); + } + } + + private async authorize(flow: LoginFlow): Promise { + const client = this.requireClient(flow); + const account = normalizeAccount(await client.getMe()); + if (!account.userId) throw new TelegramUserLoginFlowError('TELEGRAM_ERROR', 'Telegram 登录未返回用户身份'); + await this.deps.onAuthorized({ session: client.saveSession(), account }); + return account; + } + + private qrResponse(flow: QrFlow): TelegramQrLoginStatusResponse { + const response: TelegramQrLoginStatusResponse = { + flowId: flow.id, + status: flow.status, + qrData: flow.status === 'pending' ? flow.qrData : null, + expiresAt: new Date(flow.status === 'pending' && flow.tokenExpiresAt ? flow.tokenExpiresAt : flow.expiresAt).toISOString(), + version: flow.version, + }; + if (flow.status === 'password_required') response.passwordHint = flow.passwordHint; + if (flow.status === 'complete' && flow.account) response.account = flow.account; + if (flow.status === 'error' && flow.error) response.error = flow.error; + return response; + } + + private async requireFlow(owner: string, id: string): Promise> { + const flow = this.flows.get(String(id || '')); + if (!flow || flow.owner !== owner) { + throw new TelegramUserLoginFlowError('FLOW_NOT_FOUND', '登录流程不存在,请重新开始登录'); + } + if (flow.expiresAt <= this.now()) { + await this.deleteAndClose(flow); + throw new TelegramUserLoginFlowError('FLOW_EXPIRED', '登录流程已过期,请重新开始登录'); + } + if (flow.errors >= this.maxErrors) { + await this.deleteAndClose(flow); + throw new TelegramUserLoginFlowError('TOO_MANY_ERRORS', '错误次数过多,请重新开始登录'); + } + return flow; + } + + private async requirePhoneFlow(owner: string, id: string, step: PhoneFlow['step']): Promise> { + const flow = await this.requireFlow(owner, id); + if (flow.kind !== 'phone' || flow.step !== step) { + throw new TelegramUserLoginFlowError('FLOW_NOT_FOUND', '登录步骤无效,请重新开始登录'); + } + return flow; + } + + private async requireQrFlow(owner: string, id: string): Promise> { + const flow = await this.requireFlow(owner, id); + if (flow.kind !== 'qr') { + throw new TelegramUserLoginFlowError('FLOW_NOT_FOUND', '二维码登录流程不存在,请重新开始登录'); + } + return flow; + } + + private async recordError(flow: LoginFlow): Promise { + flow.errors += 1; + if (flow.errors >= this.maxErrors) await this.deleteAndClose(flow); + } + + private async removeOwnerFlows(owner: string): Promise { + for (const flow of [...this.flows.values()]) { + if (flow.owner === owner) await this.deleteAndClose(flow); + } + } + + private scheduleCleanup(id: string, expiresAt: number): ReturnType { + const timer = setTimeout(() => { + const flow = this.flows.get(id); + if (flow) void this.deleteAndClose(flow); + }, Math.max(1, expiresAt - this.now())); + timer.unref?.(); + return timer; + } + + private async deleteAndClose(flow: LoginFlow): Promise { + this.flows.delete(flow.id); + clearTimeout(flow.cleanupTimer); + await this.detachAndClose(flow); + } + + private async detachAndClose(flow: LoginFlow): Promise { + const client = flow.client; + flow.client = null; + if (!client) return; + if (flow.kind === 'qr') client.setQrLoginTokenHandler(null); + await this.closeClient(client); + } + + private requireClient(flow: LoginFlow): C { + if (!flow.client) throw new TelegramUserLoginFlowError('FLOW_NOT_FOUND', '登录流程已经结束'); + return flow.client; + } + + private async requireCredentials(): Promise { + const credentials = await this.deps.credentials(); + if (!credentials?.apiId || !credentials.apiHash) { + throw new TelegramUserLoginFlowError('API_NOT_CONFIGURED', '请先配置有效的 Telegram API ID 和 API Hash'); + } + return credentials; + } + + private newFlowId(): string { + return crypto.randomBytes(24).toString('base64url'); + } + + private publicError(error: unknown, fallback: TelegramUserLoginFlowErrorCode = 'TELEGRAM_ERROR'): TelegramUserLoginFlowError { + if (error instanceof TelegramUserLoginFlowError) return error; + const name = telegramErrorName(error); + if (/PHONE_CODE_(INVALID|EXPIRED|EMPTY)/.test(name)) { + return new TelegramUserLoginFlowError('INVALID_CODE', '验证码无效或已过期'); + } + if (/PASSWORD_HASH_INVALID/.test(name)) { + return new TelegramUserLoginFlowError('INVALID_PASSWORD', '两步验证密码错误'); + } + if (/PHONE_NUMBER_INVALID/.test(name)) { + return new TelegramUserLoginFlowError('INVALID_PHONE', '手机号无效'); + } + return new TelegramUserLoginFlowError( + fallback, + fallback === 'INVALID_PASSWORD' + ? '两步验证密码错误' + : fallback === 'INVALID_CODE' + ? '验证码无效或已过期' + : 'Telegram 登录失败,请稍后重试', + ); + } + + private async closeClient(client: C): Promise { + try { await client.disconnect(); } catch { /* best effort */ } + try { await client.destroy(); } catch { /* best effort */ } + } +} diff --git a/backend/src/services/telegramMultiAccountRuntime.ts b/backend/src/services/telegramMultiAccountRuntime.ts new file mode 100644 index 0000000..c78c3ba --- /dev/null +++ b/backend/src/services/telegramMultiAccountRuntime.ts @@ -0,0 +1,101 @@ +import type { TelegramClient } from 'telegram'; +import { telegramAccountRepository } from './telegramAccountRepository.js'; +import { + initializeTelegramUserClientPool, + telegramUserClientPool, + type SelectedTelegramDownloadAccount, + type TelegramAccountSelectionOptions, + isTelegramSessionExpiredError, +} from './telegramUserClientPool.js'; +import { registerTelegramMultiAccountAuthorizedAdapter } from './telegramMultiAccountLogin.js'; +import { createTelegramMultiAccountAuthorizedAdapter } from './telegramMultiAccountLoginAdapter.js'; +import { triggerTelegramAccountAccessSweep } from './telegramAccountAccessSweep.js'; +import { installTelegramAccountAccessSweep } from './telegramAccountAccessSweepAdapter.js'; + +let installed = false; +let initializationPromise: Promise | null = null; + +export async function initializeTelegramMultiAccountRuntime(credentials: { apiId: number; apiHash: string }): Promise { + if (initializationPromise) return initializationPromise; + const run = (async () => { + if (!installed) { + installTelegramAccountAccessSweep({ clientPool: telegramUserClientPool }); + registerTelegramMultiAccountAuthorizedAdapter(createTelegramMultiAccountAuthorizedAdapter({ + repository: telegramAccountRepository, + pool: telegramUserClientPool, + accessSweep: { trigger: options => triggerTelegramAccountAccessSweep(options) }, + })); + installed = true; + } + await initializeTelegramUserClientPool(credentials); + })(); + initializationPromise = run; + try { + await run; + } finally { + if (initializationPromise === run) initializationPromise = null; + } +} + +export async function selectTelegramDownloadAccount( + sourceKey: string, + options: TelegramAccountSelectionOptions | Iterable = {}, +): Promise | null> { + const normalized = typeof (options as Iterable)?.[Symbol.iterator] === 'function' + ? { excludeAccountIds: options as Iterable } + : options as TelegramAccountSelectionOptions; + return telegramUserClientPool.select(sourceKey, { ...normalized, scope: normalized.scope || 'download' }); +} + +export async function markTelegramAccountCooldown(accountId: string, seconds: number, error: string | null = null): Promise { + await telegramAccountRepository.markCooldown(accountId, seconds, error); + const safeSeconds = Number.isFinite(seconds) ? Math.max(1, seconds) : 60; + telegramUserClientPool.updateCooldown(accountId, new Date(Date.now() + safeSeconds * 1000), error); +} + +export async function markTelegramAccountSourceAccess( + accountId: string, + sourceKey: string, + scope: 'download' | 'scan' | 'metadata', + state: 'unknown' | 'allowed' | 'denied', + error: string | null = null, +): Promise { + await telegramAccountRepository.markSourceAccess(accountId, sourceKey, scope, state, error); + telegramUserClientPool.updateSourceAccess(accountId, sourceKey, scope, state); +} + +export async function markTelegramAccountSessionExpired(accountId: string, error: string | null = null): Promise { + await telegramAccountRepository.markSessionExpired(accountId, error); + await telegramUserClientPool.expireAccount(accountId); +} + +export function classifyTelegramDownloadAccountError(error: unknown): 'session_expired' | 'permission_denied' | 'flood_wait' | 'retryable' { + const value = error as { errorMessage?: unknown; message?: unknown; seconds?: unknown; value?: unknown } | null; + const text = `${value?.errorMessage || ''} ${value?.message || ''}`; + if (isTelegramSessionExpiredError(error)) return 'session_expired'; + if (/CHANNEL_PRIVATE|USER_NOT_PARTICIPANT|CHAT_FORBIDDEN|CHAT_ADMIN_REQUIRED|Could not find the input entity|Cannot find any entity|forbidden|privacy/i.test(text)) return 'permission_denied'; + if (Number(value?.seconds || value?.value || text.match(/FLOOD_WAIT_?(\d+)/i)?.[1] || 0) > 0 || /FLOOD|Too many requests/i.test(text)) return 'flood_wait'; + return 'retryable'; +} + +export function telegramFloodWaitSeconds(error: unknown): number { + const value = error as { errorMessage?: unknown; message?: unknown; seconds?: unknown; value?: unknown } | null; + const text = `${value?.errorMessage || ''} ${value?.message || ''}`; + return Math.max(30, Number(value?.seconds || value?.value || text.match(/FLOOD_WAIT_?(\d+)/i)?.[1] || 60)); +} + +export { + deleteTelegramUserAccount, + finishTelegramDownloadAttempt, + getTelegramAccountSourceAccess, + getTelegramUserClientPoolState, + listTelegramAccountSourceAccess, + listTelegramUserAccounts, + probeTelegramAccountSourceAccess, + reloadTelegramUserClientPool, + setTelegramUserAccountEnabled, + startTelegramDownloadAttempt, + upsertTelegramUserAccount, +} from './telegramUserClientPool.js'; + +export { telegramUserClientPool }; diff --git a/backend/src/services/telegramNotificationSettings.test.ts b/backend/src/services/telegramNotificationSettings.test.ts new file mode 100644 index 0000000..90a9132 --- /dev/null +++ b/backend/src/services/telegramNotificationSettings.test.ts @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { DEFAULT_TELEGRAM_NOTIFICATION_PREFERENCES } from './telegramNotificationPreferences.js'; +import { + buildNotificationSettingsButtonRows, + buildNotificationSettingsText, + updateNotificationPreference, +} from './telegramNotificationSettings.js'; + +const preferences = { + ...DEFAULT_TELEGRAM_NOTIFICATION_PREFERENCES, + timezone: 'Asia/Shanghai', + quietStart: '22:00', + quietEnd: '07:00', + successMode: 'digest' as const, +}; + +test('notification settings panel is compact and delegates all changes to buttons', () => { + const text = buildNotificationSettingsText(preferences); + + assert.match(text, /失败:立即 | 成功:合并摘要/); + assert.match(text, /订阅:摘要 | 安静:22:00–07:00/); + assert.match(text, /时区:Asia\/Shanghai/); + assert.match(text, /安全告警始终立即通知/); + assert.match(text, /点击按钮修改/); + assert.doesNotMatch(text, /\/notifications/); + assert.ok(text.length < 150); +}); + +test('notification settings expose direct action buttons with selected state', () => { + const rows = buildNotificationSettingsButtonRows(preferences); + assert.deepEqual(rows[0].map(button => button.data), [ + 'nt_success_immediate', + 'nt_success_digest', + 'nt_success_off', + ]); + assert.match(rows[0][1].text, /^✅/); + assert.match(rows[1][0].text, /^✅/); + assert.match(rows[2][1].text, /^✅/); + assert.equal(rows[3][0].data, 'nt_quiet_22_07'); + assert.equal(rows[4][0].data, 'nt_timezone_asia_shanghai'); +}); + +test('notification preference updates validate values and support disabling quiet hours', () => { + assert.deepEqual(updateNotificationPreference(preferences, ['quiet', 'off']), { + ...preferences, + quietStart: null, + quietEnd: null, + }); + assert.equal(updateNotificationPreference(preferences, ['failure', 'digest']).failureImmediate, false); + assert.throws(() => updateNotificationPreference(preferences, ['success', 'sometimes']), /成功通知可选值/); + assert.throws(() => updateNotificationPreference(preferences, ['quiet', '29:00-07:00']), /安静时段格式/); +}); diff --git a/backend/src/services/telegramNotificationSettings.ts b/backend/src/services/telegramNotificationSettings.ts new file mode 100644 index 0000000..45ae5a3 --- /dev/null +++ b/backend/src/services/telegramNotificationSettings.ts @@ -0,0 +1,124 @@ +import type { + TelegramNotificationPreferences, + TelegramSuccessNotificationMode, +} from './telegramNotificationPreferences.js'; + +export interface NotificationSettingsButton { + text: string; + data: string; +} + +const modeLabels: Record = { + immediate: '立即通知', + digest: '合并摘要', + off: '不通知', +}; + +function selected(active: boolean, label: string): string { + return `${active ? '✅' : '▫️'} ${label}`; +} + +export function buildNotificationSettingsButtonRows( + preferences: TelegramNotificationPreferences, +): NotificationSettingsButton[][] { + const quietEnabled = Boolean(preferences.quietStart && preferences.quietEnd); + return [ + [ + { text: selected(preferences.successMode === 'immediate', '成功·立即'), data: 'nt_success_immediate' }, + { text: selected(preferences.successMode === 'digest', '成功·摘要'), data: 'nt_success_digest' }, + { text: selected(preferences.successMode === 'off', '成功·关闭'), data: 'nt_success_off' }, + ], + [ + { text: selected(preferences.failureImmediate, '失败·立即'), data: 'nt_failure_immediate' }, + { text: selected(!preferences.failureImmediate, '失败·摘要'), data: 'nt_failure_digest' }, + ], + [ + { text: selected(!preferences.subscriptionDigest, '订阅·立即'), data: 'nt_subscription_immediate' }, + { text: selected(preferences.subscriptionDigest, '订阅·摘要'), data: 'nt_subscription_digest' }, + ], + [ + { text: selected(quietEnabled && preferences.quietStart === '22:00' && preferences.quietEnd === '07:00', '安静 22:00–07:00'), data: 'nt_quiet_22_07' }, + { text: selected(!quietEnabled, '关闭安静时段'), data: 'nt_quiet_off' }, + ], + [ + { text: selected(preferences.timezone === 'Asia/Shanghai', '时区·上海'), data: 'nt_timezone_asia_shanghai' }, + { text: selected(preferences.timezone === 'UTC', '时区·UTC'), data: 'nt_timezone_utc' }, + ], + ]; +} + +export function buildNotificationSettingsText(preferences: TelegramNotificationPreferences): string { + const quiet = preferences.quietStart && preferences.quietEnd + ? `${preferences.quietStart}–${preferences.quietEnd}` + : '未开启'; + + return [ + '🔔 **通知设置**', + '', + `失败:${preferences.failureImmediate ? '立即' : '摘要'} | 成功:${modeLabels[preferences.successMode]}`, + `订阅:${preferences.subscriptionDigest ? '摘要' : '立即'} | 安静:${quiet}`, + `时区:${preferences.timezone}`, + '安全告警始终立即通知。', + '', + '👇 点击按钮修改', + ].join('\n'); +} + +export function updateNotificationPreference( + current: TelegramNotificationPreferences, + args: string[], +): Record { + const [rawKey = '', rawValue = ''] = args; + const key = rawKey.toLowerCase(); + const value = rawValue.trim(); + const update: Record = { ...current }; + + if (key === 'timezone') { + if (!value) throw new Error('请提供时区,例如 Asia/Shanghai'); + update.timezone = value; + } else if (key === 'quiet') { + if (['off', 'none', 'disable'].includes(value.toLowerCase())) { + update.quietStart = null; + update.quietEnd = null; + return update; + } + const match = value.match(/^([01]\d|2[0-3]):([0-5]\d)-([01]\d|2[0-3]):([0-5]\d)$/); + if (!match) throw new Error('安静时段格式应为 HH:MM-HH:MM,例如 22:00-07:00;关闭请使用 quiet off'); + update.quietStart = `${match[1]}:${match[2]}`; + update.quietEnd = `${match[3]}:${match[4]}`; + } else if (key === 'success') { + if (!['immediate', 'digest', 'off'].includes(value)) { + throw new Error('成功通知可选值:immediate(立即)、digest(摘要)、off(关闭)'); + } + update.successMode = value; + } else if (key === 'failure') { + if (!['immediate', 'digest'].includes(value)) { + throw new Error('失败通知可选值:immediate(立即)或 digest(摘要)'); + } + update.failureImmediate = value === 'immediate'; + } else if (key === 'subscription') { + if (!['immediate', 'digest'].includes(value)) { + throw new Error('订阅通知可选值:immediate(立即)或 digest(摘要)'); + } + update.subscriptionDigest = value === 'digest'; + } else { + throw new Error('未知设置。请直接发送 /notifications 查看可用选项'); + } + return update; +} + +export function notificationCallbackArgs(data: string): string[] | null { + const fixed: Record = { + nt_failure_immediate: ['failure', 'immediate'], + nt_failure_digest: ['failure', 'digest'], + nt_subscription_immediate: ['subscription', 'immediate'], + nt_subscription_digest: ['subscription', 'digest'], + nt_quiet_22_07: ['quiet', '22:00-07:00'], + nt_quiet_off: ['quiet', 'off'], + nt_timezone_asia_shanghai: ['timezone', 'Asia/Shanghai'], + nt_timezone_utc: ['timezone', 'UTC'], + }; + if (fixed[data]) return fixed[data]; + const success = data.match(/^nt_success_(immediate|digest|off)$/); + return success ? ['success', success[1]] : null; +} diff --git a/backend/src/services/telegramNotificationWiring.test.ts b/backend/src/services/telegramNotificationWiring.test.ts index 345e81c..ed3ad2e 100644 --- a/backend/src/services/telegramNotificationWiring.test.ts +++ b/backend/src/services/telegramNotificationWiring.test.ts @@ -4,13 +4,17 @@ import test from 'node:test'; const schema = fs.readFileSync(new URL('../db/schema.sql', import.meta.url), 'utf8'); const commands = fs.readFileSync(new URL('./telegramCommands.ts', import.meta.url), 'utf8'); +const settings = fs.readFileSync(new URL('./telegramNotificationSettings.ts', import.meta.url), 'utf8'); +const bot = fs.readFileSync(new URL('./telegramBot.ts', import.meta.url), 'utf8'); test('notification preference and digest storage are durable', () => { assert.match(schema, /CREATE TABLE IF NOT EXISTS telegram_notification_preferences/); assert.match(schema, /CREATE TABLE IF NOT EXISTS telegram_notification_digest/); }); -test('Bot exposes notification settings while security delivery remains mandatory', () => { - assert.match(commands, /handleNotifications/); - assert.match(commands, /安全告警仍始终即时/); +test('Bot exposes an interactive notification panel while security delivery remains mandatory', () => { + assert.match(commands, /handleNotificationsCallback/); + assert.match(settings, /安全告警始终立即通知/); + assert.match(settings, /点击按钮修改/); + assert.match(bot, /data\.startsWith\('nt_'\)/); }); diff --git a/backend/src/services/telegramProgressPresentation.test.ts b/backend/src/services/telegramProgressPresentation.test.ts index f6045f8..407e6c5 100644 --- a/backend/src/services/telegramProgressPresentation.test.ts +++ b/backend/src/services/telegramProgressPresentation.test.ts @@ -9,5 +9,6 @@ assert.doesNotMatch(flood, /Google Drive/); assert.doesNotMatch(flood, /task_resume/); const paused = buildLegacyJobProgressPresentation({ jobId: '1234567890abcdef', status: 'paused', downloadStatus: 'paused' } as any); -assert.match(paused, /task_resume/); +assert.match(paused, /任务中心继续或取消/); +assert.doesNotMatch(paused, /task_resume|task_cancel/); console.log('legacy job progress presentation truth ok'); diff --git a/backend/src/services/telegramState.ts b/backend/src/services/telegramState.ts index 70cb9ad..0341a8e 100644 --- a/backend/src/services/telegramState.ts +++ b/backend/src/services/telegramState.ts @@ -26,13 +26,16 @@ export const authenticatedUsers = new Map(); // Password input state export const passwordInputState = new ScopedInteractionMap({ ttlMs: INTERACTION_TTL_MS, maxEntries: INTERACTION_MAX_ENTRIES }); -export async function revokeAuthenticatedUser(userId: number): Promise { - authenticatedUsers.delete(userId); - try { - await query('DELETE FROM telegram_auth WHERE user_id = $1', [userId]); - } catch (error) { - console.error('🤖 撤销 Telegram 授权用户失败:', error); - } +type TelegramRevokeQuery = (sql: string, params?: unknown[]) => Promise>; + +export async function revokeAuthenticatedUser( + userId: number, + dependencies: { query?: TelegramRevokeQuery; cache?: Map } = {}, +): Promise { + const runQuery = dependencies.query || (query as TelegramRevokeQuery); + const cache = dependencies.cache || authenticatedUsers; + await runQuery('DELETE FROM telegram_auth WHERE user_id = $1', [userId]); + cache.delete(userId); } export interface TelegramAllowlistReconciliationResult { diff --git a/backend/src/services/telegramStateRevocation.test.ts b/backend/src/services/telegramStateRevocation.test.ts new file mode 100644 index 0000000..73a8ba7 --- /dev/null +++ b/backend/src/services/telegramStateRevocation.test.ts @@ -0,0 +1,21 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { revokeAuthenticatedUser } from './telegramState.js'; + +test('revoking authentication only clears cache after durable deletion succeeds', async () => { + const cache = new Map([[42, { authenticatedAt: new Date() }]]); + await assert.rejects( + revokeAuthenticatedUser(42, { + cache, + query: async () => { throw new Error('database unavailable'); }, + }), + /database unavailable/, + ); + assert.equal(cache.has(42), true); + + await revokeAuthenticatedUser(42, { + cache, + query: async () => ({ rows: [], rowCount: 1 }), + }); + assert.equal(cache.has(42), false); +}); diff --git a/backend/src/services/telegramStatusPanel.test.ts b/backend/src/services/telegramStatusPanel.test.ts index 144313c..bdb21a9 100644 --- a/backend/src/services/telegramStatusPanel.test.ts +++ b/backend/src/services/telegramStatusPanel.test.ts @@ -15,7 +15,7 @@ const input = { test('status panel presents every operational component and an operation id', () => { const text = buildTelegramStatusPanel(input as any); - for (const expected of ['req-1234', 'Bot:ready', '账号下载器:expired', 's3 / Archive', 'Probe:failed', '临时磁盘', '队列', '订阅', '待对账:2', '需人工:1']) { + for (const expected of ['req-1234', 'Bot:正常', '账号下载器:登录已过期', '当前存储:s3 · Archive', '连接检查:异常', '临时磁盘:可用 1.0 KB / 4.0 KB', '队列', '订阅', '待对账:2', '需人工:1']) { assert.match(text, new RegExp(expected)); } }); diff --git a/backend/src/services/telegramStatusPanel.ts b/backend/src/services/telegramStatusPanel.ts index 0da43c7..57bb8e7 100644 --- a/backend/src/services/telegramStatusPanel.ts +++ b/backend/src/services/telegramStatusPanel.ts @@ -22,22 +22,39 @@ function percent(used: number, total: number): number { return total > 0 ? Math.max(0, Math.min(100, Math.round((used / total) * 100))) : 0; } +function formatBytesCompact(value: number): string { + if (!Number.isFinite(value) || value <= 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1); + const amount = value / (1024 ** index); + return `${amount >= 10 || index === 0 ? amount.toFixed(0) : amount.toFixed(1)} ${units[index]}`; +} + +const STATUS_LABELS: Record = { + ready: '正常', running: '运行中', connected: '已连接', disabled: '未启用', expired: '登录已过期', + failed: '异常', error: '异常', unknown: '未知', available: '正常', healthy: '正常', cooldown: '冷却中', +}; + +function statusLabel(value: string | null | undefined): string { + return STATUS_LABELS[String(value || 'unknown').toLowerCase()] || sanitizeTelegramStatusText(value || '未知'); +} + export function buildTelegramStatusPanel(input: TelegramStatusPanelInput): string { const used = Math.max(0, input.disk.totalBytes - input.disk.freeBytes); return [ '🩺 **TG Vault 诊断状态**', `操作 ID:${sanitizeTelegramStatusText(input.requestId)}`, '', - `Bot:${input.bot.status}${input.bot.degraded ? '(降级)' : ''} · 重连 ${input.bot.reconnectCount || 0}`, - `账号下载器:${input.userClient.status}${input.userClient.username ? ` / @${sanitizeTelegramStatusText(input.userClient.username)}` : ''}`, + `Bot:${statusLabel(input.bot.status)}${input.bot.degraded ? '(降级)' : ''} · 重连 ${input.bot.reconnectCount || 0} 次`, + `账号下载器:${statusLabel(input.userClient.status)}${input.userClient.username ? ` · @${sanitizeTelegramStatusText(input.userClient.username)}` : ''}`, input.userClient.action ? `账号恢复:${sanitizeTelegramStatusText(input.userClient.action)}` : null, '', - `Target:${input.target.provider} / ${sanitizeTelegramStatusText(input.target.accountName)}`, - `Probe:${input.target.probeStatus || 'unknown'}`, - input.target.cooldownUntil ? `Cooldown:${sanitizeTelegramStatusText(input.target.cooldownUntil)}` : 'Cooldown:无', - input.target.probeError ? `Probe 错误:${sanitizeTelegramStatusText(input.target.probeError)}` : null, + `当前存储:${input.target.provider} · ${sanitizeTelegramStatusText(input.target.accountName)}`, + `连接检查:${statusLabel(input.target.probeStatus)}`, + input.target.cooldownUntil ? `恢复时间:${sanitizeTelegramStatusText(input.target.cooldownUntil)}` : null, + input.target.probeError ? `存储错误:${sanitizeTelegramStatusText(input.target.probeError)}` : null, '', - `临时磁盘:${input.disk.freeBytes}/${input.disk.totalBytes} bytes 可用 · 已用 ${percent(used, input.disk.totalBytes)}%`, + `临时磁盘:可用 ${formatBytesCompact(input.disk.freeBytes)} / ${formatBytesCompact(input.disk.totalBytes)} · 已用 ${percent(used, input.disk.totalBytes)}%`, `队列:活跃 ${input.queue.active} · 等待 ${input.queue.pending} · 失败 ${input.queue.failed}${input.queue.paused ? ' · 已暂停' : ''}`, `订阅:启用 ${input.subscriptions.enabled} · 最近扫描 ${sanitizeTelegramStatusText(input.subscriptions.lastScanAt || '无')}`, input.subscriptions.lastError ? `订阅错误:${sanitizeTelegramStatusText(input.subscriptions.lastError)}` : null, diff --git a/backend/src/services/telegramUpload.ts b/backend/src/services/telegramUpload.ts index 25e9109..a79fedf 100644 --- a/backend/src/services/telegramUpload.ts +++ b/backend/src/services/telegramUpload.ts @@ -1,4 +1,5 @@ -import { TelegramClient, Api } from 'telegram'; +import { Api, TelegramClient } from 'telegram'; +import { classifyTelegramDownloadAccountError, finishTelegramDownloadAttempt, markTelegramAccountCooldown, markTelegramAccountSessionExpired, markTelegramAccountSourceAccess, selectTelegramDownloadAccount, startTelegramDownloadAttempt, telegramFloodWaitSeconds } from './telegramMultiAccountRuntime.js'; import { NewMessageEvent } from 'telegram/events/index.js'; import fs from 'fs'; import path from 'path'; @@ -1593,6 +1594,26 @@ export async function waitForStorageCooldownRetry( return signal.aborted ? 'cancelled' : 'success'; } +interface TelegramDownloadAccountFailure extends Error { + telegramAccountFailure: true; + accountId: string; + sourceKey: string; + cause?: unknown; +} + +function createTelegramDownloadAccountFailure(accountId: string, sourceKey: string, error: unknown): TelegramDownloadAccountFailure { + const failure = new Error(error instanceof Error ? error.message : String(error)) as TelegramDownloadAccountFailure; + failure.telegramAccountFailure = true; + failure.accountId = accountId; + failure.sourceKey = sourceKey; + failure.cause = error; + return failure; +} + +function isTelegramDownloadAccountFailure(error: unknown): error is TelegramDownloadAccountFailure { + return Boolean((error as TelegramDownloadAccountFailure | null)?.telegramAccountFailure); +} + // 处理单个文件上传(带重试机制) async function processFileUpload( client: TelegramClient, @@ -1810,6 +1831,9 @@ async function processFileUpload( } catch (error) { if ((error as Error)?.name === 'TelegramDownloadLeaseLostError') throw error; + if (file.persistentRef && classifyTelegramDownloadAccountError(error) !== 'retryable') { + throw createTelegramDownloadAccountFailure(String((client as any).__tgVaultAccountId || ''), String(file.persistentRef.source || ''), error); + } console.error('🤖 文件上传失败:', error); if (isStorageQuotaCooldownError(error)) { await markStorageAccountCooldown(error.storageAccountId || file.storageTarget?.accountId, error.provider, error.reason, error.cooldownUntil, error.message); @@ -2223,11 +2247,29 @@ export async function downloadTelegramChannelRange( storageTarget: StorageTargetSnapshot = storageManager.getActiveTarget(), withItemLease?: (ref: TelegramDownloadMessageRef, operation: () => Promise) => Promise, ): Promise<{ requested: number; found: number; skipped: number; failed: number; successful: number; successfulMessageIds: number[]; failedMessageIds: number[]; skippedMessageIds: number[]; firstId: number; lastId: number }> { - const userClient = getTelegramUserClient(); - if (!userClient || !isTelegramUserClientReady()) { - throw new Error('Telegram 用户账号下载器未就绪:请先配置 TELEGRAM_API_ID / TELEGRAM_API_HASH 并生成 user session'); + const selectedDownloadAccount = await selectTelegramDownloadAccount(String(source)); + if (selectedDownloadAccount) (selectedDownloadAccount.client as any).__tgVaultAccountId = selectedDownloadAccount.accountId; + const userClient: TelegramClient | null = (selectedDownloadAccount?.client as TelegramClient | undefined) || getTelegramUserClient(); + if (!userClient || (!selectedDownloadAccount && !isTelegramUserClientReady())) { + selectedDownloadAccount?.release(); + throw new Error('Telegram 用户账号下载器未就绪:请先配置 Telegram API 并至少登录一个可用账号'); } + let telegramDownloadAttemptId: string | null = null; + if (selectedDownloadAccount) { + if (executionGroupKey) { + await query( + `UPDATE telegram_background_jobs SET assigned_account_id = $2, updated_at = NOW() WHERE id = $1`, + [executionGroupKey, selectedDownloadAccount.accountId], + ); + } + telegramDownloadAttemptId = await startTelegramDownloadAttempt({ + accountId: selectedDownloadAccount.accountId, + sourceKey: String(source), + jobId: executionGroupKey || null, + }); + } + try { const safeLimit = Math.max(1, Math.floor(limit || TG_BATCH_DEFAULT_LIMIT)); const sourceEntity = source.startsWith('@') || /^-?\d+$/.test(source) || /^https?:\/\//i.test(source) ? source @@ -2557,6 +2599,30 @@ export async function downloadTelegramChannelRange( firstId: ids[0], lastId: ids[ids.length - 1], }; + } catch (error) { + if (telegramDownloadAttemptId) { + await finishTelegramDownloadAttempt(telegramDownloadAttemptId, 'failed', error instanceof Error ? error.message : String(error)).catch(() => undefined); + telegramDownloadAttemptId = null; + } + const accountError = isTelegramDownloadAccountFailure(error) ? (error.cause || error) : error; + if (selectedDownloadAccount) { + const classification = classifyTelegramDownloadAccountError(accountError); + const text = accountError instanceof Error ? accountError.message : String(accountError); + if (classification === 'flood_wait') { + await markTelegramAccountCooldown(selectedDownloadAccount.accountId, telegramFloodWaitSeconds(accountError), text); + } else if (classification === 'session_expired') { + await markTelegramAccountSessionExpired(selectedDownloadAccount.accountId, text); + } else if (classification === 'permission_denied') { + await markTelegramAccountSourceAccess(selectedDownloadAccount.accountId, String(source), 'download', 'denied', text); + } + } + throw accountError; + } finally { + if (telegramDownloadAttemptId) { + await finishTelegramDownloadAttempt(telegramDownloadAttemptId, 'succeeded').catch(() => undefined); + } + selectedDownloadAccount?.release(); + } } // Main handler for file uploads diff --git a/backend/src/services/telegramUploadReceipt.test.ts b/backend/src/services/telegramUploadReceipt.test.ts index 2d3dc76..37c8626 100644 --- a/backend/src/services/telegramUploadReceipt.test.ts +++ b/backend/src/services/telegramUploadReceipt.test.ts @@ -20,8 +20,7 @@ test('production single-upload receipt includes the actual indexed file id and f const text = buildUploadSuccess('photo.jpg', 123, 'image', 's3', 'photos', '12345678-1234-4000-8000-123456789abc', 'copied'); assert.match(text, /12345678-1234/); assert.match(text, /已生成副本/); - assert.match(text, /\/find/); - assert.match(text, /\/delete /); + assert.match(text, /可在“搜索和操作文件”中继续管理/); }); test('batch receipt remains one card and exposes retry-failed and failure details', () => { diff --git a/backend/src/services/telegramUserClient.ts b/backend/src/services/telegramUserClient.ts index 06c5d9b..cb6608d 100644 --- a/backend/src/services/telegramUserClient.ts +++ b/backend/src/services/telegramUserClient.ts @@ -6,6 +6,15 @@ import { getEffectiveTelegramBotConfig } from './telegramBotConfig.js'; import { TelegramUserWebLoginFlows, type TelegramUserLoginAccount, type TelegramUserLoginClient } from './telegramUserWebLogin.js'; import { deleteSettings, getSetting, setSetting, setSettings } from '../utils/settings.js'; import { recordTelegramUserClientFailure, recordTelegramUserClientReady } from './telegramUserClientStatus.js'; +import { + deleteTelegramUserAccount, + listTelegramUserAccounts, + reloadTelegramUserClientPool, + setTelegramUserAccountEnabled, + telegramUserClientPool, + upsertTelegramUserAccount, +} from './telegramUserClientPool.js'; +import { initializeTelegramMultiAccountRuntime } from './telegramMultiAccountRuntime.js'; export const TELEGRAM_USER_SESSION_SETTING = 'telegram_user_session'; const TELEGRAM_USER_ENABLED_SETTING = 'telegram_user_download_enabled'; @@ -27,12 +36,13 @@ function getSessionFilePath(): string { return process.env.TELEGRAM_USER_SESSION_FILE || './data/telegram_user_session.txt'; } -async function stopClient(): Promise { +async function stopLegacyClient(): Promise { const current = userClient; userClient = null; - if (!current) return; - try { await current.disconnect(); } catch { /* best effort */ } - try { await current.destroy(); } catch { /* best effort */ } + if (current) { + try { await current.disconnect(); } catch { /* best effort */ } + try { await current.destroy(); } catch { /* best effort */ } + } } export async function migrateLegacyTelegramUserSession(): Promise { @@ -59,13 +69,22 @@ function makeClient(session: string, credentials: { apiId: number; apiHash: stri } export async function initTelegramUserClient(credentials?: { apiId: number; apiHash: string }): Promise { - await stopClient(); + await stopLegacyClient(); const resolved = credentials || await getUserCredentials(); if (!resolved) { recordTelegramUserClientFailure('not_configured', '未配置 Telegram API'); return; } const sessionString = await migrateLegacyTelegramUserSession(); + await initializeTelegramMultiAccountRuntime(resolved); + const pooledClient = telegramUserClientPool.getDefaultClient(); + if (pooledClient) { + const me = await pooledClient.getMe(); + recordTelegramUserClientReady({ userId: String((me as any)?.id || ''), username: (me as any)?.username || null }); + const legacyPath = getSessionFilePath(); + if (fs.existsSync(legacyPath)) fs.rmSync(legacyPath, { force: true }); + return; + } if (!sessionString) { recordTelegramUserClientFailure('missing_session', '尚未登录 Telegram 用户账号'); return; @@ -99,6 +118,14 @@ async function persistAndActivate(session: string, account: TelegramUserLoginAcc [TELEGRAM_USER_ID_SETTING, account.userId], [TELEGRAM_USER_USERNAME_SETTING, account.username || ''], ]); + await upsertTelegramUserAccount({ + telegramUserId: account.userId, + username: account.username, + displayName: account.displayName, + session, + enabled: true, + isLegacy: true, + }); await initTelegramUserClient(); } @@ -150,23 +177,34 @@ export async function getTelegramUserAccountStatus(): Promise<{ export async function disableTelegramUserAccount(): Promise { await setSetting(TELEGRAM_USER_ENABLED_SETTING, 'false'); - await stopClient(); + for (const account of await listTelegramUserAccounts()) { + if (account.isLegacy) await setTelegramUserAccountEnabled(account.id, false); + } + await stopLegacyClient(); + await reloadTelegramUserClientPool(); recordTelegramUserClientFailure('disabled', ''); } export async function enableTelegramUserAccount(): Promise { await setSetting(TELEGRAM_USER_ENABLED_SETTING, 'true'); + for (const account of await listTelegramUserAccounts()) { + if (account.isLegacy) await setTelegramUserAccountEnabled(account.id, true); + } await initTelegramUserClient(); } export async function unlinkTelegramUserAccount(): Promise { - await stopClient(); + await stopLegacyClient(); + for (const account of await listTelegramUserAccounts()) { + if (account.isLegacy) await deleteTelegramUserAccount(account.id); + } await deleteSettings([TELEGRAM_USER_SESSION_SETTING, TELEGRAM_USER_ENABLED_SETTING, TELEGRAM_USER_ID_SETTING, TELEGRAM_USER_USERNAME_SETTING]); + await reloadTelegramUserClientPool(); const legacyPath = getSessionFilePath(); if (fs.existsSync(legacyPath)) fs.rmSync(legacyPath, { force: true }); recordTelegramUserClientFailure('missing_session', '尚未登录 Telegram 用户账号'); } -export function getTelegramUserClient(): TelegramClient | null { return userClient; } -export function isTelegramUserClientReady(): boolean { return Boolean(userClient?.connected); } +export function getTelegramUserClient(): TelegramClient | null { return telegramUserClientPool.getDefaultClient() || userClient; } +export function isTelegramUserClientReady(): boolean { return Boolean(getTelegramUserClient()?.connected); } export function getTelegramUserSessionFilePath(): string { return userSessionFilePath || path.resolve(getSessionFilePath()); } diff --git a/backend/src/services/telegramUserClientPool.test.ts b/backend/src/services/telegramUserClientPool.test.ts new file mode 100644 index 0000000..25475a1 --- /dev/null +++ b/backend/src/services/telegramUserClientPool.test.ts @@ -0,0 +1,89 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { TelegramUserClientPool } from './telegramUserClientPool.js'; + +function account(id: string, overrides: Record = {}) { + return { + id, telegramUserId: id, username: null, displayName: null, session: `session-${id}`, + enabled: true, healthState: 'healthy' as const, cooldownUntil: null, + weight: 1, priority: 0, maxConnections: 2, lastError: null, + ...overrides, + }; +} + +test('pool lazily connects eligible accounts, schedules by source permission and releases connection load', async () => { + const rows = [account('a'), account('b')]; + const access = [{ accountId: 'b', sourceKey: '@news', scope: 'download' as const, accessState: 'allowed' as const, lastError: null, checkedAt: null }]; + const clients = new Map(); + const repo: any = { + migrateLegacySystemSettings: async () => null, + listEnabledAccounts: async () => rows, + listSourceAccess: async () => access, + updateSession: async () => true, + recordHealthy: async () => true, + recordFailure: async () => true, + markSessionExpired: async () => true, + }; + const pool = new TelegramUserClientPool({ + repository: repo, + decryptSession: (value: string) => value, + saveSession: (client: any) => client.saveSession(), + createClient: (session: string, _credentials: { apiId: number; apiHash: string }, accountId: string) => { + const client = { + connected: false, + async connect() { this.connected = true; }, + async checkAuthorization() { return true; }, + async getMe() { return { id: accountId }; }, + saveSession() { return session; }, + async disconnect() { this.connected = false; }, + async destroy() {}, + }; + clients.set(accountId, client); + return client; + }, + }); + await pool.initialize({ apiId: 1, apiHash: 'hash' }); + const selected = await pool.select('@news'); + assert.equal(selected?.accountId, 'b'); + assert.equal(selected?.client, clients.get('b')); + assert.equal(pool.getActiveConnections('b'), 1); + selected?.release(); + selected?.release(); + assert.equal(pool.getActiveConnections('b'), 0); + assert.deepEqual(pool.getRuntimeState().map((state: { accountId: string; connected: boolean; activeConnections: number }) => Object.keys(state).sort()), [ + ['accountId', 'activeConnections', 'connected'], + ['accountId', 'activeConnections', 'connected'], + ]); +}); + +test('pool records authorization expiry per account and continues with healthy accounts', async () => { + const expired = account('expired', { priority: 100 }); + const healthy = account('healthy'); + const expiredMarks: string[] = []; + const repo: any = { + migrateLegacySystemSettings: async () => null, + listEnabledAccounts: async () => [expired, healthy], + listSourceAccess: async () => [], + updateSession: async () => true, + recordHealthy: async () => true, + recordFailure: async () => true, + markSessionExpired: async (id: string) => { expiredMarks.push(id); return true; }, + }; + const pool = new TelegramUserClientPool({ + repository: repo, + decryptSession: (value: string) => value, + saveSession: (client: any) => client.saveSession(), + createClient: (_session: string, _credentials: { apiId: number; apiHash: string }, accountId: string): any => ({ + connected: false, + async connect() { this.connected = true; }, + async checkAuthorization() { return accountId !== 'expired'; }, + async getMe() { return { id: accountId }; }, + saveSession() { return `saved-${accountId}`; }, + async disconnect() { this.connected = false; }, + async destroy() {}, + }), + }); + await pool.initialize({ apiId: 1, apiHash: 'hash' }); + assert.deepEqual(expiredMarks, ['expired']); + assert.equal((await pool.select('@any'))?.accountId, 'healthy'); +}); diff --git a/backend/src/services/telegramUserClientPool.ts b/backend/src/services/telegramUserClientPool.ts new file mode 100644 index 0000000..b35eceb --- /dev/null +++ b/backend/src/services/telegramUserClientPool.ts @@ -0,0 +1,285 @@ +import type { + TelegramAccountRepository, + TelegramAccountSourceAccessRecord, + TelegramAccountSourceScope, + TelegramUserAccountRecord, +} from './telegramAccountRepository.js'; +import { telegramAccountRepository } from './telegramAccountRepository.js'; +import { TelegramClient } from 'telegram'; +import { StringSession } from 'telegram/sessions/index.js'; +import { decryptCredential } from '../utils/credentialCrypto.js'; +import { + selectWeightedLeastConnectedTelegramAccount, + type TelegramAccountSchedulingOptions, + type TelegramSourceAccessState, +} from './telegramAccountScheduler.js'; + +export interface TelegramPoolCredentials { + apiId: number; + apiHash: string; +} + +export interface TelegramPooledClient { + connected?: boolean; + connect(): Promise; + checkAuthorization(): Promise; + getMe(): Promise; + disconnect(): Promise; + destroy(): Promise; +} + +interface PoolRepository extends Pick {} + +interface PoolEntry { + account: TelegramUserAccountRecord; + client: C; + activeConnections: number; +} + +export interface SelectedTelegramDownloadAccount { + accountId: string; + client: C; + release(): void; +} + +export interface TelegramAccountSelectionOptions extends TelegramAccountSchedulingOptions { + scope?: TelegramAccountSourceScope; +} + +export type PublicTelegramUserAccount = Omit & { + connected: boolean; + activeConnections: number; +}; + +function errorName(error: unknown): string { + const value = error as { errorMessage?: unknown; message?: unknown } | null; + return String(value?.errorMessage || value?.message || error || 'Telegram account connection failed'); +} + +export function isTelegramSessionExpiredError(error: unknown): boolean { + return /(AUTH_KEY_UNREGISTERED|SESSION_(REVOKED|EXPIRED)|USER_DEACTIVATED|SESSION_EXPIRED)/i.test(errorName(error)); +} + +export class TelegramUserClientPool { + private readonly entries = new Map>(); + private credentials: TelegramPoolCredentials | null = null; + private initializationTail: Promise = Promise.resolve(); + + constructor(private readonly deps: { + repository: PoolRepository; + decryptSession(value: string): string; + createClient(session: string, credentials: TelegramPoolCredentials, accountId: string): C; + saveSession?(client: C): string; + }) {} + + async initialize(credentials: TelegramPoolCredentials): Promise { + const run = this.initializationTail.then(async () => { + await this.shutdown(); + this.credentials = credentials; + await this.deps.repository.migrateLegacySystemSettings(); + const accounts = await this.deps.repository.listEnabledAccounts(); + for (const account of accounts) await this.connectAccount(account); + }); + this.initializationTail = run.catch(() => undefined); + await run; + } + + async refresh(): Promise { + if (!this.credentials) return; + await this.initialize(this.credentials); + } + + private async connectAccount(account: TelegramUserAccountRecord): Promise { + if (!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); + 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, + activeConnections: 0, + }); + } catch (error) { + if (client) await this.closeClient(client); + const message = errorName(error); + if (isTelegramSessionExpiredError(error)) await this.deps.repository.markSessionExpired(account.id, message); + else await this.deps.repository.recordFailure(account.id, message); + } + } + + async select(sourceKey: string, options: TelegramAccountSelectionOptions = {}): Promise | null> { + const scope = options.scope || 'download'; + const access = await this.deps.repository.listSourceAccess(sourceKey, scope); + const accessByAccount = new Map(access.map(row => [row.accountId, row])); + const selected = selectWeightedLeastConnectedTelegramAccount([...this.entries.values()].map(entry => ({ + accountId: entry.account.id, + enabled: entry.account.enabled, + healthState: entry.account.healthState, + cooldownUntil: entry.account.cooldownUntil, + weight: entry.account.weight, + priority: entry.account.priority, + activeConnections: entry.activeConnections, + maxConnections: entry.account.maxConnections, + sourceAccessState: accessByAccount.get(entry.account.id)?.accessState || 'unknown', + })), options); + if (!selected) return null; + const entry = this.entries.get(selected.accountId); + if (!entry) return null; + entry.activeConnections += 1; + let released = false; + return { + accountId: entry.account.id, + client: entry.client, + release: () => { + if (released) return; + released = true; + entry.activeConnections = Math.max(0, entry.activeConnections - 1); + }, + }; + } + + getDefaultClient(): C | null { + return [...this.entries.values()] + .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; + } + + getActiveConnections(accountId: string): number { + return this.entries.get(accountId)?.activeConnections || 0; + } + + getReadyAccountIds(): string[] { + return [...this.entries.keys()]; + } + + getRuntimeState(): Array<{ accountId: string; connected: boolean; activeConnections: number }> { + return [...this.entries.values()].map(entry => ({ + accountId: entry.account.id, + connected: Boolean(entry.client.connected), + activeConnections: entry.activeConnections, + })); + } + + updateCooldown(accountId: string, cooldownUntil: Date, error: string | null): void { + const entry = this.entries.get(accountId); + if (entry) entry.account = { ...entry.account, cooldownUntil, healthState: 'degraded', lastError: error }; + } + + 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. + } + + async expireAccount(accountId: string): Promise { + const entry = this.entries.get(accountId); + this.entries.delete(accountId); + if (entry) await this.closeClient(entry.client); + } + + async shutdown(): Promise { + const entries = [...this.entries.values()]; + this.entries.clear(); + await Promise.all(entries.map(entry => this.closeClient(entry.client))); + } + + private async closeClient(client: C): Promise { + try { await client.disconnect(); } catch { /* best effort */ } + try { await client.destroy(); } catch { /* best effort */ } + } +} + +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: 120, + }, + ), + saveSession: client => client.session.save() as unknown as string, +}); + +let currentCredentials: TelegramPoolCredentials | null = null; + +export async function initializeTelegramUserClientPool(credentials: TelegramPoolCredentials): Promise { + currentCredentials = credentials; + await telegramUserClientPool.initialize(credentials); +} + +export async function shutdownTelegramUserClientPool(): Promise { await telegramUserClientPool.shutdown(); } +export function getTelegramUserClientPoolState() { return telegramUserClientPool.getRuntimeState(); } +export async function reloadTelegramUserClientPool(): Promise { + if (currentCredentials) await telegramUserClientPool.initialize(currentCredentials); +} + +export async function listTelegramUserAccounts(): Promise { + const accounts = await telegramAccountRepository.listAccounts(); + const ready = new Set(telegramUserClientPool.getReadyAccountIds()); + return accounts.map(({ session: _session, ...account }) => ({ + ...account, connected: ready.has(account.id), activeConnections: telegramUserClientPool.getActiveConnections(account.id), + })); +} + +export async function selectTelegramDownloadAccount(sourceKey: string, options: TelegramAccountSelectionOptions = {}) { + return telegramUserClientPool.select(sourceKey, options); +} + +export async function markTelegramAccountCooldown(accountId: string, seconds: number, error: string | null = null): Promise { + const changed = await telegramAccountRepository.markCooldown(accountId, seconds, error); + if (changed) telegramUserClientPool.updateCooldown(accountId, new Date(Date.now() + Math.max(1, seconds) * 1000), error); + return changed; +} + +export async function markTelegramAccountSourceAccess( + accountId: string, sourceKey: string, scope: TelegramAccountSourceScope, + state: TelegramSourceAccessState, error: string | null = null, +): Promise { + return telegramAccountRepository.markSourceAccess(accountId, sourceKey, scope, state, error); +} + +export async function markTelegramAccountSessionExpired(accountId: string, error: string | null = null): Promise { + const changed = await telegramAccountRepository.markSessionExpired(accountId, error); + if (changed) await telegramUserClientPool.expireAccount(accountId); + return changed; +} + +export async function upsertTelegramUserAccount(input: Parameters[0]): Promise { + const account = await telegramAccountRepository.upsertAccount(input); + await reloadTelegramUserClientPool(); + const { session: _session, ...publicAccount } = account; + return { ...publicAccount, connected: telegramUserClientPool.getReadyAccountIds().includes(account.id), activeConnections: 0 }; +} + +export async function setTelegramUserAccountEnabled(accountId: string, enabled: boolean): Promise { + const changed = await telegramAccountRepository.setEnabled(accountId, enabled); + if (changed) await reloadTelegramUserClientPool(); + return changed; +} + +export async function deleteTelegramUserAccount(accountId: string): Promise { + await telegramUserClientPool.expireAccount(accountId); + return telegramAccountRepository.deleteAccount(accountId); +} + +export const getTelegramAccountSourceAccess = telegramAccountRepository.getSourceAccess.bind(telegramAccountRepository); +export const listTelegramAccountSourceAccess = telegramAccountRepository.listSourceAccess.bind(telegramAccountRepository); +export const probeTelegramAccountSourceAccess = telegramAccountRepository.probeSourceAccess.bind(telegramAccountRepository); +export const startTelegramDownloadAttempt = telegramAccountRepository.startDownloadAttempt.bind(telegramAccountRepository); +export const finishTelegramDownloadAttempt = telegramAccountRepository.finishDownloadAttempt.bind(telegramAccountRepository); diff --git a/backend/src/utils/fileMetadata.ts b/backend/src/utils/fileMetadata.ts new file mode 100644 index 0000000..337f31d --- /dev/null +++ b/backend/src/utils/fileMetadata.ts @@ -0,0 +1,28 @@ +export type FileType = 'image' | 'video' | 'audio' | 'document' | 'other'; + +export function getFileType(mimeType: string | null | undefined): FileType { + const normalized = mimeType?.toLowerCase() || ''; + if (normalized.startsWith('image/')) return 'image'; + if (normalized.startsWith('video/')) return 'video'; + if (normalized.startsWith('audio/')) return 'audio'; + if ( + normalized.startsWith('text/') + || normalized.includes('pdf') + || normalized.includes('document') + || normalized.includes('sheet') + || normalized.includes('presentation') + || normalized.includes('word') + || normalized.includes('excel') + || normalized.includes('powerpoint') + ) return 'document'; + return 'other'; +} + +export function formatBytes(bytes: number, binary = false): string { + if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'; + const base = 1024; + const units = binary ? ['B', 'KiB', 'MiB', 'GiB', 'TiB'] : ['B', 'KB', 'MB', 'GB', 'TB']; + const unit = Math.min(Math.floor(Math.log(bytes) / Math.log(base)), units.length - 1); + const value = bytes / (base ** unit); + return `${Number(value.toFixed(unit === 0 ? 0 : 2))} ${units[unit]}`; +} diff --git a/backend/src/utils/providerMetadata.ts b/backend/src/utils/providerMetadata.ts new file mode 100644 index 0000000..caa7d67 --- /dev/null +++ b/backend/src/utils/providerMetadata.ts @@ -0,0 +1,24 @@ +export interface ProviderMetadata { + id: string; + label: string; + emoji: string; +} + +const PROVIDERS: Record = { + local: { id: 'local', label: '本地存储', emoji: '💾' }, + onedrive: { id: 'onedrive', label: 'OneDrive', emoji: '☁️' }, + google_drive: { id: 'google_drive', label: 'Google Drive', emoji: '☁️' }, + aliyun_oss: { id: 'aliyun_oss', label: '阿里云 OSS', emoji: '☁️' }, + s3: { id: 's3', label: 'S3 存储', emoji: '📦' }, + webdav: { id: 'webdav', label: 'WebDAV', emoji: '🌐' }, + openlist: { id: 'openlist', label: 'OpenList', emoji: '🗂️' }, +}; + +export function getProviderMetadata(providerId: string): ProviderMetadata { + return PROVIDERS[providerId] || { id: providerId, label: providerId, emoji: '📦' }; +} + +export function getProviderDisplayName(providerId: string): string { + const provider = getProviderMetadata(providerId); + return `${provider.emoji} ${provider.label}`; +} diff --git a/backend/src/utils/telegramCommandRegistry.ts b/backend/src/utils/telegramCommandRegistry.ts index dbfce41..ec052c6 100644 --- a/backend/src/utils/telegramCommandRegistry.ts +++ b/backend/src/utils/telegramCommandRegistry.ts @@ -24,8 +24,8 @@ export const BOT_COMMANDS: BotCommandDefinition[] = [ { command: 'find', description: '搜索和操作文件', helpDescription: '按名称、类型、目录、日期或收藏搜索文件并打开操作卡', category: 'files', usage: '[关键词] [type:image|video|audio|document] [folder:目录] [after:YYYY-MM-DD] [before:YYYY-MM-DD] [fav]', menu: true, help: true }, { command: 'tg_sub', description: '管理频道自动同步', helpDescription: '打开频道订阅管理向导', category: 'channels', aliases: ['tg_subscribe'], menu: true, help: true }, { command: 'storage_switch', description: '切换系统默认存储', helpDescription: '切换所有新任务使用的系统默认存储', category: 'settings', aliases: ['switch_storage', 'storage_source'], menu: true, help: true }, - { command: 'target', description: '设置当前聊天存储目标', helpDescription: '设置下一次或当前聊天持续使用的存储账户;不修改系统默认', category: 'files', usage: '[once|session|clear] [local|账户ID]', menu: true, help: true }, - { command: 'ytdlp', description: '下载视频链接', helpDescription: '解析并下载一个视频链接', category: 'files', usage: '', menu: true, help: true }, + { command: 'target', description: '设置当前聊天存储目标', helpDescription: '设置下一次或当前聊天持续使用的存储账户;不修改系统默认', category: 'files', menu: true, help: true }, + { command: 'ytdlp', description: '下载视频链接', helpDescription: '解析并下载一个视频链接', category: 'files', menu: true, help: true }, { command: 'help', description: '查看完整帮助', helpDescription: '显示此帮助', category: 'main', menu: true, help: true, requiresAuth: false }, { command: 'setup_2fa', description: '配置双重验证', helpDescription: '配置双重验证 (TOTP)', category: 'security', aliases: ['setup-2fa'], menu: false, help: true }, { command: 'logout', description: '撤销本设备 Bot 认证', helpDescription: '立即退出并撤销当前 Telegram 用户的 Bot 认证', category: 'security', menu: false, help: true }, @@ -41,7 +41,7 @@ export const BOT_COMMANDS: BotCommandDefinition[] = [ { 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: 'status', description: '系统诊断状态', helpDescription: '查看 Bot、账号下载器、存储、磁盘、队列、订阅和对账状态', category: 'settings', menu: true, help: true }, - { command: 'notifications', description: '通知偏好', helpDescription: '设置成功/失败/订阅摘要、时区和安静时段;安全告警始终即时', category: 'settings', usage: '[timezone|quiet|success|failure|subscription] [值]', menu: true, help: true }, + { command: 'notifications', description: '通知偏好', helpDescription: '设置成功/失败/订阅摘要、时区和安静时段;安全告警始终即时', category: 'settings', menu: true, help: true }, { command: 'duplicate_mode', description: '重复文件处理', helpDescription: '设置重复文件处理策略', category: 'settings', aliases: ['duplicate', 'dup'], help: true }, { command: 'cleanup_settings', description: '自动清理未索引临时文件', helpDescription: '设置自动清理未登记临时文件的开关', category: 'settings', aliases: ['cleanup'], help: true }, { command: 'tg_subs', description: '查看频道订阅', helpDescription: '查看频道订阅列表', category: 'channels', aliases: ['tg_subscriptions'], help: true }, diff --git a/backend/src/utils/telegramHumanFriendlyOutput.test.ts b/backend/src/utils/telegramHumanFriendlyOutput.test.ts new file mode 100644 index 0000000..9a39bfa --- /dev/null +++ b/backend/src/utils/telegramHumanFriendlyOutput.test.ts @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { buildHelp, buildFileList } from './telegramMessages.js'; + +test('Bot help is a compact entry point instead of a command manual', () => { + const text = buildHelp(); + + assert.match(text, /点击下方按钮选择功能/); + assert.doesNotMatch(text, /<[^>]+>|\[[^\]]+\]/); + assert.doesNotMatch(text, /\/target once|\/notifications timezone|\/task_cancel/); + assert.ok(text.length < 240); +}); + +test('recent file list avoids teaching delete and pagination commands', () => { + const text = buildFileList([{ + id: '12345678-1234-4000-8000-123456789abc', + name: 'photo.jpg', + type: 'image', + size: 1024, + folder: 'photos', + created_at: '2026-08-28T00:00:00.000Z', + }], 1); + + assert.match(text, /需要搜索或操作文件,请打开“搜索和操作文件”/); + assert.doesNotMatch(text, /\/delete|\/list 20|/); +}); diff --git a/backend/src/utils/telegramMessages.ts b/backend/src/utils/telegramMessages.ts index 25b2af2..058a573 100644 --- a/backend/src/utils/telegramMessages.ts +++ b/backend/src/utils/telegramMessages.ts @@ -7,21 +7,8 @@ import { Api } from 'telegram'; import { formatBytes, getTypeEmoji } from './telegramUtils.js'; -import { buildBotHelpSections, type BotCommandDefinition } from './telegramCommandRegistry.js'; -// ─── 存储提供商显示名称 ─────────────────────────────────────── - -const PROVIDER_DISPLAY_MAP: Record = { - onedrive: '☁️ OneDrive', - aliyun_oss: '☁️ 阿里云 OSS', - s3: '📦 S3 存储', - webdav: '🌐 WebDAV', - google_drive: '☁️ Google Drive', - local: '💾 本地存储', -}; - -export function getProviderDisplayName(providerName: string): string { - return PROVIDER_DISPLAY_MAP[providerName] || `📦 ${providerName}`; -} +export { getProviderDisplayName } from './providerMetadata.js'; +import { getProviderDisplayName } from './providerMetadata.js'; interface TaskSystemPauseView { kind: 'disk_pressure' | 'storage_cooldown' | 'telegram_flood_wait'; @@ -32,34 +19,32 @@ interface TaskSystemPauseView { } function buildTaskControlLines(taskId?: string, queuePaused = false, pauseReason?: string, systemPause?: TaskSystemPauseView): string[] { - if (!taskId) return [`💡 发送 /tasks 查看实时任务状态`]; + if (!taskId) return []; if (queuePaused) { const systemPaused = Boolean(systemPause); const pausing = !systemPause && /随后暂停|完成当前文件/.test(pauseReason || ''); const recoveryLine = systemPause ? systemPause.autoResume ? systemPause.retryAt - ? `♻️ 预计在 ${systemPause.retryAt} 后自动恢复;无需手动操作` + ? `♻️ 预计在 ${systemPause.retryAt} 后自动恢复` : systemPause.recheckMs - ? `♻️ 系统每 ${Math.max(1, Math.round(systemPause.recheckMs / 1000))} 秒重新检查,条件满足后自动恢复` - : `♻️ 系统会持续检查,条件满足后自动恢复` - : `⚠️ 此状态不会自动恢复,请按原因处理后重试` + ? `♻️ 每 ${Math.max(1, Math.round(systemPause.recheckMs / 1_000))} 秒重新检查,条件满足后自动恢复` + : `♻️ 条件满足后自动恢复` + : `⚠️ 处理原因后可重试` : pausing - ? `▶️ 可点击“继续”撤销暂停请求` - : `▶️ 点击下方“继续”会恢复下载队列`; + ? `点击“继续”可撤销暂停` + : `点击“继续”恢复任务`; return [ - `⏸️ **当前状态:${systemPaused ? '系统保护暂停' : pausing ? '正在暂停' : '用户暂停'}**`, - pauseReason ? `📌 原因:${pauseReason}` : `📌 等待中的下载任务不会继续开始`, + systemPaused + ? `当前状态:系统保护暂停` + : pausing + ? `⏸️ 正在暂停` + : `当前状态:用户暂停`, + pauseReason ? `原因:${pauseReason}` : '', recoveryLine, - `🛑 点击“取消”只会结束这张后台任务卡;不会再误清空其它任务`, - ]; + ].filter(Boolean); } - return [ - `💡 队列控制:按钮只对当前聊天的任务卡有效`, - `⏸ 暂停:完成当前文件后暂停该任务`, - `▶️ 继续:继续该任务;若是用户暂停,也会解除用户暂停状态`, - `🛑 取消:结束当前任务卡并移除按钮,不会误取消其它聊天任务`, - ]; + return ['👇 使用下方按钮管理此任务。']; } export function buildTaskControlButtons(taskId?: string, queuePaused = false, systemPause?: TaskSystemPauseView, queuePausing = false, userPaused = queuePaused && !systemPause, failedCount = 0): Api.ReplyInlineMarkup | undefined { @@ -206,30 +191,18 @@ export function buildStartPrompt(): string { return `👋 **欢迎使用 TG Vault Bot!**\n\n🔐 请使用下方键盘输入密码:`; } -/** /help 帮助文本 */ -function formatRegistryCommand(command: BotCommandDefinition): string { - const usage = command.usage ? ` ${command.usage}` : ''; - return ` /${command.command}${usage} — ${command.helpDescription}`; -} - +/** /help 简洁入口 */ export function buildHelp(): string { - const sections = buildBotHelpSections().flatMap(section => [ - `**${section.title}**`, - ...section.commands.map(formatRegistryCommand), - ``, - ]); return [ - `📖 **TG Vault Bot 帮助**`, - LINE, - ``, - `**📤 文件上传**`, - ` 直接发送或转发文件即可自动上传`, - ` 支持所有类型,最大 2 GB,账号级下载器不受此限制`, - ` 多文件同时发送会自动归为一组`, - ``, - ...sections, - LINE, - `💡 **提示**:转发文件给 Bot 即可开始上传;自动清理临时文件、删除本地实体文件和删除任务历史是三种不同操作。`, + '📖 **使用帮助**', + '', + '📤 发送或转发文件:直接上传', + '🔗 发送视频链接:解析后选择格式', + '📥 任务:查看进度、暂停或取消', + '📁 保存位置:设置目录和存储目标', + '📡 频道:按日期/标签下载或管理订阅', + '', + '👇 点击下方按钮选择功能。', ].join('\n'); } @@ -298,13 +271,19 @@ interface FileListItem { created_at: string; } +function compactTelegramText(value: unknown, maxLength: number): string { + const text = String(value || '').replace(/[\r\n\t]+/g, ' ').replace(/[*_`[\]\\]/g, '').trim(); + return text.length > maxLength ? `${text.slice(0, Math.max(1, maxLength - 1))}…` : text; +} + export function buildFileList(files: FileListItem[], total: number): string { + const visibleFiles = files.slice(0, 12); const lines: string[] = [ - `📋 **最近上传的文件** (${total} 条)`, + `📋 **最近上传的文件**(本页 ${visibleFiles.length} 条)`, LINE, ]; - files.forEach((file, index) => { + visibleFiles.forEach((file, index) => { const typeEmoji = getTypeEmoji( file.type === 'image' ? 'image/' : file.type === 'video' ? 'video/' : @@ -318,20 +297,16 @@ export function buildFileList(files: FileListItem[], total: number): string { minute: '2-digit', }); - let displayName = file.name; - if (displayName.length > 25) { - displayName = displayName.substring(0, 22) + '...'; - } + const displayName = compactTelegramText(file.name, 36); + const folder = compactTelegramText(file.folder, 48); - lines.push(`${index + 1}. ${typeEmoji} **${displayName}**`); - lines.push(` ${size} · ${date}${file.folder ? ` · 📁 ${file.folder}` : ''}`); + lines.push(`${index + 1}. ${typeEmoji} **${displayName || '未命名文件'}**`); + lines.push(` ${size} · ${date}${folder ? ` · 📁 ${folder}` : ''}`); lines.push(` ID: \`${file.id.substring(0, 8)}\``); }); lines.push(''); - lines.push(`💡 删除文件: 复制上方 ID 后发送 /delete `); - lines.push(` 例:/delete ${files[0]?.id?.substring(0, 8) || 'a1b2c3d4'}`); - lines.push(`📄 更多记录: /list 20 或 /list 20 2`); + lines.push('💡 需要搜索或操作文件,请打开“搜索和操作文件”。'); return lines.join('\n'); } @@ -414,7 +389,7 @@ export function buildUploadSuccess( ...(fileId ? [`🆔 ${fileId.slice(0, 13)}`] : []), ...(duplicateOutcome === 'copied' ? ['♻️ 重复处理:已生成副本'] : duplicateOutcome === 'skipped' ? ['⏭️ 重复处理:已跳过'] : []), ``, - ...(fileId ? ['操作:/find 可搜索同目录;复制上方 ID;/delete 可进入删除确认。'] : []), + ...(fileId ? ['👇 可在“搜索和操作文件”中继续管理。'] : []), ].join('\n'); } @@ -439,7 +414,7 @@ export function buildDuplicateSkipped(fileName: string, folder: string | null | ...(folder ? [`📁 ${folder}`] : []), ...(existingId ? [`🆔 已存在: ${existingId.substring(0, 8)}`] : []), ``, - `如需保留副本,请发送 /duplicate_mode 切换为“生成副本”。`, + `如需保留副本,请打开“重复文件处理”并选择“生成副本”。`, ].join('\n'); } diff --git a/backend/src/utils/telegramOutputBounds.test.ts b/backend/src/utils/telegramOutputBounds.test.ts new file mode 100644 index 0000000..a04117f --- /dev/null +++ b/backend/src/utils/telegramOutputBounds.test.ts @@ -0,0 +1,20 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { buildFileList } from './telegramMessages.js'; + +const veryLong = '超长_[名称]*'.repeat(80); +const files = Array.from({ length: 50 }, (_, index) => ({ + id: `00000000-0000-4000-8000-${String(index).padStart(12, '0')}`, + name: `${veryLong}-${index}`, + type: 'image', + size: 1024, + folder: `${veryLong}/目录/${index}`, + created_at: '2026-08-28T00:00:00.000Z', +})); + +test('recent file output stays within Telegram limits with hostile long names and folders', () => { + const text = buildFileList(files, files.length); + assert.ok(text.length < 3900, `length=${text.length}`); + assert.doesNotMatch(text, /\*\*超长_\[名称\]\*/); + assert.match(text, /…/); +}); diff --git a/backend/src/utils/telegramPathSettings.ts b/backend/src/utils/telegramPathSettings.ts index ef6524b..71b4814 100644 --- a/backend/src/utils/telegramPathSettings.ts +++ b/backend/src/utils/telegramPathSettings.ts @@ -323,12 +323,6 @@ export function buildPathSettingsText( '**当前路径状态**', ...buildTelegramPathStateLines(chatId), '', - '**快捷命令**', - '`/p <目录>` — 仅下一次下载使用', - '`/ps <目录>` — 当前会话持续使用', - '`/pc` — 清除下一次/会话目录', - '', - '优先级:下一次目录 > 本会话目录 > 默认自动分类目录。', - '路径示例:设置 `/ps book` 后,文件直接保存到 `book`。', + '👇 点击按钮选择保存位置。', ].join('\n'); } diff --git a/backend/src/utils/telegramUtils.ts b/backend/src/utils/telegramUtils.ts index afa30b0..5dc2928 100644 --- a/backend/src/utils/telegramUtils.ts +++ b/backend/src/utils/telegramUtils.ts @@ -1,13 +1,5 @@ import path from 'path'; - -// Format bytes -export function formatBytes(bytes: number): string { - if (bytes === 0) return '0 B'; - const k = 1024; - const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; -} +export { formatBytes, getFileType } from './fileMetadata.js'; // Get file type emoji export function getTypeEmoji(mimeType: string): string { @@ -31,19 +23,6 @@ export function getTypeEmoji(mimeType: string): string { return '📁'; } -// Determine broad file type -export function getFileType(mimeType: string): string { - if (!mimeType) return 'other'; - if (mimeType.startsWith('image/')) return 'image'; - if (mimeType.startsWith('video/')) return 'video'; - if (mimeType.startsWith('audio/')) return 'audio'; - if (mimeType.includes('pdf') || mimeType.includes('document') || mimeType.includes('text') || - mimeType.includes('word') || mimeType.includes('excel') || mimeType.includes('spreadsheet') || - mimeType.includes('powerpoint') || mimeType.includes('presentation') || - mimeType.includes('markdown') || mimeType.includes('json') || mimeType.includes('xml') || - mimeType.includes('sql') || mimeType.includes('javascript') || mimeType.includes('typescript')) return 'document'; - return 'other'; -} // Get MIME type from filename export function getMimeTypeFromFilename(filename: string): string { diff --git a/frontend/nginx.conf b/frontend/nginx.conf index c86d4e7..3eebf5e 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -15,7 +15,9 @@ server { # 安全头 add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; - add_header X-XSS-Protection "1; mode=block" 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; # [重要] 优先处理 API 和 静态资源代理 (放在最前面保证高优先级) # 使用 ^~ 确保如果匹配到这些路径,就不再检查正则表达式路径 @@ -24,15 +26,12 @@ server { location ^~ /api { proxy_pass http://backend:51947; proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection 'upgrade'; proxy_set_header Host $host; proxy_set_header Range $http_range; proxy_set_header If-Range $http_if_range; proxy_buffering off; proxy_request_buffering off; proxy_read_timeout 300s; - proxy_cache_bypass $http_upgrade; client_max_body_size 2000M; } @@ -52,6 +51,12 @@ server { add_header Cache-Control "public, immutable"; } + # SPA shell must revalidate so deployments are visible immediately. + location = /index.html { + add_header Cache-Control "no-cache, no-store, must-revalidate"; + expires -1; + } + # SPA 路由支持 (放在最后作为兜底) location / { try_files $uri $uri/ /index.html; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f321d7e..86997eb 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "tg-vault", - "version": "2.2.1", + "version": "2.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tg-vault", - "version": "2.2.1", + "version": "2.3.0", "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -18,6 +18,9 @@ }, "devDependencies": { "@eslint/js": "^9.39.1", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.6", + "@types/jsdom": "^21.1.7", "@types/node": "^24.10.1", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -29,6 +32,7 @@ "framer-motion": "^12.37.0", "globals": "^16.5.0", "i18next": "^25.8.18", + "jsdom": "^26.1.0", "lucide-react": "^0.577.0", "postcss": "^8.4.38", "react-i18next": "^16.5.8", @@ -52,6 +56,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -343,6 +368,121 @@ "node": ">=6.9.0" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", @@ -1439,6 +1579,77 @@ "win32" ] }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.6", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.6.tgz", + "integrity": "sha512-Jbs9FpkkIDw8FgSc6kOVsOv8JuuqGAL7J4X1oot77JxAoDlkNn2GRkd0aYRVuQ+pVQAiHWVkE4rX/dkF5fBiCw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1491,6 +1702,18 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsdom": { + "version": "21.1.7", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-21.1.7.tgz", + "integrity": "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/tough-cookie": "*", + "parse5": "^7.0.0" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1528,6 +1751,13 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "dev": true, + "license": "MIT" + }, "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", @@ -1841,6 +2071,16 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -1858,6 +2098,17 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -1909,6 +2160,17 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/autoprefixer": { "version": "10.4.24", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.24.tgz", @@ -2223,6 +2485,20 @@ "node": ">=4" } }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -2230,6 +2506,20 @@ "dev": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2248,6 +2538,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2255,6 +2552,17 @@ "dev": true, "license": "MIT" }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -2269,6 +2577,14 @@ "dev": true, "license": "MIT" }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/electron-to-chromium": { "version": "1.5.286", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", @@ -2276,6 +2592,19 @@ "dev": true, "license": "ISC" }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/esbuild": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", @@ -2793,6 +3122,19 @@ "hermes-estree": "0.25.1" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/html-parse-stringify": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", @@ -2803,6 +3145,34 @@ "void-elements": "3.1.0" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/i18next": { "version": "25.8.18", "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.8.18.tgz", @@ -2844,6 +3214,19 @@ "@babel/runtime": "^7.23.2" } }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2943,6 +3326,13 @@ "node": ">=0.12.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -2990,6 +3380,46 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -3124,6 +3554,17 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -3240,6 +3681,13 @@ "node": ">=0.10.0" } }, + "node_modules/nwsapi": { + "version": "2.2.26", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.26.tgz", + "integrity": "sha512-LPmrA6J31t9ac9294T/WZV6AK5CUcFZ+VXQ7pNNc1+JAjeKthW7vqekOBu6pXzsxD7slwJ7M4XMhamojG3T4oA==", + "dev": true, + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -3323,6 +3771,19 @@ "node": ">=6" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -3563,6 +4024,36 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -3652,6 +4143,14 @@ } } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/react-refresh": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", @@ -3772,6 +4271,13 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -3796,6 +4302,26 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -3907,6 +4433,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwind-merge": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz", @@ -4026,6 +4559,26 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -4039,6 +4592,32 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/ts-api-utils": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", @@ -4316,6 +4895,67 @@ "node": ">=0.10.0" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -4342,6 +4982,45 @@ "node": ">=0.10.0" } }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index c2f54cb..0ce93b1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,13 +1,13 @@ { "name": "tg-vault", "private": true, - "version": "2.2.1", + "version": "2.3.0", "type": "module", "scripts": { "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "test": "tsx --test src/**/*.test.ts src/**/**/*.test.ts", + "test": "tsx --tsconfig tsconfig.app.json --test src/**/*.test.ts src/**/**/*.test.ts src/**/*.test.tsx src/**/**/*.test.tsx", "preview": "vite preview" }, "dependencies": { @@ -21,6 +21,9 @@ }, "devDependencies": { "@eslint/js": "^9.39.1", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.6", + "@types/jsdom": "^21.1.7", "@types/node": "^24.10.1", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -32,6 +35,7 @@ "framer-motion": "^12.37.0", "globals": "^16.5.0", "i18next": "^25.8.18", + "jsdom": "^26.1.0", "lucide-react": "^0.577.0", "postcss": "^8.4.38", "react-i18next": "^16.5.8", diff --git a/frontend/public/logo-160.webp b/frontend/public/logo-160.webp new file mode 100644 index 0000000..4e02b4c Binary files /dev/null and b/frontend/public/logo-160.webp differ diff --git a/frontend/public/logo-80.webp b/frontend/public/logo-80.webp new file mode 100644 index 0000000..eee8de3 Binary files /dev/null and b/frontend/public/logo-80.webp differ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7236eae..abd3f32 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,7 +3,7 @@ import { AppLayout } from "./components/layout/AppLayout"; import { Button } from "./components/ui/Button"; import { FileCard } from "./components/ui/FileCard"; import { FolderCard, type FolderData } from "./components/ui/FolderCard"; -import { Search, RefreshCw, ArrowLeft, ChevronDown, ChevronRight, CheckSquare, Cloud, HardDrive, Database, Package, Network, FolderPlus, Upload } from "lucide-react"; +import { Search, RefreshCw, ArrowLeft, ChevronDown, ChevronRight, CheckSquare, FolderPlus, Upload } from "lucide-react"; import { AnimatePresence, motion } from "framer-motion"; import { BulkActionToolbar } from "./components/ui/BulkActionToolbar"; import { useTranslation } from "react-i18next"; @@ -18,6 +18,7 @@ import { MoveModal } from "./components/ui/MoveModal"; import { Notification, type NotificationType } from "./components/ui/Notification"; import { fileApi, type BatchDeletePreview, type BatchDeleteResult, type ChunkUploadSession, type FileData, type FolderAggregation, type FileQueryOptions, type StorageConfig, type StorageStats as StorageStatsType, type UploadCapabilities } from "./services/api"; import { authService } from "./services/auth"; +import { isUnauthorizedError } from "./services/apiActionError"; import type { QueueItem } from "./components/ui/UploadQueueModal"; import { LatestRequest } from "./services/latestRequest"; import { FileQueryController } from "./services/fileQueryController"; @@ -36,7 +37,11 @@ import type { SettingsSectionId } from "./components/pages/settingsSections"; import { IndeterminateSpinner } from "./components/ui/IndeterminateSpinner"; import { UploadCenter } from "./components/pages/UploadCenter"; import { YtDlpTaskComposer } from "./components/pages/YtDlpTaskComposer"; +import { getProviderMetadata } from "./services/providerMetadata"; +import { errorMessage, isErrorNamed } from "./services/unknownError"; +import { useAuthSession } from "./hooks/useAuthSession"; +const FILE_RENDER_WINDOW_SIZE = 200; const SettingsPage = lazy(() => import("./components/pages/SettingsPage").then(module => ({ default: module.SettingsPage }))); const TasksPage = lazy(() => import("./components/pages/TasksPage").then(module => ({ default: module.TasksPage }))); @@ -61,12 +66,6 @@ function App() { sortConfig: { key: 'date', direction: 'desc' }, })); }, [initialRoute]); - // 认证状态 - const [isAuthenticated, setIsAuthenticated] = useState(false); - const [needsPassword, setNeedsPassword] = useState(true); - const [setupRequired, setSetupRequired] = useState(false); - const [telegramPinRequired, setTelegramPinRequired] = useState(false); - const [authChecking, setAuthChecking] = useState(true); const [files, setFiles] = useState(() => initialFileSnapshot?.files ?? []); const [folderAggregations, setFolderAggregations] = useState(() => initialFileSnapshot?.folders ?? []); @@ -98,6 +97,7 @@ function App() { fileQueryCacheRef.current.invalidate(); }, []); + // 改用队列管理上传状态 const [uploadQueue, setUploadQueue] = useState([]); const [recoveredUploads, setRecoveredUploads] = useState([]); @@ -137,6 +137,29 @@ function App() { const [isUploadQueuePaused, setIsUploadQueuePaused] = useState(false); const [uploadCapabilities, setUploadCapabilities] = useState(null); + const resetAuthenticatedState = useCallback(() => { + uploadManagerRef.current.reset(); + latestFileRequestRef.current.cancel(); + invalidateFileQueryCache(); + setFiles([]); + setFolderAggregations([]); + setUploadQueue([]); + setRecoveredUploads([]); + setResumingSessionIds([]); + setIsUploadQueuePaused(false); + }, [invalidateFileQueryCache]); + const { + isAuthenticated, + needsPassword, + setupRequired, + telegramPinRequired, + authChecking, + login: handleLogin, + setup: handleInitialSetup, + signOut, + markUnauthenticated, + } = useAuthSession(resetAuthenticatedState); + const [storageStats, setStorageStats] = useState(null); const [storageConfig, setStorageConfig] = useState(null); const storageStatisticsSynchronizationRef = useRef(new StorageStatisticsSynchronization()); @@ -156,6 +179,7 @@ function App() { const [currentCategory, setCurrentCategory] = useState(() => initialRoute.kind === 'files' ? initialRoute.category : initialRoute.kind); const [taskAccountId, setTaskAccountId] = useState(() => initialRoute.kind === 'tasks' ? initialRoute.accountId : null); const [viewMode, setViewMode] = useState<"grid" | "list">("grid"); + const [fileRenderWindow, setFileRenderWindow] = useState(0); const [selectedFile, setSelectedFile] = useState(null); const [deletingFile, setDeletingFile] = useState(null); const [pendingBatchDelete, setPendingBatchDelete] = useState<{ fileIds: string[]; folderNames: string[] } | null>(null); @@ -274,10 +298,9 @@ function App() { setRecoveredUploads(sessions); if (openWhenFound && sessions.length > 0) setIsQueueModalOpen(true); return sessions; - } catch (error: any) { - if (error?.message === 'UNAUTHORIZED') { - authService.clearToken(); - setIsAuthenticated(false); + } catch (error: unknown) { + if (isUnauthorizedError(error)) { + authService.invalidateSession(error.status); } else { console.error('加载未完成上传失败:', error); } @@ -312,36 +335,6 @@ function App() { .catch(error => console.error('加载上传能力失败:', error)); }, [isAuthenticated, loadIncompleteUploads]); - // 检查认证状态 - useEffect(() => { - const checkAuth = async () => { - try { - // 检查认证/首次初始化状态 - const authStatus = await authService.getAuthStatus(); - setNeedsPassword(authStatus.passwordRequired); - setSetupRequired(authStatus.setupRequired); - setTelegramPinRequired(authStatus.telegramPinRequired); - - if (authStatus.setupRequired) { - setIsAuthenticated(false); - } else if (!authStatus.passwordRequired) { - // 不需要密码,直接进入(仅兼容历史/开发模式) - setIsAuthenticated(true); - } else if (authService.isAuthenticated()) { - // 已有 token,验证是否有效 - const valid = await authService.verify(); - setIsAuthenticated(valid); - } - } catch (error) { - console.error('检查认证状态失败:', error); - } finally { - setAuthChecking(false); - } - }; - - checkAuth(); - }, []); - const buildFileQueryOptions = useCallback((signal?: AbortSignal): FileQueryOptions => { let type: FileQueryOptions['type']; if (currentCategory === 'media') type = 'media'; @@ -381,15 +374,14 @@ function App() { setFileCursor(page.nextCursor); setHasMoreFiles(page.hasMore); setIsStale(false); - } catch (error: any) { - if (error?.name === 'AbortError') return; + } catch (error: unknown) { + if (isErrorNamed(error, 'AbortError')) return; if (!request.isCurrent()) return; - if (error.message === 'UNAUTHORIZED') { - authService.clearToken(); - setIsAuthenticated(false); + if (isUnauthorizedError(error)) { + authService.invalidateSession(error.status); } else { console.error('加载文件失败:', error); - setQueryError(error.message || '加载文件失败'); + setQueryError(errorMessage(error, '加载文件失败')); setIsStale(hadData); } } finally { @@ -417,14 +409,13 @@ function App() { setHasMoreFiles(page.hasMore); setQueryError(null); setIsStale(false); - } catch (error: any) { - if (error?.name === 'AbortError' || !request.isCurrent()) return; - if (error.message === 'UNAUTHORIZED') { - authService.clearToken(); - setIsAuthenticated(false); + } catch (error: unknown) { + if (isErrorNamed(error, 'AbortError') || !request.isCurrent()) return; + if (isUnauthorizedError(error)) { + authService.invalidateSession(error.status); } else { console.error('加载更多文件失败:', error); - setQueryError(error.message || '加载更多文件失败'); + setQueryError(errorMessage(error) || '加载更多文件失败'); setIsStale(true); } } finally { @@ -441,10 +432,9 @@ function App() { const accepted = request.accept(stats); if (accepted) setStorageStats(stats); else if (expectedAccountId !== undefined) throw new Error('存储统计账户与当前活动账户不一致'); - } catch (error: any) { - if (error.message === 'UNAUTHORIZED') { - authService.clearToken(); - setIsAuthenticated(false); + } catch (error: unknown) { + if (isUnauthorizedError(error)) { + authService.invalidateSession(error.status); } else { console.error('加载存储统计失败:', error); if (expectedAccountId !== undefined) throw error; @@ -510,14 +500,12 @@ function App() { fileQueryCacheRef.current.set(queryKey, snapshot); setIsStale(false); return snapshot; - } catch (error: any) { - if (error?.name !== 'AbortError' && request.isCurrent()) { - if (error.message === 'UNAUTHORIZED') { - authService.clearToken(); - invalidateFileQueryCache(); - setIsAuthenticated(false); + } catch (error: unknown) { + if (!isErrorNamed(error, 'AbortError') && request.isCurrent()) { + if (isUnauthorizedError(error)) { + authService.invalidateSession(error.status); } else { - setQueryError(error.message || '加载文件失败'); + setQueryError(errorMessage(error, '加载文件失败')); setIsStale(hadData); } } @@ -564,38 +552,18 @@ function App() { }, 450); }, [navigateFolder]); - // 登录处理 - const handleLogin = async (password: string) => { - const result = await authService.login(password); - if (result.success && !result.requiresTOTP) { - setIsAuthenticated(true); - } - return result; - }; - - const handleInitialSetup = async (webPassword: string, telegramPin?: string) => { - const result = await authService.setup(webPassword, telegramPin); - if (result.success) { - setSetupRequired(false); - setNeedsPassword(true); - setIsAuthenticated(true); - } - return result; - }; - const handleLogout = useCallback(async () => { uploadManagerRef.current.reset(); - await authService.logout(); + await signOut(); latestFileRequestRef.current.cancel(); invalidateFileQueryCache(); - setIsAuthenticated(false); setFiles([]); setFolderAggregations([]); setUploadQueue([]); setRecoveredUploads([]); setResumingSessionIds([]); setIsUploadQueuePaused(false); - }, [invalidateFileQueryCache]); + }, [invalidateFileQueryCache, signOut]); // 派生上传状态 const isUploading = useMemo(() => { @@ -632,10 +600,9 @@ function App() { type: 'success' }); } - } catch (error: any) { - if (error.message === 'UNAUTHORIZED') { - authService.clearToken(); - setIsAuthenticated(false); + } catch (error: unknown) { + if (isUnauthorizedError(error)) { + authService.invalidateSession(error.status); } else { console.error('切换文件夹收藏状态失败:', error); setNotification({ @@ -679,20 +646,19 @@ function App() { try { await uploadManagerRef.current.enqueue(item.id, createUploadQueueInput(item, folder, targetSnapshot)); setUploadQueue(prev => prev.map(q => q.id === item.id ? { ...q, status: 'completed', progress: 100 } : q)); - } catch (err: any) { - if (err?.name === 'AbortError') { + } catch (err: unknown) { + if (isErrorNamed(err, 'AbortError')) { setUploadQueue(prev => prev.map(q => q.id === item.id ? { ...q, status: 'cancelled', error: '已取消' } : q)); return; } console.error(`File ${item.file.name} upload failed:`, err); - if (err.message === 'UNAUTHORIZED') { - authService.clearToken(); - setIsAuthenticated(false); + if (isUnauthorizedError(err)) { + authService.invalidateSession(err.status); } setUploadQueue(prev => prev.map(q => q.id === item.id ? { ...q, status: 'error', - error: err.message || '上传失败' + error: errorMessage(err, '上传失败') } : q)); } }); @@ -703,7 +669,7 @@ function App() { invalidateFileQueryCache(); await Promise.all([loadFiles(), loadStorageStats(), loadIncompleteUploads(false)]); - } catch (error: any) { + } catch (error: unknown) { console.error('批量上传过程出错:', error); } }; @@ -746,10 +712,10 @@ function App() { setRecoveredUploads(prev => prev.filter(entry => entry.uploadId !== session.uploadId)); invalidateFileQueryCache(); await Promise.all([loadFiles(), loadStorageStats()]); - } catch (error: any) { - const cancelled = error?.name === 'AbortError'; + } catch (error: unknown) { + const cancelled = isErrorNamed(error, 'AbortError'); setUploadQueue(prev => prev.map(entry => entry.id === item.id - ? { ...entry, status: cancelled ? 'cancelled' : 'error', error: cancelled ? '已取消' : (error?.message || '续传失败') } + ? { ...entry, status: cancelled ? 'cancelled' : 'error', error: cancelled ? '已取消' : errorMessage(error, '续传失败') } : entry)); await loadIncompleteUploads(false); } finally { @@ -778,8 +744,8 @@ function App() { message: cancellation === 'cancelled' ? '上传会话已取消' : '上传会话已结束', type: 'success', }); - } catch (error: any) { - setNotification({ show: true, message: error?.message || '取消上传会话失败', type: 'error' }); + } catch (error: unknown) { + setNotification({ show: true, message: errorMessage(error, '取消上传会话失败'), type: 'error' }); await loadIncompleteUploads(false); } }; @@ -821,10 +787,10 @@ function App() { : item)); invalidateFileQueryCache(); await Promise.all([loadFiles(), loadStorageStats()]); - } catch (error: any) { - const cancelled = error?.name === 'AbortError'; + } catch (error: unknown) { + const cancelled = isErrorNamed(error, 'AbortError'); setUploadQueue(prev => prev.map(item => item.id === id - ? { ...item, status: cancelled ? 'cancelled' : 'error', error: cancelled ? '已取消' : (error?.message || '上传失败') } + ? { ...item, status: cancelled ? 'cancelled' : 'error', error: cancelled ? '已取消' : (errorMessage(error, '上传失败')) } : item)); } }; @@ -842,13 +808,12 @@ function App() { setDeletingFile(null); setNotification({ show: true, message: '文件已删除', type: 'success' }); await loadStorageStats(); - } catch (error: any) { - if (error.message === 'UNAUTHORIZED') { - authService.clearToken(); - setIsAuthenticated(false); + } catch (error: unknown) { + if (isUnauthorizedError(error)) { + authService.invalidateSession(error.status); } else { console.error('删除失败:', error); - setNotification({ show: true, message: error.message || '删除失败', type: 'error' }); + setNotification({ show: true, message: errorMessage(error) || '删除失败', type: 'error' }); throw error; } } @@ -881,13 +846,12 @@ function App() { setPendingBatchDelete(null); setBatchDeletePreview(null); setNotification({ show: true, message: result.message || '删除完成', type: 'success' }); - } catch (error: any) { - if (error.message === 'UNAUTHORIZED') { - authService.clearToken(); - setIsAuthenticated(false); + } catch (error: unknown) { + if (isUnauthorizedError(error)) { + authService.invalidateSession(error.status); } else { console.error('批量删除失败:', error); - setNotification({ show: true, message: error.message || '批量删除失败', type: 'error' }); + setNotification({ show: true, message: errorMessage(error) || '批量删除失败', type: 'error' }); throw error; } } finally { @@ -902,12 +866,11 @@ function App() { setBatchDeletePreview(preview); setBatchDeleteResult(null); setPendingBatchDelete({ fileIds, folderNames }); - } catch (error: any) { - if (error.message === 'UNAUTHORIZED') { - authService.clearToken(); - setIsAuthenticated(false); + } catch (error: unknown) { + if (isUnauthorizedError(error)) { + authService.invalidateSession(error.status); } else { - setNotification({ show: true, message: error.message || '获取删除影响范围失败', type: 'error' }); + setNotification({ show: true, message: errorMessage(error) || '获取删除影响范围失败', type: 'error' }); } } }; @@ -932,10 +895,9 @@ function App() { type: 'success' }); } - } catch (error: any) { - if (error.message === 'UNAUTHORIZED') { - authService.clearToken(); - setIsAuthenticated(false); + } catch (error: unknown) { + if (isUnauthorizedError(error)) { + authService.invalidateSession(error.status); } else { console.error('切换收藏状态失败:', error); setNotification({ @@ -956,11 +918,10 @@ function App() { try { const result = await fileApi.createShareLink(fileId, password, expiration); return result.link; - } catch (error: any) { + } catch (error: unknown) { console.error("Share failed:", error); - if (error.message === 'UNAUTHORIZED') { - authService.clearToken(); - setIsAuthenticated(false); + if (isUnauthorizedError(error)) { + authService.invalidateSession(error.status); } throw error; } @@ -986,15 +947,14 @@ function App() { invalidateFileQueryCache(); setRenamingFile(null); await refreshFilesAfterMutation(); - } catch (error: any) { - if (error.message === 'UNAUTHORIZED') { - authService.clearToken(); - setIsAuthenticated(false); + } catch (error: unknown) { + if (isUnauthorizedError(error)) { + authService.invalidateSession(error.status); } else { console.error('重命名失败:', error); setNotification({ show: true, - message: error.message || '重命名失败', + message: errorMessage(error) || '重命名失败', type: 'error' }); } @@ -1013,15 +973,14 @@ function App() { } setRenamingFolder(null); await refreshFilesAfterMutation(); - } catch (error: any) { - if (error.message === 'UNAUTHORIZED') { - authService.clearToken(); - setIsAuthenticated(false); + } catch (error: unknown) { + if (isUnauthorizedError(error)) { + authService.invalidateSession(error.status); } else { console.error('重命名文件夹失败:', error); setNotification({ show: true, - message: error.message || '重命名文件夹失败', + message: errorMessage(error) || '重命名文件夹失败', type: 'error' }); } @@ -1041,11 +1000,11 @@ function App() { }); // 刷新列表 await refreshFilesAfterMutation(); - } catch (error: any) { + } catch (error: unknown) { console.error('创建文件夹失败:', error); setNotification({ show: true, - message: error.message || '创建文件夹失败', + message: errorMessage(error, '创建文件夹失败'), type: 'error' }); throw error; @@ -1148,6 +1107,14 @@ function App() { return looseFiles; }, [currentFolder, filteredFiles, looseFiles]); + useEffect(() => setFileRenderWindow(0), [currentCategory, currentFolder, debouncedSearchQuery, sortConfig]); + const displayedFileSource = currentFolder ? displayFiles : looseFiles; + const renderedFiles = useMemo(() => { + const start = fileRenderWindow * FILE_RENDER_WINDOW_SIZE; + return displayedFileSource.slice(start, start + FILE_RENDER_WINDOW_SIZE); + }, [displayedFileSource, fileRenderWindow]); + const fileRenderWindowCount = Math.max(1, Math.ceil(displayedFileSource.length / FILE_RENDER_WINDOW_SIZE)); + const mediaPreviewFiles = useMemo(() => { const base = currentFolder ? displayFiles : [...folders.flatMap(folder => folder.files.filter(file => file.name !== '.folder')), ...looseFiles]; const seen = new Set(); @@ -1207,11 +1174,11 @@ function App() { type: "success" }); } - } catch (error: any) { + } catch (error: unknown) { console.error("Move file failed:", error); setNotification({ show: true, - message: error.message || t("app.moveFailed") || "移动失败", + message: errorMessage(error) || t("app.moveFailed") || "移动失败", type: "error" }); throw error; @@ -1234,11 +1201,11 @@ function App() { }); await refreshFilesAfterMutation(); } - } catch (error: any) { + } catch (error: unknown) { console.error("Move folder failed:", error); setNotification({ show: true, - message: error.message || t("app.moveFailed") || "移动文件夹失败", + message: errorMessage(error) || t("app.moveFailed") || "移动文件夹失败", type: "error" }); throw error; @@ -1274,7 +1241,7 @@ function App() { }> setIsAuthenticated(false)} + onSignedOut={markUnauthenticated} onOpenTasksForAccount={(accountId) => navigateRoute({ kind: 'tasks', accountId, needsReplace: false })} onStorageConfigChanged={handleStorageConfigChanged} onStorageStatsRefresh={loadStorageStats} @@ -1284,7 +1251,7 @@ function App() { ) : currentCategory === "tasks" ? ( }> - setIsAuthenticated(false)} onOpenUploads={() => setIsQueueModalOpen(true)} initialAccountId={taskAccountId} /> + setIsQueueModalOpen(true)} onShowAllTasks={() => navigateRoute(routeForCategory('tasks'))} initialAccountId={taskAccountId} /> ) : currentCategory === "upload" ? ( 0 && (

- {displayFiles.map((file) => ( + {renderedFiles.map((file) => ( {viewMode === "grid" ? ( {file.type.slice(0, 3)}

{file.name}

{file.date}

{file.size}
- verifyDelete(file) : undefined} onToggleFavorite={() => handleToggleFavorite(file.id)} isFavorite={!!file.is_favorite} /> + void fileApi.downloadFile(file.id, file.name)} onRename={() => setRenamingFile(file)} onMove={() => setMovingFile(file)} onDelete={storageConfig?.capabilities.userDelete !== false ? () => verifyDelete(file) : undefined} onToggleFavorite={() => handleToggleFavorite(file.id)} isFavorite={!!file.is_favorite} /> )} @@ -1668,7 +1634,7 @@ function App() { )}
- {looseFiles.map((file) => ( + {renderedFiles.map((file) => (

{file.date}

• -
- {file.source === 'onedrive' ? : (file.source === 'google_drive' ? : (file.source === 'aliyun_oss' ? : (file.source === 's3' ? : (file.source === 'webdav' ? : ))))} - {file.source === 'onedrive' ? 'OneDrive' : (file.source === 'google_drive' ? 'Google Drive' : (file.source === 'aliyun_oss' ? 'Aliyun OSS' : (file.source === 's3' ? 'S3' : (file.source === 'webdav' ? 'WebDAV' : 'Local'))))} -
+ {(() => { const provider = getProviderMetadata(file.source); const ProviderIcon = provider.icon; return
{provider.label}
; })()}
{file.size}
- verifyDelete(file) : undefined} onToggleFavorite={() => handleToggleFavorite(file.id)} isFavorite={!!file.is_favorite} /> + void fileApi.downloadFile(file.id, file.name)} onRename={() => setRenamingFile(file)} onMove={() => setMovingFile(file)} onDelete={storageConfig?.capabilities.userDelete !== false ? () => verifyDelete(file) : undefined} onToggleFavorite={() => handleToggleFavorite(file.id)} isFavorite={!!file.is_favorite} />
)} @@ -1735,6 +1698,14 @@ function App() { )} + {displayedFileSource.length > FILE_RENDER_WINDOW_SIZE && !loading && ( + + )} + {hasMoreFiles && !loading && (
+ ); }; @@ -120,12 +123,12 @@ export const AppLayout = ({ children, activeCategory, onCategoryChange, storageS }; const categories = [ - { id: "upload", icon: UploadCloud, label: t("sidebar.uploadCenter") }, - { id: "all", icon: Folder, label: t("sidebar.files") }, - { id: "ytdlp", icon: Download, label: "YT-DLP" }, - { id: "favorites", icon: Star, label: t("sidebar.favorites") }, - { id: "tasks", icon: ListChecks, label: t("sidebar.tasks") }, - { id: "settings", icon: Settings, label: t("sidebar.settings") }, + { id: "upload", href: "/", icon: UploadCloud, label: t("sidebar.uploadCenter") }, + { id: "all", href: "/files", icon: Folder, label: t("sidebar.files") }, + { id: "ytdlp", href: "/files/ytdlp", icon: Download, label: "YT-DLP" }, + { id: "favorites", href: "/files/favorites", icon: Star, label: t("sidebar.favorites") }, + { id: "tasks", href: "/tasks", icon: ListChecks, label: t("sidebar.tasks") }, + { id: "settings", href: "/settings/general", icon: Settings, label: t("sidebar.settings") }, ]; const renderSidebarContent = (mobile = false) => { @@ -139,8 +142,9 @@ export const AppLayout = ({ children, activeCategory, onCategoryChange, storageS key={cat.id} icon={cat.icon} label={cat.label} + href={cat.href} isActive={activeCategory === cat.id} - onClick={() => handleTabClick(cat.id)} + onNavigate={() => handleTabClick(cat.id)} collapsed={collapsed} /> ))} @@ -181,7 +185,7 @@ export const AppLayout = ({ children, activeCategory, onCategoryChange, storageS >
- Logo + TG Vault {isSidebarOpen && (
- Logo + TG Vault {t("app.title")}
@@ -202,14 +207,14 @@ export const LoginPage = ({ onLogin, setupRequired = false, telegramPinRequired
setConfirmPassword(e.target.value)} - placeholder="再次输入网页管理员密码" + placeholder={t('login.confirmPasswordPlaceholder')} autoComplete="new-password" className="w-full h-12 px-4 rounded-xl border border-border bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all" disabled={loading} @@ -217,7 +222,7 @@ export const LoginPage = ({ onLogin, setupRequired = false, telegramPinRequired
{telegramPinRequired &&
-

Bot 已配置,PIN 必须正好是 4 位数字,并会加密保存。

+

{t('login.telegramPinHint')}

}
)} @@ -245,11 +250,11 @@ export const LoginPage = ({ onLogin, setupRequired = false, telegramPinRequired className="w-full h-12 mt-6 rounded-xl bg-primary text-primary-foreground font-medium flex items-center justify-center gap-2 hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed" > {loading ? ( - + ) : ( <> - {setupRequired ? '创建管理员并进入' : '登录'} + {setupRequired ? t('login.create') : t('login.signIn')} )} @@ -268,7 +273,7 @@ export const LoginPage = ({ onLogin, setupRequired = false, telegramPinRequired onClick={() => { setStep('password'); setError(''); }} className="mb-4 flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors" > - 返回修改密码 + {t('login.back')} {/* Error Message */} @@ -287,8 +292,8 @@ export const LoginPage = ({ onLogin, setupRequired = false, telegramPinRequired
-

输入身份验证码

-

请输入您身份验证器 App 生成的 6 位数字

+

{t('login.totpHeading')}

+

{t('login.totpHint')}

{loading ? ( - + ) : ( <> - 验证并登录 + {t('login.verify')} )} @@ -328,7 +333,7 @@ export const LoginPage = ({ onLogin, setupRequired = false, telegramPinRequired {/* Footer */}

- {setupRequired ? '生产环境首次访问必须先创建网页密码和 Telegram Bot 4 位密码' : '登录状态将保留 7 天'} + {setupRequired ? t('login.setupFooter') : t('login.sessionFooter')}

diff --git a/frontend/src/components/pages/SettingsPage.tsx b/frontend/src/components/pages/SettingsPage.tsx index 0704cbf..c68ac95 100644 --- a/frontend/src/components/pages/SettingsPage.tsx +++ b/frontend/src/components/pages/SettingsPage.tsx @@ -12,8 +12,10 @@ import { monitorOAuthPopup } from "../../services/oauthPopupFlow"; import { synchronizeStorageConfig } from "../../services/storageConfigSynchronization"; import { authService } from "../../services/auth"; import { SETTINGS_SECTIONS, type SettingsSectionId } from "./settingsSections"; -import { useRuntimeUiLocalization } from "./useRuntimeUiLocalization"; import { IndeterminateSpinner } from "../ui/IndeterminateSpinner"; +import { errorCode, errorMessage } from "../../services/unknownError"; +import { Dialog } from "../ui/Dialog"; +import { TelegramUserAccountsPanel } from "./TelegramUserAccountsPanel"; interface SettingsPageProps { storageStats?: StorageStats | null; @@ -158,15 +160,12 @@ const ActionDialog = ({ state, input, onInput, onCancel, onConfirm }: { onConfirm: () => void; }) => { const danger = state.tone === 'danger'; - return createPortal( -
{ if (event.target === event.currentTarget) onCancel(); }}> + return ( +
@@ -179,14 +178,14 @@ const ActionDialog = ({ state, input, onInput, onCancel, onConfirm }: {
-

{state.message}

+

{state.message}

{state.mode === 'prompt' && ( onInput(event.target.value)} - onKeyDown={event => { if (event.key === 'Enter') onConfirm(); if (event.key === 'Escape') onCancel(); }} + onKeyDown={event => { if (event.key === 'Enter') onConfirm(); }} className="mt-4 h-11 w-full rounded-lg border border-border bg-background px-3 outline-none focus:ring-2 focus:ring-primary/20" /> )} @@ -196,18 +195,15 @@ const ActionDialog = ({ state, input, onInput, onCancel, onConfirm }: {
-
, - document.body, +
); }; export const SettingsPage = ({ storageStats, onSignedOut, onOpenTasksForAccount, onStorageConfigChanged, onStorageStatsRefresh, activeSection, onSectionChange }: SettingsPageProps) => { const { t } = useTranslation(); - const pageRef = useRef(null); const oauthPopupCleanupRef = useRef<(() => void) | null>(null); const oauthPopupRef = useRef(null); - useRuntimeUiLocalization(pageRef); const [actionDialog, setActionDialog] = useState(null); const [actionNotice, setActionNotice] = useState(null); const [actionDialogInput, setActionDialogInput] = useState(''); @@ -325,12 +321,6 @@ export const SettingsPage = ({ storageStats, onSignedOut, onOpenTasksForAccount, const [confirmNewTelegramPin, setConfirmNewTelegramPin] = useState(""); const [isChangingTelegramPin, setIsChangingTelegramPin] = useState(false); const [showTelegramUserDownload, setShowTelegramUserDownload] = useState(false); - const [telegramUserLoginStep, setTelegramUserLoginStep] = useState<'phone' | 'code' | 'password' | 'complete'>('phone'); - const [telegramUserPhone, setTelegramUserPhone] = useState(''); - const [telegramUserCode, setTelegramUserCode] = useState(''); - const [telegramUserPassword, setTelegramUserPassword] = useState(''); - const [telegramUserFlowId, setTelegramUserFlowId] = useState(''); - const [isTelegramUserLoginBusy, setIsTelegramUserLoginBusy] = useState(false); const [telegramAllowedUserIdsInput, setTelegramAllowedUserIdsInput] = useState(""); const [isSavingTelegramAllowedUsers, setIsSavingTelegramAllowedUsers] = useState(false); const [cleanupRetentionDays, setCleanupRetentionDays] = useState(7); @@ -347,8 +337,8 @@ export const SettingsPage = ({ storageStats, onSignedOut, onOpenTasksForAccount, let result: { success: boolean; deletedCount?: number }; try { result = await fileApi.updateAdvancedTaskSetting(patch); - } catch (error: any) { - if (error?.code !== 'CONFIRMATION_REQUIRED') throw error; + } catch (error: unknown) { + if (errorCode(error) !== 'CONFIRMATION_REQUIRED') throw error; if (!(await requestConfirmation('该并发值可能触发 Telegram 限流、断流或账号风控。确认继续吗?', '高并发二次确认'))) return; result = await fileApi.updateAdvancedTaskSetting(patch, true); } @@ -368,8 +358,8 @@ export const SettingsPage = ({ storageStats, onSignedOut, onOpenTasksForAccount, try { const result = await fileApi.cleanupDownloadItems(cleanupRetentionDays); await showNotice(`已删除 ${result.deletedCount} 条已完成下载任务历史。`); - } catch (error: any) { - await showNotice(error.message || '删除下载任务历史失败', '操作失败'); + } catch (error: unknown) { + await showNotice(errorMessage(error) || '删除下载任务历史失败', '操作失败'); } finally { setIsCleaningDownloadItems(false); } @@ -427,8 +417,8 @@ export const SettingsPage = ({ storageStats, onSignedOut, onOpenTasksForAccount, handleCancelTelegramPinChange(); await reloadTelegramBotConfig(); await showNotice(result.message || 'Telegram Bot PIN 已修改'); - } catch (error: any) { - await showNotice(error.message || '修改 Telegram Bot PIN 失败', '修改失败'); + } catch (error: unknown) { + await showNotice(errorMessage(error) || '修改 Telegram Bot PIN 失败', '修改失败'); } finally { setIsChangingTelegramPin(false); } @@ -439,8 +429,8 @@ export const SettingsPage = ({ storageStats, onSignedOut, onOpenTasksForAccount, try { const result = await fileApi.testTelegramBotConfig({ botToken: telegramBotToken, apiId: telegramApiId, apiHash: telegramApiHash }); await showNotice(`连接成功${result.bot.username ? `:@${result.bot.username}` : ''}`); - } catch (error: any) { - await showNotice(error.message || 'Telegram Bot 凭证测试失败', '测试失败'); + } catch (error: unknown) { + await showNotice(errorMessage(error) || 'Telegram Bot 凭证测试失败', '测试失败'); } finally { setIsSavingTelegramBot(false); } }; @@ -456,8 +446,8 @@ export const SettingsPage = ({ storageStats, onSignedOut, onOpenTasksForAccount, clearTelegramBotInputs(); setShowTelegramBotForm(false); await showNotice('Telegram Bot 凭证已安全保存并启用'); - } catch (error: any) { - await showNotice(error.message || '保存 Telegram Bot 配置失败', '保存失败'); + } catch (error: unknown) { + await showNotice(errorMessage(error) || '保存 Telegram Bot 配置失败', '保存失败'); } finally { setIsSavingTelegramBot(false); } }; @@ -473,7 +463,7 @@ export const SettingsPage = ({ storageStats, onSignedOut, onOpenTasksForAccount, setTelegramBotConfig(result.config); setTelegramPin(''); await showNotice('已迁移到网页加密管理;确认运行正常后可从 .env 删除旧凭证'); - } catch (error: any) { await showNotice(error.message || '迁移失败', '迁移失败'); } + } catch (error: unknown) { await showNotice(errorMessage(error) || '迁移失败', '迁移失败'); } finally { setIsSavingTelegramBot(false); } }; @@ -485,7 +475,7 @@ export const SettingsPage = ({ storageStats, onSignedOut, onOpenTasksForAccount, ))) return; setIsSavingTelegramBot(true); try { const result = await fileApi.deleteTelegramBotConfig(); setTelegramBotConfig(result.config); clearTelegramBotInputs(); setShowTelegramBotForm(true); await showNotice('Telegram Bot 配置已删除'); } - catch (error: any) { await showNotice(error.message || '删除失败', '操作失败'); } + catch (error: unknown) { await showNotice(errorMessage(error) || '删除失败', '操作失败'); } finally { setIsSavingTelegramBot(false); } }; @@ -497,8 +487,8 @@ export const SettingsPage = ({ storageStats, onSignedOut, onOpenTasksForAccount, setTelegramAllowedUserIdsInput(result.userIds.join(', ')); await reloadStorageConfig(); await showNotice('Telegram 允许用户列表已保存'); - } catch (error: any) { - await showNotice(error.message || '更新 Telegram 允许用户列表失败', '保存失败'); + } catch (error: unknown) { + await showNotice(errorMessage(error) || '更新 Telegram 允许用户列表失败', '保存失败'); } finally { setIsSavingTelegramAllowedUsers(false); } @@ -543,8 +533,8 @@ export const SettingsPage = ({ storageStats, onSignedOut, onOpenTasksForAccount, await showNotice(status.updateAvailable ? t('updates.found', { version: status.latestVersion }) : t('updates.alreadyLatest', { version: status.currentVersion })); - } catch (error: any) { - await showNotice(error?.message || '检查版本失败', '检查失败'); + } catch (error: unknown) { + await showNotice(errorMessage(error) || '检查版本失败', '检查失败'); } finally { setIsCheckingUpdates(false); } @@ -564,9 +554,9 @@ export const SettingsPage = ({ storageStats, onSignedOut, onOpenTasksForAccount, await fileApi.probeStorageAccount(account.id); await reloadStorageConfig(); setProbeFeedback(previous => ({ accountId: account.id, tone: 'success', message: '连接测试成功', sequence: (previous?.sequence ?? 0) + 1 })); - } catch (error: any) { + } catch (error: unknown) { await reloadStorageConfig().catch(() => undefined); - setProbeFeedback(previous => ({ accountId: account.id, tone: 'error', message: error?.message || '连接测试失败,请稍后重试', sequence: (previous?.sequence ?? 0) + 1 })); + setProbeFeedback(previous => ({ accountId: account.id, tone: 'error', message: errorMessage(error) || '连接测试失败,请稍后重试', sequence: (previous?.sequence ?? 0) + 1 })); } finally { setProbingAccountId(null); } @@ -654,16 +644,16 @@ export const SettingsPage = ({ storageStats, onSignedOut, onOpenTasksForAccount, setIsSaving(true); try { - await fileApi.switchStorageProvider(provider as any, accountId); + await fileApi.switchStorageProvider(provider, accountId); const data = await reloadStorageConfig(); const statisticsFresh = await refreshStorageStats(data); await showNotice(statisticsFresh ? `已成功切换到 ${providerName}` : `已成功切换到 ${providerName},但容量统计刷新失败,请稍后手动刷新`, statisticsFresh ? '操作结果' : '切换完成'); - } catch (error: any) { + } catch (error: unknown) { await reloadStorageConfig().catch(() => undefined); - await showNotice(error.message, '操作失败'); + await showNotice(errorMessage(error), '操作失败'); } finally { setIsSaving(false); } @@ -686,11 +676,11 @@ export const SettingsPage = ({ storageStats, onSignedOut, onOpenTasksForAccount, const result = await fileApi.setUnsafeWebdavEndpointsAllowed(enabled, confirmed); setConfig(previous => previous ? { ...previous, allowUnsafeWebdavEndpoints: result.allowUnsafeWebdavEndpoints } : previous); await showNotice(enabled ? '已允许内网和不安全的 WebDAV 地址' : '已恢复 WebDAV 安全限制'); - } catch (error: any) { - if (error?.code === 'CONFIRMATION_REQUIRED') { + } catch (error: unknown) { + if (errorCode(error) === 'CONFIRMATION_REQUIRED') { await showNotice('服务端要求二次确认,请重新操作。', '未完成确认'); } else { - await showNotice(error?.message || '更新 WebDAV 安全设置失败', '操作失败'); + await showNotice(errorMessage(error) || '更新 WebDAV 安全设置失败', '操作失败'); } } finally { setIsSavingWebdavSecurity(false); @@ -768,9 +758,9 @@ export const SettingsPage = ({ storageStats, onSignedOut, onOpenTasksForAccount, statisticsFresh ? '操作结果' : '授权完成'); }, }); - } catch (error: any) { + } catch (error: unknown) { setIsSaving(false); - await showNotice('发起授权失败: ' + error.message, '授权失败'); + await showNotice('发起授权失败: ' + errorMessage(error), '授权失败'); } }; @@ -806,8 +796,8 @@ export const SettingsPage = ({ storageStats, onSignedOut, onOpenTasksForAccount, ? result.message : `${result.message};但容量统计刷新失败,请稍后手动刷新`, statisticsFresh ? '操作结果' : '删除完成'); - } catch (error: any) { - await showNotice(error.message, '操作失败'); + } catch (error: unknown) { + await showNotice(errorMessage(error), '操作失败'); } }; @@ -882,9 +872,9 @@ export const SettingsPage = ({ storageStats, onSignedOut, onOpenTasksForAccount, statisticsFresh ? '操作结果' : '授权完成'); }, }); - } catch (error: any) { + } catch (error: unknown) { setIsSaving(false); - await showNotice('发起授权失败: ' + error.message, '授权失败'); + await showNotice('发起授权失败: ' + errorMessage(error), '授权失败'); } }; @@ -899,8 +889,8 @@ export const SettingsPage = ({ storageStats, onSignedOut, onOpenTasksForAccount, await reloadStorageConfig(); await showNotice('阿里云 OSS 账户添加成功!'); setShowOSSForm(false); - } catch (error: any) { - await showNotice('添加阿里云 OSS 账户失败: ' + error.message, '添加失败'); + } catch (error: unknown) { + await showNotice('添加阿里云 OSS 账户失败: ' + errorMessage(error), '添加失败'); } finally { setIsSaving(false); } @@ -917,8 +907,8 @@ export const SettingsPage = ({ storageStats, onSignedOut, onOpenTasksForAccount, await reloadStorageConfig(); await showNotice('S3 兼容存储账户添加成功!'); setShowS3Form(false); - } catch (error: any) { - await showNotice('添加 S3 兼容存储账户失败: ' + error.message, '添加失败'); + } catch (error: unknown) { + await showNotice('添加 S3 兼容存储账户失败: ' + errorMessage(error), '添加失败'); } finally { setIsSaving(false); } @@ -935,8 +925,8 @@ export const SettingsPage = ({ storageStats, onSignedOut, onOpenTasksForAccount, await reloadStorageConfig(); await showNotice('WebDAV 存储账户添加成功!'); setShowWebDAVForm(false); - } catch (error: any) { - await showNotice('添加 WebDAV 存储账户失败: ' + error.message, '添加失败'); + } catch (error: unknown) { + await showNotice('添加 WebDAV 存储账户失败: ' + errorMessage(error), '添加失败'); } finally { setIsSaving(false); } @@ -954,8 +944,8 @@ export const SettingsPage = ({ storageStats, onSignedOut, onOpenTasksForAccount, setOpenlistPassword(''); setShowOpenListForm(false); await showNotice(t('settings.openlist.success')); - } catch (error: any) { - await showNotice(t('settings.openlist.failure', { message: error.message }), t('settings.openlist.incomplete')); + } catch (error: unknown) { + await showNotice(t('settings.openlist.failure', { message: errorMessage(error) }), t('settings.openlist.incomplete')); } finally { setIsSaving(false); } @@ -974,8 +964,8 @@ export const SettingsPage = ({ storageStats, onSignedOut, onOpenTasksForAccount, setTwoFAQrCode(data.qrDataUrl); setIs2FAActivated(data.enabled); setShow2FA(true); - } catch (error: any) { - setTwoFAError(error.message); + } catch (error: unknown) { + setTwoFAError(errorMessage(error)); } finally { setIsLoading2FA(false); } @@ -993,8 +983,8 @@ export const SettingsPage = ({ storageStats, onSignedOut, onOpenTasksForAccount, } else { setTwoFAError(result.error || "验证失败"); } - } catch (error: any) { - setTwoFAError(error.message); + } catch (error: unknown) { + setTwoFAError(errorMessage(error)); } finally { setIsActivating2FA(false); } @@ -1013,8 +1003,8 @@ export const SettingsPage = ({ storageStats, onSignedOut, onOpenTasksForAccount, } else { await showNotice(result.error || '禁用失败', '操作失败'); } - } catch (error: any) { - await showNotice(error.message, '操作失败'); + } catch (error: unknown) { + await showNotice(errorMessage(error), '操作失败'); } finally { setIsLoading2FA(false); } @@ -1050,7 +1040,6 @@ export const SettingsPage = ({ storageStats, onSignedOut, onOpenTasksForAccount, return ( {/* Security Section */} - + {/* i18n source: 安全设置 */} +
@@ -1273,7 +1263,8 @@ export const SettingsPage = ({ storageStats, onSignedOut, onOpenTasksForAccount, )} - + {/* i18n source: 网络与存储安全 */} +
@@ -1311,7 +1302,8 @@ export const SettingsPage = ({ storageStats, onSignedOut, onOpenTasksForAccount, } {activeSection === 'maintenance' && <> - + {/* i18n source: 高级任务设置 */} + {advancedTasks ?
void updateAdvancedTask({ telegramDownloadWorkers: Number(event.target.value) })}> @@ -1335,7 +1327,8 @@ export const SettingsPage = ({ storageStats, onSignedOut, onOpenTasksForAccount, } />
:
正在加载高级任务设置…
}
- + {/* i18n source: 数据维护 */} + void updateAdvancedTask({ telegramDownloadHistoryPolicy: event.target.value as AdvancedTaskSettings['telegramDownloadHistoryPolicy'] }).catch((error: any) => showNotice(error.message || '更新下载明细记录失败', '操作失败'))} + onChange={(event) => void updateAdvancedTask({ telegramDownloadHistoryPolicy: event.target.value as AdvancedTaskSettings['telegramDownloadHistoryPolicy'] }).catch((error: any) => showNotice(errorMessage(error) || '更新下载明细记录失败', '操作失败'))} className="h-10 w-full rounded-lg border border-border bg-background px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-primary/40 sm:w-auto sm:min-w-48" aria-label="下载明细记录策略" > @@ -1388,7 +1381,8 @@ export const SettingsPage = ({ storageStats, onSignedOut, onOpenTasksForAccount, {activeSection === 'telegram' && <> {/* Telegram Download Section */} - + {/* i18n source: Telegram Bot 连接 */} +
@@ -1454,7 +1448,7 @@ export const SettingsPage = ({ storageStats, onSignedOut, onOpenTasksForAccount,
- +
@@ -1504,7 +1498,7 @@ export const SettingsPage = ({ storageStats, onSignedOut, onOpenTasksForAccount,
- +
@@ -1522,7 +1516,7 @@ export const SettingsPage = ({ storageStats, onSignedOut, onOpenTasksForAccount, session 未就绪 )}
-

开启后,文件下载优先使用已登录的 Telegram 用户账号

+

开启后,文件下载由已启用的 Telegram 用户账号按权限、健康状态和负载智能调度

- - {( - -
- {!config?.telegramUserClientStatus?.userId &&
-

登录 Telegram 用户账号

-

手机号 → 验证码 → 可选两步验证密码。登录信息只会加密保存在服务端。

- {telegramUserLoginStep === 'phone' &&
setTelegramUserPhone(e.target.value)} placeholder="手机号(含国家区号,如 +86…)" className="min-w-0 flex-1 rounded-lg border border-border bg-background px-3 py-2 text-sm" />
} - {telegramUserLoginStep === 'code' &&
setTelegramUserCode(e.target.value)} placeholder="验证码" className="min-w-0 flex-1 rounded-lg border border-border bg-background px-3 py-2 text-sm" />
} - {telegramUserLoginStep === 'password' &&
setTelegramUserPassword(e.target.value)} placeholder="两步验证密码" className="min-w-0 flex-1 rounded-lg border border-border bg-background px-3 py-2 text-sm" />
} -
} - {config?.telegramUserClientStatus &&
-

状态:{{ ready: '已连接', disabled: '已停用', missing_session: '未绑定', expired: '登录已失效', not_configured: 'API 未配置', error: '连接失败', permission_denied: '无频道权限' }[config.telegramUserClientStatus.status] || config.telegramUserClientStatus.status}

- {config.telegramUserClientStatus.username &&

账号:@{config.telegramUserClientStatus.username}(ID {config.telegramUserClientStatus.userId})

} - {config.telegramUserClientStatus.checkedAt &&

最近检查:{new Date(config.telegramUserClientStatus.checkedAt).toLocaleString()}

} - {config.telegramUserClientStatus.status === 'disabled' &&

已停用,不会执行账号级下载;登录信息仍安全保留。

} - {config.telegramUserClientStatus.status !== 'disabled' && config.telegramUserClientStatus.lastError &&

最近错误:{config.telegramUserClientStatus.lastError}

} - {config.telegramUserClientStatus.status !== 'disabled' && config.telegramUserClientStatus.action &&

下一步:{config.telegramUserClientStatus.action}

} -
} - {config?.telegramUserClientStatus?.userId &&
{config.telegramUserClientStatus?.status !== 'disabled' && }停用会保留已加密保存的登录信息
} - -
-

Telegram API 凭证:{telegramBotConfig?.configured ? '已就绪' : '未配置'}

-

账号级下载器会复用 Telegram Bot 的 API ID 与 API Hash;敏感凭证和 session 均不会回显到浏览器。

-
- -
- - -
- -

- 提示:启用后,只有已登录的用户账号 session 会参与下载;如果 session 未准备好,下载会报错并停止,避免悄悄回退到旧逻辑。 -

-
-
- )} -
+
} {activeSection === 'storage' && <> {/* Storage Configuration Section (New) */} - + {/* i18n source: 存储源设置 */} +

@@ -1757,7 +1700,7 @@ export const SettingsPage = ({ storageStats, onSignedOut, onOpenTasksForAccount,

前往 Google Cloud Console 创建 OAuth 2.0 客户端 ID。 应用类型选择 Web 应用程序,并添加以下已授权的重定向 URI: - {(config as any)?.googleDriveRedirectUri || `${window.location.origin}/api/storage/google-drive/callback`} + {config?.googleDriveRedirectUri || `${window.location.origin}/api/storage/google-drive/callback`}

@@ -1938,7 +1881,7 @@ export const SettingsPage = ({ storageStats, onSignedOut, onOpenTasksForAccount,

前往 Microsoft Entra ID 控制台 并登录。授权账号可与最终存储账号不同。 注册应用时,重定向 URI 请选择 Web,并填写: - {(config as any)?.redirectUri || `${(window as any)._env_?.VITE_API_URL || import.meta.env.VITE_API_URL || window.location.origin}/api/storage/onedrive/callback`} + {config?.redirectUri || `${import.meta.env.VITE_API_URL || window.location.origin}/api/storage/onedrive/callback`}

如果填写客户端密码,请复制 Azure「证书和密码」里新建密码后的值 Value;不要复制“机密 ID/Secret ID”。复制错会导致 Microsoft 返回 AADSTS7000215 Invalid client secret。 @@ -2016,7 +1959,7 @@ export const SettingsPage = ({ storageStats, onSignedOut, onOpenTasksForAccount, {/* Aliyun OSS Configuration Section */} - +

@@ -2202,7 +2145,7 @@ export const SettingsPage = ({ storageStats, onSignedOut, onOpenTasksForAccount, {/* S3 Configuration Section */} - +
@@ -2410,7 +2353,7 @@ export const SettingsPage = ({ storageStats, onSignedOut, onOpenTasksForAccount, {/* WebDAV Configuration Section */} - +
diff --git a/frontend/src/components/pages/TasksPage.tsx b/frontend/src/components/pages/TasksPage.tsx index fc30a4d..03fde92 100644 --- a/frontend/src/components/pages/TasksPage.tsx +++ b/frontend/src/components/pages/TasksPage.tsx @@ -6,49 +6,44 @@ import { import { IndeterminateSpinner } from '../ui/IndeterminateSpinner'; import { Button } from '../ui/Button'; import { fileApi, type TaskDismissalPreview, type UnifiedTask, type UnifiedTaskSource } from '../../services/api'; -import { authService } from '../../services/auth'; +import { isUnauthorizedError } from '../../services/apiActionError'; import { cn } from '../../lib/utils'; +import { formatBytes } from '../../services/formatBytes'; import { useTranslation } from 'react-i18next'; -import { useRuntimeUiLocalization } from './useRuntimeUiLocalization'; import { dismissibleTaskSnapshot, pruneSelectedTaskKeys, scopeTasks, summarizeTaskStatuses, type TaskQuickFilter } from '../../services/taskQuickFilters'; import { createSerialPoller } from '../../services/serialPoller'; +import { Dialog } from '../ui/Dialog'; +import { errorMessage } from '../../services/unknownError'; -interface TasksPageProps { onUnauthorized?: () => void; onOpenUploads?: () => void; initialAccountId?: string | null; } +interface TasksPageProps { onUnauthorized?: () => void; onOpenUploads?: () => void; onShowAllTasks?: () => void; initialAccountId?: string | null; } interface TaskNotice { message: string; sequence: number; } -const SOURCE_OPTIONS: Array<{ value: '' | UnifiedTaskSource; label: string }> = [ - { value: '', label: '全部来源' }, { value: 'web_upload', label: 'Web 上传' }, - { value: 'telegram_bot', label: 'Telegram 文件' }, { value: 'telegram_channel', label: '频道下载' }, - { value: 'telegram_target', label: 'Telegram 会话目标' }, - { value: 'ytdlp', label: 'yt-dlp' }, { value: 'subscription', label: '频道订阅' }, +// i18n source anchors: 任务中心、全部来源、全部状态、没有符合条件的任务、确认取消任务、选择任务、全选可删除、清理终态记录、删除记录、不会删除任何文件。 + +const SOURCE_OPTIONS: Array<{ value: '' | UnifiedTaskSource; labelKey: string }> = [ + { value: '', labelKey: 'tasks.sources.all' }, { value: 'web_upload', labelKey: 'tasks.sources.webUpload' }, + { value: 'telegram_bot', labelKey: 'tasks.sources.telegramFile' }, { value: 'telegram_channel', labelKey: 'tasks.sources.channelDownload' }, + { value: 'telegram_target', labelKey: 'tasks.sources.telegramTarget' }, + { value: 'ytdlp', labelKey: 'tasks.sources.ytdlp' }, { value: 'subscription', labelKey: 'tasks.sources.subscription' }, ]; const STATUS_OPTIONS = [ - { value: '', label: '全部状态' }, { value: 'pending', label: '等待中' }, - { value: 'running', label: '运行中' }, { value: 'paused', label: '已暂停' }, - { value: 'waiting', label: '等待恢复' }, { value: 'failed', label: '失败' }, - { value: 'interrupted', label: '已中断' }, { value: 'retry_required', label: '需要重试' }, - { value: 'completed', label: '已完成' }, { value: 'cancelled', label: '已取消' }, - { value: 'scheduled', label: '已计划' }, { value: 'disabled', label: '已停用' }, + { 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: 'interrupted', labelKey: 'tasks.statuses.interrupted' }, { value: 'retry_required', labelKey: 'tasks.statuses.retryRequired' }, + { value: 'completed', labelKey: 'tasks.statuses.completed' }, { value: 'cancelled', labelKey: 'tasks.statuses.cancelled' }, + { value: 'scheduled', labelKey: 'tasks.statuses.scheduled' }, { value: 'disabled', labelKey: 'tasks.statuses.disabled' }, ]; -const SOURCE_LABELS: Record = Object.fromEntries(SOURCE_OPTIONS.filter(o => o.value).map(o => [o.value, o.label])); -const STATUS_LABELS: Record = Object.fromEntries(STATUS_OPTIONS.filter(o => o.value).map(o => [o.value, o.label])); +const SOURCE_LABELS: Record = Object.fromEntries(SOURCE_OPTIONS.filter(o => o.value).map(o => [o.value, o.labelKey])); +const STATUS_LABELS: Record = Object.fromEntries(STATUS_OPTIONS.filter(o => o.value).map(o => [o.value, o.labelKey])); const STAGE_LABELS: Record = { - waiting: '排队等待', queued: '排队等待', scanning: '扫描消息', downloading: '下载源文件', - uploading: '上传到存储', processing: '服务器处理中', awaiting_file: '等待重新选择原文件', - resumable: '上传会话可续传', waiting_for_next_scan: '等待下次扫描', waiting_for_next_task: '等待后续任务', completed: '处理完成', - failed: '处理失败', cancelled: '已取消', interrupted: '服务重启时中断', - retry_required: '需要人工重试', disabled: '已停用', + waiting: 'tasks.stages.queued', queued: 'tasks.stages.queued', scanning: 'tasks.stages.scanning', downloading: 'tasks.stages.downloading', + 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', + retry_required: 'tasks.stages.retryRequired', disabled: 'tasks.statuses.disabled', }; -function formatBytes(bytes: number): string { - if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'; - const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']; - const unit = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); - return `${(bytes / (1024 ** unit)).toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`; -} -function taskTarget(task: UnifiedTask): string { - return `${task.target.accountName || task.target.provider || '未记录存储'} / ${task.target.folder || '根目录'}`; -} function taskKey(task: Pick): string { return `${task.sourceType}:${task.id}`; } function statusTone(status: string): string { if (status === 'completed') return 'bg-emerald-50 text-emerald-700 border-emerald-200'; @@ -57,20 +52,23 @@ function statusTone(status: string): string { if (['running', 'pending'].includes(status)) return 'bg-blue-50 text-blue-700 border-blue-200'; return 'bg-amber-50 text-amber-700 border-amber-200'; } -function StatusIcon({ status }: { status: string }) { +function StatusIcon({ status, runningLabel }: { status: string; runningLabel: string }) { if (status === 'completed') return ; if (['failed', 'interrupted', 'retry_required'].includes(status)) return ; if (['cancelled', 'disabled'].includes(status)) return ; - if (status === 'running') return ; + if (status === 'running') return ; return ; } -export const TasksPage = ({ onUnauthorized, onOpenUploads, initialAccountId = null }: TasksPageProps) => { - const { i18n } = useTranslation(); +export const TasksPage = ({ onUnauthorized, onOpenUploads, onShowAllTasks, initialAccountId }: TasksPageProps) => { + const { t, i18n } = useTranslation(); const locale = i18n.resolvedLanguage?.startsWith('en') ? 'en-US' : 'zh-CN'; - const pageRef = useRef(null); - useRuntimeUiLocalization(pageRef); + const taskTarget = (task: UnifiedTask): string => t('tasks.target.path', { + storage: task.target.accountName || task.target.provider || t('tasks.target.unknownStorage'), + folder: task.target.folder || t('tasks.target.root'), + }); const [tasks, setTasks] = useState([]); + const tasksRef = useRef([]); const [source, setSource] = useState(''); const [status, setStatus] = useState(''); const [quickFilter, setQuickFilter] = useState('all'); @@ -102,12 +100,13 @@ export const TasksPage = ({ onUnauthorized, onOpenUploads, initialAccountId = nu const relevantTasks = initialAccountId ? result.tasks.filter(task => task.target.accountId === initialAccountId) : result.tasks; + tasksRef.current = relevantTasks; setTasks(relevantTasks); setError(null); setSelected(previous => pruneSelectedTaskKeys(previous, relevantTasks, scopeFilters, taskKey)); - } catch (loadError: any) { + } catch (loadError: unknown) { if (generation !== requestGeneration.current) return; - if (loadError?.message === 'UNAUTHORIZED') { authService.clearToken(); onUnauthorized?.(); return; } - setError(loadError?.message || '获取任务列表失败'); + if (isUnauthorizedError(loadError)) { onUnauthorized?.(); return; } + setError(errorMessage(loadError, t('tasks.errors.load'))); } finally { if (generation === requestGeneration.current) { setLoading(false); setRefreshing(false); } } @@ -115,6 +114,10 @@ export const TasksPage = ({ onUnauthorized, onOpenUploads, initialAccountId = nu useEffect(() => { let first = true; + const nextDelayMs = () => { + if (document.visibilityState !== 'visible') return 60_000; + return tasksRef.current.some(task => ['pending', 'running', 'paused', 'waiting', 'interrupted'].includes(task.status)) ? 5_000 : 20_000; + }; const poller = createSerialPoller({ run: async () => { await loadTasks(!first); @@ -123,6 +126,7 @@ export const TasksPage = ({ onUnauthorized, onOpenUploads, initialAccountId = nu schedule: (callback, delay) => window.setTimeout(callback, delay), cancel: handle => window.clearTimeout(handle as number), delayMs: 5_000, + nextDelayMs, }); poller.start(); return () => { @@ -142,10 +146,10 @@ export const TasksPage = ({ onUnauthorized, onOpenUploads, initialAccountId = nu const dismissibleTasks = useMemo(() => dismissibleTaskSnapshot(tasks, scopeFilters), [scopeFilters, tasks]); const taskActionLabel = (task: UnifiedTask, action: 'cancel' | 'retry'): string => { - if (action === 'retry') return task.sourceType === 'web_upload' ? '续传' : '重试'; - if (task.sourceType === 'subscription') return '改为跟随默认'; - if (task.sourceType === 'telegram_target') return '清除目标'; - return '取消'; + if (action === 'retry') return task.sourceType === 'web_upload' ? t('tasks.actions.resume') : t('tasks.actions.retry'); + if (task.sourceType === 'subscription') return t('tasks.actions.followDefault'); + if (task.sourceType === 'telegram_target') return t('tasks.actions.clearTarget'); + return t('tasks.actions.cancel'); }; const requestAction = (task: UnifiedTask, action: 'cancel' | 'retry') => { if (task.sourceType === 'web_upload' && action === 'retry') { onOpenUploads?.(); return; } @@ -157,15 +161,15 @@ export const TasksPage = ({ onUnauthorized, onOpenUploads, initialAccountId = nu try { await fileApi.controlTask(pendingAction.task.sourceType, pendingAction.task.id, pendingAction.action); const cancelledNotice = pendingAction.task.sourceType === 'subscription' - ? '频道订阅已改为跟随系统默认存储' + ? t('tasks.notices.subscriptionDefault') : pendingAction.task.sourceType === 'telegram_target' - ? 'Telegram 会话目标已清除' - : '任务已取消'; - showNotice(pendingAction.action === 'cancel' ? cancelledNotice : '任务已重新提交'); + ? t('tasks.notices.targetCleared') + : t('tasks.notices.cancelled'); + showNotice(pendingAction.action === 'cancel' ? cancelledNotice : t('tasks.notices.resubmitted')); setPendingAction(null); await loadTasks(true); - } catch (actionError: any) { - if (actionError?.message === 'UNAUTHORIZED') { authService.clearToken(); onUnauthorized?.(); } - else { setError(actionError?.message || '任务操作失败'); setPendingAction(null); } + } catch (actionError: unknown) { + if (isUnauthorizedError(actionError)) { onUnauthorized?.(); } + else { setError(errorMessage(actionError, t('tasks.errors.action'))); setPendingAction(null); } } finally { setActing(false); } }; @@ -176,9 +180,9 @@ export const TasksPage = ({ onUnauthorized, onOpenUploads, initialAccountId = nu tasks: input.tasks.map(task => ({ sourceType: task.sourceType, id: task.id })), }); setDismissalPreview(preview); - } catch (dismissError: any) { - if (dismissError?.message === 'UNAUTHORIZED') { authService.clearToken(); onUnauthorized?.(); } - else setError(dismissError?.message || '无法创建删除预览'); + } catch (dismissError: unknown) { + if (isUnauthorizedError(dismissError)) { onUnauthorized?.(); } + else setError(errorMessage(dismissError, t('tasks.errors.preview'))); } finally { setActing(false); } }; const confirmDismissal = async () => { @@ -186,11 +190,13 @@ export const TasksPage = ({ onUnauthorized, onOpenUploads, initialAccountId = nu setActing(true); try { const result = await fileApi.confirmTaskDismissal(dismissalPreview); - showNotice(result.failed.length ? `已删除 ${result.dismissed.length} 条,${result.failed.length} 条因状态变化未删除` : `已从任务中心删除 ${result.dismissed.length} 条记录`); + showNotice(result.failed.length + ? t('tasks.notices.dismissPartial', { dismissed: result.dismissed.length, failed: result.failed.length }) + : t('tasks.notices.dismissed', { count: result.dismissed.length })); setDismissalPreview(null); setSelected([]); setSelectionMode(false); await loadTasks(true); - } catch (dismissError: any) { - if (dismissError?.message === 'UNAUTHORIZED') { authService.clearToken(); onUnauthorized?.(); } - else setError(dismissError?.message || '删除任务记录失败'); + } catch (dismissError: unknown) { + if (isUnauthorizedError(dismissError)) { onUnauthorized?.(); } + else setError(errorMessage(dismissError, t('tasks.errors.dismiss'))); setDismissalPreview(null); } finally { setActing(false); } }; @@ -202,20 +208,20 @@ export const TasksPage = ({ onUnauthorized, onOpenUploads, initialAccountId = nu const selectedTasks = dismissibleTasks.filter(task => selected.includes(taskKey(task))); return ( -
+
-

任务中心

-

{initialAccountId ? '仅显示仍引用所选存储账户的任务或 Telegram 目标。' : '查看和管理 Web、Telegram、频道订阅及 yt-dlp 任务。'}

- {initialAccountId && } +

{t('tasks.title')}

+

{t(initialAccountId ? 'tasks.subtitleScoped' : 'tasks.subtitle')}

+ {initialAccountId && }
{([ - ['all', '全部', tasks.length, ''], - ['active', '进行中', summary.active, ''], - ['attention', '需处理', summary.attention, 'border-red-200 text-red-700'], - ['completed', '已完成', summary.completed, 'border-emerald-200 text-emerald-700'], - ] as const).map(([filter, label, count, tone]) => ( + ['all', 'tasks.quickFilters.all', tasks.length, ''], + ['active', 'tasks.quickFilters.active', summary.active, ''], + ['attention', 'tasks.quickFilters.attention', summary.attention, 'border-red-200 text-red-700'], + ['completed', 'tasks.quickFilters.completed', summary.completed, 'border-emerald-200 text-emerald-700'], + ] as const).map(([filter, labelKey, count, tone]) => ( ))}
@@ -235,50 +241,50 @@ export const TasksPage = ({ onUnauthorized, onOpenUploads, initialAccountId = nu
- - + +
- - - + + +
- {selectionMode &&
已选择 {selected.length} 条
} + {selectionMode &&
{t('tasks.selection.count', { count: selected.length })}
}
- {notice &&
{notice.message}
} - {error &&
{error}
} + {notice &&
{notice.message}
} + {error &&
{error}
} - {loading ?
: visibleTasks.length === 0 ?

没有符合条件的任务

调整来源或状态筛选后再查看。

: ( + {loading ?
: visibleTasks.length === 0 ?

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

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

: (
{visibleTasks.map(task => { - const stageLabel = STAGE_LABELS[task.stage] || task.stage; - const statusLabel = STATUS_LABELS[task.status] || task.status; + 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 showStage = STAGE_LABELS[task.stage] !== STATUS_LABELS[task.status] && stageLabel !== statusLabel; const detailSpeed = typeof task.detail.speed === 'string' ? task.detail.speed : null; const detailEta = typeof task.detail.eta === 'string' ? task.detail.eta : null; const checked = selected.includes(taskKey(task)); return
- {selectionMode && } + {selectionMode && }
-
{SOURCE_LABELS[task.sourceType] || task.sourceType}{statusLabel}{showStage && {stageLabel}}
+
{SOURCE_LABELS[task.sourceType] ? t(SOURCE_LABELS[task.sourceType]) : task.sourceType}{statusLabel}{showStage && {stageLabel}}

{task.title}

-
目标:{taskTarget(task)}更新:{new Date(task.updatedAt).toLocaleString(locale, { hour12: false })}{(detailSpeed || detailEta) && {detailSpeed ? `速度 ${detailSpeed}` : ''}{detailSpeed && detailEta ? ' · ' : ''}{detailEta ? `ETA ${detailEta}` : ''}}
+
{t('tasks.target.label', { target: taskTarget(task) })}{t('tasks.updated', { time: new Date(task.updatedAt).toLocaleString(locale, { hour12: false }) })}{(detailSpeed || detailEta) && {detailSpeed ? t('tasks.progress.speed', { speed: detailSpeed }) : ''}{detailSpeed && detailEta ? ' · ' : ''}{detailEta ? t('tasks.progress.eta', { eta: detailEta }) : ''}}
{(task.progress > 0 || ['running', 'paused', 'failed'].includes(task.status)) &&
{Math.round(task.progress)}%
} -
{task.counts.total > 0 && 条目 {task.counts.completed}/{task.counts.total}{task.counts.failed > 0 ? `,失败 ${task.counts.failed}` : ''}}{task.bytes.total > 0 && 数据 {formatBytes(task.bytes.transferred)} / {formatBytes(task.bytes.total)}}ID {task.id.length > 18 ? `${task.id.slice(0, 18)}...` : task.id}
+
{task.counts.total > 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.bytes.total > 0 && {t('tasks.progress.data', { transferred: formatBytes(task.bytes.transferred), total: formatBytes(task.bytes.total) })}}ID {task.id.length > 18 ? `${task.id.slice(0, 18)}...` : task.id}
{task.error &&

{task.error}

}
- {!selectionMode &&
{task.retryable && }{task.cancellable && }{task.dismissible && }
} + {!selectionMode &&
{task.retryable && }{task.cancellable && }{task.dismissible && }
}
; })}
)} - {pendingAction &&

{pendingAction.action === 'cancel' ? `确认${taskActionLabel(pendingAction.task, 'cancel')}` : '确认重试任务'}

{pendingAction.task.title}

目标保持为:{taskTarget(pendingAction.task)}

} + {pendingAction && { if (!acting) setPendingAction(null); }} labelledBy="task-action-dialog-title" closeOnEscape={!acting} closeOnBackdrop={!acting} className="w-full max-w-md">

{pendingAction.action === 'cancel' ? t('tasks.dialogs.confirmAction', { action: taskActionLabel(pendingAction.task, 'cancel') }) : t('tasks.dialogs.retryTitle')}

{pendingAction.task.title}

{t('tasks.dialogs.targetUnchanged', { target: taskTarget(pendingAction.task) })}

} - {dismissalPreview &&

确认从任务中心删除

将删除 {dismissalPreview.impact.count} 条终态记录。

只会从任务中心隐藏记录,不会删除任何文件、云端对象、订阅配置或底层任务数据。任务状态再次变化后会重新出现。

} + {dismissalPreview && { if (!acting) setDismissalPreview(null); }} labelledBy="dismiss-title" alert closeOnEscape={!acting} closeOnBackdrop={!acting} className="w-full max-w-md">

{t('tasks.dialogs.dismissTitle')}

{t('tasks.dialogs.dismissCount', { count: dismissalPreview.impact.count })}

{t('tasks.dialogs.dismissDescription')}

}
); }; diff --git a/frontend/src/components/pages/TelegramUserAccountsPanel.test.tsx b/frontend/src/components/pages/TelegramUserAccountsPanel.test.tsx new file mode 100644 index 0000000..c36e641 --- /dev/null +++ b/frontend/src/components/pages/TelegramUserAccountsPanel.test.tsx @@ -0,0 +1,95 @@ +import assert from 'node:assert/strict'; +import { after, afterEach, test } from 'node:test'; +import { JSDOM } from 'jsdom'; +import type { TelegramUserAccountsOverview } from '../../services/api'; + +const dom = new JSDOM('', { url: 'http://localhost/' }); +const names = ['window', 'document', 'navigator', 'localStorage', 'sessionStorage', 'HTMLElement', 'SVGElement', 'Node', 'Event', 'MouseEvent', 'MutationObserver', 'getComputedStyle', 'IS_REACT_ACT_ENVIRONMENT'] as const; +const originals = new Map(); +function install(name: string, value: unknown) { + originals.set(name, Object.getOwnPropertyDescriptor(globalThis, name)); + Object.defineProperty(globalThis, name, { configurable: true, writable: true, value }); +} +install('window', dom.window); +install('document', dom.window.document); +install('navigator', dom.window.navigator); +install('localStorage', dom.window.localStorage); +install('sessionStorage', dom.window.sessionStorage); +install('HTMLElement', dom.window.HTMLElement); +install('SVGElement', dom.window.SVGElement); +install('Node', dom.window.Node); +install('Event', dom.window.Event); +install('MouseEvent', dom.window.MouseEvent); +install('MutationObserver', dom.window.MutationObserver); +install('getComputedStyle', dom.window.getComputedStyle.bind(dom.window)); +install('IS_REACT_ACT_ENVIRONMENT', true); + +const { cleanup, render, screen, waitFor } = await import('@testing-library/react'); +const userEvent = (await import('@testing-library/user-event')).default; +const { fileApi } = await import('../../services/api'); +const { TelegramUserAccountsPanel } = await import('./TelegramUserAccountsPanel'); + +const overview: TelegramUserAccountsOverview = { + accounts: [{ + id: 'acct-a', userId: '123', username: 'archive_user', displayName: '归档账号', enabled: true, + connected: true, health: 'ready', checkedAt: '2026-08-29T12:00:00.000Z', lastError: null, cooldownUntil: null, + permissionSummary: { allowed: 3, denied: 1, unknown: 0, total: 4, lastCheckedAt: '2026-08-29T12:00:00.000Z' }, + scheduling: { weight: 2, activeDownloads: 1, lastSelectedAt: null }, + }], + summary: { total: 1, enabled: 1, ready: 1, coolingDown: 0, permissions: { allowed: 3, denied: 1, unknown: 0, total: 4, lastCheckedAt: null } }, + scheduling: { strategy: 'weighted_least_connections', description: '按权限与负载智能调度' }, +}; + +const originalMethods = { + get: fileApi.getTelegramUserAccounts, + qr: fileApi.startTelegramUserQrLogin, + cancel: fileApi.cancelTelegramUserLogin, +}; + +afterEach(() => cleanup()); +after(() => { + fileApi.getTelegramUserAccounts = originalMethods.get; + fileApi.startTelegramUserQrLogin = originalMethods.qr; + fileApi.cancelTelegramUserLogin = originalMethods.cancel; + dom.window.close(); + for (const name of names) { + const descriptor = originals.get(name); + if (descriptor) Object.defineProperty(globalThis, name, descriptor); + else delete (globalThis as Record)[name]; + } +}); + +test('renders account health, permission totals and opens a QR-only secret view', async () => { + fileApi.getTelegramUserAccounts = async () => overview; + let qrStarts = 0; + fileApi.startTelegramUserQrLogin = async () => ({ + ...(qrStarts++, {}), + flowId: 'flow-a', status: 'waiting_for_scan', qrCode: 'tg://login?token=TOP_SECRET_QR', + qrExpiresAt: '2099-01-01T00:00:00.000Z', expiresAt: '2099-01-01T00:00:00.000Z', + }); + fileApi.getTelegramUserLoginStatus = async () => ({ + flowId: 'flow-a', status: 'waiting_for_scan', qrCode: 'tg://login?token=TOP_SECRET_QR', + qrExpiresAt: '2099-01-01T00:00:00.000Z', expiresAt: '2099-01-01T00:00:00.000Z', + }); + fileApi.cancelTelegramUserLogin = async () => ({ success: true }); + const user = userEvent.setup({ document: dom.window.document }); + + render( undefined} requestConfirmation={async () => true} />); + await screen.findByText('归档账号'); + assert.match(screen.getByText('当前可用').parentElement?.textContent || '', /1/); + assert.match(screen.getByText(/权限:/).textContent || '', /可访问 3/); + + await user.click(screen.getByRole('button', { name: /添加账号/ })); + await screen.findByText('选择登录方式'); + assert.equal(qrStarts, 0); + await user.click(screen.getByRole('button', { name: /二维码登录/ })); + assert.equal(qrStarts, 0); + await user.click(screen.getByRole('button', { name: '下一步' })); + const qr = await screen.findByLabelText('Telegram 登录二维码'); + assert.equal(qr.tagName.toLowerCase(), 'svg'); + assert.equal(qrStarts, 1); + assert.equal(document.body.textContent?.includes('TOP_SECRET_QR'), false); + + await user.click(screen.getByRole('button', { name: '关闭添加账号' })); + await waitFor(() => assert.equal(screen.queryByRole('dialog'), null)); +}); diff --git a/frontend/src/components/pages/TelegramUserAccountsPanel.tsx b/frontend/src/components/pages/TelegramUserAccountsPanel.tsx new file mode 100644 index 0000000..61a8368 --- /dev/null +++ b/frontend/src/components/pages/TelegramUserAccountsPanel.tsx @@ -0,0 +1,435 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { CheckCircle2, Clock3, KeyRound, LoaderCircle, Plus, Power, PowerOff, RefreshCw, ShieldAlert, ShieldCheck, Trash2, UserRound, X } from 'lucide-react'; +import { QRCodeSVG } from 'qrcode.react'; +import { fileApi, type TelegramPermissionSummary, type TelegramUserAccount, type TelegramUserAccountsOverview, type TelegramUserLoginStatus } from '../../services/api'; +import { errorMessage } from '../../services/unknownError'; +import { cn } from '../../lib/utils'; +import { Button } from '../ui/Button'; +import { Dialog } from '../ui/Dialog'; + +const EMPTY_PERMISSIONS: TelegramPermissionSummary = { + allowed: 0, + denied: 0, + unknown: 0, + total: 0, + lastCheckedAt: null, +}; + +function blankOverview(): TelegramUserAccountsOverview { + return { + accounts: [], + summary: { total: 0, enabled: 0, ready: 0, coolingDown: 0, permissions: EMPTY_PERMISSIONS }, + scheduling: { + strategy: 'weighted_least_connections', + description: '智能调度会按来源权限、账号健康状态、冷却时间和当前负载选择可用账号。', + }, + }; +} + +function statusView(account: TelegramUserAccount): { label: string; className: string } { + if (!account.enabled || account.health === 'disabled') return { label: '已停用', className: 'border-border bg-muted text-muted-foreground' }; + if (account.health === 'ready' && account.connected) return { label: '可用', className: 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900 dark:bg-emerald-950/30 dark:text-emerald-300' }; + if (account.health === 'cooldown') return { label: '冷却中', className: 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900 dark:bg-amber-950/30 dark:text-amber-300' }; + if (account.health === 'permission_denied') return { label: '权限受限', className: 'border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-900 dark:bg-orange-950/30 dark:text-orange-300' }; + if (account.health === 'connecting') return { label: '连接中', className: 'border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900 dark:bg-blue-950/30 dark:text-blue-300' }; + return { label: account.health === 'expired' ? '登录已失效' : '异常', className: 'border-red-200 bg-red-50 text-red-700 dark:border-red-900 dark:bg-red-950/30 dark:text-red-300' }; +} + +function accountName(account: TelegramUserAccount): string { + return account.displayName || (account.username ? `@${account.username}` : `Telegram 账号 ${account.userId}`); +} + +function formatTime(value: string | null | undefined): string | null { + if (!value) return null; + const timestamp = Date.parse(value); + return Number.isNaN(timestamp) ? null : new Date(timestamp).toLocaleString('zh-CN', { hour12: false }); +} + +function PermissionSummary({ summary, compact = false }: { summary: TelegramPermissionSummary; compact?: boolean }) { + if (!summary.total) return 尚未检测; + return ( + + 可访问 {summary.allowed} + 不可访问 {summary.denied} + 待检测 {summary.unknown} + + ); +} + +function LoginDialog({ + open, + onClose, + onComplete, +}: { + open: boolean; + onClose: () => void; + onComplete: () => Promise; +}) { + const [method, setMethod] = useState<'choose' | 'qr' | 'phone'>('choose'); + const [selectedMethod, setSelectedMethod] = useState<'qr' | 'phone' | null>(null); + const [login, setLogin] = useState(null); + const [phone, setPhone] = useState(''); + const [code, setCode] = useState(''); + const [password, setPassword] = useState(''); + const [busy, setBusy] = useState(false); + const [failure, setFailure] = useState(null); + const closedRef = useRef(false); + const pollGenerationRef = useRef(0); + + const clearSecrets = useCallback(() => { + setPhone(''); + setCode(''); + setPassword(''); + }, []); + + const startQr = useCallback(async () => { + setBusy(true); + setFailure(null); + try { + if (login?.flowId) await fileApi.cancelTelegramUserLogin(login.flowId).catch(() => undefined); + const result = await fileApi.startTelegramUserQrLogin(); + setLogin(result); + } catch (error) { + setFailure(errorMessage(error) || '二维码生成失败,请改用手机号登录'); + } finally { + setBusy(false); + } + }, [login?.flowId]); + + useEffect(() => { + if (!open) { + pollGenerationRef.current += 1; + return; + } + closedRef.current = false; + setMethod('choose'); + setSelectedMethod(null); + setLogin(null); + setFailure(null); + clearSecrets(); + return () => { closedRef.current = true; }; + }, [open, clearSecrets]); + + useEffect(() => { + const flowId = open && login?.status === 'waiting_for_scan' ? login.flowId : null; + if (!flowId) return; + const generation = ++pollGenerationRef.current; + let stopped = false; + let timer: number | undefined; + const poll = async () => { + try { + 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('二维码已过期,请刷新后重新扫码'); + if (next.status === 'error') setFailure(next.message || '登录失败,请重试'); + if (!stopped && next.status === 'waiting_for_scan') timer = window.setTimeout(() => { void poll(); }, 1_500); + } catch (error) { + if (!closedRef.current && !stopped) { + setFailure(errorMessage(error) || '登录状态查询失败,请重试'); + timer = window.setTimeout(() => { void poll(); }, 3_000); + } + } + }; + timer = window.setTimeout(() => { void poll(); }, 1_500); + return () => { + stopped = true; + pollGenerationRef.current += 1; + if (timer !== undefined) window.clearTimeout(timer); + }; + }, [open, login?.flowId, clearSecrets, onComplete]); + + const close = () => { + const flowId = login?.flowId; + closedRef.current = true; + clearSecrets(); + setLogin(null); + setFailure(null); + if (flowId && login?.status !== 'complete') void fileApi.cancelTelegramUserLogin(flowId).catch(() => undefined); + onClose(); + }; + + const usePhone = () => { + const flowId = login?.flowId; + if (flowId) void fileApi.cancelTelegramUserLogin(flowId).catch(() => undefined); + setLogin(null); + setFailure(null); + setSelectedMethod('phone'); + setMethod('phone'); + }; + + const chooseMethod = (next: 'qr' | 'phone') => { + setSelectedMethod(next); + setFailure(null); + }; + + const continueWithSelectedMethod = () => { + if (!selectedMethod) return; + setFailure(null); + setMethod(selectedMethod); + if (selectedMethod === 'qr') void startQr(); + }; + + const backToMethodChoice = () => { + const flowId = login?.flowId; + if (flowId && login?.status !== 'complete') void fileApi.cancelTelegramUserLogin(flowId).catch(() => undefined); + setLogin(null); + setFailure(null); + setSelectedMethod(null); + setMethod('choose'); + }; + + const submitPhone = async () => { + if (!phone.trim()) return; + setBusy(true); + setFailure(null); + try { + const result = await fileApi.startTelegramUserPhoneLogin(phone.trim()); + setLogin(result); + setPhone(''); + } catch (error) { + setFailure(errorMessage(error) || '验证码发送失败'); + } finally { setBusy(false); } + }; + + const submitCode = async () => { + if (!login || !code.trim()) return; + setBusy(true); + setFailure(null); + try { + const result = await fileApi.submitTelegramUserLoginCode(login.flowId, code.trim()); + setCode(''); + setLogin(result); + if (result.status === 'complete') await onComplete(); + } catch (error) { + setFailure(errorMessage(error) || '验证码校验失败'); + } finally { setBusy(false); } + }; + + const submitPassword = async () => { + if (!login || !password) return; + setBusy(true); + 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) || '两步验证失败'); + } finally { setBusy(false); } + }; + + const complete = login?.status === 'complete'; + return ( + +
+
+
+
+

添加 Telegram 用户账号

+

支持二维码或手机号登录;手机号登录需要验证码,必要时再输入两步验证密码。登录凭证只在服务端加密保存。

+
+ +
+
+ {failure &&
{failure}
} + + {method === 'choose' &&
+
+

选择登录方式

+

先选择登录渠道,下一步再开始登录。

+
+
+ + +
+
+ +
+
} + + {method === 'qr' && !complete &&
+
+

二维码登录

+

打开 Telegram,进入“设置 → 设备 → 连接桌面设备”并扫码。

+
+
+ {login?.qrCode ? : } +
+
+ + +
+ +
} + + {method === 'phone' && !login &&
+ +

请包含国家或地区区号。输入内容不会在账号列表中显示。

+
+ setPhone(event.target.value)} autoComplete="tel" inputMode="tel" placeholder="例如 +86…" className="min-w-0 flex-1 rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary/20" /> + +
+
+ + +
+
} + + {login?.status === 'code_required' &&
+ +

验证码已发送到 Telegram{login.message ? `:${login.message}` : ''}

+
+ setCode(event.target.value)} autoComplete="one-time-code" inputMode="numeric" placeholder="输入验证码" className="min-w-0 flex-1 rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary/20" /> + +
+
} + + {login?.status === 'password_required' &&
+ +

此账号已开启 Telegram 两步验证,请输入云密码完成绑定。

+
+ setPassword(event.target.value)} autoComplete="current-password" placeholder="输入两步验证密码" className="min-w-0 flex-1 rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary/20" /> + +
+
} + + {complete &&
+ +

账号已绑定并自动启用

账号会按权限、健康状态和负载参与智能调度。

+ +
} +
+
+
+ ); +} + +export function TelegramUserAccountsPanel({ + configured, + onNotice, + requestConfirmation, +}: { + configured: boolean; + onNotice: (message: string, title?: string) => Promise | void; + requestConfirmation: (message: string, title?: string, options?: { tone?: 'default' | 'danger'; dangerDescription?: string; cancelLabel?: string; confirmLabel?: string }) => Promise; +}) { + const [overview, setOverview] = useState(blankOverview); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + const [busyAccountId, setBusyAccountId] = useState(null); + const [loginOpen, setLoginOpen] = useState(false); + + const reload = useCallback(async () => { + setLoadError(null); + try { + setOverview(await fileApi.getTelegramUserAccounts()); + } catch (error) { + setLoadError(errorMessage(error) || '账号列表加载失败'); + } finally { setLoading(false); } + }, []); + + useEffect(() => { void reload(); }, [reload]); + + const mutateAccount = async (account: TelegramUserAccount, operation: 'enable' | 'disable' | 'permissions' | 'unlink') => { + if (busyAccountId) return; + if (operation === 'unlink') { + const confirmed = await requestConfirmation( + `解除绑定“${accountName(account)}”后,将永久删除该账号已加密保存的登录信息,并立即停止它参与下载调度。\n\n其他 Telegram 账号和已下载文件不会受影响。`, + '解除绑定 Telegram 账号', + { tone: 'danger', dangerDescription: '将永久删除此账号的登录信息,无法撤销', cancelLabel: '保留账号', confirmLabel: '确认解除绑定' }, + ); + if (!confirmed) return; + } + setBusyAccountId(account.id); + try { + if (operation === 'enable') await fileApi.setTelegramUserAccountEnabled(account.id, true); + if (operation === 'disable') await fileApi.setTelegramUserAccountEnabled(account.id, false); + if (operation === 'permissions') await fileApi.checkTelegramUserAccountPermissions(account.id); + if (operation === 'unlink') await fileApi.unlinkTelegramUserAccountById(account.id); + await reload(); + if (operation === 'permissions') await onNotice('权限检测已完成,账号和总览状态已刷新。'); + if (operation === 'enable') await onNotice('账号已重新启用,将在状态就绪后参与调度。'); + if (operation === 'disable') await onNotice('账号已停用;已加密保存的登录信息会保留。'); + if (operation === 'unlink') await onNotice('账号已解除绑定,登录信息已删除。'); + } catch (error) { + await onNotice(errorMessage(error) || '账号操作失败', '操作失败'); + } finally { setBusyAccountId(null); } + }; + + return ( +
+
+
+
+

Telegram 用户账号

+ {overview.summary.total} 个 +
+

管理账号级下载器使用的多个 Telegram 用户账号。账号之间互不共享登录信息,停用只暂停调度,不会退出登录。

+
+ +
+ + {!configured &&
请先配置 Telegram Bot 的 API ID 与 API Hash,再添加用户账号。
} + {loadError &&
{loadError}
} + +
+

已启用账号

{overview.summary.enabled}/ {overview.summary.total}

+

当前可用

{overview.summary.ready}

+

冷却中

{overview.summary.coolingDown}

+

权限汇总

+
+ +
+

智能调度

+

{overview.scheduling.description || '系统会跳过无来源权限、已停用或冷却中的账号,再按权重和当前下载数选择负载较低的账号。'}

+
+ + {loading ?
正在加载账号…
: !loadError && overview.accounts.length === 0 ?

尚未绑定 Telegram 用户账号

点击“添加账号”,优先扫码完成安全登录。

:
+ {overview.accounts.map(account => { + const status = statusView(account); + const busy = busyAccountId === account.id; + return
+
+
+
+
{accountName(account)}
+ {status.label} +
+
+

权限:

+

并发任务:{account.scheduling.activeDownloads}

+

最近检测:{formatTime(account.checkedAt) || '尚未检测'}

+

调度权重:{account.scheduling.weight}

+
+ {account.cooldownUntil &&

冷却至:{formatTime(account.cooldownUntil) || '待后端更新'}

} + {account.lastError && account.health !== 'disabled' &&

最近错误:{account.lastError}

} + {!account.enabled &&

已停用,不会执行账号级下载;登录信息仍安全保留。

} +
+
+ + {account.enabled ? : } + +
+
+
; + })} +
} + +
页面不会回显手机号、session、二维码内容或 API 凭证;二维码只会传给本地二维码组件渲染。
+ setLoginOpen(false)} onComplete={async () => { await reload(); await onNotice('账号已绑定并自动启用'); }} /> +
+ ); +} diff --git a/frontend/src/components/pages/UploadCenter.tsx b/frontend/src/components/pages/UploadCenter.tsx index f21de40..9e2a591 100644 --- a/frontend/src/components/pages/UploadCenter.tsx +++ b/frontend/src/components/pages/UploadCenter.tsx @@ -5,6 +5,7 @@ import { UploadZone } from "../ui/UploadZone"; import type { UploadCapabilities } from "../../services/api"; import { Button } from "../ui/Button"; import { cn } from "../../lib/utils"; +import { formatBytes } from "../../services/formatBytes"; interface UploadCenterProps { onUpload: (files: File[], folder?: string) => void; @@ -19,13 +20,6 @@ interface UploadCenterProps { onOpenQueue: () => void; } -function formatBytes(bytes: number): string { - if (!Number.isFinite(bytes) || bytes <= 0) return "0 B"; - const units = ["B", "KiB", "MiB", "GiB", "TiB"]; - const unit = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); - return `${(bytes / (1024 ** unit)).toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`; -} - const statusLabel: Record = { pending: "等待上传", uploading: "正在上传", diff --git a/frontend/src/components/pages/frontendInteractionPolish.test.ts b/frontend/src/components/pages/frontendInteractionPolish.test.ts index 1acd2fe..bc3459f 100644 --- a/frontend/src/components/pages/frontendInteractionPolish.test.ts +++ b/frontend/src/components/pages/frontendInteractionPolish.test.ts @@ -13,7 +13,7 @@ test('task success feedback auto-dismisses while retaining a manual close contro assert.match(tasks, /useEffect\(\(\) => \{\s*if \(!notice\) return;[\s\S]*setTimeout\(\(\) => setNotice\(null\), 4_000\)/); assert.match(tasks, /\}, \[notice\?\.sequence\]\);/); assert.match(tasks, /aria-live="polite"/); - assert.match(tasks, /aria-label="关闭提示"/); + assert.match(tasks, /aria-label=\{t\('tasks\.actions\.closeNotice'\)\}/); }); test('the global header owns the theme switch on every page', () => { diff --git a/frontend/src/components/pages/settingsDialogContract.test.ts b/frontend/src/components/pages/settingsDialogContract.test.ts index 6c9a5af..ef40052 100644 --- a/frontend/src/components/pages/settingsDialogContract.test.ts +++ b/frontend/src/components/pages/settingsDialogContract.test.ts @@ -7,4 +7,5 @@ const source = fs.readFileSync(new URL('./SettingsPage.tsx', import.meta.url), ' test('settings uses product dialogs instead of browser alert, confirm, or prompt', () => { assert.doesNotMatch(source, /\b(?:window\.)?(?:alert|confirm|prompt)\s*\(/); assert.match(source, /ActionDialog/); + assert.match(source, / { + const panel = fs.readFileSync(panelUrl, 'utf8'); + for (const copy of [ + 'Telegram 用户账号', + '添加账号', + '二维码登录', + '改用手机号', + '两步验证密码', + '停用(保留登录)', + '重新启用', + '解除绑定', + '检测权限', + '权限汇总', + '智能调度', + ]) { + assert.match(panel, new RegExp(copy)); + } + assert.match(panel, /QRCodeSVG/); + assert.match(panel, / { + const panel = fs.readFileSync(panelUrl, 'utf8'); + assert.match(panel, /]*value=\{[^}]*qrCode[^}]*\}/s); + assert.doesNotMatch(panel, />\s*\{[^}]*qrCode[^}]*\}\s* { + const settings = fs.readFileSync(new URL('./SettingsPage.tsx', import.meta.url), 'utf8'); + assert.match(settings, /TelegramUserAccountsPanel/); + assert.doesNotMatch(settings, /telegramUserPhone|telegramUserCode|telegramUserPassword|telegramUserFlowId/); +}); diff --git a/frontend/src/components/pages/useRuntimeUiLocalization.ts b/frontend/src/components/pages/useRuntimeUiLocalization.ts deleted file mode 100644 index becc7ee..0000000 --- a/frontend/src/components/pages/useRuntimeUiLocalization.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { useEffect } from 'react'; -import { useTranslation } from 'react-i18next'; -import { localizeChineseText } from '../../locales/runtimeUiLocalization'; - -export function useRuntimeUiLocalization(ref: React.RefObject): void { - const { i18n } = useTranslation(); - useEffect(() => { - const root = ref.current; - if (!root) return; - const apply = () => localizeChineseText(root, i18n.resolvedLanguage || i18n.language); - apply(); - const observer = new MutationObserver(apply); - observer.observe(root, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ['placeholder', 'title', 'aria-label'] }); - return () => observer.disconnect(); - }, [i18n.language, i18n.resolvedLanguage, ref]); -} diff --git a/frontend/src/components/ui/BulkActionToolbar.tsx b/frontend/src/components/ui/BulkActionToolbar.tsx index a6c1b8f..f2201c5 100644 --- a/frontend/src/components/ui/BulkActionToolbar.tsx +++ b/frontend/src/components/ui/BulkActionToolbar.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { motion, AnimatePresence } from "framer-motion"; import { Trash2, X, CheckSquare, Share2, Copy, Calendar, Lock, Check } from "lucide-react"; import { Button } from "./Button"; +import { errorMessage } from "../../services/unknownError"; import { DatePicker } from "./DatePicker"; import { IndeterminateSpinner } from "./IndeterminateSpinner"; @@ -137,9 +138,9 @@ export const BulkActionToolbar = ({ // Don't show error message, just let user see the link } } - } catch (err: any) { + } catch (err: unknown) { console.error("Copy failed", err); - setErrorMsg(err.message || "创建链接失败"); + setErrorMsg(errorMessage(err, "创建链接失败")); } finally { setIsCopying(false); } diff --git a/frontend/src/components/ui/Button.tsx b/frontend/src/components/ui/Button.tsx index 7b7d5fb..279bf48 100644 --- a/frontend/src/components/ui/Button.tsx +++ b/frontend/src/components/ui/Button.tsx @@ -1,12 +1,6 @@ import * as React from "react"; -import { motion } from "framer-motion"; import { cn } from "../../lib/utils"; -// Note: I'm not installing cva, I'll just manual class logic for now or quick-install it. -// Actually, using CVA is cleaner but I didn't install it. I'll use simple string maps or cn logic. -// Simulating CVA behavior for simplicity or I should install cva. -// "class-variance-authority" is lightweight. I'll add it to next install batch if needed, but for now I'll just use simple props. - interface ButtonProps extends React.ButtonHTMLAttributes { variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link"; size?: "default" | "sm" | "lg" | "icon"; @@ -32,9 +26,7 @@ const Button = React.forwardRef( }; return ( - ( className )} ref={ref} - {...props as any} + {...props} /> ); } diff --git a/frontend/src/components/ui/DeleteAlert.tsx b/frontend/src/components/ui/DeleteAlert.tsx index c99d6f2..25ccae8 100644 --- a/frontend/src/components/ui/DeleteAlert.tsx +++ b/frontend/src/components/ui/DeleteAlert.tsx @@ -1,9 +1,9 @@ import { useState } from "react"; -import { motion, AnimatePresence } from "framer-motion"; +import { motion } from "framer-motion"; import { Trash2 } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Button } from "./Button"; -import { createPortal } from "react-dom"; +import { Dialog } from "./Dialog"; import type { BatchDeleteResult } from "../../services/api"; import { formatDeleteSize } from "./deletePresentation"; @@ -49,31 +49,20 @@ export const DeleteAlert = ({ const isPartial = result?.status === 'partial'; const modalContent = ( - -
- - + e.stopPropagation()} + className="w-full overflow-hidden rounded-xl border border-border bg-background shadow-2xl" >
-

+

{isPartial ? "部分删除完成" : (t("delete.title") || "确认删除")}

@@ -150,9 +139,8 @@ export const DeleteAlert = ({ )}

-
- +
); - return createPortal(modalContent, document.body); + return modalContent; }; diff --git a/frontend/src/components/ui/Dialog.tsx b/frontend/src/components/ui/Dialog.tsx index b3ea35f..e1799cb 100644 --- a/frontend/src/components/ui/Dialog.tsx +++ b/frontend/src/components/ui/Dialog.tsx @@ -25,6 +25,16 @@ export function Dialog({ className?: string; }) { const contentRef = useRef(null); + const onCloseRef = useRef(onClose); + const closeOnEscapeRef = useRef(closeOnEscape); + useEffect(() => { + onCloseRef.current = onClose; + closeOnEscapeRef.current = closeOnEscape; + }, [onClose, closeOnEscape]); + + // Keep the focus trap mounted for the lifetime of the dialog. Re-running + // this effect on every parent render would focus the first button again, + // which makes a mobile swipe jump back to the top during QR polling. useEffect(() => { if (!open) return; const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null; @@ -32,7 +42,7 @@ export function Dialog({ const focusables = () => Array.from(content?.querySelectorAll(focusSelector) || []); focusables()[0]?.focus(); const onKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape' && closeOnEscape) { event.preventDefault(); onClose(); return; } + if (event.key === 'Escape' && closeOnEscapeRef.current) { event.preventDefault(); onCloseRef.current(); return; } if (event.key !== 'Tab') return; const items = focusables(); if (!items.length) { event.preventDefault(); content?.focus(); return; } @@ -48,11 +58,11 @@ export function Dialog({ document.body.style.overflow = previousOverflow; previousFocus?.focus(); }; - }, [open, onClose, closeOnEscape]); + }, [open]); if (!open) return null; return createPortal( -
{ if (closeOnBackdrop && event.target === event.currentTarget) onClose(); }}> -
event.stopPropagation()}> +
{ if (closeOnBackdrop && event.target === event.currentTarget) onCloseRef.current(); }}> +
event.stopPropagation()}> {children}
, diff --git a/frontend/src/components/ui/FileCard.tsx b/frontend/src/components/ui/FileCard.tsx index 8f060c5..9895b7f 100644 --- a/frontend/src/components/ui/FileCard.tsx +++ b/frontend/src/components/ui/FileCard.tsx @@ -57,7 +57,7 @@ export const FileCard = ({ await fileApi.downloadFile(file.id, file.name); } catch (error: unknown) { console.error("下载失败", error); - if (error instanceof ApiActionError && error.kind === 'unauthorized') authService.clearToken(); + if (error instanceof ApiActionError && error.kind === 'unauthorized') authService.invalidateSession(error.status); setDownloadError(describeActionFailure('下载', error)); } }; diff --git a/frontend/src/components/ui/FileMenu.tsx b/frontend/src/components/ui/FileMenu.tsx index d26caad..bc69c24 100644 --- a/frontend/src/components/ui/FileMenu.tsx +++ b/frontend/src/components/ui/FileMenu.tsx @@ -1,5 +1,5 @@ import { motion, AnimatePresence } from "framer-motion"; -import { MoreVertical, Trash2, Star } from "lucide-react"; +import { Download, FolderInput, MoreVertical, Pencil, Trash2, Star } from "lucide-react"; import { useState, useRef, useEffect } from "react"; import { useTranslation } from "react-i18next"; import { Button } from "./Button"; @@ -7,10 +7,13 @@ import { Button } from "./Button"; interface FileMenuProps { onDelete?: () => void; onToggleFavorite?: () => void; + onDownload?: () => void; + onRename?: () => void; + onMove?: () => void; isFavorite?: boolean; } -export const FileMenu = ({ onDelete, onToggleFavorite, isFavorite = false }: FileMenuProps) => { +export const FileMenu = ({ onDelete, onToggleFavorite, onDownload, onRename, onMove, isFavorite = false }: FileMenuProps) => { const [isOpen, setIsOpen] = useState(false); const menuRef = useRef(null); const { t } = useTranslation(); @@ -60,6 +63,9 @@ export const FileMenu = ({ onDelete, onToggleFavorite, isFavorite = false }: Fil transition={{ duration: 0.1 }} className="absolute right-0 top-full mt-1 w-36 bg-white dark:bg-zinc-900 border border-border rounded-xl shadow-lg overflow-hidden z-50 p-1.5" > + {onDownload && } + {onRename && } + {onMove && } {onDelete &&
); - return createPortal(modalContent, document.body); + return modalContent; }; diff --git a/frontend/src/components/ui/MoveModal.tsx b/frontend/src/components/ui/MoveModal.tsx index 26b1f9e..9d08759 100644 --- a/frontend/src/components/ui/MoveModal.tsx +++ b/frontend/src/components/ui/MoveModal.tsx @@ -1,11 +1,12 @@ -import { motion, AnimatePresence } from "framer-motion"; +import { motion } from "framer-motion"; import { Folder, FolderRoot, X, Check, ArrowRight } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Button } from "./Button"; import { useState, useEffect } from "react"; -import { createPortal } from "react-dom"; +import { Dialog } from "./Dialog"; import type { FolderMovePreview } from "../../services/api"; import { performAsyncMutation } from "../../services/asyncMutation"; +import { formatBytes } from "../../services/formatBytes"; interface MoveModalProps { isOpen: boolean; @@ -67,34 +68,15 @@ export const MoveModal = ({ isOpen, onClose, onConfirm, currentFolder, folders, const isChanged = selectedFolder !== currentFolder; const canConfirm = isChanged && !isPreviewLoading && !previewError && (!isFolder || (!!preview && !preview.conflict)); - const formatBytes = (bytes: number) => { - if (!bytes) return '0 B'; - const units = ['B', 'KB', 'MB', 'GB', 'TB']; - const unit = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024))); - return `${(bytes / (1024 ** unit)).toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`; - }; const modalContent = ( - -
- {/* Backdrop */} - - - {/* Modal Container */} + e.stopPropagation()} + className="w-full overflow-hidden rounded-xl border border-border bg-background shadow-2xl" > {/* Header */}
@@ -102,15 +84,18 @@ export const MoveModal = ({ isOpen, onClose, onConfirm, currentFolder, folders,
-

+

{title || t("app.moveTo") || "移动到"}

选择目标文件夹位置

@@ -266,9 +251,8 @@ export const MoveModal = ({ isOpen, onClose, onConfirm, currentFolder, folders,
-
- + ); - return createPortal(modalContent, document.body); + return modalContent; }; diff --git a/frontend/src/components/ui/Notification.tsx b/frontend/src/components/ui/Notification.tsx index 12e3fd2..1f610ca 100644 --- a/frontend/src/components/ui/Notification.tsx +++ b/frontend/src/components/ui/Notification.tsx @@ -1,6 +1,6 @@ import React, { useEffect } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; -import { Cloud, CheckCircle, Info, XCircle } from 'lucide-react'; +import { Cloud, CheckCircle, Info, X, XCircle } from 'lucide-react'; import { cn } from '../../lib/utils'; import { IndeterminateSpinner } from './IndeterminateSpinner'; @@ -53,7 +53,11 @@ export const Notification: React.FC = ({ exit={{ opacity: 0, y: -20, scale: 0.95 }} className="fixed top-12 inset-x-0 z-[100] flex justify-center pointer-events-none px-4" > -
@@ -63,6 +67,11 @@ export const Notification: React.FC = ({

{message}

+ {type !== 'loading' && ( + + )}
)} diff --git a/frontend/src/components/ui/PreviewModal.tsx b/frontend/src/components/ui/PreviewModal.tsx index 5ec7ed6..174aa8d 100644 --- a/frontend/src/components/ui/PreviewModal.tsx +++ b/frontend/src/components/ui/PreviewModal.tsx @@ -1,5 +1,4 @@ import { motion, AnimatePresence } from "framer-motion"; -import { createPortal } from "react-dom"; import { X, FileText, Download, Video, Music, ZoomIn, ZoomOut, ChevronLeft, ChevronRight, Maximize2, RotateCcw, Copy, Check, Info, RefreshCw } from "lucide-react"; import type { FileData } from "./FileCard"; import { Button } from "./Button"; @@ -9,6 +8,7 @@ import { MobileMenu } from "./MobileMenu"; import { IndeterminateSpinner } from "./IndeterminateSpinner"; import { ApiActionError, describeActionFailure } from "../../services/apiActionError"; import { authService } from "../../services/auth"; +import { Dialog } from "./Dialog"; interface PreviewModalProps { file: FileData | null; @@ -256,7 +256,7 @@ export const PreviewModal = ({ file, onClose, onToggleFavorite, files = [], onNa await fileApi.downloadFile(file.id, file.name); } catch (error) { console.error("下载失败", error); - if (error instanceof ApiActionError && error.kind === 'unauthorized') authService.clearToken(); + if (error instanceof ApiActionError && error.kind === 'unauthorized') authService.invalidateSession(error.status); setActionError(describeActionFailure('下载', error)); } }; @@ -288,7 +288,7 @@ export const PreviewModal = ({ file, onClose, onToggleFavorite, files = [], onNa const url = await fileApi.getOriginalFileLink(file.id); window.open(url, '_blank', 'noopener,noreferrer'); } catch (error) { - if (error instanceof ApiActionError && error.kind === 'unauthorized') authService.clearToken(); + if (error instanceof ApiActionError && error.kind === 'unauthorized') authService.invalidateSession(error.status); setActionError(describeActionFailure('打开原文件', error)); } }; @@ -424,17 +424,16 @@ export const PreviewModal = ({ file, onClose, onToggleFavorite, files = [], onNa ); }; - // 使用 Portal 渲染到 body,确保全屏覆盖不受父元素影响 + if (!file) return null; + const modalContent = ( - - {file && ( + {/* 顶部工具栏 */} @@ -443,7 +442,7 @@ export const PreviewModal = ({ file, onClose, onToggleFavorite, files = [], onNa onClick={(e) => e.stopPropagation()} >
-

{file.name}

+

{file?.name}

@@ -649,10 +648,8 @@ export const PreviewModal = ({ file, onClose, onToggleFavorite, files = [], onNa onClose={handleMobileMenuClose} /> - )} - +
); - // 渲染到 document.body - return createPortal(modalContent, document.body); + return modalContent; }; diff --git a/frontend/src/components/ui/RenameModal.test.ts b/frontend/src/components/ui/RenameModal.test.ts index a71fcf4..31ad5ed 100644 --- a/frontend/src/components/ui/RenameModal.test.ts +++ b/frontend/src/components/ui/RenameModal.test.ts @@ -30,3 +30,9 @@ test('rename handlers surface failures in the modal instead of swallowing them', assert.match(app, /handleFileRename[\s\S]*setNotification\([\s\S]*throw error;/); assert.match(app, /handleFolderRename[\s\S]*setNotification\([\s\S]*throw error;/); }); + +test('rename modal uses the shared accessible dialog shell', () => { + assert.match(modal, / {isOpen && ( -
- {/* Backdrop */} - - - {/* Modal */} + {/* Header */}
@@ -114,7 +113,7 @@ export const RenameModal = ({ isOpen, onClose, onConfirm, currentName, type }: R
-

+

{t("file.renameTitle") || "重命名"}

{type === "file" && extension && ( @@ -147,7 +146,7 @@ export const RenameModal = ({ isOpen, onClose, onConfirm, currentName, type }: R )}
{error && ( -

{error}

+

{error}

)}
@@ -170,7 +169,7 @@ export const RenameModal = ({ isOpen, onClose, onConfirm, currentName, type }: R
-
+ )} ); diff --git a/frontend/src/components/ui/UploadQueueModal.tsx b/frontend/src/components/ui/UploadQueueModal.tsx index 251f700..b3afd61 100644 --- a/frontend/src/components/ui/UploadQueueModal.tsx +++ b/frontend/src/components/ui/UploadQueueModal.tsx @@ -7,6 +7,7 @@ import { getUploadQueueOutcome } from "./uploadQueueOutcome"; import type { ChunkUploadSession } from "../../services/api"; import type { UploadTelemetry } from "../../services/uploadTelemetry"; import { IndeterminateSpinner } from "./IndeterminateSpinner"; +import { formatBytes } from "../../services/formatBytes"; export interface QueueItem { id: string; @@ -37,13 +38,6 @@ interface UploadQueueModalProps { onCancelSession?: (session: ChunkUploadSession) => void; } -function formatBytes(bytes: number): string { - if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'; - const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']; - const unit = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); - return `${(bytes / (1024 ** unit)).toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`; -} - function formatDuration(seconds: number | null | undefined): string | null { if (seconds === null || seconds === undefined || !Number.isFinite(seconds)) return null; if (seconds <= 0) return '即将完成'; diff --git a/frontend/src/components/ui/componentInteractions.test.tsx b/frontend/src/components/ui/componentInteractions.test.tsx new file mode 100644 index 0000000..03c067a --- /dev/null +++ b/frontend/src/components/ui/componentInteractions.test.tsx @@ -0,0 +1,122 @@ +import assert from 'node:assert/strict'; +import { after, afterEach, test } from 'node:test'; +import { JSDOM } from 'jsdom'; +import { useState } from 'react'; + +const dom = new JSDOM('', { + url: 'http://localhost/', +}); + +const installedGlobals = [ + 'window', + 'document', + 'navigator', + 'HTMLElement', + 'Node', + 'Event', + 'KeyboardEvent', + 'MouseEvent', + 'MutationObserver', + 'getComputedStyle', + 'IS_REACT_ACT_ENVIRONMENT', +] as const; +const originalGlobals = new Map(); + +function installGlobal(name: string, value: unknown) { + originalGlobals.set(name, Object.getOwnPropertyDescriptor(globalThis, name)); + Object.defineProperty(globalThis, name, { + configurable: true, + writable: true, + value, + }); +} + +installGlobal('window', dom.window); +installGlobal('document', dom.window.document); +installGlobal('navigator', dom.window.navigator); +installGlobal('HTMLElement', dom.window.HTMLElement); +installGlobal('Node', dom.window.Node); +installGlobal('Event', dom.window.Event); +installGlobal('KeyboardEvent', dom.window.KeyboardEvent); +installGlobal('MouseEvent', dom.window.MouseEvent); +installGlobal('MutationObserver', dom.window.MutationObserver); +installGlobal('getComputedStyle', dom.window.getComputedStyle.bind(dom.window)); +installGlobal('IS_REACT_ACT_ENVIRONMENT', true); + +const { cleanup, render, screen, waitFor, within } = await import('@testing-library/react'); +const userEvent = (await import('@testing-library/user-event')).default; +const { Dialog } = await import('./Dialog'); +const { Notification } = await import('./Notification'); + +afterEach(() => cleanup()); +after(() => { + dom.window.close(); + for (const name of installedGlobals) { + const descriptor = originalGlobals.get(name); + if (descriptor) Object.defineProperty(globalThis, name, descriptor); + else delete (globalThis as Record)[name]; + } +}); + +function DialogHarness() { + const [open, setOpen] = useState(false); + + return ( + <> + + setOpen(false)} labelledBy="rendered-dialog-title"> +

Delete file?

+ + +
+ + ); +} + +test('Dialog moves focus inside, traps Tab, closes on Escape, and restores trigger focus', async () => { + const user = userEvent.setup({ document: dom.window.document }); + render(); + + const trigger = screen.getByRole('button', { name: 'Open dialog' }); + trigger.focus(); + assert.equal(document.activeElement, trigger); + + await user.click(trigger); + + const dialog = await screen.findByRole('dialog', { name: 'Delete file?' }); + const cancel = within(dialog).getByRole('button', { name: 'Cancel' }); + const confirm = within(dialog).getByRole('button', { name: 'Delete' }); + await waitFor(() => assert.equal(document.activeElement, cancel)); + + await user.tab({ shift: true }); + assert.equal(document.activeElement, confirm); + await user.tab(); + assert.equal(document.activeElement, cancel); + + await user.keyboard('{Escape}'); + + await waitFor(() => assert.equal(screen.queryByRole('dialog'), null)); + assert.equal(document.activeElement, trigger); +}); + +test('Notification exposes an assertive live region and its rendered close control works', async () => { + const user = userEvent.setup({ document: dom.window.document }); + let closeCalls = 0; + render( + { closeCalls += 1; }} + />, + ); + + const liveRegion = screen.getByRole('alert'); + assert.equal(liveRegion.getAttribute('aria-live'), 'assertive'); + assert.equal(liveRegion.getAttribute('aria-atomic'), 'true'); + assert.match(liveRegion.textContent || '', /Upload failed/); + + await user.click(screen.getByRole('button', { name: '关闭通知' })); + assert.equal(closeCalls, 1); +}); diff --git a/frontend/src/hooks/useAuthSession.ts b/frontend/src/hooks/useAuthSession.ts new file mode 100644 index 0000000..68f4a3a --- /dev/null +++ b/frontend/src/hooks/useAuthSession.ts @@ -0,0 +1,78 @@ +import { useCallback, useEffect, useState } from 'react'; +import { authService } from '../services/auth'; + +export interface AuthSessionState { + isAuthenticated: boolean; + needsPassword: boolean; + setupRequired: boolean; + telegramPinRequired: boolean; + authChecking: boolean; +} + +export function useAuthSession(onInvalidated: () => void) { + const [state, setState] = useState({ + isAuthenticated: false, + needsPassword: true, + setupRequired: false, + telegramPinRequired: false, + authChecking: true, + }); + + useEffect(() => authService.onSessionInvalidated(() => { + setState(previous => ({ ...previous, isAuthenticated: false })); + onInvalidated(); + }), [onInvalidated]); + + useEffect(() => { + let active = true; + const check = async () => { + try { + const status = await authService.getAuthStatus(); + if (!active) return; + const authenticated = status.setupRequired + ? false + : !status.passwordRequired + ? true + : await authService.verify(); + if (!active) return; + setState({ + isAuthenticated: authenticated, + needsPassword: status.passwordRequired, + setupRequired: status.setupRequired, + telegramPinRequired: status.telegramPinRequired, + authChecking: false, + }); + } catch (error) { + console.error('检查认证状态失败:', error); + if (active) setState(previous => ({ ...previous, authChecking: false })); + } + }; + void check(); + return () => { active = false; }; + }, []); + + const login = useCallback(async (password: string) => { + const result = await authService.login(password); + if (result.success && !result.requiresTOTP) setState(previous => ({ ...previous, isAuthenticated: true })); + return result; + }, []); + + const setup = useCallback(async (webPassword: string, telegramPin?: string) => { + const result = await authService.setup(webPassword, telegramPin); + if (result.success) { + setState(previous => ({ ...previous, setupRequired: false, needsPassword: true, isAuthenticated: true })); + } + return result; + }, []); + + const signOut = useCallback(async () => { + await authService.logout(); + setState(previous => ({ ...previous, isAuthenticated: false })); + }, []); + + const markUnauthenticated = useCallback(() => { + setState(previous => ({ ...previous, isAuthenticated: false })); + }, []); + + return { ...state, login, setup, signOut, markUnauthenticated }; +} diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index f891cff..bc61add 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -41,6 +41,47 @@ "loading": "Loading…", "noOtherFolders": "No other folders" }, + "login": { + "setupTitle": "First launch: create the administrator account", + "passwordTitle": "Enter your access password", + "totpTitle": "Two-factor authentication", + "passwordLabel": "Access password", + "adminPasswordLabel": "Administrator password", + "passwordPlaceholder": "Enter your password", + "adminPasswordPlaceholder": "At least 8 characters; use a strong password", + "confirmPasswordLabel": "Confirm administrator password", + "confirmPasswordPlaceholder": "Enter the administrator password again", + "telegramPinLabel": "Telegram Bot PIN (4 digits)", + "telegramPinHint": "The Bot is configured. The PIN must be exactly four digits and is stored encrypted.", + "create": "Create administrator and continue", + "signIn": "Sign in", + "signingIn": "Signing in", + "creating": "Creating administrator", + "totpHeading": "Enter the authentication code", + "totpHint": "Enter the six-digit code from your authenticator app.", + "verify": "Verify and sign in", + "verifying": "Verifying authentication code", + "back": "Back to password", + "setupFooter": "First production access requires an administrator password and, when the Bot is configured, a four-digit Telegram Bot PIN.", + "sessionFooter": "The sign-in session is kept for seven days.", + "showPassword": "Show password", + "hidePassword": "Hide password", + "errors": { + "passwordLength": "The administrator password must be at least 8 characters", + "passwordMismatch": "The administrator passwords do not match", + "pinFormat": "The Telegram Bot PIN must be exactly four digits", + "pinMatchesPassword": "The administrator password cannot match the four-digit Telegram Bot PIN", + "setupUnavailable": "The setup service is not ready", + "setupFailed": "Setup failed", + "setupRequestFailed": "The setup request failed", + "passwordRequired": "Enter your password", + "loginFailed": "Sign-in failed", + "loginRequestFailed": "The sign-in request failed", + "totpFormat": "Enter a six-digit authentication code", + "totpFailed": "Verification failed", + "totpRequestFailed": "The verification request failed" + } + }, "sidebar": { "uploadCenter": "Upload center", "files": "My files", @@ -107,13 +148,34 @@ "empty": { "title": "No files found", "description": "Try dragging files here or check your filters.", - "root": { "title": "No files here yet", "description": "Upload a file or create a folder to start organizing your assets." }, - "folder": { "title": "This folder is empty", "description": "Upload to this folder or return to its parent." }, - "search": { "title": "No matching files", "description": "Try another keyword or clear the search." }, - "filter": { "title": "No files in this category", "description": "Choose another category or upload a matching file." }, - "offline": { "title": "Cannot reach the server", "description": "Check your network connection and try again." }, - "error": { "title": "Could not load files", "description": "The service is temporarily unavailable. Try again shortly." }, - "stale": { "title": "Showing previously loaded data", "description": "Refresh failed; this content may be out of date." }, + "root": { + "title": "No files here yet", + "description": "Upload a file or create a folder to start organizing your assets." + }, + "folder": { + "title": "This folder is empty", + "description": "Upload to this folder or return to its parent." + }, + "search": { + "title": "No matching files", + "description": "Try another keyword or clear the search." + }, + "filter": { + "title": "No files in this category", + "description": "Choose another category or upload a matching file." + }, + "offline": { + "title": "Cannot reach the server", + "description": "Check your network connection and try again." + }, + "error": { + "title": "Could not load files", + "description": "The service is temporarily unavailable. Try again shortly." + }, + "stale": { + "title": "Showing previously loaded data", + "description": "Refresh failed; this content may be out of date." + }, "clearSearch": "Clear search", "clearFilter": "View all files", "retry": "Retry" @@ -201,6 +263,153 @@ "notTested": "Not tested", "testing": "Testing storage connection", "test": "Test connection" + }, + "security": { + "title": "Security settings", + "networkTitle": "Network and storage security" + }, + "maintenance": { + "title": "Data maintenance", + "advancedTasks": "Advanced task settings" + }, + "telegram": { + "botConnection": "Telegram Bot connection", + "permissions": "Telegram Bot user permissions", + "downloadSettings": "Telegram download settings" + }, + "storageSources": { + "title": "Storage sources", + "aliyunTitle": "Aliyun OSS settings", + "s3Title": "S3-compatible storage settings", + "webdavTitle": "WebDAV storage settings" + } + }, + "tasks": { + "title": "Task center", + "subtitle": "View and manage Web, Telegram, channel subscription, and yt-dlp tasks.", + "subtitleScoped": "Only tasks or Telegram targets that still reference the selected storage account are shown.", + "showAll": "Show all tasks", + "updated": "Updated: {{time}}", + "runningNotDismissible": "This task is running, so its record cannot be removed yet.", + "sources": { + "all": "All sources", + "webUpload": "Web upload", + "telegramFile": "Telegram file", + "channelDownload": "Channel download", + "telegramTarget": "Telegram conversation target", + "ytdlp": "yt-dlp", + "subscription": "Channel subscription" + }, + "statuses": { + "all": "All statuses", + "pending": "Pending", + "running": "Running", + "paused": "Paused", + "waiting": "Waiting to resume", + "failed": "Failed", + "interrupted": "Interrupted", + "retryRequired": "Retry required", + "completed": "Completed", + "cancelled": "Cancelled", + "scheduled": "Scheduled", + "disabled": "Disabled" + }, + "stages": { + "queued": "Queued", + "scanning": "Scanning messages", + "downloading": "Downloading source file", + "uploading": "Uploading to storage", + "processing": "Processing on server", + "awaitingFile": "Waiting for source file selection", + "resumable": "Upload session can resume", + "waitingForNextScan": "Waiting for next scan", + "waitingForNextTask": "Waiting for next task", + "completed": "Processing completed", + "failed": "Processing failed", + "interrupted": "Interrupted by server restart", + "retryRequired": "Manual retry required" + }, + "target": { + "path": "{{storage}} / {{folder}}", + "unknownStorage": "Unknown storage", + "root": "Root", + "label": "Target: {{target}}" + }, + "quickFilters": { + "all": "All", + "active": "Active", + "attention": "Needs attention", + "completed": "Completed" + }, + "filters": { + "sourceAria": "Filter by task source", + "statusAria": "Filter by task status" + }, + "selection": { + "enter": "Select tasks", + "exit": "Exit selection", + "count": "{{count}} selected", + "selectAll": "Select all removable", + "clear": "Clear", + "delete": "Delete selected", + "selectAria": "Select task", + "deselectAria": "Deselect task" + }, + "actions": { + "resume": "Resume", + "retry": "Retry", + "followDefault": "Follow default", + "clearTarget": "Clear target", + "cancel": "Cancel", + "cleanTerminal": "Clean terminal records", + "refreshAria": "Refresh tasks", + "refresh": "Refresh", + "closeNotice": "Close notice", + "deleteRecord": "Delete record", + "copyId": "Copy task ID", + "back": "Back", + "close": "Close", + "confirmDeleteRecord": "Confirm deletion" + }, + "loading": { + "refresh": "Refreshing tasks", + "initial": "Loading tasks", + "running": "Task is running", + "action": "Performing task action" + }, + "empty": { + "title": "No matching tasks", + "description": "Adjust the source or status filter and try again." + }, + "progress": { + "speed": "Speed {{speed}}", + "eta": "ETA {{eta}}", + "items": "Items {{completed}}/{{total}}", + "failed": ", {{count}} failed", + "data": "Data {{transferred}} / {{total}}" + }, + "dialogs": { + "confirmAction": "Confirm {{action}}", + "retryTitle": "Confirm task retry", + "targetUnchanged": "Target remains: {{target}}", + "confirmRetry": "Confirm retry", + "dismissTitle": "Confirm removal from task center", + "dismissCount": "{{count}} terminal records will be removed.", + "dismissDescription": "This only hides records from the task center. It does not delete files, cloud objects, subscription configuration, or underlying task data. The record will reappear if its status changes." + }, + "notices": { + "subscriptionDefault": "Channel subscription now follows the system default storage", + "targetCleared": "Telegram conversation target cleared", + "cancelled": "Task cancelled", + "resubmitted": "Task resubmitted", + "dismissPartial": "Removed {{dismissed}}; {{failed}} were not removed because their state changed", + "dismissed": "Removed {{count}} records from the task center" + }, + "errors": { + "load": "Could not load tasks", + "action": "Task action failed", + "preview": "Could not create deletion preview", + "dismiss": "Could not delete task records" } } } diff --git a/frontend/src/locales/runtime-en.json b/frontend/src/locales/runtime-en.json deleted file mode 100644 index d1bf0a8..0000000 --- a/frontend/src/locales/runtime-en.json +++ /dev/null @@ -1,321 +0,0 @@ -{ - "首次启动,请创建唯一管理员密码": "First launch: create the only administrator password", - "请输入访问密码": "Enter your access password", - "双重身份验证": "Two-factor authentication", - "网页管理员密码至少需要 8 位": "The web administrator password must be at least 8 characters", - "两次输入的网页密码不一致": "The web passwords do not match", - "Telegram Bot 密码必须是 4 位数字": "The Telegram Bot PIN must be exactly 4 digits", - "网页密码不能与 Telegram Bot 4 位密码相同": "The web password cannot be the same as the 4-digit Telegram Bot PIN", - "初始化接口未就绪": "The initialization service is not ready", - "初始化失败": "Initialization failed", - "初始化请求失败": "Initialization request failed", - "请输入密码": "Enter your password", - "登录失败": "Sign-in failed", - "登录请求失败": "Sign-in request failed", - "请输入 6 位数字验证码": "Enter the 6-digit verification code", - "验证请求失败": "Verification request failed", - "网页管理员密码": "Web administrator password", - "访问密码": "Access password", - "至少 8 位,建议使用强密码": "At least 8 characters; use a strong password", - "隐藏密码": "Hide password", - "显示密码": "Show password", - "确认网页密码": "Confirm web password", - "再次输入网页管理员密码": "Re-enter the web administrator password", - "Telegram Bot 密码(4 位数字)": "Telegram Bot PIN (4 digits)", - "正在创建管理员": "Creating administrator", - "正在登录": "Signing in", - "创建管理员并进入": "Create administrator and continue", - "登录": "Sign in", - "返回修改密码": "Back to edit password", - "输入身份验证码": "Enter verification code", - "请输入您身份验证器 App 生成的 6 位数字": "Enter the 6-digit code generated by your authenticator app", - "正在验证身份验证码": "Verifying code", - "验证并登录": "Verify and sign in", - "生产环境首次访问必须先创建网页密码和 Telegram Bot 4 位密码": "On first access in production, create a web password and a 4-digit Telegram Bot PIN", - "登录状态将保留 7 天": "You will stay signed in for 7 days", - "连接可用": "Connection available", - "连接失败": "Connection failed", - "尚未测试": "Not tested", - "测试连接": "Test connection", - "正在测试存储连接": "Testing storage connection", - "连接测试成功": "Connection test succeeded", - "连接测试失败,请稍后重试": "Connection test failed. Try again later", - "取消": "Cancel", - "知道了": "Got it", - "确认": "Confirm", - "操作结果": "Result", - "请确认": "Please confirm", - "请输入": "Please enter", - "确定删除 ${cleanupRetentionDays} 天前已完成的 Telegram 下载任务历史吗?\n\n只删除任务审计明细,不删除文件索引,也不删除云端文件。": "Delete completed Telegram download task history older than ${cleanupRetentionDays} days?\n\nThis only deletes task audit details. File indexes and cloud files will not be deleted.", - "删除任务历史": "Delete task history", - "已删除 ${result.deletedCount} 条已完成下载任务历史。": "Deleted ${result.deletedCount} completed download task records.", - "删除下载任务历史失败": "Failed to delete download task history", - "操作失败": "Operation failed", - "Telegram 允许用户列表已保存": "Telegram allowed users saved", - "更新 Telegram 允许用户列表失败": "Failed to update Telegram allowed users", - "保存失败": "Save failed", - "“${account.name}”连接测试成功": "Connection test succeeded for “${account.name}”", - "存储账户连接测试失败": "Storage account connection test failed", - "连接测试失败": "Connection test failed", - "本地存储": "Local storage", - "阿里云 OSS": "Alibaba Cloud OSS", - "S3 兼容存储": "S3-compatible storage", - "WebDAV 存储": "WebDAV storage", - "(指定账户)": " (specified account)", - "确定要把系统默认存储切换到 ${providerName}${accountId ? '(指定账户)' : ''}吗?\n\n这会影响所有用户后续新提交的任务;已经提交的上传、Telegram 和 yt-dlp 任务仍使用原目标。切换前会执行只读连接测试。": "Switch the system default storage to ${providerName}${accountId ? ' (specified account)' : ''}?\n\nThis affects all newly submitted tasks for every user. Existing upload, Telegram, and yt-dlp tasks will continue using their original destination. A read-only connection test will run before switching.", - "切换系统默认存储": "Switch system default storage", - "已成功切换到 ${providerName}": "Successfully switched to ${providerName}", - "请填写 Client ID 和 Client Secret": "Enter the Client ID and Client Secret", - "信息不完整": "Incomplete information", - "授权窗口被浏览器拦截,请允许弹窗后重试": "The authorization window was blocked. Allow pop-ups and try again.", - "Google Drive 授权成功并已启用!": "Google Drive authorized and enabled successfully!", - "发起授权失败: ": "Failed to start authorization: ", - "授权失败": "Authorization failed", - "确定要删除账户 “${accountName}” 吗?\n\n将删除该账户在 TG Vault 中的文件索引记录,但不会删除云端原文件。": "Delete account “${accountName}”?\n\nThis will delete the account's file index records from TG Vault, but not the original cloud files.", - "删除存储账户": "Delete storage account", - "请填写 Client ID": "Enter the Client ID", - "OneDrive 授权成功并已启用!": "OneDrive authorized and enabled successfully!", - "请填写所有必填项": "Complete all required fields", - "阿里云 OSS 账户添加成功!": "Alibaba Cloud OSS account added successfully!", - "添加阿里云 OSS 账户失败: ": "Failed to add Alibaba Cloud OSS account: ", - "添加失败": "Add failed", - "S3 兼容存储账户添加成功!": "S3-compatible storage account added successfully!", - "添加 S3 兼容存储账户失败: ": "Failed to add S3-compatible storage account: ", - "请填写账户名称和 URL": "Enter the account name and URL", - "WebDAV 存储账户添加成功!": "WebDAV storage account added successfully!", - "添加 WebDAV 存储账户失败: ": "Failed to add WebDAV storage account: ", - "验证失败": "Verification failed", - "为了安全,请确认您的管理员密码以禁用 2FA:": "For security, enter your administrator password to disable 2FA:", - "禁用双重验证": "Disable two-factor authentication", - "禁用失败": "Failed to disable", - "新密码至少需要 8 位": "The new password must be at least 8 characters", - "两次输入的新密码不一致": "The new passwords do not match", - "修改密码失败": "Failed to change password", - "确定退出所有设备吗?当前浏览器也需要重新登录。": "Sign out of all devices? You will also need to sign in again in this browser.", - "退出所有设备": "Sign out of all devices", - "退出所有设备失败": "Failed to sign out of all devices", - "安全设置": "Security settings", - "修改管理员密码": "Change administrator password", - "修改成功后会撤销全部 Web 会话,所有设备都需要使用新密码重新登录。": "After the password is changed, all web sessions will be revoked and every device must sign in again with the new password.", - "当前密码": "Current password", - "新密码(至少 8 位)": "New password (at least 8 characters)", - "再次输入新密码": "Re-enter new password", - "修改中...": "Changing...", - "修改并退出全部设备": "Change and sign out all devices", - "退出当前设备": "Sign out of this device", - "立即撤销当前浏览器的登录会话。": "Immediately revoke the current browser session.", - "退出": "Sign out", - "撤销当前密码下签发的全部 Web 会话。": "Revoke all web sessions issued under the current password.", - "全部退出": "Sign out all", - "双重验证 (2FA)": "Two-factor authentication (2FA)", - "启用 TOTP 二次验证以保护您的账户安全。支持 Google Authenticator, Authy 等应用。": "Protect your account with TOTP two-factor authentication. Supports Google Authenticator, Authy, and similar apps.", - "已启用": "Enabled", - "加载中...": "Loading...", - "隐藏设置": "Hide settings", - "重新配置": "Reconfigure", - "立即设置": "Set up now", - "禁用": "Disable", - "1. 扫描二维码": "1. Scan the QR code", - "使用您的 2FA App(如 Google Authenticator)扫描此二维码。": "Scan this QR code with your 2FA app, such as Google Authenticator.", - "2. 验证并激活": "2. Verify and activate", - "输入 App 生成的 6 位验证码以确认设置。": "Enter the 6-digit code generated by the app to confirm setup.", - "激活中...": "Activating...", - "验证激活": "Verify and activate", - "状态:已激活": "Status: Active", - "您的账户已受到 2FA 保护。登录时将要求输入验证码。": "Your account is protected by 2FA. A verification code will be required when signing in.", - "无法加载 2FA 信息": "Unable to load 2FA information", - "数据维护": "Data maintenance", - "清理已完成下载明细": "Clean up completed download records", - "删除旧的 Telegram 下载审计明细,降低大型下载任务长期占用;不会删除文件索引或云端文件。": "Delete old Telegram download audit records to reduce long-term storage use by large download tasks. File indexes and cloud files will not be deleted.", - "保留 1 天": "Keep 1 day", - "保留 7 天": "Keep 7 days", - "保留 30 天": "Keep 30 days", - "保留 90 天": "Keep 90 days", - "清理中...": "Cleaning...", - "立即清理": "Clean up now", - "Telegram Bot 设置": "Telegram Bot settings", - "允许使用 Bot 的 Telegram 用户": "Telegram users allowed to use the bot", - "由环境变量管理": "Managed by environment variable", - "已配置 ${config.telegramAllowedUserIds.length} 个": "${config.telegramAllowedUserIds.length} configured", - "未配置": "Not configured", - "输入允许通过 Telegram Bot PIN 登录的 user id,多个用英文逗号、空格或换行分隔。空列表会拒绝所有用户;首次无人认证时,首个正确输入 PIN 的用户会自动加入。": "Enter the user IDs allowed to sign in using the Telegram Bot PIN. Separate multiple IDs with commas, spaces, or line breaks. An empty list rejects all users. If no user has authenticated yet, the first user to enter the correct PIN will be added automatically.", - "例如:123456789, 987654321": "Example: 123456789, 987654321", - "获取 user id:让用户在 Telegram 私聊 @userinfobot 查看 Id。": "To get a user ID, ask the user to message @userinfobot privately on Telegram and check the ID.", - " 当前后端设置了 TELEGRAM_ALLOWED_USER_IDS,请修改 .env 并重启后端。": " TELEGRAM_ALLOWED_USER_IDS is currently set on the backend. Update .env and restart the backend.", - "保存中...": "Saving...", - "保存允许列表": "Save allowed users", - "Telegram 下载设置": "Telegram download settings", - "账号级下载器": "User-account downloader", - "未启用": "Not enabled", - "已开启": "Enabled", - "session 未就绪": "Session not ready", - "开启后,文件下载优先走已登录的 Telegram 用户账号 session": "When enabled, file downloads use the signed-in Telegram user account session first.", - "更新 Telegram 下载设置失败": "Failed to update Telegram download settings", - "停用账号级下载": "Disable user-account downloads", - "启用账号级下载": "Enable user-account downloads", - "这项功能需要你先准备用户账号 session": "This feature requires a prepared user account session", - "1. 申请 Telegram 的 api_id 和 api_hash": "1. Obtain an api_id and api_hash from Telegram", - "2. 运行 npm run login:telegram-user 生成 session 文件": "2. Run npm run login:telegram-user to generate the session file", - "3. 在后端环境变量里填写 TELEGRAM_API_ID、TELEGRAM_API_HASH、TELEGRAM_USER_SESSION_FILE": "3. Set TELEGRAM_API_ID, TELEGRAM_API_HASH, and TELEGRAM_USER_SESSION_FILE in the backend environment", - "在 .env 中配置": "Configure in .env", - "打开 Telegram API 页面": "Open Telegram API page", - "查看参考文档": "View documentation", - "提示:启用后,只有已登录的用户账号 session 会参与下载;如果 session 未准备好,下载会报错并停止,避免悄悄回退到旧逻辑。": "Note: Once enabled, downloads only use a signed-in user account session. If the session is not ready, the download will fail and stop instead of silently falling back to the previous behavior.", - "存储源设置": "Storage provider settings", - "首次配置?请参阅": "First time setting this up? See", - "存储源配置指南": "Storage provider configuration guide", - "查看详细教程。": "for detailed instructions.", - "本地存储 (Local)": "Local storage", - "文件存储在服务器本地磁盘。适合常规使用,速度最快。": "Files are stored on the server's local disk. Best for general use and offers the fastest performance.", - "正在使用": "In use", - "切换使用": "Switch to this provider", - "Google Drive 账户": "Google Drive accounts", - "管理及切换多个 Google Drive 账户": "Manage and switch between multiple Google Drive accounts", - "取消添加": "Cancel adding", - "添加新账户": "Add account", - "未命名账户": "Unnamed account", - "切换到此账户": "Switch to this account", - "尚未配置 Google Drive 账户": "No Google Drive accounts configured", - "立即添加": "Add now", - "Google Drive API 凭证": "Google Drive API credentials", - "前往 Google Cloud Console 创建 OAuth 2.0 客户端 ID。": "Go to Google Cloud Console and create an OAuth 2.0 client ID.", - "应用类型选择 Web 应用程序,并添加以下已授权的重定向 URI:": "Select Web application as the application type and add the following authorized redirect URI:", - "OAuth 2.0 客户端 ID": "OAuth 2.0 client ID", - "Web 应用程序": "Web application", - "已授权的重定向 URI": "Authorized redirect URI", - "账户名称 (显示名称)": "Account name (display name)", - "例如: 我的 Google Drive": "Example: My Google Drive", - "客户端 ID (Client ID)": "Client ID", - "客户端密钥 (Client Secret)": "Client secret", - "共享云端硬盘 ID / 团队盘 ID(可选)": "Shared drive ID / Team drive ID (optional)", - "例如: 0Axxxxxxxxxxxxxxxxx": "Example: 0Axxxxxxxxxxxxxxxxx", - "留空则使用“我的云端硬盘”。如需上传到共享云端硬盘,请填写 URL 中 folders/ 后面的共享盘 ID;授权账号必须已加入该共享盘并具备创建文件权限。": "Leave blank to use “My Drive.” To upload to a shared drive, enter the shared drive ID that appears after folders/ in its URL. The authorized account must be a member of the shared drive and have permission to create files.", - "开始授权": "Start authorization", - "点击按钮前往 Google 页面完成授权。": "Click the button to complete authorization on Google.", - "发起中...": "Starting...", - "保存并授权": "Save and authorize", - "关闭": "Close", - "Microsoft OneDrive 账户": "Microsoft OneDrive accounts", - "管理及切换多个 OneDrive 账户": "Manage and switch between multiple OneDrive accounts", - "尚未配置 OneDrive 账户": "No OneDrive accounts configured", - "Entra ID (Azure) 应用信息": "Entra ID (Azure) application details", - "前往 Microsoft Entra ID 控制台 并登录。授权账号可与最终存储账号不同。": "Go to the Microsoft Entra ID portal and sign in. The authorizing account may differ from the final storage account.", - "注册应用时,重定向 URI 请选择 Web,并填写:": "When registering the application, select Web for the redirect URI and enter:", - "Microsoft Entra ID 控制台": "Microsoft Entra ID portal", - "重定向 URI": "Redirect URI", - "如果填写客户端密码,请复制 Azure「证书和密码」里新建密码后的值 Value;不要复制“机密 ID/Secret ID”。复制错会导致 Microsoft 返回 AADSTS7000215 Invalid client secret。": "If you provide a client secret, copy the Value shown after creating it under Azure “Certificates & secrets.” Do not copy the “Secret ID.” Copying the wrong field causes Microsoft to return AADSTS7000215 Invalid client secret.", - "证书和密码": "Certificates & secrets", - "值 Value": "Value", - "机密 ID/Secret ID": "Secret ID", - "应用程序 (客户端) ID": "Application (client) ID", - "租户 ID (Tenant ID)": "Tenant ID", - "默认为 common": "Defaults to common", - "账户名称 (可选)": "Account name (optional)", - "自定义显示名称,例如:个人网盘": "Custom display name, for example: Personal Drive", - "客户端密码 (Client Secret - 可选)": "Client secret (optional)", - "公共客户端模式可不填": "Leave blank for public client mode", - "开始授权新账户": "Authorize a new account", - "点击下方按钮前往微软页面完成授权,系统将自动识别并添加该账户。": "Click the button below to complete authorization on Microsoft. The account will be detected and added automatically.", - "阿里云 OSS 设置": "Alibaba Cloud OSS settings", - "Aliyun OSS 账户": "Alibaba Cloud OSS accounts", - "管理及切换多个阿里云 OSS 存储源": "Manage and switch between multiple Alibaba Cloud OSS storage providers", - "尚未配置 Aliyun OSS 账户": "No Alibaba Cloud OSS accounts configured", - "阿里云 OSS 凭证信息": "Alibaba Cloud OSS credentials", - "请提供您的阿里云 OSS 访问凭证。建议使用具有最小权限的 RAM 用户。": "Provide your Alibaba Cloud OSS access credentials. A RAM user with minimum required permissions is recommended.", - "账户显示名称": "Account display name", - "例如:我的备份 OSS": "Example: My Backup OSS", - "区域 (Region)": "Region", - "存储空间 (Bucket)": "Bucket", - "保存配置": "Save configuration", - "保存后系统将尝试连接此 OSS 账户。": "The system will test the connection to this OSS account after saving.", - "正在保存...": "Saving...", - "保存账户": "Save account", - "S3 兼容存储设置": "S3-compatible storage settings", - "S3 兼容存储账户": "S3-compatible storage accounts", - "管理及切换多个 S3 (MinIO, Cloudflare R2, AWS S3 等) 存储源": "Manage and switch between multiple S3 storage providers, including MinIO, Cloudflare R2, and AWS S3", - "尚未配置 S3 兼容存储账户": "No S3-compatible storage accounts configured", - "S3 兼容存储凭证信息": "S3-compatible storage credentials", - "支持 MinIO, Cloudflare R2, AWS S3 等。请确保已开启跨域访问 (CORS)。": "Supports MinIO, Cloudflare R2, AWS S3, and other providers. Make sure cross-origin access (CORS) is enabled.", - "例如:我的 MinIO 存储": "Example: My MinIO Storage", - "节点地址 (Endpoint)": "Endpoint", - "强制路径风格 (Force Path Style) - MinIO 或私有化部署建议勾选": "Force path style — recommended for MinIO or self-hosted deployments", - "保存后系统将尝试连接此 S3 账户。": "The system will test the connection to this S3 account after saving.", - "WebDAV 存储设置": "WebDAV storage settings", - "WebDAV 存储账户": "WebDAV storage accounts", - "管理及切换多个 WebDAV (坚果云, InfiniCLOUD, Synology 等) 存储源": "Manage and switch between multiple WebDAV storage providers, including Nutstore, InfiniCLOUD, and Synology", - "尚未配置 WebDAV 存储账户": "No WebDAV storage accounts configured", - "WebDAV 凭证信息": "WebDAV credentials", - "请提供您的 WebDAV 服务器地址及登录凭证。": "Provide your WebDAV server address and sign-in credentials.", - "例如:我的坚果云": "Example: My Nutstore", - "服务器 URL": "Server URL", - "用户名 (可选)": "Username (optional)", - "WebDAV 用户名": "WebDAV username", - "密码 / 应用口令 (可选)": "Password / app password (optional)", - "WebDAV 密码": "WebDAV password", - "保存后系统将尝试连接此 WebDAV 账户。": "The system will test the connection to this WebDAV account after saving.", - "服务器存储": "Server storage", - "可用空间": "Available space", - "TG Vault 存储": "TG Vault storage", - "${storageStats.tgvault.fileCount} 个文件": "${storageStats.tgvault.fileCount} files", - "加载存储信息中...": "Loading storage information...", - "全部来源": "All sources", - "Web 上传": "Web uploads", - "Telegram 文件": "Telegram files", - "频道下载": "Channel downloads", - "频道订阅": "Channel subscriptions", - "全部状态": "All statuses", - "等待中": "Pending", - "运行中": "Running", - "已暂停": "Paused", - "等待恢复": "Waiting to resume", - "失败": "Failed", - "已中断": "Interrupted", - "需要重试": "Retry required", - "已完成": "Completed", - "已取消": "Cancelled", - "已计划": "Scheduled", - "已停用": "Disabled", - "排队等待": "Queued", - "扫描消息": "Scanning messages", - "下载源文件": "Downloading source file", - "上传到存储": "Uploading to storage", - "服务器处理中": "Processing on server", - "等待重新选择原文件": "Waiting for source file reselection", - "等待下次扫描": "Waiting for next scan", - "处理完成": "Processing complete", - "处理失败": "Processing failed", - "服务重启时中断": "Interrupted during service restart", - "需要人工重试": "Manual retry required", - "未记录存储": "Storage not recorded", - "根目录": "Root folder", - "${storage} / ${task.target.folder || '根目录'}": "${storage} / ${task.target.folder || 'Root folder'}", - "获取任务列表失败": "Failed to load tasks", - "任务已取消": "Task cancelled", - "任务已重新提交": "Task resubmitted", - "任务操作失败": "Task operation failed", - "任务中心": "Task Center", - "查看 Web、Telegram、频道订阅和 yt-dlp 的统一执行状态。": "View unified execution status for Web, Telegram, channel subscriptions, and yt-dlp.", - "进行中 ${summary.active}": "Active ${summary.active}", - "需处理 ${summary.failed}": "Needs attention ${summary.failed}", - "已完成 ${summary.completed}": "Completed ${summary.completed}", - "按任务来源筛选": "Filter by task source", - "按任务状态筛选": "Filter by task status", - "刷新": "Refresh", - "重试": "Retry", - "没有符合条件的任务": "No matching tasks", - "调整来源或状态筛选后再查看。": "Adjust the source or status filters and try again.", - "目标:${taskTarget(task)}": "Target: ${taskTarget(task)}", - "更新:${new Date(task.updatedAt).toLocaleString('zh-CN', { hour12: false })}": "Updated: ${new Date(task.updatedAt).toLocaleString('zh-CN', { hour12: false })}", - "速度 ${detailSpeed}": "Speed ${detailSpeed}", - "条目 ${task.counts.completed}/${task.counts.total}": "Items ${task.counts.completed}/${task.counts.total}", - ",失败 ${task.counts.failed}": ", ${task.counts.failed} failed", - "数据 ${formatBytes(task.bytes.transferred)} / ${formatBytes(task.bytes.total)}": "Data ${formatBytes(task.bytes.transferred)} / ${formatBytes(task.bytes.total)}", - "复制任务 ID": "Copy task ID", - "选择文件续传": "Select file to resume", - "确认取消任务": "Confirm task cancellation", - "确认重试任务": "Confirm task retry", - "目标保持为:${taskTarget(pendingAction.task)}": "Target remains: ${taskTarget(pendingAction.task)}", - "返回": "Back", - "确认取消": "Confirm cancellation", - "确认重试": "Confirm retry" -} diff --git a/frontend/src/locales/runtimeUiLocalization.test.ts b/frontend/src/locales/runtimeUiLocalization.test.ts deleted file mode 100644 index 163a477..0000000 --- a/frontend/src/locales/runtimeUiLocalization.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import fs from 'node:fs'; -import en from './runtime-en.json' with { type: 'json' }; - -const sources = ['../components/pages/SettingsPage.tsx', '../components/pages/TasksPage.tsx'] - .map(path => fs.readFileSync(new URL(path, import.meta.url), 'utf8')) - .join('\n'); - -test('runtime English catalog covers core hard-coded settings and task journeys', () => { - const required = ['安全设置', '数据维护', 'Telegram Bot 设置', '存储源设置', '任务中心', '全部来源', '全部状态', '没有符合条件的任务', '确认取消任务']; - for (const text of required) assert.equal(typeof (en as Record)[text], 'string', `missing ${text}`); - assert.match(sources, /任务中心/); -}); diff --git a/frontend/src/locales/runtimeUiLocalization.ts b/frontend/src/locales/runtimeUiLocalization.ts deleted file mode 100644 index 8321579..0000000 --- a/frontend/src/locales/runtimeUiLocalization.ts +++ /dev/null @@ -1,65 +0,0 @@ -import runtimeEn from './runtime-en.json'; - -const englishByChinese = runtimeEn as Record; -const chineseByEnglish = Object.fromEntries(Object.entries(englishByChinese).map(([zh, en]) => [en, zh])); -const originals = new WeakMap>(); - -function translateTemplate(text: string, english: boolean): string { - const forward: Array<[RegExp, string]> = [ - [/^已配置 (\d+) 个$/, '$1 configured'], [/^保留 (\d+) 天$/, 'Keep $1 days'], [/^(\d+) 个文件$/, '$1 files'], - [/^目标:/, 'Target: '], [/^更新:/, 'Updated: '], [/^速度 /, 'Speed '], [/^条目 /, 'Items '], [/^数据 /, 'Data '], - [/^进行中 (\d+)$/, 'Active $1'], [/^需处理 (\d+)$/, 'Needs attention $1'], [/^已完成 (\d+)$/, 'Completed $1'], - ]; - const reverse: Array<[RegExp, string]> = [ - [/^(\d+) configured$/, '已配置 $1 个'], [/^Keep (\d+) days$/, '保留 $1 天'], [/^(\d+) files$/, '$1 个文件'], - [/^Target: /, '目标:'], [/^Updated: /, '更新:'], [/^Speed /, '速度 '], [/^Items /, '条目 '], [/^Data /, '数据 '], - [/^Active (\d+)$/, '进行中 $1'], [/^Needs attention (\d+)$/, '需处理 $1'], [/^Completed (\d+)$/, '已完成 $1'], - ]; - let result = text; - for (const [pattern, replacement] of english ? forward : reverse) result = result.replace(pattern, replacement); - return result; -} - -function translateValue(value: string, english: boolean): string { - return (english ? englishByChinese[value] : chineseByEnglish[value]) || translateTemplate(value, english); -} - -export function localizeChineseText(root: ParentNode, language: string): void { - const english = language.toLowerCase().startsWith('en'); - const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); - const nodes: Text[] = []; - while (walker.nextNode()) nodes.push(walker.currentNode as Text); - for (const node of nodes) { - if (node.parentElement?.closest('script, style, code, pre')) continue; - const raw = node.nodeValue || ''; - const trimmed = raw.trim(); - if (!trimmed) continue; - const remembered = originals.get(node)?.text; - const source = english ? (remembered || trimmed) : (remembered || trimmed); - const translated = translateValue(source, english); - if (english && translated !== source && !remembered) originals.set(node, { text: source }); - if (!english && remembered) { - node.nodeValue = raw.replace(trimmed, remembered); - originals.delete(node); - } else if (translated !== trimmed) { - node.nodeValue = raw.replace(trimmed, translated); - } - } - root.querySelectorAll('[placeholder],[title],[aria-label]').forEach(element => { - for (const name of ['placeholder', 'title', 'aria-label']) { - const value = element.getAttribute(name); - if (!value) continue; - const key = `attr:${name}`; - const remembered = originals.get(element)?.[key]; - const source = remembered || value; - const translated = translateValue(source, english); - if (english && translated !== source && !remembered) originals.set(element, { ...(originals.get(element) || {}), [key]: source }); - if (!english && remembered) { - element.setAttribute(name, remembered); - const next = { ...(originals.get(element) || {}) }; - delete next[key]; - originals.set(element, next); - } else if (translated !== value) element.setAttribute(name, translated); - } - }); -} diff --git a/frontend/src/locales/zh.json b/frontend/src/locales/zh.json index d69960e..ccdf462 100644 --- a/frontend/src/locales/zh.json +++ b/frontend/src/locales/zh.json @@ -41,6 +41,47 @@ "loading": "加载中…", "noOtherFolders": "暂无其它文件夹" }, + "login": { + "setupTitle": "首次启动,请创建管理员账户", + "passwordTitle": "请输入访问密码", + "totpTitle": "双重身份验证", + "passwordLabel": "访问密码", + "adminPasswordLabel": "网页管理员密码", + "passwordPlaceholder": "请输入密码", + "adminPasswordPlaceholder": "至少 8 位,建议使用强密码", + "confirmPasswordLabel": "确认网页密码", + "confirmPasswordPlaceholder": "再次输入网页管理员密码", + "telegramPinLabel": "Telegram Bot PIN(4 位数字)", + "telegramPinHint": "Bot 已配置,PIN 必须正好是 4 位数字,并会加密保存。", + "create": "创建管理员并进入", + "signIn": "登录", + "signingIn": "正在登录", + "creating": "正在创建管理员", + "totpHeading": "输入身份验证码", + "totpHint": "请输入身份验证器 App 生成的 6 位数字。", + "verify": "验证并登录", + "verifying": "正在验证身份验证码", + "back": "返回修改密码", + "setupFooter": "生产环境首次访问必须先创建网页密码;Bot 已配置时还需创建 Telegram Bot 4 位 PIN。", + "sessionFooter": "登录状态将保留 7 天。", + "showPassword": "显示密码", + "hidePassword": "隐藏密码", + "errors": { + "passwordLength": "网页管理员密码至少需要 8 位", + "passwordMismatch": "两次输入的网页密码不一致", + "pinFormat": "Telegram Bot PIN 必须是 4 位数字", + "pinMatchesPassword": "网页密码不能与 Telegram Bot 4 位 PIN 相同", + "setupUnavailable": "初始化接口未就绪", + "setupFailed": "初始化失败", + "setupRequestFailed": "初始化请求失败", + "passwordRequired": "请输入密码", + "loginFailed": "登录失败", + "loginRequestFailed": "登录请求失败", + "totpFormat": "请输入 6 位数字验证码", + "totpFailed": "验证失败", + "totpRequestFailed": "验证请求失败" + } + }, "sidebar": { "uploadCenter": "上传中心", "files": "我的文件", @@ -107,13 +148,34 @@ "empty": { "title": "未找到文件", "description": "尝试拖拽文件到这里,或检查筛选条件。", - "root": { "title": "这里还没有文件", "description": "上传文件或创建文件夹,开始整理您的数字资产。" }, - "folder": { "title": "此文件夹为空", "description": "上传到当前位置,或返回上一级目录。" }, - "search": { "title": "没有匹配的文件", "description": "换个关键词,或清除搜索条件。" }, - "filter": { "title": "此分类暂无文件", "description": "切换分类或上传对应类型的文件。" }, - "offline": { "title": "无法连接服务器", "description": "检查网络连接后重试。" }, - "error": { "title": "文件列表加载失败", "description": "服务暂时不可用,请稍后重试。" }, - "stale": { "title": "正在显示上次的数据", "description": "刷新失败;当前内容可能不是最新状态。" }, + "root": { + "title": "这里还没有文件", + "description": "上传文件或创建文件夹,开始整理您的数字资产。" + }, + "folder": { + "title": "此文件夹为空", + "description": "上传到当前位置,或返回上一级目录。" + }, + "search": { + "title": "没有匹配的文件", + "description": "换个关键词,或清除搜索条件。" + }, + "filter": { + "title": "此分类暂无文件", + "description": "切换分类或上传对应类型的文件。" + }, + "offline": { + "title": "无法连接服务器", + "description": "检查网络连接后重试。" + }, + "error": { + "title": "文件列表加载失败", + "description": "服务暂时不可用,请稍后重试。" + }, + "stale": { + "title": "正在显示上次的数据", + "description": "刷新失败;当前内容可能不是最新状态。" + }, "clearSearch": "清除搜索", "clearFilter": "查看全部文件", "retry": "重试" @@ -201,6 +263,153 @@ "notTested": "尚未测试", "testing": "正在测试存储连接", "test": "测试连接" + }, + "security": { + "title": "安全设置", + "networkTitle": "网络与存储安全" + }, + "maintenance": { + "title": "数据维护", + "advancedTasks": "高级任务设置" + }, + "telegram": { + "botConnection": "Telegram Bot 连接", + "permissions": "Telegram Bot 用户权限", + "downloadSettings": "Telegram 下载设置" + }, + "storageSources": { + "title": "存储源设置", + "aliyunTitle": "阿里云 OSS 设置", + "s3Title": "S3 兼容存储设置", + "webdavTitle": "WebDAV 存储设置" + } + }, + "tasks": { + "title": "任务中心", + "subtitle": "查看和管理 Web、Telegram、频道订阅及 yt-dlp 任务。", + "subtitleScoped": "仅显示仍引用所选存储账户的任务或 Telegram 目标。", + "showAll": "显示全部任务", + "updated": "更新:{{time}}", + "runningNotDismissible": "任务运行中,暂不可删除记录", + "sources": { + "all": "全部来源", + "webUpload": "Web 上传", + "telegramFile": "Telegram 文件", + "channelDownload": "频道下载", + "telegramTarget": "Telegram 会话目标", + "ytdlp": "yt-dlp", + "subscription": "频道订阅" + }, + "statuses": { + "all": "全部状态", + "pending": "等待中", + "running": "运行中", + "paused": "已暂停", + "waiting": "等待恢复", + "failed": "失败", + "interrupted": "已中断", + "retryRequired": "需要重试", + "completed": "已完成", + "cancelled": "已取消", + "scheduled": "已计划", + "disabled": "已停用" + }, + "stages": { + "queued": "排队等待", + "scanning": "扫描消息", + "downloading": "下载源文件", + "uploading": "上传到存储", + "processing": "服务器处理中", + "awaitingFile": "等待重新选择原文件", + "resumable": "上传会话可续传", + "waitingForNextScan": "等待下次扫描", + "waitingForNextTask": "等待后续任务", + "completed": "处理完成", + "failed": "处理失败", + "interrupted": "服务重启时中断", + "retryRequired": "需要人工重试" + }, + "target": { + "path": "{{storage}} / {{folder}}", + "unknownStorage": "未记录存储", + "root": "根目录", + "label": "目标:{{target}}" + }, + "quickFilters": { + "all": "全部", + "active": "进行中", + "attention": "需处理", + "completed": "已完成" + }, + "filters": { + "sourceAria": "按任务来源筛选", + "statusAria": "按任务状态筛选" + }, + "selection": { + "enter": "选择任务", + "exit": "退出选择", + "count": "已选择 {{count}} 条", + "selectAll": "全选可删除", + "clear": "清空", + "delete": "删除所选", + "selectAria": "选择任务", + "deselectAria": "取消选择任务" + }, + "actions": { + "resume": "续传", + "retry": "重试", + "followDefault": "改为跟随默认", + "clearTarget": "清除目标", + "cancel": "取消", + "cleanTerminal": "清理终态记录", + "refreshAria": "刷新任务", + "refresh": "刷新", + "closeNotice": "关闭提示", + "deleteRecord": "删除记录", + "copyId": "复制任务 ID", + "back": "返回", + "close": "关闭", + "confirmDeleteRecord": "确认删除记录" + }, + "loading": { + "refresh": "正在刷新任务", + "initial": "正在加载任务", + "running": "任务正在运行", + "action": "正在执行任务操作" + }, + "empty": { + "title": "没有符合条件的任务", + "description": "调整来源或状态筛选后再查看。" + }, + "progress": { + "speed": "速度 {{speed}}", + "eta": "ETA {{eta}}", + "items": "条目 {{completed}}/{{total}}", + "failed": ",失败 {{count}}", + "data": "数据 {{transferred}} / {{total}}" + }, + "dialogs": { + "confirmAction": "确认{{action}}", + "retryTitle": "确认重试任务", + "targetUnchanged": "目标保持为:{{target}}", + "confirmRetry": "确认重试", + "dismissTitle": "确认从任务中心删除", + "dismissCount": "将删除 {{count}} 条终态记录。", + "dismissDescription": "只会从任务中心隐藏记录,不会删除任何文件、云端对象、订阅配置或底层任务数据。任务状态再次变化后会重新出现。" + }, + "notices": { + "subscriptionDefault": "频道订阅已改为跟随系统默认存储", + "targetCleared": "Telegram 会话目标已清除", + "cancelled": "任务已取消", + "resubmitted": "任务已重新提交", + "dismissPartial": "已删除 {{dismissed}} 条,{{failed}} 条因状态变化未删除", + "dismissed": "已从任务中心删除 {{count}} 条记录" + }, + "errors": { + "load": "获取任务列表失败", + "action": "任务操作失败", + "preview": "无法创建删除预览", + "dismiss": "删除任务记录失败" } } } diff --git a/frontend/src/services/actionErrorWiring.test.ts b/frontend/src/services/actionErrorWiring.test.ts index b6a8032..4d46865 100644 --- a/frontend/src/services/actionErrorWiring.test.ts +++ b/frontend/src/services/actionErrorWiring.test.ts @@ -22,5 +22,6 @@ test('file card and preview render actionable classified failure copy', () => { test('unauthorized action errors trigger the shared logout path', () => { assert.match(card, /error instanceof ApiActionError && error.kind === 'unauthorized'/); assert.match(preview, /error instanceof ApiActionError && error.kind === 'unauthorized'/); - assert.match(card, /authService\.clearToken/); + assert.match(card, /authService\.invalidateSession/); + assert.match(preview, /authService\.invalidateSession/); }); diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 58e0447..804470d 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -1,339 +1,64 @@ import { authService } from './auth'; +import { ApiActionError } from './apiActionError'; +import { apiRequest } from './httpClient'; import { sha256Hex } from './chunkHash'; import { API_BASE } from './config'; -import { classifyBatchDeleteResponse, type BatchDeleteResult } from './batchDeleteContract'; +import { classifyBatchDeleteResponse } from './batchDeleteContract'; import { chunkBounds, parseChunkUploadInit } from './chunkUploadProtocol'; import { apiActionErrorFromResponse } from './apiActionError'; import { parseXhrError } from './xhrError'; +import { getApiHeaders } from './clients/clientHeaders'; +import { systemClient } from './clients/systemClient'; +import { tasksClient } from './clients/tasksClient'; +import type { + AdvancedTaskSettings, + BatchDeletePreview, + BatchDeleteResult, + ChunkUploadCancelStatus, + ChunkUploadSession, + CreateYtDlpTaskResult, + FileData, + FileQueryOptions, + FilesPage, + FolderAggregation, + FolderMovePreview, + MediaStatus, + OAuthStartResult, + StorageAccount, + StorageConfig, + StorageDeleteImpact, + StorageStats, + TaskDismissalInput, + TaskDismissalPreview, + TaskDismissalResult, + TaskFilters, + TelegramBotPublicConfig, + TelegramPermissionCheckResult, + TelegramUserAccountStatus, + TelegramUserAccountsOverview, + TelegramUserLoginComplete, + TelegramUserLoginStatus, + TelegramUserPhoneLoginStarted, + TelegramUserQrLoginStarted, + UnifiedTaskList, + UnifiedTaskSource, + UpdateStatus, + UploadCapabilities, + UploadProgress, + UploadTargetSnapshot, +} from './apiTypes'; -export interface FileData { - id: string; - name: string; - stored_name: string; - type: 'image' | 'video' | 'audio' | 'document' | 'other'; - mime_type: string; - size: string; - date: string; - thumbnailUrl?: string; - previewUrl: string; - width?: number; - height?: number; - source?: string; - folder?: string; - created_at: string; - is_favorite?: boolean; -} +export type * from './apiTypes'; -export interface MediaStatus { - available: boolean; - source?: string; - code?: 'MEDIA_SOURCE_MISSING' | 'MEDIA_QUOTA_EXCEEDED' | 'MEDIA_RATE_LIMITED' | 'MEDIA_UPSTREAM_UNAVAILABLE' | 'FILE_NOT_FOUND'; - error?: string; - reason?: 'not_found' | 'trashed'; -} - -export interface StorageCapabilities { - share: boolean; - sharePassword: boolean; - shareExpiration: boolean; - quota: boolean; - userDelete: boolean; -} - -export interface StorageStats { - provider: string; - accountId: string | null; - capabilities: StorageCapabilities; - temporary: { totalBytes: number; usedBytes: number; freeBytes: number; usedPercent: number }; - indexed: { usedBytes: number; fileCount: number }; - remoteQuota: { totalBytes: number; usedBytes: number; freeBytes: number; usedPercent: number } | null; - health: { probeStatus: 'available' | 'failed' | null; lastProbedAt: string | null; cooldownUntil: string | null; cooldownReason: string | null }; - server: { - total: string; - totalBytes: number; - used: string; - usedBytes: number; - free: string; - freeBytes: number; - usedPercent: number; - }; - tgvault: { - used: string; - usedBytes: number; - fileCount: number; - usedPercent?: number; - }; -} - -export interface UploadProgress { - loaded: number; - total: number; - percent: number; -} - -export interface UploadCapabilities { - acceptsAnyFile: boolean; - simpleUploadThresholdBytes: number; - simpleUploadMaxBytes: number; - chunkBytes: number; - maxChunkUploadBytes: number; - globalSessionBudgetBytes: number; - maxChunks: number; - sessionTtlMs: number; -} - -export interface UploadTargetSnapshot { - provider: string; - accountId: string | null; - accountName?: string | null; - folder?: string | null; -} - -export interface ChunkUploadSession { - uploadId: string; - filename: string; - mimeType: string; - folder: string | null; - status: 'open' | 'completing' | 'failed'; - totalChunks: number; - uploadedChunks: number[]; - uploadedChunkHashes: Record; - receivedBytes: number; - totalSize: number; - progress: number; - maxChunkBytes: number; - targetProvider: string; - targetAccountId: string | null; - targetAccountName?: string | null; - expiresAt: string; - error?: string | null; -} - -export type ChunkUploadCancelStatus = 'cancelled' | 'busy' | 'terminal' | 'not_found'; - -export type UnifiedTaskSource = 'telegram_bot' | 'telegram_channel' | 'ytdlp' | 'web_upload' | 'subscription' | 'telegram_target'; - -export interface UnifiedTask { - id: string; - sourceType: UnifiedTaskSource; - kind: string; - title: string; - status: string; - stage: string; - progress: number; - ownerUserId: number | null; - chatId: string | null; - source: string | null; - target: { - provider: string | null; - accountId: string | null; - accountName: string | null; - folder: string | null; - }; - counts: { total: number; completed: number; failed: number }; - bytes: { total: number; transferred: number }; - detail: Record; - error: string | null; - retryable: boolean; - cancellable: boolean; - dismissible: boolean; - createdAt: string; - updatedAt: string; - finishedAt: string | null; -} - -export interface UnifiedTaskList { - tasks: UnifiedTask[]; - total: number; - returned: number; - generatedAt: string; -} - -export interface CreateYtDlpTaskResult { - success: true; - task: UnifiedTask; -} - -export interface TaskDismissalPreview { - confirmationToken: string; - snapshotId: string; - context: string; - expiresAt: number; - impact: { - count: number; - bySource: Record; - byStatus: Record; - filesDeleted: false; - cloudObjectsDeleted: false; - subscriptionsDeleted: false; - }; -} - -export interface TaskDismissalResult { - status: 'complete' | 'partial'; - dismissed: Array<{ sourceType: UnifiedTaskSource; id: string }>; - failed: Array<{ sourceType: UnifiedTaskSource; id: string; reason: string }>; - filesDeleted: false; - cloudObjectsDeleted: false; -} - -export interface StorageAccount { - id: string; - name: string; - type: string; - is_active: boolean; - capabilities: StorageCapabilities; - last_probe_status: 'available' | 'failed' | null; - last_probe_error: string | null; - last_probed_at: string | null; -} - -export interface TelegramBotPublicConfig { - configured: boolean; - enabled: boolean; - required: boolean; - pinConfigured: boolean; - source: 'web' | 'environment' | 'none'; - status: string; - bot: { username: string | null; displayName: string | null } | null; - lastConnectedAt: string | null; - lastError: string | null; - action: string | null; -} - -export interface UpdateStatus { - enabled: boolean; - currentVersion: string; - latestVersion: string | null; - updateAvailable: boolean; - releaseName: string | null; - releaseUrl: string | null; - publishedAt: string | null; - checkedAt: string | null; - stale: boolean; - error: string | null; -} - -export interface TelegramUserAccountStatus { - configured: boolean; - enabled: boolean; - connected: boolean; - account: { userId: string; username: string | null; displayName: string | null } | null; -} - -export interface TelegramUserLoginComplete { step: 'complete'; account: NonNullable } - -export interface StorageConfig { - provider: string; - activeAccountId: string | null; - activeAccountName?: string; - capabilities: StorageCapabilities; - accounts: StorageAccount[]; - redirectUri: string; - googleDriveRedirectUri: string; - telegramUserDownloadEnabled?: boolean; - telegramUserSessionReady?: boolean; - telegramUserClientStatus?: { status: string; userId: string | null; username: string | null; checkedAt: string | null; lastError: string | null; action: string | null }; - telegramAllowedUserIds?: number[]; - telegramAllowedUserIdsFromEnv?: boolean; - allowUnsafeWebdavEndpoints: boolean; -} - -export interface FolderMovePreview { - sourcePath: string; - destinationParent: string | null; - finalPath: string; - fileCount: number; - folderCount: number; - totalSizeBytes: number; - conflict: boolean; - conflictReason?: string; - noChange: boolean; -} - -export interface FilesPage { - files: FileData[]; - nextCursor: string | null; - hasMore: boolean; -} - -export interface FileQueryOptions { - cursor?: string | null; - limit?: number; - q?: string; - type?: 'image' | 'video' | 'audio' | 'document' | 'other' | 'media'; - folder?: string | null; - favorite?: boolean; - sort?: 'name' | 'date'; - direction?: 'asc' | 'desc'; - signal?: AbortSignal; -} - -export interface FolderAggregation { - name: string; - fileCount: number; - totalSizeBytes: number; - latestDate: string; - isFavorite: boolean; - coverFile: FileData | null; -} - -export interface BatchDeletePreview { - confirmationToken: string; - fileCount: number; - dataFileCount: number; - placeholderCount: number; - folderCount: number; - totalSizeBytes: number; - expiresAt: string; -} - -export type { BatchDeleteResult }; - -export interface AdvancedTaskSettings { - telegramDownloadWorkers: number; - telegramFileConcurrency: number; - duplicateMode: 'copy' | 'skip'; - autoCleanupOrphans: boolean; - telegramDownloadHistoryPolicy: 'errors_only' | 'all'; - highRisk: { telegramDownloadWorkers: boolean; telegramFileConcurrency: boolean }; -} - -export interface StorageDeleteImpact { - accountId: string; - accountName: string; - provider: string; - fileCount: number; - totalSizeBytes: number; - folderCount: number; - activeLeaseCount: number; - activeTaskCount: number; - activeUploadCount: number; - remoteObjectsDeleted: false; -} - -export interface OAuthStartResult { - authUrl: string; - flowNonce: string; - frontendOrigin: string; - expiresAt: string; -} - -// 获取带认证的请求头 -function getHeaders(additionalHeaders: Record = {}): HeadersInit { - return { - ...authService.getAuthHeaders(), - ...additionalHeaders, - }; -} +const getHeaders = getApiHeaders; async function postStorageAccount(path: string, body: unknown, providerLabel: string): Promise { const controller = new AbortController(); const timeout = window.setTimeout(() => controller.abort(), 65_000); let response: Response; try { - response = await fetch(`${API_BASE}${path}`, { + response = await apiRequest(`${API_BASE}${path}`, { credentials: 'include', method: 'POST', headers: getHeaders({ 'Content-Type': 'application/json' }), @@ -348,7 +73,6 @@ async function postStorageAccount(path: string, body: unknown, providerLabel: } finally { window.clearTimeout(timeout); } - if (response.status === 401) throw new Error('UNAUTHORIZED'); if (!response.ok) { const error = await response.json().catch(() => null); throw new Error(error?.error || `添加 ${providerLabel} 账户失败`); @@ -361,11 +85,10 @@ class FileAPI { async getUploadCapabilities(): Promise { if (!this.uploadCapabilitiesPromise) { - this.uploadCapabilitiesPromise = fetch(`${API_BASE}/api/upload/capabilities`, { + this.uploadCapabilitiesPromise = apiRequest(`${API_BASE}/api/upload/capabilities`, { credentials: 'include', headers: getHeaders(), }).then(async response => { - if (response.status === 401 || response.status === 428) throw new Error('UNAUTHORIZED'); if (!response.ok) throw new Error('获取上传能力失败'); return response.json(); }).catch(error => { @@ -384,12 +107,11 @@ class FileAPI { if (options.favorite !== undefined) params.set('favorite', String(options.favorite)); if (options.sort) params.set('sort', options.sort); if (options.direction) params.set('direction', options.direction); - const response = await fetch(`${API_BASE}/api/files?${params.toString()}`, { + const response = await apiRequest(`${API_BASE}/api/files?${params.toString()}`, { credentials: 'include', headers: getHeaders(), signal: options.signal, }); - if (response.status === 401 || response.status === 428) throw new Error('UNAUTHORIZED'); if (!response.ok) throw new Error('获取文件列表失败'); return response.json(); } @@ -401,12 +123,11 @@ class FileAPI { if (options.favorite !== undefined) params.set('favorite', String(options.favorite)); if (options.sort) params.set('sort', options.sort); if (options.direction) params.set('direction', options.direction); - const response = await fetch(`${API_BASE}/api/files/folders/aggregation?${params.toString()}`, { + const response = await apiRequest(`${API_BASE}/api/files/folders/aggregation?${params.toString()}`, { credentials: 'include', headers: getHeaders(), signal: options.signal, }); - if (response.status === 401 || response.status === 428) throw new Error('UNAUTHORIZED'); if (!response.ok) throw new Error('获取文件夹统计失败'); const payload = await response.json(); return payload.folders; @@ -420,21 +141,20 @@ class FileAPI { // 获取单个文件 async getFile(id: string): Promise { - const response = await fetch(`${API_BASE}/api/files/${id}`, { + const response = await apiRequest(`${API_BASE}/api/files/${id}`, { credentials: 'include', headers: getHeaders(), }); - if (response.status === 401 || response.status === 428) throw new Error('UNAUTHORIZED'); if (!response.ok) throw new Error('获取文件信息失败'); return response.json(); } async getMediaStatus(fileId: string): Promise { - const response = await fetch(`${API_BASE}/api/files/${fileId}/media-status`, { + const response = await apiRequest(`${API_BASE}/api/files/${fileId}/media-status`, { credentials: 'include', headers: getHeaders(), + classifyErrors: false, }); - if (response.status === 401 || response.status === 428) throw new Error('UNAUTHORIZED'); const payload = await response.json().catch(() => ({})); return { available: response.ok && payload.available !== false, @@ -458,11 +178,10 @@ class FileAPI { } async getIncompleteChunkUploads(): Promise { - const response = await fetch(`${API_BASE}/api/chunked/sessions`, { + const response = await apiRequest(`${API_BASE}/api/chunked/sessions`, { credentials: 'include', headers: getHeaders(), }); - if (response.status === 401 || response.status === 428) throw new Error('UNAUTHORIZED'); if (!response.ok) throw new Error('获取未完成上传失败'); const payload = await response.json(); return payload.sessions || []; @@ -490,12 +209,12 @@ class FileAPI { } async cancelChunkUpload(uploadId: string): Promise { - const response = await fetch(`${API_BASE}/api/chunked/${uploadId}`, { + const response = await apiRequest(`${API_BASE}/api/chunked/${uploadId}`, { credentials: 'include', method: 'DELETE', headers: getHeaders(), + acceptedStatuses: [404, 409], }); - if (response.status === 401 || response.status === 428) throw new Error('UNAUTHORIZED'); const payload = await response.json().catch(() => ({})); if (response.status === 409 && payload.status === 'busy') return 'busy'; if (response.status === 404) return 'not_found'; @@ -506,97 +225,23 @@ class FileAPI { } async createYtDlpTask(input: { url: string; format: 'best' | 'audio' }): Promise { - const response = await fetch(`${API_BASE}/api/tasks/ytdlp`, { - credentials: 'include', - method: 'POST', - headers: getHeaders({ 'Content-Type': 'application/json' }), - body: JSON.stringify(input), - }); - if (response.status === 401 || response.status === 428) throw new Error('UNAUTHORIZED'); - const payload = await response.json().catch(() => ({})); - if (!response.ok) throw new Error(payload.error || '创建 yt-dlp 任务失败'); - return payload; + return tasksClient.createYtDlpTask(input); } - async getTasks(filters: { source?: string; status?: string; accountId?: string; limit?: number } = {}): Promise { - const params = new URLSearchParams({ limit: String(filters.limit ?? 200) }); - if (filters.source) params.set('source', filters.source); - if (filters.status) params.set('status', filters.status); - if (filters.accountId) params.set('accountId', filters.accountId); - const response = await fetch(`${API_BASE}/api/tasks?${params.toString()}`, { - credentials: 'include', - headers: getHeaders(), - }); - if (response.status === 401 || response.status === 428) throw new Error('UNAUTHORIZED'); - if (!response.ok) { - const payload = await response.json().catch(() => ({})); - throw new Error(payload.error || '获取任务列表失败'); - } - return response.json(); + async getTasks(filters: TaskFilters = {}): Promise { + return tasksClient.getTasks(filters); } async controlTask(sourceType: UnifiedTaskSource, id: string, action: 'cancel' | 'retry'): Promise { - let confirmationToken: string | undefined; - if (action === 'cancel') { - const confirmation = await fetch(`${API_BASE}/api/tasks/${encodeURIComponent(sourceType)}/${encodeURIComponent(id)}/cancel-confirmation`, { - credentials: 'include', - method: 'POST', - headers: getHeaders(), - }); - if (confirmation.status === 401 || confirmation.status === 428) throw new Error('UNAUTHORIZED'); - const payload = await confirmation.json().catch(() => ({})); - if (!confirmation.ok || !payload.confirmationToken) { - throw new Error(payload.error || '无法创建任务取消确认'); - } - confirmationToken = String(payload.confirmationToken); - } - const response = await fetch(`${API_BASE}/api/tasks/${encodeURIComponent(sourceType)}/${encodeURIComponent(id)}/${action}`, { - credentials: 'include', - method: 'POST', - headers: getHeaders({ - 'Content-Type': 'application/json', - ...(confirmationToken ? { 'X-Confirmation-Token': confirmationToken } : {}), - }), - }); - if (response.status === 401 || response.status === 428) throw new Error('UNAUTHORIZED'); - if (!response.ok) { - const payload = await response.json().catch(() => ({})); - throw new Error(payload.message || payload.error || '任务操作失败'); - } + return tasksClient.controlTask(sourceType, id, action); } - async prepareTaskDismissal(input: { - tasks?: Array<{ sourceType: UnifiedTaskSource; id: string }>; - source?: string; - status?: string; - accountId?: string; - }): Promise { - const response = await fetch(`${API_BASE}/api/tasks/dismissals/prepare`, { - credentials: 'include', - method: 'POST', - headers: getHeaders({ 'Content-Type': 'application/json' }), - body: JSON.stringify(input), - }); - if (response.status === 401 || response.status === 428) throw new Error('UNAUTHORIZED'); - const payload = await response.json().catch(() => ({})); - if (!response.ok) throw new Error(payload.error || '无法创建任务删除预览'); - return payload; + async prepareTaskDismissal(input: TaskDismissalInput): Promise { + return tasksClient.prepareTaskDismissal(input); } async confirmTaskDismissal(preview: TaskDismissalPreview): Promise { - const response = await fetch(`${API_BASE}/api/tasks/dismissals/confirm`, { - credentials: 'include', - method: 'POST', - headers: getHeaders({ - 'Content-Type': 'application/json', - 'X-Confirmation-Token': preview.confirmationToken, - }), - body: JSON.stringify({ snapshotId: preview.snapshotId, context: preview.context }), - }); - if (response.status === 401 || response.status === 428) throw new Error('UNAUTHORIZED'); - const payload = await response.json().catch(() => ({})); - if (!response.ok && response.status !== 207) throw new Error(payload.error || '删除任务记录失败'); - return payload; + return tasksClient.confirmTaskDismissal(preview); } // 简单上传(适用于小文件) @@ -632,7 +277,9 @@ class FileAPI { reject(new Error('解析响应失败')); } } else { - reject(parseXhrError(xhr.status, xhr.responseText)); + const error = parseXhrError(xhr.status, xhr.responseText); + if (error instanceof ApiActionError && error.kind === 'unauthorized') authService.invalidateSession(error.status); + reject(error); } }); @@ -679,14 +326,14 @@ class FileAPI { maxChunkBytes = resumeSession.maxChunkBytes; totalChunks = resumeSession.totalChunks; if (resumeSession.status === 'failed') { - const retryResponse = await fetch(`${API_BASE}/api/chunked/${uploadId}/retry`, { + const retryResponse = await apiRequest(`${API_BASE}/api/chunked/${uploadId}/retry`, { credentials: 'include', method: 'POST', headers: getHeaders(), signal, }); if (!retryResponse.ok) throw new Error('服务器无法重新打开该上传会话'); } onProgress?.({ loaded: uploadedBytes, total: file.size, percent: Math.round((uploadedBytes / file.size) * 100) }); } else { - const initResponse = await fetch(`${API_BASE}/api/chunked/init`, { + const initResponse = await apiRequest(`${API_BASE}/api/chunked/init`, { credentials: 'include', method: 'POST', headers: getHeaders({ 'Content-Type': 'application/json' }), @@ -700,7 +347,6 @@ class FileAPI { }), signal, }); - if (initResponse.status === 401 || initResponse.status === 428) throw new Error('UNAUTHORIZED'); if (!initResponse.ok) { const payload = await initResponse.json().catch(() => ({})); throw new Error(payload.error || '初始化分块上传失败'); @@ -737,7 +383,7 @@ class FileAPI { const chunk = file.slice(start, end); const chunkHash = await sha256Hex(chunk); - const chunkResponse = await fetch(`${API_BASE}/api/chunked/chunk`, { + const chunkResponse = await apiRequest(`${API_BASE}/api/chunked/chunk`, { credentials: 'include', method: 'POST', headers: getHeaders({ @@ -751,7 +397,6 @@ class FileAPI { signal, }); - if (chunkResponse.status === 401 || chunkResponse.status === 428) throw new Error('UNAUTHORIZED'); if (!chunkResponse.ok) { const payload = await chunkResponse.json().catch(() => ({})); throw new Error(payload.error || `上传分块 ${chunkIndex + 1}/${totalChunks} 失败`); @@ -768,7 +413,7 @@ class FileAPI { } } - const completeResponse = await fetch(`${API_BASE}/api/chunked/complete`, { + const completeResponse = await apiRequest(`${API_BASE}/api/chunked/complete`, { credentials: 'include', method: 'POST', headers: getHeaders({ 'Content-Type': 'application/json' }), @@ -776,7 +421,6 @@ class FileAPI { signal, }); - if (completeResponse.status === 401 || completeResponse.status === 428) throw new Error('UNAUTHORIZED'); if (!completeResponse.ok) { const payload = await completeResponse.json().catch(() => ({})); throw new Error(payload.error || '完成分块上传失败'); @@ -818,31 +462,28 @@ class FileAPI { // 删除文件 async deleteFile(id: string): Promise<{ status: 'complete'; deletedIds: string[]; message: string }> { - const confirmationResponse = await fetch(`${API_BASE}/api/files/${id}/delete-confirmation`, { + const confirmationResponse = await apiRequest(`${API_BASE}/api/files/${id}/delete-confirmation`, { credentials: 'include', method: 'POST', headers: getHeaders(), }); - if (confirmationResponse.status === 401 || confirmationResponse.status === 428) throw new Error('UNAUTHORIZED'); if (!confirmationResponse.ok) throw new Error((await confirmationResponse.json().catch(() => ({}))).error || '无法创建删除确认'); const { confirmationToken } = await confirmationResponse.json(); - const response = await fetch(`${API_BASE}/api/files/${id}`, { + const response = await apiRequest(`${API_BASE}/api/files/${id}`, { credentials: 'include', method: 'DELETE', headers: getHeaders({ 'X-Confirmation-Token': confirmationToken }), }); - if (response.status === 401 || response.status === 428) throw new Error('UNAUTHORIZED'); const payload = await response.json().catch(() => ({})); if (!response.ok) throw new Error(payload.error || payload.details || '删除文件失败'); return payload; } async previewBatchDelete(fileIds: string[], folderNames: string[]): Promise { - const response = await fetch(`${API_BASE}/api/files/batch-delete/preview`, { + const response = await apiRequest(`${API_BASE}/api/files/batch-delete/preview`, { credentials: 'include', method: 'POST', headers: getHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ fileIds, folderNames }), }); - if (response.status === 401 || response.status === 428) throw new Error('UNAUTHORIZED'); if (!response.ok) { const error = await response.json().catch(() => ({})); throw new Error(error.error || '获取删除影响范围失败'); @@ -852,19 +493,19 @@ class FileAPI { // 批量删除 async batchDelete(confirmationToken: string): Promise { - const response = await fetch(`${API_BASE}/api/files/batch-delete`, { + const response = await apiRequest(`${API_BASE}/api/files/batch-delete`, { credentials: 'include', method: 'POST', headers: getHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ confirmationToken }), + classifyErrors: false, }); - if (response.status === 401 || response.status === 428) throw new Error('UNAUTHORIZED'); return classifyBatchDeleteResponse(response); } // 创建分享链接 async createShareLink(fileId: string, password?: string, expiration?: string): Promise<{ link: string }> { - const response = await fetch(`${API_BASE}/api/files/${fileId}/share`, { + const response = await apiRequest(`${API_BASE}/api/files/${fileId}/share`, { credentials: 'include', method: 'POST', headers: getHeaders({ 'Content-Type': 'application/json' }), @@ -877,7 +518,7 @@ class FileAPI { // 获取下载 URL (直接链接或签名链接) async getDownloadLink(id: string): Promise { - const response = await fetch(`${API_BASE}/api/files/${id}/download-url`, { + const response = await apiRequest(`${API_BASE}/api/files/${id}/download-url`, { credentials: 'include', headers: getHeaders(), }); @@ -891,7 +532,7 @@ class FileAPI { } async getOriginalFileLink(id: string): Promise { - const response = await fetch(`${API_BASE}/api/files/${id}/original`, { credentials: 'include', headers: getHeaders(), redirect: 'manual' }); + const response = await apiRequest(`${API_BASE}/api/files/${id}/original`, { credentials: 'include', headers: getHeaders(), redirect: 'manual', acceptedStatuses: [301, 302, 303, 307, 308] }); if (!response.ok && response.type !== 'opaqueredirect') throw await apiActionErrorFromResponse(response, '打开原文件失败'); return response.headers.get('Location') || `${API_BASE}/api/files/${id}/original`; } @@ -919,16 +560,15 @@ class FileAPI { // 获取存储统计 async getAdvancedTaskSettings(): Promise { - const response = await fetch(`${API_BASE}/api/storage/config/advanced-tasks`, { + const response = await apiRequest(`${API_BASE}/api/storage/config/advanced-tasks`, { credentials: 'include', headers: getHeaders(), }); - if (response.status === 401 || response.status === 428) throw new Error('UNAUTHORIZED'); if (!response.ok) throw new Error((await response.json().catch(() => ({}))).error || '获取高级任务设置失败'); return response.json(); } async updateAdvancedTaskSetting(patch: Partial>, confirmed = false): Promise<{ success: boolean; deletedCount?: number }> { - const response = await fetch(`${API_BASE}/api/storage/config/advanced-tasks`, { + const response = await apiRequest(`${API_BASE}/api/storage/config/advanced-tasks`, { credentials: 'include', method: 'PATCH', headers: getHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ ...patch, confirmed }), @@ -943,35 +583,32 @@ class FileAPI { } async getStorageStats(): Promise { - const response = await fetch(`${API_BASE}/api/storage/stats`, { + const response = await apiRequest(`${API_BASE}/api/storage/stats`, { credentials: 'include', headers: getHeaders(), }); - if (response.status === 401 || response.status === 428) throw new Error('UNAUTHORIZED'); if (!response.ok) throw new Error('获取存储统计失败'); return response.json(); } // 获取存储配置 async getStorageConfig(): Promise { - const response = await fetch(`${API_BASE}/api/storage/config`, { + const response = await apiRequest(`${API_BASE}/api/storage/config`, { credentials: 'include', headers: getHeaders(), }); - if (response.status === 401 || response.status === 428) throw new Error('UNAUTHORIZED'); if (!response.ok) throw new Error('获取存储配置失败'); return response.json(); } async setUnsafeWebdavEndpointsAllowed(enabled: boolean, confirmed = false): Promise<{ success: boolean; allowUnsafeWebdavEndpoints: boolean }> { - const response = await fetch(`${API_BASE}/api/storage/config/webdav-security`, { + const response = await apiRequest(`${API_BASE}/api/storage/config/webdav-security`, { credentials: 'include', method: 'PATCH', headers: getHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ enabled, confirmed }), }); const payload = await response.json().catch(() => ({})); - if (response.status === 401 || response.status === 428) throw new Error('UNAUTHORIZED'); if (!response.ok) { const error = new Error(payload.error || '更新 WebDAV 安全设置失败') as Error & { code?: string }; error.code = payload.code; @@ -981,34 +618,17 @@ class FileAPI { } async getTelegramBotConfig(): Promise { - const response = await fetch(`${API_BASE}/api/storage/config/telegram-bot`, { credentials: 'include', headers: getHeaders() }); - if (response.status === 401 || response.status === 428) throw new Error('UNAUTHORIZED'); + const response = await apiRequest(`${API_BASE}/api/storage/config/telegram-bot`, { credentials: 'include', headers: getHeaders() }); if (!response.ok) throw new Error('获取 Telegram Bot 配置失败'); return response.json(); } async getUpdateStatus(): Promise { - const response = await fetch(`${API_BASE}/api/system/update-status`, { - credentials: 'include', - cache: 'no-store', - headers: getHeaders(), - }); - if (response.status === 401 || response.status === 428) throw new Error('UNAUTHORIZED'); - if (!response.ok) throw new Error('获取版本信息失败'); - return response.json(); + return systemClient.getUpdateStatus(); } async checkForUpdates(): Promise { - const response = await fetch(`${API_BASE}/api/system/update-check`, { - method: 'POST', - credentials: 'include', - cache: 'no-store', - headers: getHeaders(), - }); - if (response.status === 401 || response.status === 428) throw new Error('UNAUTHORIZED'); - if (response.status === 429) throw new Error('检查过于频繁,请稍后再试'); - if (!response.ok) throw new Error('检查版本失败'); - return response.json(); + return systemClient.checkForUpdates(); } async testTelegramBotConfig(input: { botToken: string; apiId: string; apiHash: string }): Promise<{ success: boolean; bot: { username: string | null; displayName: string | null } }> { @@ -1036,17 +656,61 @@ class FileAPI { } private async telegramBotConfigRequest(path: string, method: string, body: unknown): Promise { - const response = await fetch(`${API_BASE}${path}`, { + const response = await apiRequest(`${API_BASE}${path}`, { credentials: 'include', method, headers: getHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify(body), }); const payload = await response.json().catch(() => ({})); - if (response.status === 401 || response.status === 428) throw new Error('UNAUTHORIZED'); if (!response.ok) throw new Error(payload.error || 'Telegram Bot 配置操作失败'); return payload; } + async getTelegramUserAccounts(): Promise { + 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'); + return this.normalizeTelegramUserLoginStatus(payload) as TelegramUserQrLoginStarted; + } + + async getTelegramUserLoginStatus(flowId: string): Promise { + const payload = await this.telegramUserRequest(`/api/storage/config/telegram-user/login/${encodeURIComponent(flowId)}`, 'GET'); + return this.normalizeTelegramUserLoginStatus(payload); + } + + async cancelTelegramUserLogin(flowId: string): Promise<{ success: boolean }> { + 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 }); + return this.normalizeTelegramUserLoginStatus(payload) as TelegramUserPhoneLoginStarted; + } + + async submitTelegramUserLoginCode(flowId: string, code: string): Promise { + const payload = await this.telegramUserRequest('/api/storage/config/telegram-accounts/login/code', 'POST', { flowId, code }); + return this.normalizeTelegramUserLoginStatus(payload, flowId); + } + + async submitTelegramUserLoginPassword(flowId: string, password: string): Promise { + const payload = await this.telegramUserRequest('/api/storage/config/telegram-accounts/login/password', 'POST', { flowId, password }); + return this.normalizeTelegramUserLoginStatus(payload, flowId); + } + + async setTelegramUserAccountEnabled(accountId: string, enabled: boolean): Promise<{ success: boolean; enabled: boolean }> { + return this.telegramUserRequest(`/api/storage/config/telegram-user/accounts/${encodeURIComponent(accountId)}/${enabled ? 'enable' : 'disable'}`, 'POST'); + } + + async unlinkTelegramUserAccountById(accountId: string): Promise<{ success: boolean }> { + return this.telegramUserRequest(`/api/storage/config/telegram-user/accounts/${encodeURIComponent(accountId)}`, 'DELETE'); + } + + async checkTelegramUserAccountPermissions(accountId: string): Promise { + return this.telegramUserRequest(`/api/storage/config/telegram-user/accounts/${encodeURIComponent(accountId)}/permissions/check`, 'POST'); + } + async getTelegramUserAccount(): Promise { return this.telegramUserRequest('/api/storage/config/telegram-user', 'GET'); } @@ -1071,22 +735,42 @@ class FileAPI { return this.telegramUserRequest('/api/storage/config/telegram-user', 'DELETE'); } + private normalizeTelegramUserLoginStatus(payload: any, fallbackFlowId = ''): TelegramUserLoginStatus { + if (payload?.step === 'complete') { + return { flowId: String(payload.flowId || fallbackFlowId), status: 'complete', account: payload.account }; + } + if (payload?.step === 'password_required') { + return { flowId: String(payload.flowId || fallbackFlowId), status: 'password_required' }; + } + const rawStatus = String(payload?.status || (payload?.delivery ? 'code_required' : 'error')); + const status = rawStatus === 'pending' ? 'waiting_for_scan' : rawStatus as TelegramUserLoginStatus['status']; + const qrCode = typeof payload?.qrCode === 'string' + ? payload.qrCode + : typeof payload?.qrData === 'string' ? payload.qrData : undefined; + return { + flowId: String(payload?.flowId || fallbackFlowId), + status, + ...(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' ? '短信' : 'Telegram 应用' } : payload?.message ? { message: String(payload.message) } : {}), + } as TelegramUserLoginStatus; + } + private async telegramUserRequest(path: string, method: string, body?: unknown): Promise { - const response = await fetch(`${API_BASE}${path}`, { credentials: 'include', method, headers: getHeaders({ 'Content-Type': 'application/json' }), body: body === undefined ? undefined : JSON.stringify(body) }); + const response = await apiRequest(`${API_BASE}${path}`, { credentials: 'include', method, headers: getHeaders({ 'Content-Type': 'application/json' }), body: body === undefined ? undefined : JSON.stringify(body) }); const payload = await response.json().catch(() => ({})); - if (response.status === 401 || response.status === 428) throw new Error('UNAUTHORIZED'); if (!response.ok) throw new Error(payload.error || 'Telegram 用户账号操作失败'); return payload; } async setTelegramUserDownloadEnabled(enabled: boolean): Promise<{ success: boolean; enabled: boolean }> { - const response = await fetch(`${API_BASE}/api/storage/config/telegram-user-download`, { + const response = await apiRequest(`${API_BASE}/api/storage/config/telegram-user-download`, { credentials: 'include', method: 'POST', headers: getHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ enabled }), }); - if (response.status === 401 || response.status === 428) throw new Error('UNAUTHORIZED'); if (!response.ok) { const error = await response.json().catch(() => ({})); throw new Error(error.error || '更新 Telegram 用户下载设置失败'); @@ -1095,13 +779,12 @@ class FileAPI { } async setTelegramAllowedUserIds(userIds: string): Promise<{ success: boolean; userIds: number[] }> { - const response = await fetch(`${API_BASE}/api/storage/config/telegram-allowed-users`, { + const response = await apiRequest(`${API_BASE}/api/storage/config/telegram-allowed-users`, { credentials: 'include', method: 'POST', headers: getHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ userIds }), }); - if (response.status === 401 || response.status === 428) throw new Error('UNAUTHORIZED'); if (!response.ok) { const error = await response.json().catch(() => ({})); throw new Error(error.error || '更新 Telegram 允许用户列表失败'); @@ -1110,13 +793,12 @@ class FileAPI { } async cleanupDownloadItems(retentionDays: number = 7): Promise<{ success: boolean; deletedCount: number; retentionDays: number }> { - const response = await fetch(`${API_BASE}/api/storage/maintenance/download-items/cleanup`, { + const response = await apiRequest(`${API_BASE}/api/storage/maintenance/download-items/cleanup`, { credentials: 'include', method: 'POST', headers: getHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ retentionDays }), }); - if (response.status === 401 || response.status === 428) throw new Error('UNAUTHORIZED'); if (!response.ok) { const error = await response.json().catch(() => ({})); throw new Error(error.error || '清理下载任务明细失败'); @@ -1126,13 +808,12 @@ class FileAPI { // 更新 OneDrive 配置 async updateOneDriveConfig(clientId: string, clientSecret: string, refreshToken: string, tenantId: string = 'common', name?: string): Promise<{ success: boolean; message: string }> { - const response = await fetch(`${API_BASE}/api/storage/config/onedrive`, { + const response = await apiRequest(`${API_BASE}/api/storage/config/onedrive`, { credentials: 'include', method: 'PUT', headers: getHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ clientId, clientSecret, refreshToken, tenantId, name }), }); - if (response.status === 401 || response.status === 428) throw new Error('UNAUTHORIZED'); if (!response.ok) throw new Error('更新配置失败'); return response.json(); } @@ -1159,13 +840,12 @@ class FileAPI { // 切换存储提供商或账户 async switchStorageProvider(provider: 'local' | 'onedrive' | 'aliyun_oss' | 's3' | 'webdav' | 'openlist' | 'google_drive', accountId?: string): Promise<{ success: boolean; message: string; scope?: string; inFlightTargetsPreserved?: boolean }> { - const response = await fetch(`${API_BASE}/api/storage/switch`, { + const response = await apiRequest(`${API_BASE}/api/storage/switch`, { credentials: 'include', method: 'POST', headers: getHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ provider, accountId }), }); - if (response.status === 401 || response.status === 428) throw new Error('UNAUTHORIZED'); if (!response.ok) { const error = await response.json(); throw new Error(error.error || '切换存储失败'); @@ -1175,22 +855,20 @@ class FileAPI { // 获取所有账户 async getAccounts(): Promise { - const response = await fetch(`${API_BASE}/api/storage/accounts`, { + const response = await apiRequest(`${API_BASE}/api/storage/accounts`, { credentials: 'include', headers: getHeaders(), }); - if (response.status === 401 || response.status === 428) throw new Error('UNAUTHORIZED'); if (!response.ok) throw new Error('获取账户列表失败'); return response.json(); } async probeStorageAccount(accountId: string): Promise<{ success: boolean; accountId: string; provider: string; status: 'available'; checkedAt: string }> { - const response = await fetch(`${API_BASE}/api/storage/accounts/${encodeURIComponent(accountId)}/probe`, { + const response = await apiRequest(`${API_BASE}/api/storage/accounts/${encodeURIComponent(accountId)}/probe`, { credentials: 'include', method: 'POST', headers: getHeaders({ 'Content-Type': 'application/json' }), }); - if (response.status === 401 || response.status === 428) throw new Error('UNAUTHORIZED'); if (!response.ok) { const error = await response.json().catch(() => ({})); throw new Error(error.error || '存储账户连接测试失败'); @@ -1200,21 +878,19 @@ class FileAPI { // 删除账户:先获取影响快照,用户确认后再传回一次性令牌执行。 async previewAccountDeletion(accountId: string): Promise<{ confirmationToken: string; expiresAt: number; impact: StorageDeleteImpact }> { - const response = await fetch(`${API_BASE}/api/storage/accounts/${encodeURIComponent(accountId)}/delete-confirmation`, { + const response = await apiRequest(`${API_BASE}/api/storage/accounts/${encodeURIComponent(accountId)}/delete-confirmation`, { credentials: 'include', method: 'POST', headers: getHeaders(), }); - if (response.status === 401 || response.status === 428) throw new Error('UNAUTHORIZED'); if (!response.ok) throw new Error((await response.json().catch(() => ({}))).error || '无法创建删除确认'); return response.json(); } async deleteAccount(accountId: string, confirmationToken: string): Promise<{ success: boolean; message: string }> { - const response = await fetch(`${API_BASE}/api/storage/accounts/${encodeURIComponent(accountId)}`, { + const response = await apiRequest(`${API_BASE}/api/storage/accounts/${encodeURIComponent(accountId)}`, { credentials: 'include', method: 'DELETE', headers: getHeaders({ 'X-Confirmation-Token': confirmationToken }), }); - if (response.status === 401 || response.status === 428) throw new Error('UNAUTHORIZED'); if (!response.ok) { const error = await response.json(); throw new Error(error.error || '删除账户失败'); @@ -1223,7 +899,7 @@ class FileAPI { } async createFolder(folderName: string): Promise<{ success: boolean; folder: string }> { - const response = await fetch(`${API_BASE}/api/files/folders`, { + const response = await apiRequest(`${API_BASE}/api/files/folders`, { credentials: 'include', method: 'POST', headers: { @@ -1243,13 +919,12 @@ class FileAPI { // 重命名文件 async renameFile(id: string, name: string): Promise<{ success: boolean; name: string }> { - const response = await fetch(`${API_BASE}/api/files/${id}/rename`, { + const response = await apiRequest(`${API_BASE}/api/files/${id}/rename`, { credentials: 'include', method: 'PATCH', headers: getHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ name }), }); - if (response.status === 401 || response.status === 428) throw new Error('UNAUTHORIZED'); if (!response.ok) { const error = await response.json(); throw new Error(error.error || '重命名失败'); @@ -1259,13 +934,12 @@ class FileAPI { // 重命名文件夹 async renameFolder(oldName: string, newName: string): Promise<{ success: boolean; name: string }> { - const response = await fetch(`${API_BASE}/api/files/rename-folder`, { + const response = await apiRequest(`${API_BASE}/api/files/rename-folder`, { credentials: 'include', method: 'PATCH', headers: getHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ oldName, newName }), }); - if (response.status === 401 || response.status === 428) throw new Error('UNAUTHORIZED'); if (!response.ok) { const error = await response.json(); throw new Error(error.error || '重命名文件夹失败'); @@ -1275,13 +949,12 @@ class FileAPI { // 移动文件 async moveFile(id: string, folder: string | null): Promise<{ success: boolean; folder: string | null }> { - const response = await fetch(`${API_BASE}/api/files/${id}/move`, { + const response = await apiRequest(`${API_BASE}/api/files/${id}/move`, { credentials: 'include', method: 'PATCH', headers: getHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ folder }), }); - if (response.status === 401 || response.status === 428) throw new Error('UNAUTHORIZED'); if (!response.ok) { const error = await response.json(); throw new Error(error.error || '移动失败'); @@ -1291,13 +964,12 @@ class FileAPI { // 移动文件夹 async moveFolder(oldName: string, newName: string | null): Promise<{ success: boolean; folder: string | null }> { - const response = await fetch(`${API_BASE}/api/files/move-folder`, { + const response = await apiRequest(`${API_BASE}/api/files/move-folder`, { credentials: 'include', method: 'PATCH', headers: getHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ oldName, newName }), }); - if (response.status === 401 || response.status === 428) throw new Error('UNAUTHORIZED'); if (!response.ok) { const error = await response.json(); throw new Error(error.error || '移动文件夹失败'); @@ -1306,14 +978,13 @@ class FileAPI { } async previewMoveFolder(oldName: string, newName: string | null, signal?: AbortSignal): Promise { - const response = await fetch(`${API_BASE}/api/files/move-folder/preview`, { + const response = await apiRequest(`${API_BASE}/api/files/move-folder/preview`, { credentials: 'include', method: 'POST', headers: getHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ oldName, newName }), signal, }); - if (response.status === 401 || response.status === 428) throw new Error('UNAUTHORIZED'); const payload = await response.json().catch(() => ({})); if (!response.ok) throw new Error(payload.error || '获取移动影响范围失败'); return payload; @@ -1326,44 +997,39 @@ class FileAPI { // 切换文件收藏状态 async toggleFavorite(fileId: string): Promise<{ success: boolean; isFavorite: boolean }> { - const response = await fetch(`${API_BASE}/api/files/${fileId}/favorite`, { + const response = await apiRequest(`${API_BASE}/api/files/${fileId}/favorite`, { credentials: 'include', method: 'POST', headers: getHeaders({ 'Content-Type': 'application/json' }), }); - if (response.status === 401 || response.status === 428) throw new Error('UNAUTHORIZED'); if (!response.ok) throw new Error('切换收藏状态失败'); return response.json(); } // 切换文件夹收藏状态 async toggleFolderFavorite(folderName: string): Promise<{ success: boolean; isFavorite: boolean }> { - const response = await fetch(`${API_BASE}/api/files/folders/favorite`, { + const response = await apiRequest(`${API_BASE}/api/files/folders/favorite`, { credentials: 'include', method: 'POST', headers: getHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ folderName }), }); - if (response.status === 401 || response.status === 428) throw new Error('UNAUTHORIZED'); if (!response.ok) throw new Error('切换文件夹收藏状态失败'); return response.json(); } // 健康检查 async healthCheck(): Promise<{ status: string; timestamp: string }> { - const response = await fetch(`${API_BASE}/health`); - if (!response.ok) throw new Error('健康检查失败'); - return response.json(); + return systemClient.healthCheck(); } async getOneDriveAuthUrl(clientId: string, tenantId: string = 'common', clientSecret?: string, name?: string): Promise { - const response = await fetch(`${API_BASE}/api/storage/config/onedrive/auth-url`, { + const response = await apiRequest(`${API_BASE}/api/storage/config/onedrive/auth-url`, { credentials: 'include', method: 'POST', headers: getHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ clientId, tenantId, clientSecret, name }), }); - if (response.status === 401 || response.status === 428) throw new Error('UNAUTHORIZED'); if (!response.ok) { const error = await response.json(); throw new Error(error.error || '获取授权地址失败'); @@ -1372,13 +1038,12 @@ class FileAPI { } async getGoogleDriveAuthUrl(clientId: string, clientSecret: string, name?: string, sharedDriveId?: string): Promise { - const response = await fetch(`${API_BASE}/api/storage/config/google-drive/auth-url`, { + const response = await apiRequest(`${API_BASE}/api/storage/config/google-drive/auth-url`, { credentials: 'include', method: 'POST', headers: getHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ clientId, clientSecret, name, sharedDriveId }), }); - if (response.status === 401 || response.status === 428) throw new Error('UNAUTHORIZED'); if (!response.ok) { const error = await response.json(); throw new Error(error.error || '获取授权地址失败'); diff --git a/frontend/src/services/apiActionError.test.ts b/frontend/src/services/apiActionError.test.ts index df1bb4a..b91d595 100644 --- a/frontend/src/services/apiActionError.test.ts +++ b/frontend/src/services/apiActionError.test.ts @@ -6,7 +6,7 @@ const response = (status: number, headers: Record = {}, body: Rec test('401 expires session with actionable sign-in copy', async () => { const error = await apiActionErrorFromResponse(response(401), '下载失败'); assert.equal(error.kind, 'unauthorized'); - assert.equal(describeActionFailure('下载', error), '登录会话已失效,已为你退出,请重新登录后下载'); + assert.equal(describeActionFailure('下载', error), '登录会话已失效,请重新登录后下载'); }); test('429 parses Retry-After', async () => { const error = await apiActionErrorFromResponse(response(429, { 'Retry-After': '90' }), '分享失败'); diff --git a/frontend/src/services/apiActionError.ts b/frontend/src/services/apiActionError.ts index 8813a04..a20dcf4 100644 --- a/frontend/src/services/apiActionError.ts +++ b/frontend/src/services/apiActionError.ts @@ -28,6 +28,10 @@ export class ApiActionError extends Error { } } +export function isUnauthorizedError(error: unknown): error is ApiActionError { + return error instanceof ApiActionError && error.kind === 'unauthorized'; +} + export async function apiActionErrorFromResponse(response: Response, fallback: string, now = Date.now()): Promise { const payload = await response.json().catch(() => ({})) as ApiErrorPayload; const code = typeof payload.code === 'string' ? payload.code : undefined; @@ -45,7 +49,7 @@ export async function apiActionErrorFromResponse(response: Response, fallback: s } export function describeActionFailure(action: string, error: unknown): string { - if (error instanceof ApiActionError) return error.kind === 'unauthorized' ? `登录会话已失效,已为你退出,请重新登录后${action}` : `${action}失败:${error.message}`; + if (error instanceof ApiActionError) return error.kind === 'unauthorized' ? `登录会话已失效,请重新登录后${action}` : `${action}失败:${error.message}`; if (error instanceof DOMException && ['NotAllowedError', 'SecurityError'].includes(error.name)) return `${action}失败,请检查浏览器剪贴板权限后重试,也可手动选择内容复制`; return error instanceof Error && error.message ? `${action}失败:${error.message}` : `${action}失败,请稍后重试`; } diff --git a/frontend/src/services/apiTypes.ts b/frontend/src/services/apiTypes.ts new file mode 100644 index 0000000..f0c68d3 --- /dev/null +++ b/frontend/src/services/apiTypes.ts @@ -0,0 +1,429 @@ +import type { BatchDeleteResult } from './batchDeleteContract'; + +export interface FileData { + id: string; + name: string; + stored_name: string; + type: 'image' | 'video' | 'audio' | 'document' | 'other'; + mime_type: string; + size: string; + date: string; + thumbnailUrl?: string; + previewUrl: string; + width?: number; + height?: number; + source?: string; + folder?: string; + created_at: string; + is_favorite?: boolean; +} + +export interface MediaStatus { + available: boolean; + source?: string; + code?: 'MEDIA_SOURCE_MISSING' | 'MEDIA_QUOTA_EXCEEDED' | 'MEDIA_RATE_LIMITED' | 'MEDIA_UPSTREAM_UNAVAILABLE' | 'FILE_NOT_FOUND'; + error?: string; + reason?: 'not_found' | 'trashed'; +} + +export interface StorageCapabilities { + share: boolean; + sharePassword: boolean; + shareExpiration: boolean; + quota: boolean; + userDelete: boolean; +} + +export interface StorageStats { + provider: string; + accountId: string | null; + capabilities: StorageCapabilities; + temporary: { totalBytes: number; usedBytes: number; freeBytes: number; usedPercent: number }; + indexed: { usedBytes: number; fileCount: number }; + remoteQuota: { totalBytes: number; usedBytes: number; freeBytes: number; usedPercent: number } | null; + health: { probeStatus: 'available' | 'failed' | null; lastProbedAt: string | null; cooldownUntil: string | null; cooldownReason: string | null }; + server: { + total: string; + totalBytes: number; + used: string; + usedBytes: number; + free: string; + freeBytes: number; + usedPercent: number; + }; + tgvault: { + used: string; + usedBytes: number; + fileCount: number; + usedPercent?: number; + }; +} + +export interface UploadProgress { + loaded: number; + total: number; + percent: number; +} + +export interface UploadCapabilities { + acceptsAnyFile: boolean; + simpleUploadThresholdBytes: number; + simpleUploadMaxBytes: number; + chunkBytes: number; + maxChunkUploadBytes: number; + globalSessionBudgetBytes: number; + maxChunks: number; + sessionTtlMs: number; +} + +export interface UploadTargetSnapshot { + provider: string; + accountId: string | null; + accountName?: string | null; + folder?: string | null; +} + +export interface ChunkUploadSession { + uploadId: string; + filename: string; + mimeType: string; + folder: string | null; + status: 'open' | 'completing' | 'failed'; + totalChunks: number; + uploadedChunks: number[]; + uploadedChunkHashes: Record; + receivedBytes: number; + totalSize: number; + progress: number; + maxChunkBytes: number; + targetProvider: string; + targetAccountId: string | null; + targetAccountName?: string | null; + expiresAt: string; + error?: string | null; +} + +export type ChunkUploadCancelStatus = 'cancelled' | 'busy' | 'terminal' | 'not_found'; + +export type UnifiedTaskSource = 'telegram_bot' | 'telegram_channel' | 'ytdlp' | 'web_upload' | 'subscription' | 'telegram_target'; + +export interface UnifiedTask { + id: string; + sourceType: UnifiedTaskSource; + kind: string; + title: string; + status: string; + stage: string; + progress: number; + ownerUserId: number | null; + chatId: string | null; + source: string | null; + target: { + provider: string | null; + accountId: string | null; + accountName: string | null; + folder: string | null; + }; + counts: { total: number; completed: number; failed: number }; + bytes: { total: number; transferred: number }; + detail: Record; + error: string | null; + retryable: boolean; + cancellable: boolean; + dismissible: boolean; + createdAt: string; + updatedAt: string; + finishedAt: string | null; +} + +export interface UnifiedTaskList { + tasks: UnifiedTask[]; + total: number; + returned: number; + generatedAt: string; +} + +export interface CreateYtDlpTaskResult { + success: true; + task: UnifiedTask; +} + +export interface TaskFilters { + source?: string; + status?: string; + accountId?: string; + limit?: number; +} + +export interface TaskDismissalInput { + tasks?: Array<{ sourceType: UnifiedTaskSource; id: string }>; + source?: string; + status?: string; + accountId?: string; +} + +export interface TaskDismissalPreview { + confirmationToken: string; + snapshotId: string; + context: string; + expiresAt: number; + impact: { + count: number; + bySource: Record; + byStatus: Record; + filesDeleted: false; + cloudObjectsDeleted: false; + subscriptionsDeleted: false; + }; +} + +export interface TaskDismissalResult { + status: 'complete' | 'partial'; + dismissed: Array<{ sourceType: UnifiedTaskSource; id: string }>; + failed: Array<{ sourceType: UnifiedTaskSource; id: string; reason: string }>; + filesDeleted: false; + cloudObjectsDeleted: false; +} + +export interface StorageAccount { + id: string; + name: string; + type: string; + is_active: boolean; + capabilities: StorageCapabilities; + last_probe_status: 'available' | 'failed' | null; + last_probe_error: string | null; + last_probed_at: string | null; +} + +export interface TelegramBotPublicConfig { + configured: boolean; + enabled: boolean; + required: boolean; + pinConfigured: boolean; + source: 'web' | 'environment' | 'none'; + status: string; + bot: { username: string | null; displayName: string | null } | null; + lastConnectedAt: string | null; + lastError: string | null; + action: string | null; +} + +export interface UpdateStatus { + enabled: boolean; + currentVersion: string; + latestVersion: string | null; + updateAvailable: boolean; + releaseName: string | null; + releaseUrl: string | null; + publishedAt: string | null; + checkedAt: string | null; + stale: boolean; + error: string | null; +} + +export interface TelegramUserAccountStatus { + configured: boolean; + enabled: boolean; + connected: boolean; + account: { userId: string; username: string | null; displayName: string | null } | null; +} + +export interface TelegramUserLoginComplete { + step: 'complete'; + account: NonNullable; +} + +export type TelegramUserAccountHealth = 'ready' | 'connecting' | 'disabled' | 'cooldown' | 'expired' | 'permission_denied' | 'error'; +export type TelegramSourcePermissionState = 'allowed' | 'denied' | 'unknown'; + +export interface TelegramPermissionSummary { + allowed: number; + denied: number; + unknown: number; + total: number; + lastCheckedAt: string | null; +} + +export interface TelegramSourcePermission { + sourceId: string; + sourceName: string | null; + status: TelegramSourcePermissionState; + checkedAt: string | null; + reason: string | null; +} + +export interface TelegramUserAccount { + id: string; + userId: string; + username: string | null; + displayName: string | null; + enabled: boolean; + connected: boolean; + health: TelegramUserAccountHealth; + checkedAt: string | null; + lastError: string | null; + cooldownUntil: string | null; + permissionSummary: TelegramPermissionSummary; + permissions?: TelegramSourcePermission[]; + scheduling: { + weight: number; + activeDownloads: number; + lastSelectedAt: string | null; + }; +} + +export interface TelegramUserAccountsOverview { + accounts: TelegramUserAccount[]; + summary: { + total: number; + enabled: number; + ready: number; + coolingDown: number; + permissions: TelegramPermissionSummary; + }; + scheduling: { + strategy: 'weighted_least_connections'; + description: string; + }; + accessSweep?: TelegramAccessSweepSummary; +} + +export type TelegramUserLoginState = 'pending' | 'waiting_for_scan' | 'code_required' | 'password_required' | 'complete' | 'expired' | 'cancelled' | 'error'; + +export interface TelegramUserLoginAccount { + userId: string; + username: string | null; + displayName: string | null; +} + +export interface TelegramUserLoginStatus { + flowId: string; + status: TelegramUserLoginState; + qrCode?: string; + qrExpiresAt?: string; + expiresAt?: string; + account?: TelegramUserLoginAccount; + message?: string; +} + +export interface TelegramUserQrLoginStarted extends TelegramUserLoginStatus { + status: 'waiting_for_scan'; + qrCode: string; + qrExpiresAt: string; +} + +export interface TelegramUserPhoneLoginStarted extends TelegramUserLoginStatus { + status: 'code_required'; + delivery: 'app' | 'sms'; +} + +export interface TelegramAccessSweepSummary { + runId: string | null; + status: 'idle' | 'queued' | 'running' | 'completed' | 'failed'; + startedAt: string | null; + completedAt: string | null; + counts: { accounts: number; sources: number; probes: number; allowed: number; denied: number; error: number }; + lastError: string | null; +} + +export interface TelegramPermissionCheckResult { + success: boolean; + summary: TelegramAccessSweepSummary; +} + +export interface StorageConfig { + provider: string; + activeAccountId: string | null; + activeAccountName?: string; + capabilities: StorageCapabilities; + accounts: StorageAccount[]; + redirectUri: string; + googleDriveRedirectUri: string; + telegramUserDownloadEnabled?: boolean; + telegramUserSessionReady?: boolean; + telegramUserClientStatus?: { status: string; userId: string | null; username: string | null; checkedAt: string | null; lastError: string | null; action: string | null }; + telegramAllowedUserIds?: number[]; + telegramAllowedUserIdsFromEnv?: boolean; + allowUnsafeWebdavEndpoints: boolean; +} + +export interface FolderMovePreview { + sourcePath: string; + destinationParent: string | null; + finalPath: string; + fileCount: number; + folderCount: number; + totalSizeBytes: number; + conflict: boolean; + conflictReason?: string; + noChange: boolean; +} + +export interface FilesPage { + files: FileData[]; + nextCursor: string | null; + hasMore: boolean; +} + +export interface FileQueryOptions { + cursor?: string | null; + limit?: number; + q?: string; + type?: 'image' | 'video' | 'audio' | 'document' | 'other' | 'media'; + folder?: string | null; + favorite?: boolean; + sort?: 'name' | 'date'; + direction?: 'asc' | 'desc'; + signal?: AbortSignal; +} + +export interface FolderAggregation { + name: string; + fileCount: number; + totalSizeBytes: number; + latestDate: string; + isFavorite: boolean; + coverFile: FileData | null; +} + +export interface BatchDeletePreview { + confirmationToken: string; + fileCount: number; + dataFileCount: number; + placeholderCount: number; + folderCount: number; + totalSizeBytes: number; + expiresAt: string; +} + +export type { BatchDeleteResult }; + +export interface AdvancedTaskSettings { + telegramDownloadWorkers: number; + telegramFileConcurrency: number; + duplicateMode: 'copy' | 'skip'; + autoCleanupOrphans: boolean; + telegramDownloadHistoryPolicy: 'errors_only' | 'all'; + highRisk: { telegramDownloadWorkers: boolean; telegramFileConcurrency: boolean }; +} + +export interface StorageDeleteImpact { + accountId: string; + accountName: string; + provider: string; + fileCount: number; + totalSizeBytes: number; + folderCount: number; + activeLeaseCount: number; + activeTaskCount: number; + activeUploadCount: number; + remoteObjectsDeleted: false; +} + +export interface OAuthStartResult { + authUrl: string; + flowNonce: string; + frontendOrigin: string; + expiresAt: string; +} diff --git a/frontend/src/services/auth.ts b/frontend/src/services/auth.ts index 98d9cec..f400ff0 100644 --- a/frontend/src/services/auth.ts +++ b/frontend/src/services/auth.ts @@ -6,7 +6,19 @@ export interface AuthStatus { telegramPinRequired: boolean; } +export const AUTH_SESSION_INVALIDATED_EVENT = 'tgvault:auth-session-invalidated'; + +export interface AuthSessionInvalidatedDetail { + status: number; +} + class AuthService { + private invalidateForAuthStatus(response: Response): boolean { + if (response.status !== 401 && response.status !== 428) return false; + this.invalidateSession(response.status); + return true; + } + constructor() { // Cookie-only auth: clear legacy localStorage tokens if present. this.clearToken(); @@ -25,6 +37,17 @@ class AuthService { localStorage.removeItem('tg_vault_token_expiry'); } + invalidateSession(status: number) { + this.clearToken(); + window.dispatchEvent(new CustomEvent(AUTH_SESSION_INVALIDATED_EVENT, { detail: { status } })); + } + + onSessionInvalidated(listener: (detail: AuthSessionInvalidatedDetail) => void): () => void { + const handle = (event: Event) => listener((event as CustomEvent).detail); + window.addEventListener(AUTH_SESSION_INVALIDATED_EVENT, handle); + return () => window.removeEventListener(AUTH_SESSION_INVALIDATED_EVENT, handle); + } + isAuthenticated(): boolean { // The backend /verify endpoint is the source of truth for the HttpOnly cookie. return true; @@ -170,6 +193,7 @@ class AuthService { body: JSON.stringify({ currentPassword, newPassword }), }); const data = await response.json().catch(() => ({})); + if (this.invalidateForAuthStatus(response)) return { success: false, error: '登录会话已失效,请重新登录' }; if (!response.ok) return { success: false, error: data.error || '修改密码失败' }; this.clearToken(); return { success: true }; @@ -186,6 +210,7 @@ class AuthService { headers: this.getAuthHeaders(), }); const data = await response.json().catch(() => ({})); + if (this.invalidateForAuthStatus(response)) return { success: false, error: '登录会话已失效,请重新登录' }; if (!response.ok) return { success: false, error: data.error || '退出所有设备失败' }; this.clearToken(); return { success: true }; @@ -203,6 +228,7 @@ class AuthService { }); if (!response.ok) { + if (this.invalidateForAuthStatus(response)) throw new Error('登录会话已失效,请重新登录'); const data = await response.json(); throw new Error(data.error || '获取 2FA 信息失败'); } @@ -227,6 +253,7 @@ class AuthService { }); const data = await response.json(); + if (this.invalidateForAuthStatus(response)) return { success: false, error: '登录会话已失效,请重新登录' }; if (!response.ok) { return { success: false, error: data.error || '激活失败' }; } @@ -251,6 +278,7 @@ class AuthService { }); const data = await response.json(); + if (this.invalidateForAuthStatus(response)) return { success: false, error: '登录会话已失效,请重新登录' }; if (!response.ok) { return { success: false, error: data.error || '禁用失败' }; } diff --git a/frontend/src/services/authSessionInvalidation.test.ts b/frontend/src/services/authSessionInvalidation.test.ts new file mode 100644 index 0000000..321e226 --- /dev/null +++ b/frontend/src/services/authSessionInvalidation.test.ts @@ -0,0 +1,11 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import test from 'node:test'; + +const auth = fs.readFileSync(new URL('./auth.ts', import.meta.url), 'utf8'); + +test('settings auth operations invalidate globally on expired sessions', () => { + assert.match(auth, /invalidateForAuthStatus/); + assert.match(auth, /response\.status !== 401 && response\.status !== 428/); + assert.ok((auth.match(/this\.invalidateForAuthStatus\(response\)/g) || []).length >= 5); +}); diff --git a/frontend/src/services/clients/clientHeaders.ts b/frontend/src/services/clients/clientHeaders.ts new file mode 100644 index 0000000..db5b43b --- /dev/null +++ b/frontend/src/services/clients/clientHeaders.ts @@ -0,0 +1,8 @@ +import { authService } from '../auth'; + +export function getApiHeaders(additionalHeaders: Record = {}): HeadersInit { + return { + ...authService.getAuthHeaders(), + ...additionalHeaders, + }; +} diff --git a/frontend/src/services/clients/systemClient.ts b/frontend/src/services/clients/systemClient.ts new file mode 100644 index 0000000..cc2ab34 --- /dev/null +++ b/frontend/src/services/clients/systemClient.ts @@ -0,0 +1,35 @@ +import type { UpdateStatus } from '../apiTypes'; +import { apiRequest } from '../httpClient'; +import { getApiHeaders } from './clientHeaders'; + +export class SystemClient { + async getUpdateStatus(): Promise { + const response = await apiRequest('/api/system/update-status', { + credentials: 'include', + cache: 'no-store', + headers: getApiHeaders(), + }); + if (!response.ok) throw new Error('获取版本信息失败'); + return response.json(); + } + + async checkForUpdates(): Promise { + const response = await apiRequest('/api/system/update-check', { + method: 'POST', + credentials: 'include', + cache: 'no-store', + headers: getApiHeaders(), + }); + if (response.status === 429) throw new Error('检查过于频繁,请稍后再试'); + if (!response.ok) throw new Error('检查版本失败'); + return response.json(); + } + + async healthCheck(): Promise<{ status: string; timestamp: string }> { + const response = await apiRequest('/health'); + if (!response.ok) throw new Error('健康检查失败'); + return response.json(); + } +} + +export const systemClient = new SystemClient(); diff --git a/frontend/src/services/clients/tasksClient.ts b/frontend/src/services/clients/tasksClient.ts new file mode 100644 index 0000000..f1d839d --- /dev/null +++ b/frontend/src/services/clients/tasksClient.ts @@ -0,0 +1,100 @@ +import type { + CreateYtDlpTaskResult, + TaskDismissalInput, + TaskDismissalPreview, + TaskDismissalResult, + TaskFilters, + UnifiedTaskList, + UnifiedTaskSource, +} from '../apiTypes'; +import { apiRequest } from '../httpClient'; +import { getApiHeaders } from './clientHeaders'; + +export class TasksClient { + async createYtDlpTask(input: { url: string; format: 'best' | 'audio' }): Promise { + const response = await apiRequest('/api/tasks/ytdlp', { + credentials: 'include', + method: 'POST', + headers: getApiHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify(input), + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload.error || '创建 yt-dlp 任务失败'); + return payload; + } + + async getTasks(filters: TaskFilters = {}): Promise { + const params = new URLSearchParams({ limit: String(filters.limit ?? 200) }); + if (filters.source) params.set('source', filters.source); + if (filters.status) params.set('status', filters.status); + if (filters.accountId) params.set('accountId', filters.accountId); + const response = await apiRequest(`/api/tasks?${params.toString()}`, { + credentials: 'include', + headers: getApiHeaders(), + }); + if (!response.ok) { + const payload = await response.json().catch(() => ({})); + throw new Error(payload.error || '获取任务列表失败'); + } + return response.json(); + } + + async controlTask(sourceType: UnifiedTaskSource, id: string, action: 'cancel' | 'retry'): Promise { + const sourcePath = encodeURIComponent(sourceType); + const taskPath = encodeURIComponent(id); + let confirmationToken: string | undefined; + if (action === 'cancel') { + const confirmation = await apiRequest(`/api/tasks/${sourcePath}/${taskPath}/cancel-confirmation`, { + credentials: 'include', + method: 'POST', + headers: getApiHeaders(), + }); + const payload = await confirmation.json().catch(() => ({})); + if (!confirmation.ok || !payload.confirmationToken) { + throw new Error(payload.error || '无法创建任务取消确认'); + } + confirmationToken = String(payload.confirmationToken); + } + const response = await apiRequest(`/api/tasks/${sourcePath}/${taskPath}/${action}`, { + credentials: 'include', + method: 'POST', + headers: getApiHeaders({ + 'Content-Type': 'application/json', + ...(confirmationToken ? { 'X-Confirmation-Token': confirmationToken } : {}), + }), + }); + if (!response.ok) { + const payload = await response.json().catch(() => ({})); + throw new Error(payload.message || payload.error || '任务操作失败'); + } + } + + async prepareTaskDismissal(input: TaskDismissalInput): Promise { + const response = await apiRequest('/api/tasks/dismissals/prepare', { + credentials: 'include', + method: 'POST', + headers: getApiHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify(input), + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload.error || '无法创建任务删除预览'); + return payload; + } + + async confirmTaskDismissal(preview: TaskDismissalPreview): Promise { + const response = await apiRequest('/api/tasks/dismissals/confirm', { + credentials: 'include', + method: 'POST', + headers: getApiHeaders({ + 'Content-Type': 'application/json', + 'X-Confirmation-Token': preview.confirmationToken, + }), + body: JSON.stringify({ snapshotId: preview.snapshotId, context: preview.context }), + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok && response.status !== 207) throw new Error(payload.error || '删除任务记录失败'); + return payload; + } +} + +export const tasksClient = new TasksClient(); diff --git a/frontend/src/services/config.ts b/frontend/src/services/config.ts index 0a48d01..7e1438b 100644 --- a/frontend/src/services/config.ts +++ b/frontend/src/services/config.ts @@ -1,12 +1,13 @@ const getApiBase = () => { - // 优先使用构建时注入的变量 - const envUrl = import.meta.env.VITE_API_URL; + // import.meta.env is injected by Vite; Node-based unit tests intentionally have no env object. + const env = (import.meta as ImportMeta & { env?: { VITE_API_URL?: string; PROD?: boolean } }).env; + const envUrl = env?.VITE_API_URL; if (envUrl && envUrl !== 'http://localhost:51947' && envUrl !== '') { return envUrl; } // 如果在生产环境且没有配置,fallback 到相对路径(同域代理模式) - if (import.meta.env.PROD) { + if (env?.PROD) { return ''; } return 'http://localhost:51947'; diff --git a/frontend/src/services/formatBytes.ts b/frontend/src/services/formatBytes.ts new file mode 100644 index 0000000..8eaaad9 --- /dev/null +++ b/frontend/src/services/formatBytes.ts @@ -0,0 +1,9 @@ +export function formatBytes(bytes: number, options: { binary?: boolean; maximumFractionDigits?: number } = {}): string { + if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'; + const binary = options.binary ?? true; + const base = binary ? 1024 : 1000; + const units = binary ? ['B', 'KiB', 'MiB', 'GiB', 'TiB'] : ['B', 'KB', 'MB', 'GB', 'TB']; + const unit = Math.min(Math.floor(Math.log(bytes) / Math.log(base)), units.length - 1); + const maximumFractionDigits = options.maximumFractionDigits ?? (unit === 0 ? 0 : 1); + return `${(bytes / (base ** unit)).toFixed(maximumFractionDigits)} ${units[unit]}`; +} diff --git a/frontend/src/services/globalSpinnerCoverage.test.ts b/frontend/src/services/globalSpinnerCoverage.test.ts index 1ddb0c7..aa695a3 100644 --- a/frontend/src/services/globalSpinnerCoverage.test.ts +++ b/frontend/src/services/globalSpinnerCoverage.test.ts @@ -25,7 +25,7 @@ test('all known unknown-duration loading surfaces use the shared spinner', () => test('loading spinners supply contextual accessible labels', () => { const combined = files.map(relative => fs.readFileSync(new URL(relative, import.meta.url), 'utf8')).join('\n'); assert.match(combined, /label="正在验证登录状态"/); - assert.match(combined, /label="正在加载任务"/); + assert.match(combined, /label=\{t\('tasks\.loading\.initial'\)\}/); assert.match(combined, /label="正在加载预览"/); assert.match(combined, /label="正在处理上传"/); }); diff --git a/frontend/src/services/httpClient.test.ts b/frontend/src/services/httpClient.test.ts new file mode 100644 index 0000000..51e8c68 --- /dev/null +++ b/frontend/src/services/httpClient.test.ts @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +const installBrowserGlobals = () => { + const listeners = new Map void>>(); + const storage = new Map(); + Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + value: { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, value), + removeItem: (key: string) => storage.delete(key), + }, + }); + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + addEventListener: (type: string, listener: (event: Event) => void) => { + const set = listeners.get(type) ?? new Set(); + set.add(listener); + listeners.set(type, set); + }, + removeEventListener: (type: string, listener: (event: Event) => void) => listeners.get(type)?.delete(listener), + dispatchEvent: (event: Event) => { + listeners.get(event.type)?.forEach(listener => listener(event)); + return true; + }, + }, + }); +}; + +test('http client includes credentials, merges headers, and invalidates auth for 401', async () => { + installBrowserGlobals(); + const [{ apiRequest }, { authService }] = await Promise.all([import('./httpClient.js'), import('./auth.js')]); + let request: { input: RequestInfo | URL; init?: RequestInit } | undefined; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + request = { input, init }; + return new Response(JSON.stringify({ error: 'expired' }), { status: 401, headers: { 'Content-Type': 'application/json', 'X-Request-Id': 'req-auth' } }); + }) as typeof fetch; + let invalidated = 0; + const unsubscribe = authService.onSessionInvalidated(detail => { invalidated = detail.status; }); + await assert.rejects(() => apiRequest('/api/files', { headers: { 'X-Test': 'yes' } }), (error: any) => error.kind === 'unauthorized' && error.requestId === 'req-auth'); + unsubscribe(); + assert.equal(request?.init?.credentials, 'include'); + assert.equal(new Headers(request?.init?.headers).get('X-Test'), 'yes'); + assert.equal(invalidated, 401); +}); + +test('http client preserves AbortError and supports protocol statuses', async () => { + installBrowserGlobals(); + const { apiRequest } = await import('./httpClient.js'); + globalThis.fetch = (async () => new Response(JSON.stringify({ status: 'busy' }), { status: 409 })) as typeof fetch; + const response = await apiRequest('/api/chunked/id', { acceptedStatuses: [409] }); + assert.equal(response.status, 409); + + globalThis.fetch = (async () => { throw new DOMException('cancelled', 'AbortError'); }) as typeof fetch; + await assert.rejects(() => apiRequest('/api/files'), (error: any) => error?.name === 'AbortError'); +}); + +test('http client preserves browser opaque manual redirects for original links', async () => { + installBrowserGlobals(); + const { apiRequest } = await import('./httpClient.js'); + globalThis.fetch = (async () => ({ ok: false, status: 0, type: 'opaqueredirect', headers: new Headers() }) as Response) as typeof fetch; + const response = await apiRequest('/api/files/id/original', { redirect: 'manual' }); + assert.equal(response.type, 'opaqueredirect'); +}); diff --git a/frontend/src/services/httpClient.ts b/frontend/src/services/httpClient.ts new file mode 100644 index 0000000..ce364f0 --- /dev/null +++ b/frontend/src/services/httpClient.ts @@ -0,0 +1,48 @@ +import { API_BASE } from './config'; +import { apiActionErrorFromResponse } from './apiActionError'; +import { authService } from './auth'; + +export interface HttpRequestOptions extends RequestInit { + acceptedStatuses?: readonly number[]; + fallback?: string; + classifyErrors?: boolean; +} + +function apiUrl(path: string): string { + if (/^https?:\/\//i.test(path) || (API_BASE && path.startsWith(API_BASE))) return path; + return `${API_BASE}${path.startsWith('/') ? path : `/${path}`}`; +} + +function mergeHeaders(headers?: HeadersInit): Headers { + const merged = new Headers(headers); + if (!merged.has('Accept')) merged.set('Accept', 'application/json'); + return merged; +} + +export async function apiRequest(path: string, options: HttpRequestOptions = {}): Promise { + const { acceptedStatuses = [], fallback = '请求失败', classifyErrors = true, ...init } = options; + const response = await fetch(apiUrl(path), { + ...init, + credentials: init.credentials ?? 'include', + headers: mergeHeaders(init.headers), + }); + const rejected = !response.ok && !acceptedStatuses.includes(response.status); + // Cross-origin manual redirects are intentionally opaque in browsers (status 0). + const opaqueManualRedirect = init.redirect === 'manual' && response.type === 'opaqueredirect'; + if (rejected && !opaqueManualRedirect && (classifyErrors || response.status === 401 || response.status === 428)) { + const error = await apiActionErrorFromResponse(response, fallback); + if (error.kind === 'unauthorized') authService.invalidateSession(error.status); + throw error; + } + return response; +} + +export async function apiJson(path: string, options: HttpRequestOptions = {}): Promise { + const response = await apiRequest(path, options); + return response.json() as Promise; +} + +export async function apiJsonOr(path: string, fallbackValue: T, options: HttpRequestOptions = {}): Promise { + const response = await apiRequest(path, options); + return response.json().catch(() => fallbackValue) as Promise; +} diff --git a/frontend/src/services/loginExperience.test.ts b/frontend/src/services/loginExperience.test.ts index 376f00a..2e3cdcb 100644 --- a/frontend/src/services/loginExperience.test.ts +++ b/frontend/src/services/loginExperience.test.ts @@ -1,34 +1,31 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import test from 'node:test'; +import en from '../locales/en.json' with { type: 'json' }; +import zh from '../locales/zh.json' with { type: 'json' }; const loginPage = fs.readFileSync(new URL('../components/pages/LoginPage.tsx', import.meta.url), 'utf8'); -const runtimeEnglish = JSON.parse(fs.readFileSync(new URL('../locales/runtime-en.json', import.meta.url), 'utf8')) as Record; -const loginCopy = [ - '首次启动,请创建唯一管理员密码', - '请输入访问密码', - '双重身份验证', - '网页管理员密码', - '访问密码', - '登录', - '登录状态将保留 7 天', -]; +function resolve(catalog: Record, path: string): unknown { + return path.split('.').reduce((value, key) => value && typeof value === 'object' ? (value as Record)[key] : undefined, catalog); +} test('the public login screen exposes a language switch before authentication', () => { assert.match(loginPage, //); }); -test('the English runtime catalog covers the public login journey', () => { - for (const text of loginCopy) { - assert.equal(typeof runtimeEnglish[text], 'string', `missing English login copy: ${text}`); - } - const chineseLiterals = [...loginPage.matchAll(/["'`]([^"'`\r\n]*[\u3400-\u9fff][^"'`\r\n]*)["'`]/g)] - .map(match => match[1]) - .filter(text => !text.includes('请求失败,状态码')); - for (const text of chineseLiterals) { - assert.equal(typeof runtimeEnglish[text], 'string', `missing English login literal: ${text}`); +test('the public login journey uses stable locale keys in both languages', () => { + const keys = [ + 'login.setupTitle', 'login.passwordTitle', 'login.totpTitle', + 'login.adminPasswordLabel', 'login.passwordLabel', 'login.signIn', + 'login.sessionFooter', 'login.telegramPinHint', + ]; + for (const key of keys) { + assert.equal(typeof resolve(en as Record, key), 'string', `missing English login key: ${key}`); + assert.equal(typeof resolve(zh as Record, key), 'string', `missing Chinese login key: ${key}`); + assert.match(loginPage, new RegExp(key.replaceAll('.', '\\.'))); } + assert.doesNotMatch(loginPage, /useRuntimeUiLocalization|MutationObserver/); }); test('login credential fields declare password-manager autocomplete semantics', () => { diff --git a/frontend/src/services/openListStorageExperience.test.ts b/frontend/src/services/openListStorageExperience.test.ts index e9679b5..e1165d9 100644 --- a/frontend/src/services/openListStorageExperience.test.ts +++ b/frontend/src/services/openListStorageExperience.test.ts @@ -5,6 +5,7 @@ import fs from 'node:fs'; const app = fs.readFileSync(new URL('../App.tsx', import.meta.url), 'utf8'); const settings = fs.readFileSync(new URL('../components/pages/SettingsPage.tsx', import.meta.url), 'utf8'); const api = fs.readFileSync(new URL('../services/api.ts', import.meta.url), 'utf8'); +const apiTypes = fs.readFileSync(new URL('../services/apiTypes.ts', import.meta.url), 'utf8'); const bulk = fs.readFileSync(new URL('../components/ui/BulkActionToolbar.tsx', import.meta.url), 'utf8'); test('settings exposes only a minimal native OpenList connection form', () => { @@ -19,7 +20,7 @@ test('settings exposes only a minimal native OpenList connection form', () => { }); test('OpenList user deletion is hidden in file and bulk actions while backend capability remains authoritative', () => { - assert.match(api, /userDelete: boolean/); + assert.match(apiTypes, /userDelete: boolean/); assert.match(app, /storageConfig\?\.capabilities\.userDelete/); assert.match(app, /onDelete=\{storageConfig/); assert.match(bulk, /canDelete/); diff --git a/frontend/src/services/providerMetadata.ts b/frontend/src/services/providerMetadata.ts new file mode 100644 index 0000000..87652b8 --- /dev/null +++ b/frontend/src/services/providerMetadata.ts @@ -0,0 +1,24 @@ +import { Cloud, Database, HardDrive, Network, Package, Server, type LucideIcon } from 'lucide-react'; + +export interface ProviderMetadata { + id: string; + label: string; + icon: LucideIcon; +} + +const providers: Record = { + local: { id: 'local', label: 'Local', icon: HardDrive }, + onedrive: { id: 'onedrive', label: 'OneDrive', icon: Cloud }, + google_drive: { id: 'google_drive', label: 'Google Drive', icon: Database }, + aliyun_oss: { id: 'aliyun_oss', label: 'Aliyun OSS', icon: Database }, + s3: { id: 's3', label: 'S3', icon: Package }, + webdav: { id: 'webdav', label: 'WebDAV', icon: Network }, + openlist: { id: 'openlist', label: 'OpenList', icon: Server }, +}; + +export function getProviderMetadata(provider?: string | null): ProviderMetadata { + const id = provider || 'local'; + return providers[id] || { id, label: id, icon: Server }; +} + +export const STORAGE_PROVIDER_METADATA = providers; diff --git a/frontend/src/services/serialPoller.ts b/frontend/src/services/serialPoller.ts index 1eb45be..1e3c1d9 100644 --- a/frontend/src/services/serialPoller.ts +++ b/frontend/src/services/serialPoller.ts @@ -3,6 +3,7 @@ export interface SerialPollerOptions { schedule: (callback: () => void, delayMs: number) => unknown; cancel: (handle: unknown) => void; delayMs: number; + nextDelayMs?: () => number; } export function createSerialPoller(options: SerialPollerOptions) { @@ -17,7 +18,7 @@ export function createSerialPoller(options: SerialPollerOptions) { await options.run(); } finally { running = false; - if (!stopped) timer = options.schedule(() => void tick(), options.delayMs); + if (!stopped) timer = options.schedule(() => void tick(), options.nextDelayMs?.() ?? options.delayMs); } }; diff --git a/frontend/src/services/storageProductExperience.test.ts b/frontend/src/services/storageProductExperience.test.ts index 7f44a28..9aabb9e 100644 --- a/frontend/src/services/storageProductExperience.test.ts +++ b/frontend/src/services/storageProductExperience.test.ts @@ -6,6 +6,7 @@ const app = fs.readFileSync(new URL('../App.tsx', import.meta.url), 'utf8'); const toolbar = fs.readFileSync(new URL('../components/ui/BulkActionToolbar.tsx', import.meta.url), 'utf8'); const settings = fs.readFileSync(new URL('../components/pages/SettingsPage.tsx', import.meta.url), 'utf8'); const api = fs.readFileSync(new URL('../services/api.ts', import.meta.url), 'utf8'); +const apiTypes = fs.readFileSync(new URL('../services/apiTypes.ts', import.meta.url), 'utf8'); test('share UI is driven by backend capabilities and hides unsupported fields', () => { assert.match(app, /shareCapabilities=\{storageConfig\?\.capabilities\}/); @@ -16,8 +17,8 @@ test('share UI is driven by backend capabilities and hides unsupported fields', test('storage account deletion previews every server-side task reference and opens the filtered task center when blocked', () => { assert.match(api, /previewAccountDeletion/); - assert.match(api, /expiresAt: number/); - assert.match(api, /confirmationResponse\.status === 401 \|\| confirmationResponse\.status === 428/); + assert.match(apiTypes, /expiresAt: number/); + assert.match(api, /delete-confirmation/); assert.match(settings, /impact\.fileCount/); assert.match(settings, /impact\.folderCount/); assert.match(settings, /activeLeaseCount/); diff --git a/frontend/src/services/storageTaskReferencesExperience.test.ts b/frontend/src/services/storageTaskReferencesExperience.test.ts index 1ae77f0..7dec0b5 100644 --- a/frontend/src/services/storageTaskReferencesExperience.test.ts +++ b/frontend/src/services/storageTaskReferencesExperience.test.ts @@ -4,18 +4,18 @@ import test from 'node:test'; const page = fs.readFileSync(new URL('../components/pages/TasksPage.tsx', import.meta.url), 'utf8'); const app = fs.readFileSync(new URL('../App.tsx', import.meta.url), 'utf8'); -const api = fs.readFileSync(new URL('./api.ts', import.meta.url), 'utf8'); +const apiTypes = fs.readFileSync(new URL('./apiTypes.ts', import.meta.url), 'utf8'); test('storage account references are deep-linked into a filtered task center', () => { assert.match(app, /kind: 'tasks', accountId/); assert.match(app, /initialAccountId=\{taskAccountId\}/); assert.match(page, /task\.target\.accountId === initialAccountId/); - assert.match(page, /仅显示仍引用所选存储账户/); + assert.match(page, /tasks\.subtitleScoped/); }); test('task center exposes release actions for persistent Telegram references', () => { - assert.match(api, /'telegram_target'/); - assert.match(page, /Telegram 会话目标/); - assert.match(page, /改为跟随默认/); - assert.match(page, /清除目标/); + assert.match(apiTypes, /'telegram_target'/); + assert.match(page, /tasks\.sources\.telegramTarget/); + assert.match(page, /tasks\.actions\.followDefault/); + assert.match(page, /tasks\.actions\.clearTarget/); }); diff --git a/frontend/src/services/taskDismissalExperience.test.ts b/frontend/src/services/taskDismissalExperience.test.ts index 3e92ba3..a1d0944 100644 --- a/frontend/src/services/taskDismissalExperience.test.ts +++ b/frontend/src/services/taskDismissalExperience.test.ts @@ -3,14 +3,16 @@ import test from 'node:test'; import fs from 'node:fs'; const api = fs.readFileSync(new URL('./api.ts', import.meta.url), 'utf8'); +const apiTypes = fs.readFileSync(new URL('./apiTypes.ts', import.meta.url), 'utf8'); +const tasksClient = fs.readFileSync(new URL('./clients/tasksClient.ts', import.meta.url), 'utf8'); const page = fs.readFileSync(new URL('../components/pages/TasksPage.tsx', import.meta.url), 'utf8'); test('task API exposes terminal dismissal preview and confirm contracts', () => { - assert.match(api, /dismissible: boolean/); + assert.match(apiTypes, /dismissible: boolean/); assert.match(api, /prepareTaskDismissal/); assert.match(api, /confirmTaskDismissal/); - assert.match(api, /dismissals\/prepare/); - assert.match(api, /dismissals\/confirm/); + assert.match(tasksClient, /dismissals\/prepare/); + assert.match(tasksClient, /dismissals\/confirm/); }); test('task center supports single, multi-select, and filter-scoped cleanup', () => { diff --git a/frontend/src/services/telegramUserAccountsApi.test.ts b/frontend/src/services/telegramUserAccountsApi.test.ts new file mode 100644 index 0000000..6bbdf17 --- /dev/null +++ b/frontend/src/services/telegramUserAccountsApi.test.ts @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import { after, test } from 'node:test'; + +const listeners = new Map void>>(); +const storage = new Map(); +Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: { getItem: (key: string) => storage.get(key) ?? null, setItem: (key: string, value: string) => storage.set(key, value), removeItem: (key: string) => storage.delete(key) } }); +Object.defineProperty(globalThis, 'window', { configurable: true, value: { + setTimeout, clearTimeout, + addEventListener: (type: string, listener: (event: Event) => void) => { const set = listeners.get(type) ?? new Set(); set.add(listener); listeners.set(type, set); }, + removeEventListener: (type: string, listener: (event: Event) => void) => listeners.get(type)?.delete(listener), + dispatchEvent: (event: Event) => { listeners.get(event.type)?.forEach(listener => listener(event)); return true; }, +} }); + +const { fileApi } = await import('./api'); +const originalFetch = globalThis.fetch; +after(() => { globalThis.fetch = originalFetch; }); + +function mockJson(payload: unknown) { + globalThis.fetch = (async () => new Response(JSON.stringify(payload), { status: 200, headers: { 'Content-Type': 'application/json' } })) as typeof fetch; +} + +test('QR login adapter maps backend pending/qrData to the public polling contract without exposing raw fields', async () => { + mockJson({ flowId: 'qr-1', status: 'pending', qrData: 'tg://login?token=secret', expiresAt: '2026-08-29T12:00:00.000Z', version: 1 }); + assert.deepEqual(await fileApi.startTelegramUserQrLogin(), { + flowId: 'qr-1', status: 'waiting_for_scan', qrCode: 'tg://login?token=secret', qrExpiresAt: '2026-08-29T12:00:00.000Z', expiresAt: '2026-08-29T12:00:00.000Z', + }); +}); + +test('phone and 2FA responses normalize legacy backend step responses into UI states', async () => { + mockJson({ flowId: 'phone-1', delivery: 'app', expiresAt: '2026-08-29T12:00:00.000Z' }); + assert.deepEqual(await fileApi.startTelegramUserPhoneLogin('+86…'), { + flowId: 'phone-1', status: 'code_required', expiresAt: '2026-08-29T12:00:00.000Z', delivery: 'app', message: 'Telegram 应用', + }); + + mockJson({ step: 'password_required' }); + assert.deepEqual(await fileApi.submitTelegramUserLoginCode('phone-1', '12345'), { flowId: 'phone-1', status: 'password_required' }); + + mockJson({ step: 'complete', account: { userId: '42', username: 'vault', displayName: null } }); + assert.deepEqual(await fileApi.submitTelegramUserLoginPassword('phone-1', 'secret'), { + flowId: 'phone-1', status: 'complete', account: { userId: '42', username: 'vault', displayName: null }, + }); +}); diff --git a/frontend/src/services/telegramUserAccountsContract.test.ts b/frontend/src/services/telegramUserAccountsContract.test.ts new file mode 100644 index 0000000..8396ce9 --- /dev/null +++ b/frontend/src/services/telegramUserAccountsContract.test.ts @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import test from 'node:test'; + +const api = fs.readFileSync(new URL('./api.ts', import.meta.url), 'utf8'); +const types = fs.readFileSync(new URL('./apiTypes.ts', import.meta.url), 'utf8'); + +test('Telegram multi-account API exposes list, QR polling, account lifecycle and permission checks', () => { + for (const method of [ + 'getTelegramUserAccounts', + 'startTelegramUserQrLogin', + 'getTelegramUserLoginStatus', + 'cancelTelegramUserLogin', + 'startTelegramUserPhoneLogin', + 'submitTelegramUserLoginCode', + 'submitTelegramUserLoginPassword', + 'setTelegramUserAccountEnabled', + 'unlinkTelegramUserAccountById', + 'checkTelegramUserAccountPermissions', + ]) { + assert.match(api, new RegExp(`async ${method}\\b`), `missing ${method}`); + } + + assert.match(api, /\/api\/storage\/config\/telegram-user\/accounts/); + assert.match(api, /encodeURIComponent\(accountId\)/); + assert.match(api, /encodeURIComponent\(flowId\)/); + assert.match(api, /telegram-accounts\/login\/phone/); + assert.match(api, /telegram-accounts\/login\/code/); +}); + +test('public Telegram account types include health, permission summary and scheduling without secrets', () => { + for (const typeName of [ + 'TelegramUserAccount', + 'TelegramUserAccountHealth', + 'TelegramPermissionSummary', + 'TelegramUserAccountsOverview', + 'TelegramUserLoginState', + 'TelegramUserQrLoginStarted', + ]) { + assert.match(types, new RegExp(`export (?:interface|type) ${typeName}\\b`), `missing ${typeName}`); + } + + const start = types.indexOf('export interface TelegramUserAccount {'); + const end = types.indexOf('export interface TelegramUserAccountsOverview', start); + assert.ok(start >= 0 && end > start); + const publicAccount = types.slice(start, end); + assert.doesNotMatch(publicAccount, /\bphone\b|phoneNumber|phoneCodeHash|sessionString|StringSession|apiHash|token:/i); +}); + +test('QR payload remains an opaque typed field and is never logged by the client', () => { + assert.match(types, /qrCode:\s*string/); + const telegramClient = api.slice(api.indexOf('async getTelegramUserAccounts'), api.indexOf('async setTelegramUserDownloadEnabled')); + assert.doesNotMatch(telegramClient, /console\.(?:log|info|debug|warn|error)/); +}); diff --git a/frontend/src/services/telegramUserLoginExperience.test.ts b/frontend/src/services/telegramUserLoginExperience.test.ts index 811e459..f1c5e72 100644 --- a/frontend/src/services/telegramUserLoginExperience.test.ts +++ b/frontend/src/services/telegramUserLoginExperience.test.ts @@ -3,31 +3,36 @@ import fs from 'node:fs'; import test from 'node:test'; const settings = fs.readFileSync(new URL('../components/pages/SettingsPage.tsx', import.meta.url), 'utf8'); +const panel = fs.readFileSync(new URL('../components/pages/TelegramUserAccountsPanel.tsx', import.meta.url), 'utf8'); const api = fs.readFileSync(new URL('./api.ts', import.meta.url), 'utf8'); -test('Settings provides a Chinese phone -> code -> optional 2FA account login wizard', () => { - for (const copy of ['登录 Telegram 用户账号', '手机号', '验证码', '两步验证密码', '验证码已发送', '账号已绑定并自动启用']) { - assert.match(settings, new RegExp(copy)); +const loginUi = settings + panel; + +test('Settings provides a login-method choice before QR or phone authentication', () => { + for (const copy of ['添加 Telegram 用户账号', '选择登录方式', '下一步', '二维码登录', '改用手机号', '手机号', '验证码', '两步验证密码', '验证码已发送', '账号已绑定并自动启用']) { + assert.match(loginUi, new RegExp(copy)); } - assert.match(settings, /telegramUserLoginStep/); - assert.match(settings, /autoComplete="one-time-code"/); - assert.match(settings, /autoComplete="current-password"/); - assert.match(settings, /type="password"/); + assert.match(panel, /QRCodeSVG/); + assert.match(panel, /autoComplete="one-time-code"/); + assert.match(panel, /autoComplete="current-password"/); + assert.match(panel, /type="password"/); }); -test('bound accounts hide the login form, keep mobile actions readable, and disabled state is not presented as an error', () => { - assert.match(settings, /!config\?\.telegramUserClientStatus\?\.userId &&/); - assert.match(settings, /flex-col\s+gap-2\s+sm:flex-row/); - assert.match(settings, /w-full\s+sm:w-auto/); - assert.match(settings, /telegramUserClientStatus\?\.status !== 'disabled' &&