src/im/lark/ 是飞书侧接入层,核心是 event-dispatcher.ts(3880 行):长连接建立、事件 ACK/去重、bot 身份探测、@mention 路由、话题/普通群 scope 判定,最终把消息分发为「新开会话」或「回注既有会话」。会话执行与 worker 生命周期见 daemon-worker。

长连接入口

startLarkEventDispatcher(larkAppId, appSecret, handlers, brand)(event-dispatcher.ts:2634)为每个 bot 创建一个 Lark.WSClient(SDK 长连接,无需公网 webhook):

  • 域名:sdkDomain(brand)(lark-hosts.ts)——国际版租户必须连 larksuite.com,否则收不到任何事件
  • 代理:createLarkWsAgent() 把 wss/ws 映射回 https/http 走 ProxyAgent,与 SDK 前置 HTTPS 引导请求对齐
  • 自愈双防线(3806-3878):① pingTimeout: 30s,发 ping 后 30s 无入站帧即掐断 socket 触发 SDK 重连(专治 TCP 半开静默卡死);② 每分钟探测连接状态,SDK 重连耗尽停在 failed 后重新 start() 发起全新握手,无需手动 restart daemon

事件注册在 new Lark.EventDispatcher({}).register({...})(3659):im.message.receive_v1(消息)、card.action.trigger(卡片回调,见 card-streaming)、im.chat.member.bot.added/deleted_v1(进群自动开工/清缓存)、群成员变更(清群统计缓存)、drive.notice.comment_add_v1(文档评论触发)、vc.bot.meeting_*(会议 agent)。

ACK 安全与去重

Lark WS SDK 只在 handler promise resolve 后才发唯一 ACK 帧;飞书有 3s 预算,超时按 15s/5min/1h/6h 分级重投。因此 scheduleAckSafeEvent(617)同步 claim + setImmediate 把重活挪到后台,handler 立即返回保 ACK 及时。claim 与 setImmediate 之间禁止 await(FIFO 保序不变量)。

去重两层:

  • 消息路径:持久化 claimMessageOnce(seen-message-store,按 message_id),daemon 重启或 6h 重投不能重放已处理消息
  • 其他事件:内存 claimEventOnce,2h TTL(覆盖 15s/5min/1h 重投档),BoundedMap + 定时/定容 prune

入口屏障:rawMessageIngressAnchor(661)按 chat 级(非 thread 级)串行化入站——话题种子初始没有 thread_id,后续首条回复才归一化到种子 message_id,按 thread 分 lane 无法证明到达顺序。实际执行栅栏是 serializeByAnchor(utils/anchor-serializer.ts):同 anchor 串行、跨 anchor 并发。

消息 → bot → 会话 分发链路

processMessageEvent(约 2740)是单条消息的判定主流程:

  1. ensureBotOpenId:闭合启动竞态——open_id 探测是 fire-and-forget,窗口内到达的 @ 会因 botOpenId=undefined 被静默丢弃且事件已 ACK(永久丢失),故路由前 await 去重探针
  2. 身份学习:从 mentions[] 学其他 bot 的 open_id(open_id 按 app 隔离,cross-ref 存盘)、学自己的 union_id(无 receive-all scope 时收不到自己消息的回声,@mention 是唯一可靠来源)
  3. bot 发送方分支:自己的回声只处理 /close;团队拉群里登记队友 union_id;机器人大厅只登记身份不做任务路由;message-listener 规则匹配;外部 bot 仅 @ 本 bot 才路由(例外:autoStartOnNewTopic 免 @ 自动开工,含其他 bot 开的新话题),未授权陌生 bot 改发 grant 授权卡。同部署兄弟 bot 冷启动自愈:live /members/bots roster 验证后回填 cross-ref,fail-closed
  4. 人类分支:oncall 自动绑定(必须在权限判定前)→ canTalk 授权 → 斜杠元命令在路由前拦截(/grant /invite /reply-mode /substitute /introduce,mention-targets.ts 解析「命令词之后的 @ 才是目标」)→ 路由判定 → dispatch

@mention 判定

isBotMentioned(1109)按 open_id/union_id/app_id 多形态匹配 mentions[];message-parser.ts 提供 mentionOpenId/mentionAppId/extractMentionIdentities/extractPostAtParticipants(text 与 post 富文本两种形态,post 的 mentions 常为空需走 inline at 节点)。

scope/anchor 路由判定

decideRoutingWithSource(2302)产出 {scope: 'thread'|'chat', anchor}(RoutingSource:real-thread/p2p/topic-chat/regular-group-thread/regular-group-chat):

场景判定结果
私聊(默认 p2pMode: chat)整段 DM 折叠chat-scope,anchor=chatId
带 root_id+thread_id既有话题内回复thread-scope,anchor=root_id
话题群顶层消息getChatMode 缓存(5min TTL)= ‘topic’新话题种子,anchor=本消息 message_id
普通群顶层/reply-mode:new-topic → 每 @ 起新话题;chat/shared/chat-topic → 平铺thread-scope(anchor=msg) 或 chat-scope(anchor=chatId)
普通群原生话题种子(omt_*)仅 chat-topic 模式单独隔离thread-scope
  • /t /topic 强制话题:maybeApplyForceTopicOverride(2148)把普通群 chat-scope 改写为 thread-scope、anchor=本消息,bot 首回复种子化一个新 Lark 话题
  • 共享话题折叠:maybeFoldMentionedRegularGroupThreadToChat / maybeApplySharedTopicSeed 把普通群 thread 折回 chat-scope 共享会话(replyRootId 记录可见回复落点),与授权门共用同一个 evaluateBotTalk 结论,避免「能路由不能 fold」的分裂
  • 群模式转换:话题群翻回普通群时 forceRefresh 复核缓存(防 5min 陈旧缓存错建 thread-scope 会话);onChatModeConverted hook 让 daemon 驱逐失效的 chat-scope 会话

会话分发

dispatchHumanMessageViaHandlers(1892):handlers.isSessionOwner(anchor) 为真 → handleThreadReply(daemon.ts:17781,注入既有会话),否则 → handleNewTopic(daemon.ts:16495,按 anchor 开新会话)。anchor 即会话锚:thread-scope 为话题根 message_id,chat-scope 为 chatId;beforeSessionTurn hook 可替换为 daemon 持有的合成 anchor。会话锚定与接管语义见 session-model。

防抖:转发话题的宽限窗口

转发进群的新话题种子常紧跟一条澄清回复。ForwardFollowupBuffer(forward-followup-buffer.ts)把种子延迟 forwardFollowupWaitMs(config.ts:147 默认 1.5s,上限 10s)再 dispatch;窗口内出现 root 指向该种子的同发送者回复即配对合并(种子内容作上下文)。种子持久化到 forward-followup-store,daemon 重启后恢复剩余等待;dispatch 严格按 anchor 串行(capMs=0),跟随消息不会与种子的会话创建竞速。多话题编排见 multi-topic,多 bot 路由见 multi-bot。

授权门简述

canTalk/evaluateTalk/evaluateBotTalk(1397/1414/1510)是单一 talk 谓词,放行腿包括:open 模式、allowlist、grant 授权、oncall 绑定、已知 peer bot、团队 bot(union_id 锁 bot 发送方)、平台团队成员。人/bot 判定同源(test/bot-talk-parity.test.ts 守护),bot 专属逻辑只剩 cross-ref 冷启动自愈。详细角色体系见 roles。