跳转到内容

17. Telegram、WhatsApp 与 Lark 消息网关

Messaging Gateway 把外部聊天变成 SessionManager 的另一个输入/输出端口。它没有为机器人另建 Agent runtime,而是把“平台消息 ↔ session message/event”做成双向路由,并在中间加入绑定、访问控制、审批交互、格式降级和平台生命周期。

flowchart LR
TG["Telegram adapter"]
WA["WhatsApp adapter + worker"]
LK["Lark adapter"]
CMD["Commands"]
RT["Router"]
GW["MessagingGateway"]
SM["SessionManager"]
RD["Renderer"]
BS["BindingStore"]
REG["MessagingGatewayRegistry"]
TG --> GW
WA --> GW
LK --> GW
GW --> CMD
GW --> RT
CMD --> BS
RT --> BS
CMD --> SM
RT --> SM
SM -->|"session event fan-out"| REG
REG --> GW
GW --> RD
RD --> TG
RD --> WA
RD --> LK

角色分工:

组件 职责
MessagingGatewayRegistry 每 workspace gateway、配置、凭据、adapter runtime、pairing
MessagingGateway adapter wiring、session event fan-out、button interaction
Router 已绑定消息的访问检查与入站转发
Commands /new/bind/pair/stop 等控制面
Renderer session event → 平台消息/编辑/按钮
BindingStore channel/topic ↔ session 持久绑定
PlatformAdapter 平台协议与统一能力接口

主入口见 registry.tsgateway.ts

17.2 为什么 Registry 在 Gateway 之上

Section titled “17.2 为什么 Registry 在 Gateway 之上”

Gateway本身是 workspace-scoped;Registry是进程级单例,维护:

Map<workspaceId, {
gateway,
configStore,
topicRegistry,
adapters/runtime,
bot identities
}>

它还实现 server-core 的 IMessagingGatewayRegistry,供 RPC handler读取配置、bindings、pending senders、发起 pairing和连接/断开平台。

这样 server-core只依赖接口,不反向依赖 Telegram/WhatsApp SDK;Automation通过 setAutomationBinder callback请求 topic binding,也避免 SessionManager→messaging package循环依赖。源码:registry.ts

17.3 Event fan-out 让桌面与机器人看到同一事实流

Section titled “17.3 Event fan-out 让桌面与机器人看到同一事实流”

SessionManager只发一次 sessions.EVENT。bootstrap用 createFanOutSink() 把它同时交给:

  • WebSocket/UI publisher;
  • Messaging Registry;
  • 其他需要的 sink。

Messaging不轮询 session transcript,也不监听磁盘。它消费与 renderer相同的事件:text、tool、permission、plan、error、complete。

这是重要的一致性设计:如果机器人另从 JSONL推导“新内容”,流式边界、intermediate text和已完成 turn很容易与桌面不同。

PlatformAdapter 把三个 SDK收敛为:

initialize / destroy / isConnected
onMessage / onButtonPress
sendText / editMessage / sendButtons / sendTyping / sendFile
clearButtons?
setAcceptedSupergroupChatId?
createForumTopic?
handleWebhook?

每个 adapter还声明 capability:

{
messageEditing,
inlineButtons,
maxButtons,
maxMessageLength,
markdown,
webhookSupport
}

Renderer根据能力退化,而不是根据 platform === ... 到处硬编码。不过权限、WhatsApp安全策略、forum topic等真正存在产品语义差异的地方仍显式判断平台。

三个 adapter都输出 IncomingMessage

{
platform,
channelId,
threadId?,
messageId,
senderId,
senderName?,
senderUsername?,
senderIsBot?,
text,
attachments?,
replyToMessageId?,
timestamp,
raw
}

这里保留 raw 用于诊断/未来能力,但路由和权限只依赖规范字段。threadId 只在 Telegram forum topic有值;它是绑定 key的一部分,不能只用 supergroup channelId

sequenceDiagram
participant P as Platform Adapter
participant G as Gateway
participant C as Commands
participant R as Router
participant B as BindingStore
participant S as SessionManager
P->>G: IncomingMessage
alt text begins with slash
G->>C: handleCommand
C-->>G: handled?
end
G->>R: route
R->>B: findByChannel(platform, channel, thread)
alt bound
R->>R: evaluateBindingAccess
R->>R: resolve local attachments
R->>S: sendMessage(sessionId, text, attachments)
else unbound
R->>C: help/new/bind/pair flow
end

