Skip to content

feat(driver): 新增 Telegram 网盘驱动 - #118

Open
wumingzhinu wants to merge 2 commits into
OpenListTeam:mainfrom
wumingzhinu:feat/telegram-driver
Open

wumingzhinu wants to merge 2 commits into
OpenListTeam:mainfrom
wumingzhinu:feat/telegram-driver

Conversation

@wumingzhinu

@wumingzhinu wumingzhinu commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

Summary / 摘要

参考 https://github.com/katelya77/K-Vault 实现,为本仓库新增 Telegram 网盘驱动:
一个 Telegram 聊天作为平铺的消息池,每条文件消息对应一个文件;目录结构由 OpenList 侧
维护的索引记录。实现了完整 StorageDriver 语义(list / get / mkdir / rename /
remove / move / copy / put / createReadStream)。

驱动名:telegram(别名 tg)。


⚠️ 遇到的核心问题:Telegram 无法列举文件

最初想照搬 K-Vault 的「manifest 写进聊天」思路,但查 Bot API 文档后发现这条路是死的:

事实 后果
没有 getMessage 无法按 message_id 回读任意消息
getUpdates 只保留 24 小时 离线超过一天的消息永久丢失
getUpdates 不含 bot 自己发出的消息 连自己发的消息都拿不到
没有任何「列出会话历史消息」的方法 「这个聊天里有哪些文件」无法从 Bot API 得到

把目录清单写进聊天,就再也读不回来,等于数据不可访问。

K-Vault 能用 Telegram 当网盘,是因为它的上传都经由自己的 webhook,映射关系一直握在
自己手里(见 functions/utils/telegram.js 的 shouldWriteTelegramMetadata)。本驱动
要实现完整文件系统语义,必须能主动回答目录内容,因此改为:Telegram 只负责存字节,
目录结构由 OpenList 侧索引负责。
这同时绕开了聊天消息 4096 字符的文本上限。


🔧 使用前置步骤

1. 创建 bot 并拿到 token

在 Telegram 里找 @BotFather → /newbot → 按提示起名,
得到形如 123456:AAxxxxxxxxxxxxxxxxxxxxxxxxxxxxx 的 token。

2. 准备一个聊天,并把 bot 拉进去

聊天类型 能否使用 说明
与 bot 的私聊 ✅ 最简单,零配置;只有你能看到
超级群 / 频道 ✅ 适合多人协作,需把 bot 设为管理员
普通群组 ✅ 同上

推荐私聊或私有频道:bot 发出的每条文件消息对该聊天的所有成员可见,
频道不要设为公开(带 t.me/xxx 链接那种),否则等于公开分享文件。

  • 频道:bot 需有 Post Messages 权限。
  • 群组:bot 需能正常发言即可(超级群默认只回复提及/回复它的消息或命令,
    需在 @Botfather 的 /setprivacy → Disable 关闭隐私模式)。

3. 拿到 chat_id

三种情况任选:

场景 chat_id 填法
公开频道 直接填用户名,形如 @mychannel
有用户名的私聊/群 填 @username
私密频道 / 普通群 填数字 id,形如 -1002442994673(超级群/频道带 -100 前缀)

数字 id 可用 @userinfobot 或自己调一次
https://api.telegram.org/bot<token>/getUpdates 查看。id 写错的后果:
驱动在 init() 阶段就报 chat not found,不会等第一次 list 才莫名其妙失败。

4. 在 OpenList 添加存储

配置项 必填 说明
bot_token ✅ BotFather 给的 token,形如 123456:AA...
chat_id ✅ 数字 id 或 @username
api_base 自定义 Bot API 端点(自建反代 / 测试),默认 https://api.telegram.org
root_path 把驱动挂到聊天根下的一个子目录,如 /vault。属于视图层,同一直驱可多个 storage 各自设不同值

绑定存储时会调用 getMe + getChat 校验凭据与聊天可达性。


