17. Telegram、WhatsApp 与 Lark 消息网关
Messaging Gateway 把外部聊天变成 SessionManager 的另一个输入/输出端口。它没有为机器人另建 Agent runtime,而是把“平台消息 ↔ session message/event”做成双向路由,并在中间加入绑定、访问控制、审批交互、格式降级和平台生命周期。
17.1 总体结构
Section titled “17.1 总体结构”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.ts、gateway.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很容易与桌面不同。
17.4 统一 Adapter 合约
Section titled “17.4 统一 Adapter 合约”PlatformAdapter 把三个 SDK收敛为:
initialize / destroy / isConnectedonMessage / onButtonPresssendText / editMessage / sendButtons / sendTyping / sendFileclearButtons?setAcceptedSupergroupChatId?createForumTopic?handleWebhook?每个 adapter还声明 capability:
{ messageEditing, inlineButtons, maxButtons, maxMessageLength, markdown, webhookSupport}Renderer根据能力退化,而不是根据 platform === ... 到处硬编码。不过权限、WhatsApp安全策略、forum topic等真正存在产品语义差异的地方仍显式判断平台。
17.5 入站消息标准化
Section titled “17.5 入站消息标准化”三个 adapter都输出 IncomingMessage:
{ platform, channelId, threadId?, messageId, senderId, senderName?, senderUsername?, senderIsBot?, text, attachments?, replyToMessageId?, timestamp, raw}这里保留 raw 用于诊断/未来能力,但路由和权限只依赖规范字段。threadId 只在 Telegram forum topic有值;它是绑定 key的一部分,不能只用 supergroup channelId。
17.6 入站主链
Section titled “17.6 入站主链”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。
17.7 Channel binding 的基数
Section titled “17.7 Channel binding 的基数”ChannelBinding 记录:
workspaceId + sessionIdplatform + channelId + threadId?channelName + enabled + createdAtBindingConfig约束是:
- 一个
(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。
17.8 绑定如何创建
Section titled “17.8 绑定如何创建”支持三条路径:
/new [name]:创建 session并立即绑定当前 chat/topic;/bind:列最近 sessions,按钮或 id/index选择后绑定;/pair <code>:从应用某个 session发起的一次性配对。
控制命令定义在 commands.ts。此外 Automation可以把新 session自动绑定到 Telegram forum topic,第 17.24 节详述。
17.9 Pairing code 是短期能力票据
Section titled “17.9 Pairing code 是短期能力票据”PairingCodeManager 的约束:
- 6位十进制;
- 5分钟 TTL;
- 只在内存,不持久化;
(platform, code)索引;- workspace必须匹配;
consume()原子删除,只能使用一次;- 每 workspace每分钟最多生成10个;
- 每
(workspace,platform,sender)每分钟最多尝试5次。
它有两种 intent:
session 当前 chat/topic → 指定 sessionworkspace-supergroup 把 Telegram forum注册为 workspace accepted group代码本身不是身份认证的全部。首次成功 pairing还会把 sender seed为 platform owner,后续依赖 owner/access策略。
17.10 两级访问控制
Section titled “17.10 两级访问控制”访问决策分两层:
Workspace/platform: open | owner-only
Binding: inherit | allow-list | open- bot sender永远拒绝;
- binding
open允许; - binding
allow-list要求 sender id在列表; - binding
inherit回落 workspace策略; - workspace
owner-only要求 sender在 owners。
Pre-binding命令只看 workspace policy。/pair 是 bootstrap例外,/help 是信息例外;其他 /new、/bind 等先过 gate。
当前平台边界
Section titled “当前平台边界”readPlatformAccessMode() 对非 Telegram直接返回 open,owners也只读 Telegram配置,见 access-control.ts。因此类型看似通用,但 0.11.2 的 workspace owner控制实质主要覆盖 Telegram;不能据 PlatformType 推断三平台安全语义完全相同。
17.11 迁移默认值为何刻意不同
Section titled “17.11 迁移默认值为何刻意不同”新 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。
17.12 被拒 sender 不是只写日志
Section titled “17.12 被拒 sender 不是只写日志”非 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。
17.13 附件链
Section titled “17.13 附件链”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。
17.14 出站主链
Section titled “17.14 出站主链”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。
17.15 三种响应模式
Section titled “17.15 三种响应模式”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 在段落/换行/空格处分块。
17.16 为什么不逐 token edit
Section titled “17.16 为什么不逐 token edit”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入口没锁”的漏洞。
17.19 Permission button 的幂等
Section titled “17.19 Permission button 的幂等”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.ts、gateway.ts。
17.20 Plan 审批与 compact
Section titled “17.20 Plan 审批与 compact”plan_submitted 在 Telegram/Lark生成:
[Accept plan] [Accept & compact]计划不超过3500字符时内联;更长时展示前15行并附 plan.md。Button里不是直接塞 path,而是短期 PlanTokenRegistry token。
Accept & compact 的顺序:
- 记录 pending compact accept;
setPendingPlanExecution(sessionId, planPath);- 向 session发送
/compact; - 监听
compaction_complete; - 再 accept plan并恢复 Agent。
它不能在发出 /compact 后立即 accept,否则 compaction与新执行会并发改会话。源码:renderer.ts、gateway.ts。
17.21 Telegram Adapter
Section titled “17.21 Telegram Adapter”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不会意外响应它被拉入的任意群。
17.22 Lark Adapter
Section titled “17.22 Lark Adapter”LarkAdapter 使用 Lark/Feishu SDK的 WSClient 长连接,无需公网 webhook:
- token实际编码 appId/appSecret/domain配置;
- 监听
im.message.receive_v1与card.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 <--> AUTHAdapter与 worker之间用 stdout/stdin增量 JSONL frame;send命令带 id,send_result 解析成 pending promise。worker退出或 destroy时必须 drain所有 pending,防调用永久悬挂。源码:adapters/whatsapp/index.ts、protocol.ts。
隔离收益与 Pi子进程相似:
- Baileys依赖/连接异常不拖垮主 Agent进程;
- auth state和协议生命周期独立;
- 打包时可明确选择 worker entry;
- stdout保留机器协议,stderr做诊断。
17.24 WhatsApp self-chat 与回声防护
Section titled “17.24 WhatsApp self-chat 与回声防护”默认 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() 只退化成编号文本,不会生成可回调按钮。
17.25 Telegram forum 与 Automation
Section titled “17.25 Telegram forum 与 Automation”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无法区分“缺凭据”“需要扫码”“正在重连”和“连接错误”。
17.27 Disconnect 与 Forget 语义不同
Section titled “17.27 Disconnect 与 Forget 语义不同”disconnectPlatform:停止 adapter、关闭 enabled,但保留 owners/access/supergroup/selfChat/domain等非 secret设置;Telegram/Lark token删除;forgetPlatform:在 disconnect基础上,WhatsApp还递归删除 auth state。
保留策略避免用户重连后安全配置意外回到 public;真正“忘记设备”才清身份材料。源码:registry.ts。
17.28 失败语义
Section titled “17.28 失败语义”| 失败 | 当前处理 |
|---|---|
| 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。
17.29 持久化纪律与原子性缺口
Section titled “17.29 持久化纪律与原子性缺口”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 change17.30 安全审计清单
Section titled “17.30 安全审计清单”Messaging入口的威胁面大于桌面输入框,至少要逐项验证:
- 所有文本、命令和 callback都过访问控制;
- bot/self echo不会形成无限循环;
- pairing有 TTL、scope、单次消费与 sender限频;
- group topic用
(chat,thread)而非只用 chat; - secret不进入消息 transcript/log/config;
- credential request不在外部聊天收集明文;
- 附件大小、路径、MIME和临时文件受限;
- long message与 Markdown escaping不允许注入平台语法;
- stale permission/plan按钮不能重复生效;
- owner批准与 binding批准不能混淆;
- disconnect不意外重置访问策略;
- logs避免保存完整私聊正文和 token。
17.31 测试版图
Section titled “17.31 测试版图”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看见并点击所有按钮的端到端矩阵。
17.32 新增第四个平台的步骤
Section titled “17.32 新增第四个平台的步骤”- 实现
PlatformAdapter和真实 capability; - 把 SDK payload规范成
IncomingMessage/ButtonPress; - 定义 Markdown、长度、编辑、button退化;
- 在 Registry加入 credential/runtime lifecycle;
- 明确 workspace owner策略是否真正支持该平台;
- 决定 chat/thread/channel identity与 binding key;
- 定义 attachment下载位置、限制和清理;
- 明确 permission/credential是否允许在 chat处理;
- 测 callback访问控制、echo、重连与销毁;
- 更新 UI config和 i18n。
不能只做到“能收发文字”。真正的平台接入还包括身份、线程、幂等、审批、限流和生命周期。
17.33 可迁移结论
Section titled “17.33 可迁移结论”- 外部聊天应复用核心 session runtime,而不是复制一套 bot Agent。
- 平台适配要声明 capability,让 renderer显式降级。
- channel→session是一对一,session→channel可以一对多。
- 控制命令、普通文本、按钮 callback都必须经过一致权限入口。
- Pairing code是短期能力,不是长期身份;成功后仍要建立 owner/binding策略。
- 流式模型事件必须被重新节流和聚合,不能逐 token映射平台 API。
- 审批按钮需要 claim、清理与 delivered确认三重幂等。
- configured、connected、reconnect required与error必须分开建模。
- 对不支持交互的平台,安全退化通常是回桌面,而不是伪造按钮。
- 若要承诺消息必达,event fan-out之上还需要持久 outbox。
下一章从实现机制退一步,盘点 0.11.2 的工程质量、测试形状、最值得复用的架构模式,以及二次开发时最先应偿还的结构债。