命令先于普通路由是有意的:已绑定 chat中的 /stop 仍应控制 session,而不是被当成用户 prompt。精确 parser支持 Telegram 的 /pair@BotName 123456,同时避免旧式 startsWith('/new')/newuser 误判,见 commands.ts

ChannelBinding 记录:

workspaceId + sessionId
platform + channelId + threadId?
channelName + enabled + createdAt
BindingConfig

约束是:

  • 一个 (platform, channelId, threadId) 同时只绑定一个 session;
  • 一个 session可以有多个 bindings;
  • 同一 Telegram supergroup的不同 topic可以绑定不同 session;
  • General topic/DM 的 threadId=undefined 与具体 topic独立。

bind() 会先驱逐同 channel tuple的旧 binding,再创建新 UUID;只改策略必须用 updateBindingConfig() 保留 binding id,见 binding-store.ts

支持三条路径:

  1. /new [name]:创建 session并立即绑定当前 chat/topic;
  2. /bind:列最近 sessions,按钮或 id/index选择后绑定;
  3. /pair <code>:从应用某个 session发起的一次性配对。

控制命令定义在 commands.ts。此外 Automation可以把新 session自动绑定到 Telegram forum topic,第 17.24 节详述。

PairingCodeManager 的约束:

  • 6位十进制;
  • 5分钟 TTL;
  • 只在内存,不持久化;
  • (platform, code) 索引;
  • workspace必须匹配;
  • consume() 原子删除,只能使用一次;
  • 每 workspace每分钟最多生成10个;
  • (workspace,platform,sender) 每分钟最多尝试5次。

它有两种 intent:

session 当前 chat/topic → 指定 session
workspace-supergroup 把 Telegram forum注册为 workspace accepted group

代码本身不是身份认证的全部。首次成功 pairing还会把 sender seed为 platform owner,后续依赖 owner/access策略。

访问决策分两层:

Workspace/platform:
open | owner-only
Binding:
inherit | allow-list | open

evaluateBindingAccess() 的顺序:

  1. bot sender永远拒绝;
  2. binding open 允许;
  3. binding allow-list 要求 sender id在列表;
  4. binding inherit 回落 workspace策略;
  5. workspace owner-only 要求 sender在 owners。

Pre-binding命令只看 workspace policy。/pair 是 bootstrap例外,/help 是信息例外;其他 /new/bind 等先过 gate。

readPlatformAccessMode() 对非 Telegram直接返回 open,owners也只读 Telegram配置,见 access-control.ts。因此类型看似通用,但 0.11.2 的 workspace owner控制实质主要覆盖 Telegram;不能据 PlatformType 推断三平台安全语义完全相同。

新 binding默认:

responseMode: 'progress'
approvalChannel: telegram/lark ? 'chat' : 'app'
accessMode: 'inherit'

旧 binding若没有 accessMode,normalize为 open,而不是突然锁死生产 bot。新 workspace首次 pairing则倾向 owner-only

这是兼容性与安全迁移的经典取舍:

  • 新对象使用安全默认;
  • 旧对象保持行为,UI提示 owner主动 lockdown;
  • normalization集中在类型层,所有读取路径一致。

源码:types.ts

非 bot拒绝会写入 pending.json,让 Settings显示“待允许用户”。PendingSendersStore

  • 最多50条;
  • 7天 TTL;
  • LRU/按最近尝试排序;
  • 重复尝试累加 count;
  • key包含 platform、sender、reason、bindingId;
  • owner级与 binding allow-list级请求分开。

区分 reason很关键:批准 not-owner 应加入 workspace owners;批准 not-on-binding-allowlist 只应改目标 binding。把两者混起来会造成权限扩大。

友好拒绝回复每 sender有1小时 cooldown;bot sender静默丢弃,防 bot loop。统一的 executeRejection() 被文本、命令和按钮路径复用,见 access-control.ts

Adapter先下载附件并填 localPath,Router再调用 shared readFileAttachment()

platform file/media id
→ adapter/worker download to local path
→ IncomingAttachment
→ Router.resolveAttachments
→ FileAttachment(base64/utf8 + mime/name)
→ SessionManager.sendMessage

没有 localPath 或读取失败的附件会跳过。源码:router.ts

安全要求与 app上传相同:

  • 限制大小与类型;
  • temp文件生命周期要可控;
  • filename不能决定任意落盘路径;
  • PDF/image进入模型前仍是不可信内容。

WhatsApp worker将附件限制为20 MiB,下载前后都检查大小,见 media.ts