📦 硬限制(Bot API 规定,无法绕过)

操作 上限 触发条件
上传 sendDocument 50 MB 超限在 put 时直接抛错
下载 getFile 20 MB 超限时 getFile 不返回 file_path

超过 20 MB 的文件在 list 阶段就能判定,FileItem.raw_url_error 会直接写明原因,
而不是等到下载才失败。

copy / move 用 copyMessage 在 Telegram 侧复制消息,比「下载再上传」省一个带宽
往返,也不受 20 MB 下载上限影响。


🏗 实现要点

索引设计

  • 存放位置:DB 的 settings 表,key = telegram_index_<sha256(bot_token|chat_id)[0:32]>
  • 形态:{ 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)。

其他

  • group → supergroup 迁移:旧的数字 chat_id 失效时,Telegram 在
    parameters.migrate_to_chat_id 给出新 id,客户端自动跟进并重试一次。
  • 错误信息绝不回显 token:URL 形如 /bot<token>/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 的调用点传入的是父目录。

第 4 个是接口语义错误,靠读 op/storage.ts 的调用点才确认。


🔗 参考项目

项目 用途
katelya77/K-Vault 主要参考。Telegram 适配器的 Bot API 调用方式、api_base 归一化、按 mime 选上传方法、sendAudio 失败回落 sendDocument 的经验均来自 server/lib/storage/adapters/telegram.js
Telegram Bot API 官方文档 核对上限与方法是否存在:确认 50MB 上传 / 20MB 下载、text 1-4096 字符、ChatFullInfo.description 0-255 字符,并确认不存在 getMessage

参考项目的已知问题(本驱动已规避):K-Vault 是扁平存储、无目录概念,且必需
经由自己的 webhook 才能拿到文件清单;直接照搬到 OpenList 的文件系统语义下不可行。


⚡ 未验证 / 注意事项

  • 没有真实 Bot Token,全部逻辑基于桩 fetch 验证。 上线前请用真实 bot 跑一轮
    init / put / list / 下载 / 删除。
  • Telegram 作为存储后端,群成员能看到 bot 发出的所有文件消息。适合自用或私有频道;
    不要放在公开频道 / 公开群,那等于公开分享文件。敏感数据请配合 meta 密码。
  • 本驱动不做服务端加密:Telegram 服务端能看到明文。需要端到端加密的场景请叠加
    crypt 驱动。

文件清单

src/backend/drivers/telegram/
├── types.ts            # Addition 配置项与 Telegram 响应类型
├── index.ts            # 索引纯逻辑(编解码 / 路径 / 哈希),无网络无 DB
├── store.ts            # 索引在 DB settings 表的读写
├── util.ts             # Bot API 客户端(token 不回显、迁移跟随)
├── driver.ts           # StorageDriver 实现
├── index.test.ts       # 纯逻辑单测 × 10
└── driver.test.ts      # 驱动语义单测 × 15
src/backend/internal/op/storage.ts   # 驱动注册(telegram / tg)

wumingzhinu and others added 2 commits October 10, 2026 21:13
参考实现: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_<sha256(bot_token|chat_id)[0:32]>`
- 形态:`{ 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<token>/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) <noreply@anthropic.com>
- 移除 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) <noreply@anthropic.com>

@PIKACHUIM PIKACHUIM left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

20M感觉意义不是很大,可以参考下列项目支持KWS或者MTProto:
https://github.com/ToiCF/CF-Workers-TGProxy
https://github.com/PIKACHUIM/TGFileWorker

@wumingzhinu

Copy link
Copy Markdown
Contributor Author

不知道,我看b站有项目,但是视频也没说20M,也是cloudflare部署的,我只是让ai移植过来

@wumingzhinu

Copy link
Copy Markdown
Contributor Author

Uploading Screenshot_2026-10-10-22-19-07-75_439a3fec0400f8974d35eed09a31f914.jpg…

