OpenClaw Telegram 插件可靠性工程指南:从持久化入站到流式出站的维护者契约
【免费下载链接】openclawThe AI that really does things. Any OS. Any Platform. The lobster way. 🦞项目地址: https://gitcode.com/GitHub_Trending/cl/openclaw
本指南围绕 extensions/telegram/AGENTS.md 展开,系统解析 OpenClaw 内置 Telegram 通道插件在消息可靠性、流式回答、上下文授权与交互面设计上的维护者决策与评审不变量。读完你将掌握:核心持久的 ingress drain 如何与 Telegram 特有的轮询/Webhook 传输协同、为什么“先落盘再应答”是防丢消息的根基、以及任何改动如何通过 crash-window 与真实 Telegram 探测验证。
extensions/telegram/AGENTS.md是仓库维护者为 Telegram 插件划定的“评审绑定不变量”——它不是实现细节的随记,而是有意为之的架构决策。文档基于 Telegram Bot API 10.3 验证(2026-08-24),所有改动都应在此框架内进行。
一、文档定位:先理解插件边界
在深入 Telegram 具体规则前,必须同时阅读 extensions/AGENTS.md 中的插件边界规则。其核心结论是:extensions/下的所有内置插件都遵循与第三方插件相同的边界约束——
- 生产代码只允许从
openclaw/plugin-sdk/*与插件自身的局部 barrel(如api.ts、runtime-api.ts)导入; - 禁止直接导入
src/**、src/channels/**或其它扩展的src/**; - 插件运行时依赖归属插件自身
package.json,运行时不会自动安装依赖,安装/更新/doctor 才是修复点; - 插件可用性来自 manifest 所有权 + 定向激活,不允许依赖 import 时的全局注册副作用。
从文件结构看,Telegram 插件是仓库中代码量最大的通道插件之一:extensions/telegram/src 下按bot/(grammY 中间件与派发)、miniapp/(Telegram Mini App)、telegram-ingress-*(持久化入站)、bot-message-dispatch.*(出站派发)等维度组织,并配套大量*.test.ts单测与*.runtime.test.ts运行时集成测试。这与本文档“行为改动需要真实证明、可靠性改动需要崩溃窗口证明”的评审标准互为表里。
二、核心 drain 契约:Telegram 不重复实现的东西
2.1 契约归属
文档明确指出:入站持久化 drain 的核心契约不属于 Telegram 插件,而是核心仓(core)的资产,归属 src/channels/message/ingress-drain.ts(以及 claim-owner、retry-policy 两个配套模块)。Telegram 插件只是在核心 drain 之上做传输适配。
对应的证据文件:
- src/channels/message/ingress-drain.test.ts
- src/channels/message/ingress-claim-owner.test.ts
- src/channels/message/ingress-retry-policy.test.ts
2.2 六条核心契约逐条解读
结合 ingress-drain.ts 源码,可以印证每一条契约的落地方式:
1. 完成行通过complete()墓碑化,绝不delete()
代码中completeClaimWithRetry负责写墓碑(tombstone),createIngressWriter提供的三个原语是completeClaimWithRetry/releaseClaim/failClaim。墓碑化保证了崩溃恢复时能看到“该事件已处理”的事实,而物理删除则无法区分“从未处理”与“已处理但丢了记录”。
2. 在“回合接管(turn adoption)”时 complete,而不是在 settle 时
源码生命周期中,onAdopted回调里执行state.phase = "adopted"; clearStallTimer(state); await completeClaimWithRetry(...)(ingress-drain.ts)。也就是说:一旦事件被某个回合正式收养,立即落墓碑并释放 lane,而不是等整个回合结束。deferral(延迟交接)期间 claim 仍然被持有,watchdog 保持武装;watchdog 超时走共享的重试处置。
3. 统一的重试策略:attempt 下限 + age 门槛(默认 8 次 / 24 小时)
src/channels/message/ingress-retry-policy.ts 定义了四个默认值:
| 参数 | 默认值 | 含义 |
|---|---|---|
maxAttempts | 8 | 最大尝试次数(attempt 下限) |
deadLetterMinAgeMs | 24h | 死信最小年龄门槛 |
baseMs | 1 000 | 指数退避初始延迟(factor 2,无抖动) |
maxMs | 3 * 60_000 | 退避封顶 |
关键语义在shouldDeadLetterRetryableIngressEvent:死信必须同时满足 attempt ≥ maxAttempts 且 now - receivedAt ≥ 24h。超限但未到龄的事件会继续以封顶延迟重试,直到年龄满足——这是为了避免刚收到的事件因瞬时故障被过早丢进死信。
4. 派发/延迟期间以claimLeaseMs / 3为周期做 claim 续租心跳
armClaimRefresh中intervalMs = Math.max(1, Math.floor(claimLeaseMs / 3))(ingress-drain.ts),心跳一直持续到墓碑提交为止(包含 complete 重试的 wedge 窗口)。若refreshClaim返回 false,说明 claim token 已被其它 owner 接管,则触发markLeaseReclaimed的 guillotine 封闭:后续onAdopted会抛IngressAdoptionLostError,且不允许再 release/fail 他人持有的 claim。
5. 瞬时失败绝不静默 complete——通过 disposition 走 release/fail
applyFailureDisposition是唯一的失败出口:GatewayDrainingError直接 release 且不消耗失败预算;否则调用resolveIngressFailureDisposition,得到fail(死信)或release(保留待重试)两种处置。
6. supersede 只作用于收养前(pre-adoption)
supersedeActiveIfNeeded只对未收养的工作生效;收养后中断属于核心的 reply-run registry / queue interrupt 职责,Telegram 侧不干预。
2.3 崩溃窗口语义:为什么“complete at adoption”是安全的
源码注释点明了顺序敏感点:“adoption 的 tombstone 重试 wedge 期间,dispatch 副作用已经发生,此时不能 release claim(否则重放风险)”。因此代码在墓碑重试期间把 phase 先置为adopted,即使 tombstone 写入失败也保持 claim 持有,避免同一事件被双发。这正是文档“Never silently complete on transient failure”背后的工程动机。
三、Telegram 自有的传输与通道策略
核心 drain 是通用骨架,Telegram 插件在其上落实传输层策略,主要证据集中在 extensions/telegram/src/telegram-ingress-spool.ts、telegram-ingress-worker.ts、telegram-ingress-drain-factory.ts。
3.1 两种传输都必须“先持久化,后应答”(Durable-before-ack)
- 轮询(Polling):ingress worker 只有在父进程把 spool 提交(
writeTelegramSpooledUpdate)并确认后,才推进本地 offset。从 telegram-ingress-worker.ts 的消息协议可以看到 parent ↔ worker 之间存在显式的spool-ack命令(ok: true/false),worker 仅在ok: true后才继续推进 offset。 - Webhook:只有 spool 写入成功后才返回 HTTP 200;写入失败返回非 200,这本身就是 Telegram 的 redelivery 契约。
为什么这是防丢消息的根基:Telegram 的 getUpdates 以 offset 推进确认消息已消费,Webhook 以 200 确认已接收。如果先应答后落盘,进程在应答与落盘之间崩溃,消息就永久丢失且 Telegram 不再重投。先落盘后应答把“至少一次投递”建立在本地持久化之上。
3.2 update_id ↔ 事件 ID 编码与 lane 推导
telegram-ingress-spool.ts提供resolveTelegramUpdateId(必须是非负安全整数)与telegramQueueEventId(update_id用 0 左填充到 16 位作为事件 ID),保证事件 ID 稳定且可按顺序排序。lane 推导走getTelegramSequentialKey(sequential-key),形成 per-chat/per-topic 的串行化通道;这部分逻辑必须留在 spool/lane 推导层,不允许在别处另起炉灶。
3.3 轮询与 Webhook 共用同一套 drain
两种传输最终都走createTelegramTransportIngressDrain(...).drainOnce(),即“先入队,再泵一次 drain”,禁止任何私有 claim 循环。telegram-ingress-drain-factory.ts的注释点明设计意图:“一个 monitor 同时服务 polling + webhook:channel 侧追加、共享 claim → dispatch 带 turnAdoptionLifecycle → 在收养时 complete”。回调(callback_query)的应答在onDurableAdmission(落盘提交之后、claim 之前)执行,避免 callback 应答抹掉 Telegram 的重投路径。
3.4 停滞超时:OPENCLAW_TELEGRAM_SPOOLED_HANDLER_TIMEOUT_MS
环境变量OPENCLAW_TELEGRAM_SPOOLED_HANDLER_TIMEOUT_MS映射到adoptionStallTimeoutMs,默认5 分钟(DEFAULT_INGRESS_ADOPTION_STALL_MS = 5 * 60 * 1000)。解析逻辑在 telegram-ingress-drain.ts:优先取显式配置,其次取环境变量,最后回退默认值,且经过clampPositiveTimerTimeoutMs校验。停滞 watchdog 在 claim→adoption 之间触发,超时后走共享重试处置而非直接静默完成。
3.5 不可重试分类器与 supersede 谓词
- 不可重试分类器:
telegram-ingress-non-retryable.ts负责把“缺 harness、dispatch-dedupe 回滚”等场景判为不可重试,直接 fail(死信)而非 release。 - supersede 谓词:
telegram-ingress-supersede.ts规定——只有文本消息、看起来已授权的显式命令(以及待处理的 ambient room_event)可以 supersede 未收养的同 lane 工作;普通消息永远不能 supersede。room_event(如“进入会话”的系统事件)共享 sequential lane,因此后续用户回合可以在收养前 supersede 它;已收养的用户回合则绝不被触碰(核心 drain 的 supersede 仅限收养前)。
3.6 禁止每消息全量存储写
文档明确:热路径上的 SQLite 写入必须是**逐条目(per-entry)**的,禁止每次发送/读取都重写整份缓存。历史教训是 sent-message-cache 回归——重写缓存会让事件循环停顿,而这个停顿会伪装成轮询停滞,难以排查。
3.7 传输错误分类
getUpdates worker 的本地重试规则:
- Bot API5xx与429本地重试,并遵循
parameters.retry_after; - 401/404保持致命(fatal);
- 409必须传播给父会话(parent session),由父会话负责 webhook-conflict 恢复;
- 解析 Bot API 错误体要防御性处理:错误码在
error_code而非.code,且非 2xx 响应体不一定是 JSON(例如 502 可能返回 HTML 页面)。
3.8 发送漏斗对等性(Send funnel parity)
持久化漏斗(send.ts)与流式漏斗(bot/delivery.*)必须同样优雅降级:
| 触发场景 | 降级行为 |
|---|---|
| 富文本实体 400 | 回退为纯文本 |
| caption 解析 400 | 回退为纯 caption |
| quote-not-found 400 | 回退为传统回复(legacy reply) |
新增的恢复逻辑必须进共享谓词(send-error-predicates.ts、reply-parameters.ts),绝不只修其中一个漏斗——否则两个漏斗行为漂移,同一消息在不同路径下表现不一致。
3.9 出站洪峰等待与 webhook 安全顺序
- 出站洪峰等待遵循
retry_after,封顶值为TELEGRAM_OUTBOUND_RETRY_AFTER_CAP_MS(见 extensions/telegram/src/retry-after.ts,值为 60_000ms),不允许把 Telegram 发送重新钳制到通用通道重试上限。 - Webhook 安全顺序:先校验 secret header(常量时间比较、单 header 强制、401 时关闭连接),然后才做请求限流;限流预算只统计认证失败的尝试,从而保证 Telegram 自身的投递永远不会被限流误伤。
- 所有插件拥有的 undici 传输必须在所有退出路径关闭:轮询会话、webhook 关闭与启动失败、probe-cache 驱逐。
四、流式回答(Streaming)的维护者决策
4.1 为什么禁用 sendMessageDraft
Telegram 的 draft(草稿)只是私聊中 30 秒的临时预览,最终投递仍需独立的sendMessage。OpenClaw 的流式实现采用sendMessage建立消息 +editMessageText持续编辑 + 原地定稿,用户看到的是一个持续存在的答案气泡,而不是草稿闪烁。
4.2 只拥有一个可见预览消息
流式过程只允许一个可见的预览消息,向前编辑它;除非最终编辑真的失败,否则不额外发送一条最终气泡。这与 4.1 结合,保证用户在聊天里看到稳定单一的回答。
4.3 保留首预览防抖
如果 provider 发送 token 级增量,应把增量合并为累积预览文本,而不是移除防抖。防抖存在的意义是避免高频 delta 打爆 Bot API 调用配额。
4.4 在 Telegram 层尊重 Telegram 限制
- 文本超过4096 字符时链式拆分为续写消息;
- 投票保持当前 Bot API 的12 选项上限。
这些限制必须在 Telegram 层处理,不能指望上层语义自动适配。
五、Telegram API 所有权
5.1 优先 grammY 原生能力
当 grammY 原语与 Telegram 原生 helper 已经能直接建模所需行为时,优先复用 grammY,禁止自造重复的 Bot API 包装。这与扩展边界哲学一致:核心已拥有的能力不应在通道层重复实现。
5.2 节流是 bot-token 作用域的
所有使用同一 token 的 Telegram API 客户端共享同一个 grammYapiThrottler()实例。多实例节流会导致同一个 bot 的请求配额被重复计算,超出 429 防护的设计意图。
5.3 话题(topic)语义的两个硬规则
- 不要在没有话题元数据的情况下静默重试失败的话题发送——投到错误表面(wrong surface)的成功比响亮的 Telegram 报错更糟糕;
- DM 话题与论坛话题是两回事:
direct_messages_topic_id与message_thread_id不可互换。
六、上下文与授权
6.1 回复上下文的来源边界
回复(reply)上下文只能来自OpenClaw 观察到的消息。虽然 Bot API 的 update 暴露reply_to_message,但 Bot API没有任意getMessage(chat, id)的后期回填(hydration)路径——也就是说不能事后按 chat+id 任意取回旧消息补上下文。因此回复链必须在接收时完整捕获。
6.2 本地上下文优先于陈旧回复祖先
提示词中,当前本地聊天的上下文必须压过陈旧的回复祖先链。很久以前被回复的消息不应看起来像当前活跃对话。这是防止模型“跑题到旧话题”的关键提示工程约束。
6.3 群组历史窗口:永远开启、滚动前进
- 群组的历史窗口永远开启,并以
historyLimit为界。文档明确禁止重引入 prompt-history 门控模式——那次回归曾让 ambient rooms(房间事件类会话)失明。 - 历史窗口是滚动的:用“自你上次回复以来”的自条目水位(self-entry watermark)选取视图,禁止重引入破坏性清空。原因很实在:room_event 不持久化到 session,清掉的上下文无法恢复。
6.4 授权模型的三个要点
- 配对(pairing)仅限 DM。群组与话题的授权必须走显式配置的 allowlist;
- Telegram allowlist 使用数字 sender ID。用户名是可选的、可变的,不能作为 Bot API 中可靠的任意用户查询键;
- 群组与频道的可见回复由策略控制:普通房间回复保持私密,除非配置
messages.groupChat.visibleReplies: "automatic",或 agent 显式调用message.send。
七、交互面(Interactive Surfaces)
- 原生回调保持结构化:审批(approval)、原生命令(native command)、插件、单选(select)、多选(multiselect)回调不得作为原始回调文本透传;
- 回调值必须逐字节保真,包括
env|prod这类带分隔符的值——解析端依赖分隔符还原语义; - 原生斜杠命令保持快路径(fast-pathable):在完整 workspace 与 agent-turn 初始化之前就能路由执行,保证常用命令的低延迟响应。
八、评审标准:什么改动需要什么证明
文档把证明要求分成两类,这是所有 Telegram 相关 PR 的验收门槛:
| 改动类型 | 覆盖范围 | 证明要求 |
|---|---|---|
| 行为改动 | 传输(transport)、流式(streaming)、话题(topics)、回调(callbacks)、授权(authorization)、回复上下文(reply context) | 真实 Telegram 证明:优先 bot-to-bot QA 通道或等效的真实 Telegram 探测,禁止仅凭合成(synthetic)验证 |
| 可靠性改动 | spool、drain、retry、ack、offset 路径 | crash-window 或 restart-replay 测试证明,而非仅 happy-path 测试 |
仓库内大量*.runtime.test.ts、*.e2e.test.ts与webhook.test.ts(如对OPENCLAW_TELEGRAM_SPOOLED_HANDLER_TIMEOUT_MS的 env stub 验证)正是这一标准的具体执行形态:用重启重放与崩溃窗口测试来钉死持久化不变量。
九、快速核对清单
改任何extensions/telegram/下的代码前,逐条自检:
- 是否触碰了核心 drain 契约(complete-at-adoption、tombstone、claim 心跳、统一重试)?若是,先读 src/channels/message/ingress-drain.ts,确认没有在 Telegram 层重复实现;
- 入站是否仍满足“先持久化后应答”(polling offset 在 spool 确认后推进,webhook 在 spool 写成功后 200)?
- lane 推导与 update_id 编码是否仍在 spool/sequential-key 层?
- 失败是否走了 disposition(release/fail)而非静默 complete?
- 流式是否仍单预览消息 + 原地定稿,未重引入
sendMessageDraft? - 发送漏斗的两个路径(
send.ts与bot/delivery.*)是否共享同一降级谓词? - 授权是否用数字 sender ID、配对是否仅 DM、群组可见回复是否受策略控制?
- 回调值是否结构化且逐字节保真?
- 测试是否匹配评审标准:行为改动有真实 Telegram 证明,可靠性改动有 crash-window/restart-replay 证明?
这份清单直接映射本文档的全部不变量;对每一项的深入实现,都可以在extensions/telegram/src与其配套测试中找到对应代码证据。
【免费下载链接】openclawThe AI that really does things. Any OS. Any Platform. The lobster way. 🦞项目地址: https://gitcode.com/GitHub_Trending/cl/openclaw
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考