sequenceDiagram
participant S as SessionManager
participant F as Event fan-out
participant G as Gateway
participant B as BindingStore
participant R as Renderer
participant A as PlatformAdapter
S->>F: sessions.EVENT
F->>G: event + workspace target
G->>B: findBySession(sessionId)
loop every active binding
G->>A: connected?
G->>R: handle(event,binding,adapter)
R->>A: send/edit/buttons/file
end

一个 session可绑定多个平台,所以每个 binding都有独立 render state。Renderer必须以 binding.id 为 key,而不是 session id;否则 Telegram正在编辑的 message id会错误复用到 Lark或另一个 chat。

Renderer 支持:

模式 行为 适用
streaming 首 token发消息,约3.5s批量 edit;每个 text complete可成消息 追求桌面式实时感
progress 一个气泡:thinking → tool status → final 默认,降低噪音/限流
final_only complete前静默,最后发一次 不支持编辑或低噪音场景

progress 会忽略 intermediate assistant text,只累计非 intermediate final;若 run以 tool结束、没有干净 final,则回退最后一次 assistant text,避免气泡永远停在“thinking”。

不支持 edit的平台会把 progress 退化为 complete时单次发送。长文本按 adapter maxMessageLength 在段落/换行/空格处分块。

Telegram有 API edit限流。Renderer:

  • 最小 edit interval约3500ms;
  • 只在文本变长时编辑;
  • progress status未变化不重复 edit;
  • 429时指数增加 interval,最高15秒;
  • 30秒后恢复默认。

流式 UI的网络成本取决于“可见更新频率”,不是模型 token频率。把每个 delta变平台请求会快速触发限流并制造消息闪烁。

17.17 权限审批:位置不改变权限本身

Section titled “17.17 权限审批:位置不改变权限本身”

BindingConfig.approvalChannel 只决定在哪里审批:chat或 app;Session permission mode仍决定是否需要审批

Telegram/Lark且 adapter支持 button时:

permission_request
→ [Allow] [Deny]
→ perm:allow:<requestId>
→ gateway.respondToPermission(sessionId, requestId, allowed, remember=false)

WhatsApp固定 approvalChannel='app',只通知“去桌面批准”;credential request也不把 secret输入流程放进聊天。源码:renderer.ts

这是合理的风险分层:外部聊天群成员、消息转发和平台存档都扩大 secret/approval暴露面。

17.18 Button path 必须重新做访问控制

Section titled “17.18 Button path 必须重新做访问控制”

Inline button可见不代表点击者被授权。Gateway对 bind:perm:plan: callback重新 gate:

  • bind: 要 workspace owner;
  • permission/plan遵循 binding policy;
  • bot点击静默丢弃;
  • reject同样写 pending store并限频回复。

若只检查生成按钮时的权限,群里任何看到按钮的人都能代 owner点击,这是典型“文本入口锁了,callback入口没锁”的漏洞。

Gateway保存:

Map<requestId, {
bindingId, sessionId, platform,
channelId, threadId?, messageId
}>

收到点击时先 delete(requestId) 抢占,再清 inline keyboard,最后调用 respondToPermission();并且只有返回 delivered=true才发“Allowed/Denied”。

任何后续非同 request permission event都会 sweep旧 prompt和按钮。这样覆盖:

  • 用户双击;
  • 两人同时点;
  • 桌面先批准、聊天后点击;
  • Agent已退出;
  • 同一 session有多个 bindings。

源码:gateway.tsgateway.ts

plan_submitted 在 Telegram/Lark生成:

[Accept plan] [Accept & compact]

计划不超过3500字符时内联;更长时展示前15行并附 plan.md。Button里不是直接塞 path,而是短期 PlanTokenRegistry token。

Accept & compact 的顺序:

  1. 记录 pending compact accept;
  2. setPendingPlanExecution(sessionId, planPath)
  3. 向 session发送 /compact
  4. 监听 compaction_complete
  5. 再 accept plan并恢复 Agent。

它不能在发出 /compact 后立即 accept,否则 compaction与新执行会并发改会话。源码:renderer.tsgateway.ts

TelegramAdapter 基于 grammY,0.11.2走 polling:

  • 初始化前清旧 webhook,避免 polling收不到 update;
  • 默认只接 DM;
  • 配对 supergroup后接收该 group的 forum topic;
  • 支持 Markdown V2格式、typing、message edit、buttons、files;
  • 支持创建 forum topic;
  • polling异常按指数 backoff重连,上限5分钟;
  • adapter声明 webhookSupport: false,即使类型留了 handler。

接受 chat的规则不只是“bot在群里”:必须是 private chat或已登记 supergroup。这样 bot不会意外响应它被拉入的任意群。

LarkAdapter 使用 Lark/Feishu SDK的 WSClient 长连接,无需公网 webhook:

  • token实际编码 appId/appSecret/domain配置;
  • 监听 im.message.receive_v1card.action.trigger
  • Markdown转换成 Lark post结构;
  • buttons使用 interactive card;
  • text/post/card更新走不同 API;
  • edit过期错误可视为非致命;
  • card create失败会尝试 plain-text fallback。

当前 SDK public type没有 stop,destroy() 主要清引用并等待底层 socket回收。反复 reconnect/adapter replacement是否会短暂保留旧连接,是值得加集成测试和显式 SDK lifecycle wrapper的债务。

17.23 WhatsApp:为什么再次使用子进程

Section titled “17.23 WhatsApp:为什么再次使用子进程”

WhatsApp adapter不直接在 Electron/server进程加载 Baileys,而是 spawn @craft-agent/messaging-whatsapp-worker

flowchart LR
G["WhatsAppAdapter"]
P["JSONL command/event protocol"]
W["worker.cjs"]
B["Baileys / WhatsApp Web"]
AUTH["multi-file auth state"]
G <--> P
P <--> W
W <--> B
W <--> AUTH

Adapter与 worker之间用 stdout/stdin增量 JSONL frame;send命令带 id,send_result 解析成 pending promise。worker退出或 destroy时必须 drain所有 pending,防调用永久悬挂。源码:adapters/whatsapp/index.tsprotocol.ts

隔离收益与 Pi子进程相似:

  • Baileys依赖/连接异常不拖垮主 Agent进程;
  • auth state和协议生命周期独立;
  • 打包时可明确选择 worker entry;
  • stdout保留机器协议,stderr做诊断。

默认 selfChatMode 开启:用户在自己的 WhatsApp self-chat给 Agent发消息,而不是让 bot自动处理所有联系人来信。Worker分类规则:

fromMe + id in sentIds → 自己刚发出的 Agent回复,跳过
fromMe + 非 self chat → 普通自己发出的消息,跳过
fromMe + self chat → 视为用户输入
not fromMe + selfChatMode → 联系人/群来信,跳过

Agent回复还可加 prefix,已发送 message id保留最近500个用于 echo识别。源码:filter.ts

WhatsApp能力声明不支持 edit/buttons,权限固定到 app;所谓 sendButtons() 只退化成编号文本,不会生成可回调按钮。

Workspace先配对一个开启 Topics的 supergroup。Registry会调用 getChat确认:

  • type确为 supergroup
  • isForum=true
  • bot有读/创建 topic所需权限。

Automation指定 topicName 时:

fresh automation session
→ TopicRegistry.findOrCreate(topicName)
→ Telegram createForumTopic if cache miss
→ bind(session, chatId, threadId)
→ 后续 session event发到该 topic

失败是 best-effort:任务 session继续运行,只记录无法绑定 topic,避免消息集成故障阻塞核心自动化。见 registry.ts

17.26 配置、凭据与运行状态分离

Section titled “17.26 配置、凭据与运行状态分离”

每 workspace消息目录包含:

messaging/
├── config.json
├── bindings.json
├── pending.json
├── topics.json
└── whatsapp-auth/...

Telegram/Lark bearer secret走 CredentialManager,不写普通 config;WhatsApp auth是多文件协议状态。Config持久“是否启用、访问策略、supergroup、自聊模式”等意图。

运行状态另有:

'disconnected' | 'connecting' | 'connected' |
'reconnect_required' | 'error'

它包含 configured/connected/identity/lastError/updatedAt,由 Registry推送给 UI。配置为 enabled不代表当前已连接;把二者混成 boolean会让 Settings无法区分“缺凭据”“需要扫码”“正在重连”和“连接错误”。

  • disconnectPlatform:停止 adapter、关闭 enabled,但保留 owners/access/supergroup/selfChat/domain等非 secret设置;Telegram/Lark token删除;
  • forgetPlatform:在 disconnect基础上,WhatsApp还递归删除 auth state。

保留策略避免用户重连后安全配置意外回到 public;真正“忘记设备”才清身份材料。源码:registry.ts