@PIKACHUIM

Copy link
Copy Markdown
Member

不知道,我看b站有项目,但是视频也没说20M,也是cloudflare部署的,我只是让ai移植过来

感谢您的贡献,BOT API就是有20M的限制,这也是为什么一开始没有加TG驱动的原因
这个驱动加上确实会很好,不过感觉实现方式可以再商榷一下,欢迎多交流

@pikachuren pikachuren left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🙏 感谢贡献 PR #118!以下是本次代码审查报告。


总体评价 ✅ Approve ⭐⭐⭐⭐⭐

head commit reviewed: 20b58832a7b5df0ea7dcc54479b22dd3c34e36ea

设计决策完全正确:Telegram Bot API 无法列举消息,因此放弃「拉取历史」思路,改为 OpenList 侧维护目录索引,是唯一可行的路。createReadStream 在请求时实时调用 getFile 取新鲜直链并透传 Range,解决了 Telegram 直链有效期短的根本问题。测试采用构造注入(IndexStore 接口 + fakeFetch)而非全局 Monkey Patch,架构干净,测试可靠性高。


发现的问题

P1 — 生产路径下索引读写缺少并发保护

store.ts 的生产实现采用「读 DB → 修改 → 写回」模式:

export async function saveIndex(key: string, idx: IndexShape, env?: any): Promise<void> {
  const db = await getDb(env)          // 读最新快照
  // ... 修改 settings 条目
  await saveDb(db, env)                // 整体覆盖写回
}

Cloudflare Workers 的并发隔离是「每请求一个 isolate」,两个并发的写操作(例如同时 put 两个文件)都会通过 getDb/saveDb 读出同一版本,后写的会覆盖前写的索引变更,导致文件条目丢失。

建议参考已有的 singleflight(#73 已合并)对索引写操作做进程内合并,或在 saveIndex 中加入类似 saveDb 的版本号校验,检测到冲突时重试。单实例轻度使用下这个问题概率低,但需在 PR 描述或注释中明确记录此限制。


P2 — 索引存储在 settings 表,内容体积无上限

索引 JSON 保存在 db.settings 条目中(group: 99)。文件数量大时(数千条),整个索引会随 getDb / saveDb 的每次调用反复序列化/反序列化,既影响性能,也接近 Workers KV 的单值写入限制(25 MB)。建议在 index.ts 的注释中注明「已知限制:索引体积随文件数线性增长,不建议超过 ~10,000 条目」,或考虑后续分片存储。


P2 — 20MB 下载限制和 50MB 上传限制仅在注释和 raw_url_error 中提示

Telegram Bot API 的 getFile 对 >20MB 文件不返回 file_path,目前通过在 list() 时把 raw_url_error 填入 "超过 20MB" 来告知用户。建议在 put() 时若检测到 buf.byteLength > 50 * 1024 * 1024 提前抛出友好错误,而不是等 Telegram API 返回 Request Entity Too Large。


P2 — bot_token 安全

TelegramApiError 的错误消息来自 TelegramApiClient,该类持有 token 字段但注释明确约定「不回显 token」。测试中也有专门断言(!e.message.includes("123456"))。经核查 util.ts 中 url() 用 token 拼 URL 但只在 fetch 内部使用,未出现在抛出的错误字符串中。✅


测试覆盖确认 ✅

涵盖 15 个核心场景:init、put/list 语义、目录排序、mkdir/rename/remove、递归目录改名、递归删除、root_path 隔离、同名覆盖、非法文件名、copy(含空目录保留)、move、20MB 限制提示、createReadStream Range 透传、错误消息不含 token。测试质量优秀。


🤖 AI 自动审核声明:本评论由 AI 辅助自动化评审生成,重点聚焦功能正确性与安全性;审查不能替代人工判断,最终合并决策请由维护者确认。如发现评审有误,欢迎直接回复。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants