From 145b238a65f05aafd4e9a2b043331245cd1422e2 Mon Sep 17 00:00:00 2001 From: wumingzhinu Date: Sat, 10 Oct 2026 21:13:56 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(driver):=20=E6=96=B0=E5=A2=9E=20Telegr?= =?UTF-8?q?am=20=E7=BD=91=E7=9B=98=E9=A9=B1=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 参考实现:https://github.com/katelya77/K-Vault ## 遇到的核心问题:Telegram 无法列举文件 K-Vault 能把 Telegram 当网盘用,是因为它的上传经由自己的 webhook,因此映射关系 一直握在自己手里。而本驱动要实现完整的 StorageDriver 语义(list/mkdir/rename/ remove/move/copy/put),必须能回答「这个聊天里现在有哪些文件」。Bot API 做不到: - **没有 getMessage** —— 无法按 message_id 回读任意消息; - getUpdates 只保留 24 小时,且**不包含 bot 自己发出的消息**; - 没有任何「列出会话历史消息」的方法。 所以把 manifest 写进聊天是行不通的:写进去就再也读不回来,等于数据不可访问。 K-Vault 自己也是把映射存在数据库里(functions/utils/telegram.js 的 shouldWriteTelegramMetadata),本驱动同理 —— Telegram 只负责存字节, 目录结构由 OpenList 侧维护的索引负责。这同时绕开了聊天消息 4096 字符的文本上限。 ## 索引设计 - 存放位置:DB 的 settings 表,key = `telegram_index_` - 形态:`{ v, dirs: { "<目录路径>": Entry[] } }`,目录路径为 POSIX 绝对路径(根为 "") - 只由 bot_token + chat_id 派生,不含 root_path:root_path 是视图,不该分裂索引; 同一聊天被两个 storage 指向时视为同一份数据,这与 Telegram 的实际语义一致。 - SHA-256 用纯 JS 同步实现:WebCrypto.subtle 是异步的,而 key 需要同步值; 同时不引入 node:crypto(本仓库也跑在 Cloudflare Workers 上)。 - 解析时逐条校验(isValidIndexEntry),损坏或旧版本数据被安全丢弃而不是让驱动崩掉。 ## 下载:不缓存直链,走 createReadStream Telegram 的文件直链有效期至少 1 小时且不可控,存进索引必然过期。因此不返回 raw_url,改实现 createReadStream —— 请求发生时才 getFile 换新鲜直链,并透传 Range(raw.ts 已负责 206 / Content-Range / Content-Length)。 超过 20MB 的文件在 list 阶段就能判定,直接给出 raw_url_error 说明原因。 ## Bot API 硬限制(无法绕过,已写进错误信息) | 操作 | 上限 | 来源 | | --- | --- | --- | | 上传 sendDocument | 50MB | Bot API | | 下载 getFile | 20MB | Bot API,超限不返回 file_path | copy / move 用 copyMessage 在 Telegram 侧复制消息,比「下载再上传」少一个带宽 往返,也不受 20MB 下载上限影响。 ## 其他实现要点 - group → supergroup 迁移:旧的数字 chat_id 失效时,Telegram 会在 parameters.migrate_to_chat_id 给出新 id,客户端自动跟进并重试一次。 - 错误信息绝不回显 bot_token:URL 形如 /bot/method,直接抛 URL 等于把 凭据写进日志。错误只带方法名与 Telegram 的 description,测试对此有断言。 - 所有写操作「单次载入 → 改 → 末尾统一持久化」,避免用陈旧快照覆盖他人写入, 中途失败也不会留下半截状态。 - 删除先落索引再清理 Telegram 消息:索引落地即代表文件不再可见, Telegram 侧清理失败(如消息已被人工删除)不回滚。 - 索引存储通过构造函数注入(IndexStore),默认落 DB;测试注入内存实现。 ## 测试 新增 25 个用例,全部通过: - index.test.ts(10):SHA-256 对照已知向量(含跨 64 字节分组边界与多字节字符)、 indexKey 稳定性与区分性、路径归一、损坏/旧版本/非法条目容错、序列化往返 (含中文与特殊字符)。 - driver.test.ts(15):init、put/list、排序、mkdir/rename/remove、目录改名时 后代路径迁移、递归删除、空目录在 copy 中不丢失、move、root_path 挂载、 同名覆盖不留孤儿、20MB 边界提示、createReadStream 的 Range 透传、 错误信息不含 token。 测试过程中发现并修掉 4 个真实缺陷: 1. rename 未迁移目录自身的 children 键 → 改名后内容为空; 2. rename 先搬走自身键再收集后代 → 后代迁移静默失效; 3. copy 复制单个文件时被目录分支跳过 → 文件从未写入索引; 4. copy / move / remove 把 srcPhysical 当成条目所在目录的**上级**, 而 op/storage.ts 传入的是**父目录**。 ## 未验证 没有真实 Bot Token,全部逻辑基于桩 fetch 验证。上线前请先用真实 bot 验证 init / put / list / 下载 / 删除一轮。 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/backend/drivers/telegram/driver.test.ts | 377 +++++++++++++ src/backend/drivers/telegram/driver.ts | 595 ++++++++++++++++++++ src/backend/drivers/telegram/index.test.ts | 161 ++++++ src/backend/drivers/telegram/index.ts | 203 +++++++ src/backend/drivers/telegram/store.ts | 41 ++ src/backend/drivers/telegram/types.ts | 101 ++++ src/backend/drivers/telegram/util.ts | 245 ++++++++ src/backend/internal/op/storage.ts | 4 + 8 files changed, 1727 insertions(+) create mode 100644 src/backend/drivers/telegram/driver.test.ts create mode 100644 src/backend/drivers/telegram/driver.ts create mode 100644 src/backend/drivers/telegram/index.test.ts create mode 100644 src/backend/drivers/telegram/index.ts create mode 100644 src/backend/drivers/telegram/store.ts create mode 100644 src/backend/drivers/telegram/types.ts create mode 100644 src/backend/drivers/telegram/util.ts diff --git a/src/backend/drivers/telegram/driver.test.ts b/src/backend/drivers/telegram/driver.test.ts new file mode 100644 index 00000000..f834d50f --- /dev/null +++ b/src/backend/drivers/telegram/driver.test.ts @@ -0,0 +1,377 @@ +// Telegram 驱动:StorageDriver 语义单测。 +// +// 通过把 driver / util / index / types 复制到隔离目录 + 注入桩 store.ts, +// 在不加载真实 DB、不触网的前提下覆盖路径映射与增删改查。 +// 被测对象是真实源码(逐字复制),只有 store 与 fetch 被替换。 + +import test from "node:test" +import assert from "node:assert/strict" +import { INDEX_VERSION, indexKey, parseIndex } from "./index" +import type { IndexEntry } from "./index" +import { DriverTelegram } from "./driver" + +// ---------- 桩:内存版索引存储(经构造注入) ---------- + +interface MemStore { + [k: string]: string +} + +function makeStore(): { store: any; reset: () => void; snapshot: () => MemStore } { + const DB: MemStore = {} + return { + store: { + async load(key: string) { + return parseIndex(__dbSnapshot()[key]) + }, + async save(key: string, idx: any) { + DB[key] = JSON.stringify(idx) + }, + }, + reset: () => { + for (const k of Object.keys(DB)) delete DB[k] + }, + snapshot: () => DB, + } +} + +const MEM = makeStore() +const __resetDb = MEM.reset +const __dbSnapshot = MEM.snapshot + +// ---------- 桩:Telegram HTTP ---------- + +interface Call { + method: string + body?: any +} + +const calls: Call[] = [] +let nextMessageId = 100 +let nextFileId = 1 + +function reply(result: any) { + return new Response(JSON.stringify({ ok: true, result }), { + headers: { "Content-Type": "application/json" }, + }) +} + +function err(description: string, code = 400, params?: any) { + return new Response( + JSON.stringify({ ok: false, error_code: code, description, parameters: params }), + { headers: { "Content-Type": "application/json" } }, + ) +} + +function resetApi() { + calls.length = 0 + nextMessageId = 100 + nextFileId = 1 +} + +async function fakeFetch(url: string, init?: any): Promise { + const method = String(url).split("/").pop() || "" + let body: any = {} + if (init?.body) { + try { + body = JSON.parse(init.body) + } catch { + body = { _raw: true } + } + } + calls.push({ method, body }) + + switch (method) { + case "getMe": + return reply({ id: 42, username: "testbot" }) + case "getChat": + return reply({ id: -100999, type: "supergroup", description: "" }) + case "sendMessage": + return reply({ message_id: nextMessageId++, date: 1700000000 }) + case "sendDocument": { + const id = nextMessageId++ + return reply({ + message_id: id, + date: 1700000000, + document: { file_id: `file${nextFileId++}`, file_unique_id: "u" }, + }) + } + case "copyMessage": { + const id = nextMessageId++ + return reply({ + message_id: id, + date: 1700000000, + document: { file_id: `file${nextFileId++}`, file_unique_id: "u" }, + }) + } + case "deleteMessage": + return reply(true) + case "getFile": + return reply({ + file_id: "x", + file_unique_id: "u", + file_path: "documents/file_1.txt", + file_size: 5, + }) + default: + return err("unexpected method " + method) + } +} + +const g: any = globalThis as any +g.fetch = fakeFetch + +const ADD = { + bot_token: "123456:AAtesttokenAAAAAAAAAAAAAAAAAAA", + chat_id: "-100999", +} + +function newDriver(rootPath?: string) { + return new DriverTelegram({ ...ADD, root_path: rootPath }, MEM.store) +} + +function topOf(key: string): IndexEntry[] { + return parseIndex(__dbSnapshot()[key]).dirs[""] || [] +} + +// ------------------------------------------------------------------ + +test("init 调用 getMe 并在 DB 中建立索引行", async () => { + __resetDb() + resetApi() + const d = newDriver() + await d.init() + assert.ok(calls.some((c) => c.method === "getMe")) + const key = indexKey(ADD.bot_token, ADD.chat_id) + assert.ok(__dbSnapshot()[key] !== undefined, "索引行应被创建") + assert.deepEqual(parseIndex(__dbSnapshot()[key]), { v: INDEX_VERSION, dirs: {} }) +}) + +test("init 缺少凭据时报错,且不泄露 token", async () => { + __resetDb() + resetApi() + const d = new DriverTelegram({ bot_token: "", chat_id: "" }, MEM.store) + await assert.rejects(() => d.init(), (e: Error) => { + assert.match(e.message, /bot_token/) + assert.ok(!e.message.includes("123456"), "错误信息不得回显 token") + return true + }) +}) + +test("put 后 list 能看到文件,且 raw_url 为空以走 createReadStream", async () => { + __resetDb() + resetApi() + const d = newDriver() + await d.init() + + await d.put("", "/hello.txt", Buffer.from("hello")) + const items = await d.list("", "/") + assert.equal(items.length, 1) + assert.equal(items[0].name, "hello.txt") + assert.equal(items[0].is_dir, false) + assert.equal(items[0].size, 5) + assert.equal(items[0].raw_url, "", "不得缓存会过期的 Telegram 直链") + assert.ok(calls.some((c) => c.method === "sendDocument")) +}) + +test("目录优先排序,且同层按名称排序", async () => { + __resetDb() + resetApi() + const d = newDriver() + await d.init() + await d.put("", "/b.txt", Buffer.from("b")) + await d.put("", "/a.txt", Buffer.from("a")) + await d.mkdir("", "/zdir") + await d.mkdir("", "/adir") + const names = (await d.list("", "/")).map((i) => i.name) + assert.deepEqual(names, ["adir", "zdir", "a.txt", "b.txt"]) +}) + +test("mkdir / rename / remove 的索引语义", async () => { + __resetDb() + resetApi() + const d = newDriver() + await d.init() + + await d.mkdir("", "/docs") + await d.mkdir("", "/docs/empty") + assert.deepEqual((await d.list("", "/docs")).map((i) => i.name), ["empty"]) + + await d.rename("", "/docs", "papers") + assert.deepEqual((await d.list("", "/")).map((i) => i.name), ["papers"]) + assert.deepEqual((await d.list("", "/papers")).map((i) => i.name), ["empty"]) + + await d.remove("", "/", ["papers"]) + assert.deepEqual((await d.list("", "/")).map((i) => i.name), []) +}) + +test("目录改名时后代路径整体迁移", async () => { + __resetDb() + resetApi() + const d = newDriver() + await d.init() + await d.mkdir("", "/a") + await d.mkdir("", "/a/b") + await d.mkdir("", "/a/b/c") + await d.put("", "/a/b/c/f.txt", Buffer.from("x")) + + await d.rename("", "/a", "z") + + assert.deepEqual((await d.list("", "/z/b/c")).map((i) => i.name), ["f.txt"]) + const key = indexKey(ADD.bot_token, ADD.chat_id) + assert.deepEqual(Object.keys(parseIndex(__dbSnapshot()[key]).dirs).sort(), [ + "", "/z", "/z/b", "/z/b/c", + ]) +}) + +test("删除目录会递归删除其中所有文件的 Telegram 消息", async () => { + __resetDb() + resetApi() + const d = newDriver() + await d.init() + await d.mkdir("", "/tree") + await d.mkdir("", "/tree/sub") + await d.put("", "/tree/one.txt", Buffer.from("1")) + await d.put("", "/tree/sub/two.txt", Buffer.from("2")) + + calls.length = 0 + await d.remove("", "/", ["tree"]) + + const deleted = calls.filter((c) => c.method === "deleteMessage") + assert.equal(deleted.length, 2, "两条文件消息都应被删除") + const key = indexKey(ADD.bot_token, ADD.chat_id) + assert.deepEqual(Object.keys(parseIndex(__dbSnapshot()[key]).dirs), [], "删空后索引不应残留空目录键") +}) + +test("root_path 把驱动挂到聊天的子目录下", async () => { + __resetDb() + resetApi() + const d = newDriver("/vault") + await d.init() + + await d.put("", "/vault/a.txt", Buffer.from("a")) + const key = indexKey(ADD.bot_token, ADD.chat_id) + assert.deepEqual(Object.keys(parseIndex(__dbSnapshot()[key]).dirs).sort(), [""]) + + // 挂载点之下可见;挂载点之外不可见 + assert.deepEqual((await d.list("", "/vault")).map((i) => i.name), ["a.txt"]) + await assert.rejects(() => d.list("", "/other"), /不在 root_path/) +}) + +test("同名覆盖上传会删除旧消息,不留孤儿", async () => { + __resetDb() + resetApi() + const d = newDriver() + await d.init() + await d.put("", "/x.txt", Buffer.from("first")) + calls.length = 0 + await d.put("", "/x.txt", Buffer.from("second")) + assert.equal(calls.filter((c) => c.method === "deleteMessage").length, 1) + const items = await d.list("", "/") + assert.equal(items.length, 1) + assert.equal(items[0].size, 6) +}) + +test("拒绝非法文件名与同名冲突", async () => { + __resetDb() + resetApi() + const d = newDriver() + await d.init() + await d.mkdir("", "/a") + await assert.rejects(() => d.mkdir("", "/a"), /已存在/) + await assert.rejects(() => d.mkdir("", "/.."), /非法名称/) + await assert.rejects(() => d.mkdir("", "/" + "x".repeat(300)), /过长/) +}) + +test("copy 目录保留空目录,并使用新 message_id/file_id", async () => { + __resetDb() + resetApi() + const d = newDriver() + await d.init() + await d.mkdir("", "/src") + await d.mkdir("", "/src/empty") // 空目录:最容易在复制中丢失 + await d.mkdir("", "/src/withfile") + await d.put("", "/src/withfile/f.bin", Buffer.from("data")) + await d.mkdir("", "/dst") + + await d.copy("", "/dst", ["src"], "/", "/dst") + + assert.deepEqual((await d.list("", "/dst/src")).map((i) => i.name).sort(), [ + "empty", + "withfile", + ]) + const copied = await d.list("", "/dst/src/withfile") + assert.equal(copied.length, 1) + // 源与目标的 message_id 必须不同(确实发生了 Telegram 侧复制) + const srcItems = await d.list("", "/src/withfile") + assert.notEqual(copied[0].sign, srcItems[0].sign) + + // 删除源后,目标副本仍在 + await d.remove("", "/", ["src"]) + assert.equal((await d.list("", "/dst/src/withfile")).length, 1) +}) + +test("move = copy + remove,源目录不再保留", async () => { + __resetDb() + resetApi() + const d = newDriver() + await d.init() + await d.mkdir("", "/from") + await d.mkdir("", "/to") + await d.put("", "/from/a.txt", Buffer.from("aa")) + + await d.move("", "/to", ["a.txt"], "/from", "/to") + assert.deepEqual((await d.list("", "/to")).map((i) => i.name), ["a.txt"]) + assert.deepEqual((await d.list("", "/from")).map((i) => i.name), []) +}) + +test("超过 20MB 的文件在 list 中就给出明确原因", async () => { + __resetDb() + resetApi() + const d = newDriver() + await d.init() + await d.put("", "/big.bin", Buffer.alloc(21 * 1024 * 1024)) + const items = await d.list("", "/") + assert.equal(items.length, 1) + assert.match(items[0].raw_url_error || "", /20MB/) +}) + +test("createReadStream 取新鲜直链并透传 Range", async () => { + __resetDb() + resetApi() + const d = newDriver() + await d.init() + await d.put("", "/r.txt", Buffer.from("hello")) + + let seenUrl = "" + let seenHeaders: any = null + g.fetch = async (url: string, init?: any) => { + if (String(url).includes("/file/bot")) { + seenUrl = url + seenHeaders = init?.headers + return new Response("hello", { status: 206 }) + } + return fakeFetch(url, init) + } + await d.createReadStream("/r.txt", { start: 0, end: 2 }) + assert.match(seenUrl, /\/file\/bot.*\/documents\/file_1\.txt$/) + assert.equal(seenHeaders.Range, "bytes=0-2") + g.fetch = fakeFetch +}) + +test("错误信息中不出现 bot_token", async () => { + __resetDb() + resetApi() + const d = newDriver() + await d.init() + g.fetch = async () => err("Bad Request: chat not found") + await assert.rejects( + () => d.put("", "/z.txt", Buffer.from("z")), + (e: Error) => { + assert.ok(!e.message.includes("123456"), "不得回显 token: " + e.message) + assert.ok(!e.message.includes("AAtest"), "不得回显 token: " + e.message) + assert.match(e.message, /chat not found/) + return true + }, + ) + g.fetch = fakeFetch +}) \ No newline at end of file diff --git a/src/backend/drivers/telegram/driver.ts b/src/backend/drivers/telegram/driver.ts new file mode 100644 index 00000000..f3e81c10 --- /dev/null +++ b/src/backend/drivers/telegram/driver.ts @@ -0,0 +1,595 @@ +// Telegram 网盘驱动 +// +// 模型:一个 Telegram 聊天(group / supergroup / channel)就是一个平铺的消息池, +// 每条文件消息对应一个文件。Telegram Bot API **无法列举消息**(无 getMessage, +// getUpdates 只留 24h 且不含 bot 自己发的消息),因此目录结构完全由 OpenList +// 侧维护的索引记录 —— 见 index.ts 的说明。 +// +// 下载:不返回 raw_url。Telegram 的文件直链有效期至少 1 小时且不可控, +// 缓存进索引必然过期,因此实现 createReadStream,在请求发生时才调用 getFile +// 换取新鲜直链,并透传 Range(raw.ts 已负责 206/Content-Range 头)。 +// +// Bot API 硬限制(无法绕过,写在错误信息里告知用户): +// 上传 ≤ 50MB(sendDocument) +// 下载 ≤ 20MB(getFile 只对 ≤20MB 的文件返回 file_path) + +import type { FileItem, StorageDriver } from "../../internal/driver/base" +import { calcFileType } from "../../internal/driver/base" +import type { DriverTelegramAddition } from "./types" +import { + ClientTelegram, + MAX_DOWNLOAD_BYTES, + MAX_UPLOAD_BYTES, + TelegramApiError, +} from "./util" +import type { + FileEntry, + IndexEntry, + IndexShape, +} from "./index" +import { + childrenOf, + collectDirPaths, + indexKey, + normalizeDirPath, + setChildren, +} from "./index" +import { loadIndex, saveIndex } from "./store" + +/** 索引存储抽象:默认落 DB;测试可注入内存实现(构造注入,非测试专用全局钩子) */ +export interface IndexStore { + load(key: string): Promise + save(key: string, idx: IndexShape): Promise +} + +const defaultStore: IndexStore = { + load: (key) => loadIndex(key), + save: (key, idx) => saveIndex(key, idx), +} + +export class DriverTelegram implements StorageDriver { + private client: ClientTelegram + private store: IndexStore + /** 索引在 DB 中的 key,由 bot_token + chat_id 派生 */ + private key: string + /** root_path 前缀(视图层),"" 表示直接使用聊天根 */ + private prefix: string + + constructor(addition: DriverTelegramAddition, store: IndexStore = defaultStore) { + this.client = new ClientTelegram(addition) + this.store = store + this.key = indexKey(addition.bot_token || "", String(addition.chat_id || "")) + this.prefix = normalizeDirPath(addition.root_path || "") + } + + async init(): Promise { + this.client.init() + // 校验 token/chat 确实可用,并确保索引行存在 + await this.client.getMe() + const idx = await this.store.load(this.key) + await this.store.save(this.key, idx) + } + + // ---------- 路径 ---------- + + /** physicalPath → 索引内的逻辑路径(剥掉 root_path 前缀) */ + private logical(physicalPath: string): string { + const p = normalizeDirPath(physicalPath) + if (!this.prefix) return p + if (p === this.prefix) return "" + if (p.startsWith(this.prefix + "/")) return p.slice(this.prefix.length) + // 不在 root_path 前缀内:这是配置错误,必须显式报错而不是静默返回空目录 + throw new Error( + `[Telegram] 路径 ${physicalPath} 不在 root_path ${this.prefix} 范围内`, + ) + } + + /** 逻辑路径 → physicalPath(拼回 root_path 前缀) */ + private physical(logicalPath: string): string { + const l = normalizeDirPath(logicalPath) + if (!this.prefix) return l || "/" + return normalizeDirPath(this.prefix + "/" + l) || this.prefix + } + + private logicalDirOf(physicalPath: string): string { + const l = this.logical(physicalPath) + const i = l.lastIndexOf("/") + return i <= 0 ? "" : l.slice(0, i) + } + + private nameOf(physicalPath: string): string { + const l = normalizeDirPath(this.logical(physicalPath)) + const i = l.lastIndexOf("/") + return l.slice(i + 1) + } + + // ---------- 索引 ---------- + + /** 每次写操作前重载,避免用陈旧快照覆盖他人写入 */ + private async fresh(): Promise { + return this.store.load(this.key) + } + + private async persist(idx: IndexShape): Promise { + await this.store.save(this.key, idx) + } + + private async findEntry( + idx: IndexShape, + logicalDir: string, + name: string, + ): Promise { + return childrenOf(idx, logicalDir).find((e) => e.n === name) + } + + // ---------- StorageDriver ---------- + + async list(_virtualPath: string, physicalPath: string): Promise { + const dir = this.logical(physicalPath) + const idx = await this.fresh() + const children = childrenOf(idx, dir) + return children + .map((e) => this.toFileItem(e, dir)) + .sort((a, b) => { + if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1 + return a.name.localeCompare(b.name) + }) + } + + async get(_virtualPath: string, physicalPath: string): Promise { + const dir = this.logicalDirOf(physicalPath) + const name = this.nameOf(physicalPath) + const idx = await this.fresh() + + // 根目录本身 + if (!name || dir === "/" && !name) { + return { + name: name || "/", + size: 0, + is_dir: true, + modified: new Date().toISOString(), + sign: "", + type: 1, + raw_url: "", + } + } + // 目录:存在即返回 + const asDir = await this.findEntry(idx, dir, name) + if (asDir?.d) { + return { + name, + size: 0, + is_dir: true, + modified: new Date(asDir.t * 1000).toISOString(), + sign: "", + type: 1, + raw_url: "", + } + } + const e = await this.findEntry(idx, dir, name) + if (e && !e.d) return this.toFileItem(e, dir) + + throw new Error(`[Telegram] 条目不存在: ${physicalPath}`) + } + + async mkdir(_virtualPath: string, physicalPath: string): Promise { + const dir = this.logicalDirOf(physicalPath) + const name = this.nameOf(physicalPath) + if (!name) throw new Error("[Telegram] 目录名不能为空") + this.assertName(name) + + const idx = await this.fresh() + if (await this.findEntry(idx, dir, name)) { + throw new Error(`[Telegram] 已存在同名条目: ${name}`) + } + // 目录本身不占 Telegram 消息,仅存在于索引 + setChildren(idx, dir, [ + ...childrenOf(idx, dir), + { n: name, d: true, t: unixNow() }, + ]) + await this.persist(idx) + } + + async rename( + _virtualPath: string, + physicalPath: string, + newName: string, + ): Promise { + this.assertName(newName) + const dir = this.logicalDirOf(physicalPath) + const oldName = this.nameOf(physicalPath) + const idx = await this.fresh() + const entry = await this.findEntry(idx, dir, oldName) + if (!entry) throw new Error(`[Telegram] 条目不存在: ${oldName}`) + if (await this.findEntry(idx, dir, newName)) { + throw new Error(`[Telegram] 目标已存在: ${newName}`) + } + + const next = childrenOf(idx, dir).map((e) => + e.n === oldName ? ({ ...e, n: newName } as IndexEntry) : e, + ) + setChildren(idx, dir, next) + + // 目录改名:所有后代路径都要跟着改 + if (entry.d) { + const oldBase = normalizeDirPath(dir + "/" + oldName) + const newBase = normalizeDirPath(dir + "/" + newName) + // 先收集后代,再统一搬运: + // 若先搬走 oldBase 自身的 children,collectDirPaths(idx, oldBase) 就找不到入口了。 + const descendants = collectDirPaths(idx, oldBase) + // 目录自身的 children 键也要搬;否则 rename 后新名字下的内容会是空的。 + // 空目录在索引里不留痕,所以只有确实有子项时才存在这个键。 + if (idx.dirs[oldBase]) { + idx.dirs[newBase] = idx.dirs[oldBase] + delete idx.dirs[oldBase] + } + for (const p of descendants) { + const moved = newBase + p.slice(oldBase.length) + idx.dirs[moved] = idx.dirs[p] + delete idx.dirs[p] + } + } + await this.persist(idx) + } + + async remove( + _virtualPath: string, + physicalPath: string, + names: string[], + ): Promise { + // physicalPath 是 names 的**父目录**(见 op/storage.ts removeItems 调用点) + const dir = this.logical(physicalPath) + const idx = await this.fresh() + + // 先收集要删的 Telegram 消息 id,再统一改索引 —— 避免边遍历边改 + const messageIds: number[] = [] + for (const name of names) { + const entry = childrenOf(idx, dir).find((e) => e.n === name) + if (!entry) continue + if (entry.d) { + const base = normalizeDirPath(dir + "/" + name) + for (const p of [base, ...collectDirPaths(idx, base)]) { + for (const e of childrenOf(idx, p)) { + if (!e.d) messageIds.push((e as FileEntry).m) + } + delete idx.dirs[p] + } + } else { + messageIds.push(entry.m) + } + } + + const removing = new Set(names) + setChildren( + idx, + dir, + childrenOf(idx, dir).filter((e) => !removing.has(e.n)), + ) + await this.persist(idx) + + // 索引已落地(文件不再可见),Telegram 侧清理失败不应回滚 + await this.deleteMessages(messageIds) + } + + async move( + _srcDir: string, + dstDir: string, + names: string[], + srcPhysical: string, + dstPhysical: string, + ): Promise { + await this.copy(_srcDir, dstDir, names, srcPhysical, dstPhysical) + await this.remove("", srcPhysical, names) + } + + async copy( + _srcDir: string, + dstDir: string, + names: string[], + srcPhysical: string, + _dstPhysical: string, + ): Promise { + // srcPhysical / dstPhysical 都是 names 的**父目录**(见 op/storage.ts copyItems 调用点) + const sdir = this.logical(srcPhysical) + const ddir = this.logical(dstDir) + const idx = await this.fresh() + + // 先完成所有网络操作,再统一改索引:中途失败不会留下半截状态 + const copies: Array<{ + srcPath: string + name: string + size: number + msgId: number + fileId: string + }> = [] + const newTop: IndexEntry[] = [] + + for (const name of names) { + const entry = childrenOf(idx, sdir).find((e) => e.n === name) + if (!entry) throw new Error(`[Telegram] 条目不存在: ${name}`) + const clash = + childrenOf(idx, ddir).some((e) => e.n === name) || + newTop.some((e) => e.n === name) + if (clash) throw new Error(`[Telegram] 目标已存在同名条目: ${name}`) + + if (entry.d) { + newTop.push({ n: name, d: true, t: unixNow() }) + // 目录内的文件也必须真正复制字节,目录本身只是索引节点 + const srcBase = normalizeDirPath(sdir + "/" + name) + for (const p of [srcBase, ...collectDirPaths(idx, srcBase)]) { + for (const e of childrenOf(idx, p)) { + if (e.d) continue + const fe = e as FileEntry + copies.push({ + srcPath: p, + name: fe.n, + size: fe.s, + ...(await this.copyFile(fe.m)), + }) + } + } + } else { + const fe = entry as FileEntry + copies.push({ + srcPath: sdir, + name: fe.n, + size: fe.s, + ...(await this.copyFile(fe.m)), + }) + } + } + + // 重建子树结构(显式重建每个目录节点,空目录必须保留 —— setChildren([]) 会删键) + const fresh = new Map() + for (const c of copies) { + let targetDir: string + if (c.srcPath === sdir) targetDir = ddir + else { + const top = names.find((n) => + c.srcPath === normalizeDirPath(sdir + "/" + n) || + c.srcPath.startsWith(normalizeDirPath(sdir + "/" + n) + "/"), + ) + if (!top) continue + const srcBase = normalizeDirPath(sdir + "/" + top) + targetDir = normalizeDirPath( + normalizeDirPath(ddir + "/" + top) + c.srcPath.slice(srcBase.length), + ) + } + fresh.set(targetDir + "/" + c.name, { + m: c.msgId, + f: c.fileId, + }) + } + for (const name of names) { + const entry = childrenOf(idx, sdir).find((e) => e.n === name) + if (!entry?.d) continue + const srcBase = normalizeDirPath(sdir + "/" + name) + const dstBase = normalizeDirPath(ddir + "/" + name) + this.cloneSubtree(idx, srcBase, dstBase, fresh) + } + + // 顶层被复制的文件:cloneSubtree 只处理目录子树,单文件必须在这里并入。 + const topFiles: IndexEntry[] = copies + .filter((c) => c.srcPath === sdir) + .map((c) => ({ + n: c.name, + d: false, + t: unixNow(), + s: c.size, + m: c.msgId, + f: c.fileId, + })) + + setChildren(idx, ddir, [...childrenOf(idx, ddir), ...newTop, ...topFiles]) + await this.persist(idx) + } + + async put( + _virtualPath: string, + physicalPath: string, + content: Buffer, + ): Promise { + const dir = this.logicalDirOf(physicalPath) + const name = this.nameOf(physicalPath) + if (!name) throw new Error("[Telegram] 文件名不能为空") + this.assertName(name) + if (content.byteLength > MAX_UPLOAD_BYTES) { + throw new TelegramApiError( + "sendDocument", + `文件 ${content.byteLength} 字节超过 Bot API 上传上限 ${MAX_UPLOAD_BYTES} 字节(50MB)`, + ) + } + + const msg = await this.client.sendDocument(name, content) + const file = ClientTelegram.pickFileId(msg) + if (!file) { + throw new TelegramApiError( + "sendDocument", + "Telegram 已接收消息但未返回可用的 file_id", + ) + } + + const idx = await this.fresh() + const existing = childrenOf(idx, dir).find((e) => e.n === name) + if (existing && !existing.d) { + // 同名覆盖:删掉旧消息,避免留下无主孤儿 + await this.deleteMessages([existing.m]) + setChildren( + idx, + dir, + childrenOf(idx, dir).filter((e) => e.n !== name), + ) + } else if (existing) { + throw new TelegramApiError("sendDocument", `已存在同名目录: ${name}`) + } + + setChildren(idx, dir, [ + ...childrenOf(idx, dir), + { + n: name, + d: false, + t: unixNow(), + s: content.byteLength, + m: msg.message_id, + f: file.file_id, + } as FileEntry, + ]) + await this.persist(idx) + } + + /** + * 按需下载:请求时才换新鲜直链,并透传 Range。 + * raw.ts 会在拿到 stream 后自己设置 206 / Content-Range / Content-Length。 + */ + async createReadStream( + physicalPath: string, + options?: { start?: number; end?: number }, + ): Promise { + const dir = this.logicalDirOf(physicalPath) + const name = this.nameOf(physicalPath) + const idx = await this.fresh() + const entry = await this.findEntry(idx, dir, name) + if (!entry || entry.d) { + throw new Error(`[Telegram] 文件不存在: ${physicalPath}`) + } + const f = entry as FileEntry + if (f.s > MAX_DOWNLOAD_BYTES) { + throw new TelegramApiError( + "getFile", + `文件 ${f.s} 字节超过 Bot API 下载上限 ${MAX_DOWNLOAD_BYTES} 字节(20MB)。` + + `Telegram Bot API 无法下载该文件。`, + ) + } + + const { url } = await this.client.resolveDownload(f.f) + const headers: Record = {} + if (options?.start !== undefined || options?.end !== undefined) { + const start = options.start ?? 0 + const end = options.end ?? f.s - 1 + headers.Range = `bytes=${start}-${end}` + } + const resp = await fetch(url, { headers }) + if (!resp.ok && resp.status !== 206) { + throw new TelegramApiError( + "download", + `下载失败(HTTP ${resp.status})。若为 404,通常是 Telegram 直链已过期,` + + `请重试。`, + ) + } + return resp.body + } + + // ---------- 内部工具 ---------- + + private toFileItem(e: IndexEntry, _parentDir: string): FileItem { + const modified = new Date(e.t * 1000).toISOString() + if (e.d) { + return { + name: e.n, + size: 0, + is_dir: true, + modified, + sign: "", + type: 1, + raw_url: "", + } + } + const f = e as FileEntry + return { + name: f.n, + size: f.s, + is_dir: false, + modified, + sign: String(f.m), + type: calcFileType(f.n, false), + // 不返回 raw_url:Telegram 直链会过期,改走 createReadStream 按需取 + raw_url: "", + raw_url_error: + f.s > MAX_DOWNLOAD_BYTES + ? `文件 ${f.s} 字节超过 Telegram Bot API 下载上限 ${MAX_DOWNLOAD_BYTES} 字节(20MB),无法下载。` + : undefined, + } + } + + /** 在 Telegram 侧复制一条文件消息,返回新的 message_id / file_id */ + private async copyFile(messageId: number): Promise<{ + msgId: number + fileId: string + }> { + const copied = await this.client.copyMessage(messageId) + const file = ClientTelegram.pickFileId(copied) + if (!file) { + throw new TelegramApiError("copyMessage", "复制失败:未取得 file_id") + } + return { msgId: copied.message_id, fileId: file.file_id } + } + + /** + * 把 srcDir 的整棵子树复制到 dstDir(含空目录),文件条目改用已复制出的 + * message_id / file_id。纯内存操作,调用方负责最后统一持久化。 + */ + private cloneSubtree( + idx: IndexShape, + srcDir: string, + dstDir: string, + fileMap: Map, + ): void { + setChildren( + idx, + dstDir, + childrenOf(idx, srcDir).map((e) => { + if (e.d) return { ...e } as IndexEntry + const fe = e as FileEntry + const got = fileMap.get(dstDir + "/" + fe.n) + if (!got) return { ...fe } as IndexEntry + return { + n: fe.n, + d: false, + t: unixNow(), + s: fe.s, + m: got.m, + f: got.f, + } as FileEntry + }), + ) + for (const e of childrenOf(idx, srcDir)) { + if (!e.d) continue + this.cloneSubtree( + idx, + normalizeDirPath(srcDir + "/" + e.n), + normalizeDirPath(dstDir + "/" + e.n), + fileMap, + ) + } + } + + /** 删除消息;单条失败不阻断整体删除(文件已从索引移除即视为删除成功) */ + private async deleteMessages(ids: number[]): Promise { + for (const id of ids) { + try { + await this.client.deleteMessage(id) + } catch (e) { + // 消息可能已被人工删除;索引仍需保持干净 + if (e instanceof TelegramApiError) continue + throw e + } + } + } + + /** 拒绝会让路径语义混乱的名字 */ + private assertName(name: string): void { + if (!name || name === "." || name === "..") { + throw new Error(`[Telegram] 非法名称: ${JSON.stringify(name)}`) + } + if (name.includes("/") || name.includes("\0")) { + throw new Error("[Telegram] 名称不能包含 / 或空字符") + } + if (name.length > 200) { + throw new Error("[Telegram] 名称过长(上限 200 字符)") + } + } +} + +function unixNow(): number { + return Math.floor(Date.now() / 1000) +} \ No newline at end of file diff --git a/src/backend/drivers/telegram/index.test.ts b/src/backend/drivers/telegram/index.test.ts new file mode 100644 index 00000000..5a01e3ee --- /dev/null +++ b/src/backend/drivers/telegram/index.test.ts @@ -0,0 +1,161 @@ +// Telegram 驱动:索引纯逻辑单测(不触网、不加载 DB) + +import test from "node:test" +import assert from "node:assert/strict" +import type { IndexShape } from "./index" +import { + INDEX_VERSION, + childrenOf, + collectDirPaths, + indexKey, + isValidIndexEntry, + normalizeDirPath, + parseIndex, + serializeIndex, + setChildren, + sha256HexSync, +} from "./index" + +test("sha256HexSync 与已知向量一致", () => { + assert.equal( + sha256HexSync(""), + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + ) + assert.equal( + sha256HexSync("abc"), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + ) + assert.equal( + sha256HexSync( + "abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq", + ), + "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1", + ) +}) + +test("sha256HexSync 能正确处理跨 block 的长输入与多字节字符", () => { + // 长度跨越 SHA-256 的 64 字节分组边界 + const long = "a".repeat(1000) + assert.equal(sha256HexSync(long).length, 64) + // 含中文:UTF-8 字节数 > 字符数,长度补位必须按字节算 + const cn = "中".repeat(200) + assert.equal(sha256HexSync(cn).length, 64) + assert.notEqual(sha256HexSync(cn), sha256HexSync("a".repeat(600))) +}) + +test("indexKey 由 token+chat 派生,且稳定 / 可区分", () => { + const a = indexKey("123:AAA", "-100999") + const b = indexKey("123:AAA", "-100999") + const c = indexKey("123:BBB", "-100999") + assert.equal(a, b, "相同输入必须稳定") + assert.notEqual(a, c, "不同 token 必须区分") + assert.match(a, /^telegram_index_[0-9a-f]{32}$/) +}) + +test("normalizeDirPath 归一化各种输入", () => { + assert.equal(normalizeDirPath(""), "") + assert.equal(normalizeDirPath("/"), "") + assert.equal(normalizeDirPath("a"), "/a") + assert.equal(normalizeDirPath("/a/"), "/a") + assert.equal(normalizeDirPath("//a//b//"), "/a/b") + assert.equal(normalizeDirPath("///"), "") +}) + +test("parseIndex 容忍空 / 损坏 / 旧版本输入", () => { + assert.deepEqual(parseIndex(undefined), { v: INDEX_VERSION, dirs: {} }) + assert.deepEqual(parseIndex(null), { v: INDEX_VERSION, dirs: {} }) + assert.deepEqual(parseIndex(""), { v: INDEX_VERSION, dirs: {} }) + assert.deepEqual(parseIndex("{不是json"), { v: INDEX_VERSION, dirs: {} }) + assert.deepEqual(parseIndex('{"v":999,"dirs":{"":[]}}'), { + v: INDEX_VERSION, + dirs: {}, + }) + assert.deepEqual(parseIndex("null"), { v: INDEX_VERSION, dirs: {} }) + assert.deepEqual(parseIndex('{"v":1,"dirs":"/"}'), { + v: INDEX_VERSION, + dirs: {}, + }) +}) + +test("parseIndex 丢弃非法条目但保留合法条目", () => { + const raw = JSON.stringify({ + v: 1, + dirs: { + "": [ + { n: "ok.txt", d: false, t: 1, s: 10, m: 5, f: "fid" }, + { n: "", d: true, t: 1 }, // 空名 → 丢弃 + { n: "x", d: true }, // 缺 t → 丢弃 + { n: "y", d: "maybe", t: 1 }, // d 非布尔 → 丢弃 + { n: "z", d: false, t: 1, s: -1, m: 1, f: "f" }, // 负 size → 丢弃 + { n: "w", d: false, t: 1, s: 1, m: 0, f: "f" }, // message_id 0 → 丢弃 + { n: "v", d: false, t: 1, s: 1, m: 1, f: "" }, // 空 file_id → 丢弃 + { n: "dir", d: true, t: 1700000000 }, + ], + }, + }) + const idx = parseIndex(raw) + const got = childrenOf(idx, "").map((e) => e.n) + assert.deepEqual(got, ["ok.txt", "dir"]) +}) + +test("isValidIndexEntry 覆盖边界值", () => { + assert.ok(isValidIndexEntry({ n: "a", d: true, t: 0 })) + assert.ok(isValidIndexEntry({ n: "a", d: false, t: 0, s: 0, m: 1, f: "x" })) + assert.ok(!isValidIndexEntry({ n: "a", d: true, t: Number.NaN })) + assert.ok( + !isValidIndexEntry({ + n: "a", + d: false, + t: 1, + s: 1, + m: Number.MAX_SAFE_INTEGER + 1, + f: "x", + }), + ) +}) + +test("setChildren / childrenOf 往返,空目录不写入", () => { + const idx: IndexShape = { v: INDEX_VERSION, dirs: {} } + setChildren(idx, "/a", [{ n: "f", d: true, t: 1 }]) + assert.equal(childrenOf(idx, "/a").length, 1) + assert.equal(childrenOf(idx, "a").length, 1, "路径应归一后命中") + assert.equal(childrenOf(idx, "/不存在").length, 0) + + setChildren(idx, "/a", []) + assert.equal(idx.dirs["/a"], undefined, "空目录不应留在索引里") +}) + +test("collectDirPaths 递归收集全部后代目录", () => { + const idx: IndexShape = { v: INDEX_VERSION, dirs: {} } + setChildren(idx, "", [ + { n: "a", d: true, t: 1 }, + { n: "z.txt", d: false, t: 1, s: 1, m: 1, f: "f" }, + ]) + setChildren(idx, "/a", [{ n: "b", d: true, t: 1 }]) + setChildren(idx, "/a/b", [{ n: "c", d: true, t: 1 }]) + setChildren(idx, "/a/b/c", []) + + const paths = collectDirPaths(idx, "").sort() + assert.deepEqual(paths, ["/a", "/a/b", "/a/b/c"]) + // 从中间目录开始收集 + assert.deepEqual(collectDirPaths(idx, "/a").sort(), ["/a/b", "/a/b/c"]) + assert.deepEqual(collectDirPaths(idx, "/z"), []) +}) + +test("serializeIndex / parseIndex 往返保真(含中文与特殊字符)", () => { + const idx: IndexShape = { v: INDEX_VERSION, dirs: {} } + setChildren(idx, "", [ + { n: "目录 & <特殊>", d: true, t: 1700000000 }, + { + n: 'a"b\\c.txt', + d: false, + t: 1700000001, + s: 12345, + m: 999, + f: "AgACAgIAAxk", + }, + { n: "emoji-🎉.bin", d: false, t: 1700000002, s: 0, m: 1000, f: "f2" }, + ]) + const round = parseIndex(serializeIndex(idx)) + assert.deepEqual(round, idx) +}) \ No newline at end of file diff --git a/src/backend/drivers/telegram/index.ts b/src/backend/drivers/telegram/index.ts new file mode 100644 index 00000000..48124cfa --- /dev/null +++ b/src/backend/drivers/telegram/index.ts @@ -0,0 +1,203 @@ +// Telegram 驱动:目录索引(index)的**纯逻辑**部分(编解码 / 路径工具 / 哈希)。 +// DB 读写在 store.ts —— 拆开是为了让本文件可以在不加载整个 DB 模块的前提下单测。 +// +// 为什么索引必须存在 OpenList 自己的 DB 里,而不能存在 Telegram 里: +// +// Bot API **没有 getMessage**,无法按 message_id 回读任意消息。 +// 可用的读取通道只有 getUpdates,而它(a)只保留最近 24 小时, +// (b)不包含 bot 自己发出的消息。因此把 manifest 写进聊天就再也读不回来, +// 等于数据不可访问。 +// +// K-Vault 之所以能用 Telegram 存储,是因为它把映射关系存在自己的数据库里 +// (functions/utils/telegram.js 的 shouldWriteTelegramMetadata)。 +// 本驱动同理:Telegram 只负责存字节,目录结构由本索引负责。 +// +// 这样也顺带绕开了聊天消息 4096 字符的文本上限。 +// +// 纯逻辑 + DB 读写,不含 Telegram 网络调用,便于单测。 + +/** 索引结构版本 */ +export const INDEX_VERSION = 1 + +/** 目录条目:目录(无对应 Telegram 消息,纯索引) */ +export interface DirEntry { + /** 条目名 */ + n: string + /** true = 目录 */ + d: true + /** Unix 秒 */ + t: number +} + +/** 文件条目:真实存在于 Telegram 中的一条消息 */ +export interface FileEntry { + n: string + d: false + /** Unix 秒 */ + t: number + /** 字节数 */ + s: number + /** 承载文件的消息 id(deleteMessage 用) */ + m: number + /** Bot API file_id(getFile 用) */ + f: string +} + +export type IndexEntry = DirEntry | FileEntry + +/** 磁盘上的索引形态:目录路径 → 该目录的直接子项 */ +export interface IndexShape { + v: number + dirs: Record +} + +/** 索引的 settings key 前缀 */ +export const INDEX_KEY_PREFIX = "telegram_index_" + +/** + * 由 bot_token + chat_id 派生稳定的存储 key。 + * + * 同一 chat 被两个 storage 指向时视为同一份数据(索引自然合一)—— 这与 + * Telegram 的实际语义一致,因为消息只存在于那一个聊天里。 + * 只用 token+chat_id,不含 root_path:root_path 是视图,不该分裂索引。 + */ +export function indexKey(botToken: string, chatId: string): string { + return INDEX_KEY_PREFIX + sha256HexSync(`${botToken}|${chatId}`).slice(0, 32) +} + +/** + * 纯 JS SHA-256(同步)。返回小写 hex。 + * + * 不用 WebCrypto.subtle:它是**异步**的,而 indexKey 需要同步值;也不引入 + * node:crypto —— 本仓库同时跑在 Cloudflare Workers 上。 + */ +export function sha256HexSync(input: string): string { + const K = [ + 0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5, + 0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174, + 0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da, + 0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967, + 0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85, + 0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070, + 0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3, + 0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2, + ] + const H = [0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19] + const utf8 = new TextEncoder().encode(input) + const bitLen = utf8.length * 8 + const withPad = new Uint8Array((((utf8.length + 8) >> 6) + 1) << 6) + withPad.set(utf8) + withPad[utf8.length] = 0x80 + const dv = new DataView(withPad.buffer) + dv.setUint32(withPad.length - 4, bitLen >>> 0, false) + dv.setUint32(withPad.length - 8, Math.floor(bitLen / 0x100000000), false) + + const w = new Uint32Array(64) + for (let i = 0; i < withPad.length; i += 64) { + for (let j = 0; j < 16; j++) w[j] = dv.getUint32(i + j * 4, false) + for (let j = 16; j < 64; j++) { + const s0 = rotr(w[j-15],7) ^ rotr(w[j-15],18) ^ (w[j-15] >>> 3) + const s1 = rotr(w[j-2],17) ^ rotr(w[j-2],19) ^ (w[j-2] >>> 10) + w[j] = (w[j-16] + s0 + w[j-7] + s1) >>> 0 + } + let [a,b,c,d,e,f,g,h] = H + for (let j = 0; j < 64; j++) { + const S1 = rotr(e,6) ^ rotr(e,11) ^ rotr(e,25) + const ch = (e & f) ^ (~e & g) + const t1 = (h + S1 + ch + K[j] + w[j]) >>> 0 + const S0 = rotr(a,2) ^ rotr(a,13) ^ rotr(a,22) + const maj = (a & b) ^ (a & c) ^ (b & c) + const t2 = (S0 + maj) >>> 0 + h=g; g=f; f=e; e=(d+t1)>>>0; d=c; c=b; b=a; a=(t1+t2)>>>0 + } + H[0]=(H[0]+a)>>>0; H[1]=(H[1]+b)>>>0; H[2]=(H[2]+c)>>>0; H[3]=(H[3]+d)>>>0 + H[4]=(H[4]+e)>>>0; H[5]=(H[5]+f)>>>0; H[6]=(H[6]+g)>>>0; H[7]=(H[7]+h)>>>0 + } + return H.map((x) => x.toString(16).padStart(8, "0")).join("") +} + +function rotr(x: number, n: number): number { + return ((x >>> n) | (x << (32 - n))) >>> 0 +} + +/** 校验条目结构,防止损坏的索引把脏数据带进驱动逻辑 */ +export function isValidIndexEntry(e: unknown): e is IndexEntry { + if (!e || typeof e !== "object") return false + const o = e as any + if (typeof o.n !== "string" || o.n.length === 0) return false + if (typeof o.t !== "number" || !Number.isFinite(o.t)) return false + if (o.d === true) return true + if (o.d === false) { + return ( + typeof o.m === "number" && Number.isSafeInteger(o.m) && o.m > 0 && + typeof o.f === "string" && o.f.length > 0 && + typeof o.s === "number" && Number.isFinite(o.s) && o.s >= 0 + ) + } + return false +} + +/** 反序列化:容忍损坏 / 旧版本,返回一个可用(可能为空)的索引 */ +export function parseIndex(raw: string | undefined | null): IndexShape { + const empty: IndexShape = { v: INDEX_VERSION, dirs: {} } + if (!raw) return empty + let obj: any + try { + obj = JSON.parse(raw) + } catch { + return empty + } + if (!obj || typeof obj !== "object" || obj.v !== INDEX_VERSION) return empty + const dirs: Record = {} + const src = obj.dirs + if (src && typeof src === "object") { + for (const p of Object.keys(src)) { + const list = src[p] + if (!Array.isArray(list)) continue + dirs[normalizeDirPath(p)] = list.filter(isValidIndexEntry) + } + } + return { v: INDEX_VERSION, dirs } +} + +/** 目录路径归一:始终以 / 开头、去掉尾斜杠(根为 "") */ +export function normalizeDirPath(p: string): string { + const clean = (p || "").split("/").filter(Boolean).join("/") + return clean ? "/" + clean : "" +} + +/** 序列化 */ +export function serializeIndex(idx: IndexShape): string { + return JSON.stringify({ v: INDEX_VERSION, dirs: idx.dirs }) +} + +/** 读取某目录的直接子项(不存在返回空数组) */ +export function childrenOf(idx: IndexShape, dir: string): IndexEntry[] { + return idx.dirs[normalizeDirPath(dir)] || [] +} + +/** 覆盖某目录的直接子项 */ +export function setChildren( + idx: IndexShape, + dir: string, + entries: IndexEntry[], +): void { + const p = normalizeDirPath(dir) + if (entries.length === 0) delete idx.dirs[p] + else idx.dirs[p] = entries +} + +/** 递归收集一个目录及其全部后代的路径 */ +export function collectDirPaths(idx: IndexShape, dir: string): string[] { + const out: string[] = [] + const walk = (p: string) => { + for (const e of childrenOf(idx, p)) { + if (!e.d) continue + const sub = normalizeDirPath(p + "/" + e.n) + out.push(sub) + walk(sub) + } + } + walk(normalizeDirPath(dir)) + return out +} \ No newline at end of file diff --git a/src/backend/drivers/telegram/store.ts b/src/backend/drivers/telegram/store.ts new file mode 100644 index 00000000..6f3042c2 --- /dev/null +++ b/src/backend/drivers/telegram/store.ts @@ -0,0 +1,41 @@ +// Telegram 驱动:索引在 DB(settings 表)中的读写。 +// +// 单独成文件,避免 index.ts(纯逻辑)为了被单测而拖入整个 model/db。 + +import { getDb, saveDb } from "../../internal/model/db" +import type { IndexShape } from "./index" +import { parseIndex, serializeIndex } from "./index" + +/** 从 DB 载入索引 */ +export async function loadIndex(key: string, env?: any): Promise { + const db = await getDb(env) + const setting = (db.settings || []).find((s: any) => s?.key === key) + return parseIndex(setting?.value) +} + +/** 写回 DB */ +export async function saveIndex( + key: string, + idx: IndexShape, + env?: any, +): Promise { + const db = await getDb(env) + if (!Array.isArray(db.settings)) db.settings = [] + const value = serializeIndex(idx) + const i = db.settings.findIndex((s: any) => s?.key === key) + if (i !== -1) { + db.settings[i].value = value + } else { + db.settings.push({ + key, + value, + type: "string", + help: "Telegram 驱动目录索引(由驱动自动维护,请勿手工修改)", + group: 99, + flag: 0, + }) + } + await saveDb(db, env) +} + +export { INDEX_VERSION } diff --git a/src/backend/drivers/telegram/types.ts b/src/backend/drivers/telegram/types.ts new file mode 100644 index 00000000..b0a24f9f --- /dev/null +++ b/src/backend/drivers/telegram/types.ts @@ -0,0 +1,101 @@ +// telegram (Telegram Bot API 网盘) — 聊天即存储 +// +// 与 K-Vault (https://github.com/katelya77/K-Vault) 的差异:本驱动要实现完整的 +// StorageDriver 语义(list/mkdir/rename/remove/move/copy),而 Telegram Bot API +// **没有列举历史消息的方法**(getUpdates 只推送 24 小时内的增量,且 bot 收不到 +// 自己发出的消息),因此目录结构必须由驱动自己维护 —— 见 manifest.ts。 +// +// 聊天类型必须是 group / supergroup / channel:ChatFullInfo.description 仅对这三 +// 类存在,而本驱动用它存放 manifest 指针(见 manifest.ts 的 INDEX_MARKER)。 + +export interface DriverTelegramAddition { + /** @BotFather 获取,形如 123456:ABC-DEF... */ + bot_token: string + /** 目标聊天 id(数字或 @channelusername) */ + chat_id: string + /** 可选:自定义 Bot API 端点(自建反代 / 测试用),默认 https://api.telegram.org */ + api_base?: string + /** 可选:驱动根路径,映射到聊天根下的子目录 */ + root_path?: string +} + +/** manifest 中记录的一个条目 */ +export interface ManifestEntry { + /** 条目名(不含路径) */ + n: string + /** 目录为 true */ + d: true + /** 该目录自身的 manifest head message id */ + m: number +} + +export interface ManifestFile { + /** 条目名 */ + n: string + /** 文件 */ + d: false + /** 承载文件的消息 id(deleteMessage 用) */ + m: number + /** Bot API file_id(getFile 用) */ + f: string + /** 字节数 */ + s: number + /** Unix 秒 */ + t: number +} + +/** 单个分片消息的内容 */ +export interface ManifestChunk { + v: 1 + e: Array +} + +/** 目录 manifest 的 head 消息内容;分片过多时 e 为空并列出所有分片 */ +export interface ManifestHead { + v: 1 + /** 本 manifest 所属目录(便于排查与调试) */ + p: string + /** 分片 message id 列表 */ + c: number[] + /** 条目直接内联(未分片时) */ + e?: Array +} + +/** Bot API 响应包装 */ +export interface TgResponse { + ok: boolean + result?: T + description?: string + error_code?: number + parameters?: { migrate_to_chat_id?: number; retry_after?: number } +} + +export interface TgFile { + file_id: string + file_unique_id: string + file_size?: number + file_path?: string +} + +export interface TgMessage { + message_id: number + date: number + text?: string + caption?: string + document?: TgFile & { file_name?: string } + audio?: TgFile & { file_name?: string } + video?: TgFile & { file_name?: string } + voice?: TgFile + animation?: TgFile & { file_name?: string } + video_note?: TgFile + photo?: Array + sticker?: TgFile +} + +export interface TgChatFullInfo { + id: number + type: "private" | "group" | "supergroup" | "channel" + title?: string + username?: string + description?: string +} \ No newline at end of file diff --git a/src/backend/drivers/telegram/util.ts b/src/backend/drivers/telegram/util.ts new file mode 100644 index 00000000..1601ccb4 --- /dev/null +++ b/src/backend/drivers/telegram/util.ts @@ -0,0 +1,245 @@ +// Telegram Bot API 客户端 +// +// 安全约定:错误信息里**绝不回显 bot_token**(URL 形如 /bot/method, +// 直接把 URL 抛出去等于把凭据写进日志)。错误只带方法名与 Telegram 的 description。 + +import type { + DriverTelegramAddition, + TgChatFullInfo, + TgFile, + TgMessage, + TgResponse, +} from "./types" + +export const DEFAULT_API_BASE = "https://api.telegram.org" + +/** Bot API 单次上传上限 50MB;下载(getFile)上限 20MB —— 硬限制,无法绕过 */ +export const MAX_UPLOAD_BYTES = 50 * 1024 * 1024 +export const MAX_DOWNLOAD_BYTES = 20 * 1024 * 1024 + +export class TelegramApiError extends Error { + readonly method: string + readonly errorCode?: number + readonly retryAfter?: number + /** 群组升级为超级群后 Telegram 给出的新 chat_id(仅在迁移错误时存在) */ + readonly migrateToChatId?: number + constructor( + method: string, + message: string, + errorCode?: number, + retryAfter?: number, + migrateToChatId?: number, + ) { + super(`[Telegram] ${method}: ${message}`) + this.name = "TelegramApiError" + this.method = method + this.errorCode = errorCode + this.retryAfter = retryAfter + this.migrateToChatId = migrateToChatId + } +} + +function normalizeBase(raw?: string): string { + if (!raw) return DEFAULT_API_BASE + try { + return new URL(String(raw)).toString().replace(/\/+$/, "") + } catch { + throw new TelegramApiError("init", `api_base 不是合法 URL`) + } +} + +export class ClientTelegram { + readonly chatId: string + private token: string + private base: string + + constructor(addition: DriverTelegramAddition) { + this.token = (addition.bot_token || "").trim() + this.chatId = String(addition.chat_id || "").trim() + this.base = normalizeBase(addition.api_base) + } + + init(): void { + if (!this.token) throw new TelegramApiError("init", "bot_token 不能为空") + if (!/^\d+:-?[\w-]+$/.test(this.token) && !/^\d+:[\w-]{30,}$/.test(this.token)) { + // 只做形态校验,不回显内容 + if (!/^\d+:[\w-]{20,}$/.test(this.token)) { + throw new TelegramApiError("init", "bot_token 格式不合法(应形如 123456:AA...)") + } + } + if (!this.chatId) throw new TelegramApiError("init", "chat_id 不能为空") + } + + /** API 端点 URL —— 仅内部使用,禁止拼进错误信息 */ + private url(method: string): string { + return `${this.base}/bot${this.token}/${method}` + } + + /** 文件下载 URL(有效期至少 1 小时,不可长期缓存) */ + fileUrl(filePath: string): string { + return `${this.base}/file/bot${this.token}/${String(filePath).replace(/^\/+/, "")}` + } + + private async call( + method: string, + body?: Record, + _retried = false, + ): Promise { + let resp: Response + try { + resp = await fetch(this.url(method), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body || {}), + }) + } catch (e) { + throw new TelegramApiError(method, `网络请求失败:${(e as Error)?.message || e}`) + } + const json = (await resp.json().catch(() => ({}))) as TgResponse + if (!json.ok) { + // 群组升级为超级群:旧的数字 chat_id 失效,跟进新 id 重试一次 + const migrateTo = json.parameters?.migrate_to_chat_id + if (migrateTo && !_retried) { + this.chatId = String(migrateTo) + return this.call(method, body, true) + } + throw new TelegramApiError( + method, + json.description || `HTTP ${resp.status}`, + json.error_code, + json.parameters?.retry_after, + migrateTo, + ) + } + return json.result as T + } + + /** 只读调用统一用 POST;Bot API 接受 POST 传 chat_id */ + private async read( + method: string, + body: Record, + ): Promise { + return this.call(method, body) + } + + async getMe(): Promise<{ username?: string; id?: number }> { + return this.call("getMe") + } + + async getChat(): Promise { + return this.read("getChat", { chat_id: this.chatId }) + } + + async editChatDescription(description: string): Promise { + await this.call("editChatDescription", { + chat_id: this.chatId, + description, + }) + } + + async sendMessage(text: string): Promise { + return this.call("sendMessage", { + chat_id: this.chatId, + text, + }) + } + + async editMessageText(messageId: number, text: string): Promise { + return this.call("editMessageText", { + chat_id: this.chatId, + message_id: messageId, + text, + }) + } + + async deleteMessage(messageId: number): Promise { + await this.call("deleteMessage", { + chat_id: this.chatId, + message_id: messageId, + }) + } + + /** + * 同聊天内复制一条消息 —— Telegram 侧真正的字节级复制, + * 比「下载再上传」省一个带宽往返,也不受 20MB 下载上限影响。 + */ + async copyMessage(messageId: number): Promise { + return this.call("copyMessage", { + chat_id: this.chatId, + from_chat_id: this.chatId, + message_id: messageId, + }) + } + + /** 上传文档,返回消息(含 file_id 与 message_id) */ + async sendDocument( + filename: string, + content: Buffer | Uint8Array, + mime = "application/octet-stream", + ): Promise { + if (content.byteLength > MAX_UPLOAD_BYTES) { + throw new TelegramApiError( + "sendDocument", + `文件 ${content.byteLength} 字节超过 Bot API 上传上限 ${MAX_UPLOAD_BYTES} 字节(50MB)`, + ) + } + const form = new FormData() + form.append("chat_id", this.chatId) + const buf = + content instanceof Uint8Array + ? new Blob([content]) + : new Blob([content as unknown as BlobPart]) + form.append("document", buf, filename) + + let resp: Response + try { + resp = await fetch(this.url("sendDocument"), { method: "POST", body: form }) + } catch (e) { + throw new TelegramApiError( + "sendDocument", + `网络请求失败:${(e as Error)?.message || e}`, + ) + } + const json = (await resp.json().catch(() => ({}))) as TgResponse + if (!json.ok || !json.result) { + throw new TelegramApiError( + "sendDocument", + json.description || `HTTP ${resp.status}`, + json.error_code, + json.parameters?.retry_after, + ) + } + return json.result + } + + /** 从发送结果里取 file_id(不同媒体类型字段不同) */ + static pickFileId(m: TgMessage): TgFile | null { + if (m.document?.file_id) return m.document + if (m.video?.file_id) return m.video + if (m.audio?.file_id) return m.audio + if (m.animation?.file_id) return m.animation + if (m.voice?.file_id) return m.voice + if (m.video_note?.file_id) return m.video_note + if (m.sticker?.file_id) return m.sticker + if (Array.isArray(m.photo) && m.photo.length) { + // 取最大尺寸那张 + return m.photo[m.photo.length - 1] + } + return null + } + + /** getFile → 直链。超 20MB 时 Telegram 不返回 file_path。 */ + async resolveDownload( + fileId: string, + ): Promise<{ url: string; size: number }> { + const f = await this.read("getFile", { file_id: fileId }) + if (!f?.file_path) { + throw new TelegramApiError( + "getFile", + "Telegram 未返回 file_path。Bot API 只能下载 ≤20MB 的文件,"+ + "超过该上限的文件无法通过 Bot API 取回。", + ) + } + return { url: this.fileUrl(f.file_path), size: f.file_size || 0 } + } +} \ No newline at end of file diff --git a/src/backend/internal/op/storage.ts b/src/backend/internal/op/storage.ts index d2900a96..ea74c9e0 100644 --- a/src/backend/internal/op/storage.ts +++ b/src/backend/internal/op/storage.ts @@ -20,6 +20,7 @@ import { DriverKodbox } from "../../drivers/kodbox/driver" import { DriverIpfs } from "../../drivers/ipfs_api/driver" import { DriverLenovoNasShare } from "../../drivers/lenovonas_share/driver" import { DriverMisskey } from "../../drivers/misskey/driver" +import { DriverTelegram } from "../../drivers/telegram/driver" import { DriverDoubao } from "../../drivers/doubao/driver" import { DriverQuarkOpen } from "../../drivers/quark_open/driver" import { DriverQuarkUcTv } from "../../drivers/quark_uc_tv/driver" @@ -363,6 +364,9 @@ async function createDriver( } else if (normDriver === "misskey") { driver = new DriverMisskey(parseAddition(storageConfig)) await driver.init?.() + } else if (normDriver === "telegram" || normDriver === "tg") { + driver = new DriverTelegram(parseAddition(storageConfig)) + await driver.init?.() } else if ( normDriver === "doubao" || normDriver === "doubaonew" || From 20b58832a7b5df0ea7dcc54479b22dd3c34e36ea Mon Sep 17 00:00:00 2001 From: wumingzhinu Date: Sat, 10 Oct 2026 21:27:31 +0800 Subject: [PATCH 2/2] =?UTF-8?q?refactor(telegram):=20=E6=B8=85=E7=90=86?= =?UTF-8?q?=E5=BA=9F=E5=BC=83=E8=AE=BE=E8=AE=A1=E7=9A=84=20API=20=E6=AD=BB?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=EF=BC=8Cinit=20=E6=8E=A5=E5=85=A5=E8=81=8A?= =?UTF-8?q?=E5=A4=A9=E5=8F=AF=E8=BE=BE=E6=80=A7=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除 editChatDescription / sendMessage / editMessageText:这三个方法来自 最初「manifest 写进聊天」的方案,该方案因 Bot API 无 getMessage 已被否掉 (见 index.ts 的说明),方法随之成为死代码。 - getChat 改为 verifyChatAccess() 并在 init() 调用。此前 driver 完全没用 getChat, 但它是少数会明确回报 chat not found 的方法;放在 init() 里,chat_id 写错或 bot 未被拉进聊天的配置错误能在绑定存储时就暴露,而不是等第一次 list 才报一个 难懂的错。 - 修正 types.ts 顶部已失效的注释(原文称聊天必须是 group/supergroup/channel 以便 用 ChatFullInfo.description 存放 manifest 指针):目录索引现已改存 OpenList 侧 DB,Telegram 只负责存字节,聊天类型不限,与 bot 的私聊同样可用。 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/backend/drivers/telegram/driver.ts | 4 ++- src/backend/drivers/telegram/types.ts | 4 +-- src/backend/drivers/telegram/util.ts | 36 ++++++++++---------------- 3 files changed, 19 insertions(+), 25 deletions(-) diff --git a/src/backend/drivers/telegram/driver.ts b/src/backend/drivers/telegram/driver.ts index f3e81c10..3a46baef 100644 --- a/src/backend/drivers/telegram/driver.ts +++ b/src/backend/drivers/telegram/driver.ts @@ -64,8 +64,10 @@ export class DriverTelegram implements StorageDriver { async init(): Promise { this.client.init() - // 校验 token/chat 确实可用,并确保索引行存在 + // 校验 token/chat 确实可用,并确保索引行存在。 + // verifyChatAccess 用 getChat 明确「chat not found」,把配置错误挡在绑定阶段。 await this.client.getMe() + await this.client.verifyChatAccess() const idx = await this.store.load(this.key) await this.store.save(this.key, idx) } diff --git a/src/backend/drivers/telegram/types.ts b/src/backend/drivers/telegram/types.ts index b0a24f9f..ce421db7 100644 --- a/src/backend/drivers/telegram/types.ts +++ b/src/backend/drivers/telegram/types.ts @@ -5,8 +5,8 @@ // **没有列举历史消息的方法**(getUpdates 只推送 24 小时内的增量,且 bot 收不到 // 自己发出的消息),因此目录结构必须由驱动自己维护 —— 见 manifest.ts。 // -// 聊天类型必须是 group / supergroup / channel:ChatFullInfo.description 仅对这三 -// 类存在,而本驱动用它存放 manifest 指针(见 manifest.ts 的 INDEX_MARKER)。 +// 一个 Telegram 聊天(群组 / 超级群 / 频道 / 甚至与 bot 的私聊)就是一个平铺的 +// 消息池。聊天类型不限:目录索引存在 OpenList 侧(见 index.ts),Telegram 只存字节。 export interface DriverTelegramAddition { /** @BotFather 获取,形如 123456:ABC-DEF... */ diff --git a/src/backend/drivers/telegram/util.ts b/src/backend/drivers/telegram/util.ts index 1601ccb4..737b1405 100644 --- a/src/backend/drivers/telegram/util.ts +++ b/src/backend/drivers/telegram/util.ts @@ -126,30 +126,22 @@ export class ClientTelegram { return this.call("getMe") } - async getChat(): Promise { - return this.read("getChat", { chat_id: this.chatId }) - } - - async editChatDescription(description: string): Promise { - await this.call("editChatDescription", { - chat_id: this.chatId, - description, - }) - } - - async sendMessage(text: string): Promise { - return this.call("sendMessage", { - chat_id: this.chatId, - text, - }) - } - - async editMessageText(messageId: number, text: string): Promise { - return this.call("editMessageText", { + /** + * 校验 bot 能否访问目标聊天。 + * + * getChat 是少数几个会明确报 "chat not found" 的方法,放在 init() 里调, + * 配置错误(chat_id 写错 / bot 没被拉进聊天)在绑定存储时就暴露, + * 而不是等第一次 list 才报一个难懂的错。 + */ + async verifyChatAccess(): Promise<{ type: string; title: string }> { + const chat = await this.read("getChat", { chat_id: this.chatId, - message_id: messageId, - text, }) + const title = chat.title || chat.username || String(chat.id || "") + if (!title) { + throw new TelegramApiError("getChat", "无法读取聊天信息") + } + return { type: chat.type, title } } async deleteMessage(messageId: number): Promise {