失败 当前处理
adapter未连接 丢该 binding的出站 event并记录 warning
send/edit失败 Renderer局部 catch;complete可能重试最终文本
429 增大 edit interval
无 binding 进入 Commands/help
session不存在 bind按钮提示 not found
attachment下载/读失败 跳过该附件
worker退出 drain pending、runtime error/disconnected
permission prompt已过期 callback静默 no-op并清按钮
plan token过期 提示回桌面重试
topic创建失败 automation继续,记录失败

一个明显限制:adapter断开时的 session events不会持久化成“待外发队列”。重连后桌面 transcript仍完整,但聊天可能漏掉离线期间输出。若产品承诺消息必达,需要 per-binding delivery log/outbox与去重 id,而不只是 event fan-out。

Binding/Config/Pending stores都使用同步 JSON读写,小规模时简单可靠,并且 binding change listener只在 write成功后触发。

但它们直接 writeFileSync(target),没有 temp+rename/journal:进程在截断与写完之间崩溃可能得到损坏 JSON,load后重置为空。这里比 session JSONL的重要性低,但 bindings丢失会使外部入口失联。

建议复用统一 atomic JSON writer:

serialize + validate
→ write target.tmp
→ fsync when needed
→ rename over target
→ notify change

Messaging入口的威胁面大于桌面输入框,至少要逐项验证:

  1. 所有文本、命令和 callback都过访问控制;
  2. bot/self echo不会形成无限循环;
  3. pairing有 TTL、scope、单次消费与 sender限频;
  4. group topic用 (chat,thread) 而非只用 chat;
  5. secret不进入消息 transcript/log/config;
  6. credential request不在外部聊天收集明文;
  7. 附件大小、路径、MIME和临时文件受限;
  8. long message与 Markdown escaping不允许注入平台语法;
  9. stale permission/plan按钮不能重复生效;
  10. owner批准与 binding批准不能混淆;
  11. disconnect不意外重置访问策略;
  12. logs避免保存完整私聊正文和 token。

messaging-gateway 有约25个 test文件,重点覆盖:

  • binding create/update/migration;
  • pairing TTL、scope、rate limit、atomic consume;
  • command parser与 /new/bind/pair
  • pre-binding/binding/button access矩阵;
  • pending sender LRU/TTL/批准目标;
  • renderer三种模式、plan与permission;
  • stale/double button幂等;
  • Telegram DM/supergroup过滤;
  • Lark adapter/card;
  • registry config preservation;
  • topic registry。

WhatsApp worker另测 inbound filter、upsert和media;adapter lifecycle测试 worker crash/destroy时 pending promise必定结束。

仍值得补的高价值集成测试:

  • 真实或录制的 Telegram 429/reconnect序列;
  • Lark adapter多次 replace后连接数;
  • crash midway时 JSON store恢复;
  • gateway离线 outbox/重连(若实现);
  • 同 session多 binding同时 permission的竞争;
  • public supergroup中非 owner看见并点击所有按钮的端到端矩阵。
  1. 实现 PlatformAdapter 和真实 capability;
  2. 把 SDK payload规范成 IncomingMessage/ButtonPress
  3. 定义 Markdown、长度、编辑、button退化;
  4. 在 Registry加入 credential/runtime lifecycle;
  5. 明确 workspace owner策略是否真正支持该平台;
  6. 决定 chat/thread/channel identity与 binding key;
  7. 定义 attachment下载位置、限制和清理;
  8. 明确 permission/credential是否允许在 chat处理;
  9. 测 callback访问控制、echo、重连与销毁;
  10. 更新 UI config和 i18n。

不能只做到“能收发文字”。真正的平台接入还包括身份、线程、幂等、审批、限流和生命周期。

  1. 外部聊天应复用核心 session runtime,而不是复制一套 bot Agent。
  2. 平台适配要声明 capability,让 renderer显式降级。
  3. channel→session是一对一,session→channel可以一对多。
  4. 控制命令、普通文本、按钮 callback都必须经过一致权限入口。
  5. Pairing code是短期能力,不是长期身份;成功后仍要建立 owner/binding策略。
  6. 流式模型事件必须被重新节流和聚合,不能逐 token映射平台 API。
  7. 审批按钮需要 claim、清理与 delivered确认三重幂等。
  8. configured、connected、reconnect required与error必须分开建模。
  9. 对不支持交互的平台,安全退化通常是回桌面,而不是伪造按钮。
  10. 若要承诺消息必达,event fan-out之上还需要持久 outbox。

下一章从实现机制退一步,盘点 0.11.2 的工程质量、测试形状、最值得复用的架构模式,以及二次开发时最先应偿还的结构债。