当给飞书里的 OpenClaw 机器人发一条消息后,到底发生了什么?

18 阅读8分钟

这篇文章想探讨一个我自己在本机跑 OpenClaw 时一直很好奇的问题:

我的电脑明明在内网,没有公网 IP、没有端口映射,飞书 bot 居然能正常收消息,并且还能调用 MiniMax 回过去。

这一切是怎么成立的?消息从飞书 App 被我点下“发送”开始,到最终变成 bot 的一条回复之间,中间到底走了多少步?

我顺着仓库从 extensions/feishu/extensions/minimax/、再到 gateway 入口追了一遍,把整条链路拆开写在这里。适合:

  • 想搞清楚 OpenClaw 飞书通道是怎么跑通的
  • 想了解“本机服务接入飞书”到底依赖什么网络模型
  • 想扩展一个自己的 channel 插件,但还摸不清入口在哪

文章里引用的源码都用相对路径,你 clone 下来直接能跳到对应文件。


先给结论

当你在飞书里给 OpenClaw bot 发一条消息,整个过程其实可以压缩成一句话:

你本机上的 OpenClaw 预先主动连到了飞书云端,消息沿着这条长连接被推回本机;然后 gateway 做权限、路由、上下文拼装,再交给模型,模型回完再沿飞书 API 推回群聊或私聊。

一句话看起来简单,但内部其实有 7 个明显的阶段。我们一个一个拆开。


总览流程图

先看总图,后面所有步骤都是在这张图里的具体一段。

flowchart TD
    A["飞书 App(你这边)"] -->|你发一条 DM / 群里 @bot| B["飞书云端(服务端)"]
    B -->|沿已建立的 WebSocket 长连接推送| C["本机 OpenClaw — Feishu 插件<br/>monitorWebSocket() 保持长连接<br/>解析事件 / 判权限 / 会话路由"]
    C --> D["OpenClaw Gateway<br/>会话 / agent / 工具调度"]
    D -->|公网 API 调用| E["模型(MiniMax 等)"]
    E -->|模型回复| F["OpenClaw reply-dispatcher<br/>组织文本 / 卡片 / 流式"]
    F -->|走飞书 Open API| G["飞书云端"]
    G --> H["你在飞书里看到 bot 的回复"]

    classDef local fill:#E8F5E9,stroke:#2E7D32,color:#1B5E20;
    classDef cloud fill:#E3F2FD,stroke:#1565C0,color:#0D47A1;
    classDef user  fill:#FFF8E1,stroke:#F9A825,color:#E65100;
    class A,H user;
    class B,E,G cloud;
    class C,D,F local;

下面我们按这张图从上往下拆。


第 1 步:飞书云怎么知道要通知你这台机器?

很多人第一反应是:

“飞书应该是给我电脑的某个地址发了个请求吧?”

但你本机是内网,没有公网 IP。飞书云显然不能直接敲你家路由器的门。

真正发生的是反过来的

是 OpenClaw 本机,先主动连到了飞书云端,保持一条长连接。飞书只是把新事件沿这条已建好的连接推回来。

仓库文档里明确写了:

extensions/feishu/ 的默认模式是 WebSocket,webhook 只是可选。

对应到代码,这条长连接在飞书插件的 monitorWebSocket 里:

// extensions/feishu/src/monitor.transport.ts
export async function monitorWebSocket({ account, accountId, runtime, ... }) {
  log(`feishu[${accountId}]: starting WebSocket connection...`);
  const wsClient = await createFeishuWSClient(account);
  wsClients.set(accountId, wsClient);

  // 省略:绑定 eventDispatcher、处理 abort 等
  void wsClient.start({ eventDispatcher });
}

底层用的是飞书官方 SDK 的 WSClient

// extensions/feishu/src/client.ts
type FeishuClientSdk = Pick<
  typeof Lark,
  "AppType" | "Client" | "defaultHttpInstance"
  | "Domain" | "EventDispatcher" | "LoggerLevel" | "WSClient"
>;

这个模型的直观解释

你可以把它想成“打电话”:

  • ❌ 不是飞书主动拨你家电话
  • ✅ 而是你先拨飞书的电话,飞书在这通电话里告诉你:“有新消息来了”

这就是为什么你即使在内网也能让飞书 bot 正常收消息

  • 对路由器来说,这是一条向外的连接,通常都是放行的;
  • 回来的数据只是同一条连接的返回流量,也没问题;
  • 完全不需要公网 IP、不需要端口映射、不需要反向代理。

对比一下你一旦切到 webhook 模式(非默认),情况就反过来了:飞书变成主动方,你要有个能被公网访问的 HTTP 服务,这是另一套话题。


第 2 步:事件进入 OpenClaw 后,第一件事是结构化

一条消息事件从 WebSocket 进来之后,飞书插件会把它解析成一个结构化上下文,便于后面所有逻辑统一处理。

关键函数是 parseFeishuMessageEvent()

// extensions/feishu/src/bot.ts
export function parseFeishuMessageEvent(event, botOpenId, _botName) {
  const rawContent = parseMessageContent(event.message.content, event.message.message_type);
  const mentionedBot = checkBotMentioned(event, botOpenId);
  const content = normalizeMentions(rawContent, event.message.mentions, botOpenId);

  const ctx = {
    chatId:       event.message.chat_id,
    messageId:    event.message.message_id,
    senderOpenId: event.sender.sender_id.open_id,
    chatType:     event.message.chat_type,  // "p2p" | "group" | ...
    mentionedBot,
    rootId:       event.message.root_id,
    parentId:     event.message.parent_id,
    threadId:     event.message.thread_id,
    content,
    // ... 还有一些字段
  };
  return ctx;
}

这里有两个容易被忽略、但很关键的点:

  1. mentionedBot 会在这时就算好 后面判断“群里要不要响应”时,就不用再临时解析一次了。

  2. bot 自己的 @ mention 会被剥掉 比如你发的是 @OpenClaw /help,剥掉以后交给命令识别的是干净的 /help,否则 bot 的 mention 字符会把斜杠命令整体挤掉。

消息进入 OpenClaw 那一刻,就已经从“飞书原始事件”抽象成了“我们自己的 FeishuMessageContext”,后面都用它做决策。


第 3 步:权限检查 — 不是所有消息都会真的进模型

这一步容易被用户忽略:你发的消息不一定会真的进模型,甚至 bot 可能连一次响应都不会给你。

飞书插件会分两类处理:私聊(DM)和群聊。

3.1 私聊:看 dmPolicy

默认值来自:

// extensions/feishu/src/bot.ts
const dmPolicy = feishuCfg?.dmPolicy ?? "pairing";

常见三种:

  • pairing(默认):第一次给 bot 发 DM,bot 不会直接回答,而是给你一个配对码,让你走 openclaw pairing approve feishu <CODE> 才能激活;
  • allowlist:只允许明确在名单里的 user;
  • open:谁都能聊,但要配合可信网络使用。

3.2 群聊:看 groupPolicy + requireMention

典型决策逻辑:

// extensions/feishu/src/bot.ts(简化)
const { groupPolicy } = resolveOpenProviderRuntimeGroupPolicy({ ... });

const groupAllowed = isFeishuGroupAllowed({
  groupPolicy,
  allowFrom: groupAllowFrom,
  senderId: ctx.chatId,  // 注意:这里判的是“这个群是否允许”
});
if (!groupAllowed) return;

({ requireMention } = resolveFeishuReplyPolicy({ ... }));

if (requireMention && !ctx.mentionedBot) {
  // 没 @bot,就不触发回复,只记进 pending history
  return;
}

可以总结成一张小表:

场景行为
群不在 allowlist静默忽略
群在 allowlist,但要求 @bot,而你没 @静默忽略(会缓进历史)
群 allow + @了 bot继续往下走
私聊 + dmPolicy=pairing + 未配对bot 只回一个 pairing 码

所以如果你在群里发 bot 没反应,大概率不是 bug,是这一步按你的配置拦住了。


第 4 步:路由 — 决定“这句话属于哪个 agent 的哪个会话”

过了权限以后,OpenClaw 要决定:

“这条消息属于哪个 agent、哪个会话?”

简化后的核心几行:

// extensions/feishu/src/bot.ts
const feishuFrom = `feishu:${ctx.senderOpenId}`;
const feishuTo   = isGroup ? `chat:${ctx.chatId}` : `user:${ctx.senderOpenId}`;
const peerId     = isGroup ? (groupSession?.peerId ?? ctx.chatId) : ctx.senderOpenId;

let route = core.channel.routing.resolveAgentRoute({
  cfg,
  channel: "feishu",
  accountId: account.accountId,
  peer: { kind: isGroup ? "group" : "direct", id: peerId },
  parentPeer,
});

结果是得到一个 sessionKey。这个 key 决定:

  • 后续的对话历史写到哪
  • 命中哪个 agent
  • 从哪个 session 恢复上下文

一些值得留意的行为:

  • 群聊通常是“一个群一个会话”,
  • 但如果是话题 / thread 模式,可能是“一个群线程一个会话”甚至“线程 + 发言人”一个会话;
  • 私聊一般是“一个用户一个会话”。

这里还有个很有意思的分支:动态创建 agent。当你开启了 dynamicAgentCreation,并且是第一次 DM bot 的新用户,OpenClaw 会给这个用户单独拉起一个独立的 agent(有独立 workspace),然后再路由过去。


第 5 步:补上下文 — 这不只是发一句话给模型

到这步你可能以为可以直接把 content 丢给模型了。其实没那么简单。

在真正发给模型之前,飞书插件会尽量补上“这句话所处的语境”,具体有几类:

5.1 引用消息(parent)

// extensions/feishu/src/bot.ts(简化)
if (ctx.parentId) {
  quotedMessageInfo = await getMessageFeishu({
    cfg, messageId: ctx.parentId, accountId: account.accountId,
  });
  quotedContent = quotedMessageInfo?.content;
}

被你 reply 的那条消息会被一并带进 prompt。

5.2 媒体(图片 / 文件)

const mediaList = await resolveFeishuMediaList({
  cfg, messageId: ctx.messageId, messageType, content, maxBytes, log, accountId,
});
const mediaPayload = buildAgentMediaPayload(mediaList);

这些会转成多模态 payload,具备视觉能力的模型可以直接读。

5.3 线程历史(topic)

在 topic / thread 场景,插件还会拉这个 thread 的近 N 条消息,做两件事:

  • 按 allowlist 过滤掉不该看的发言;
  • 用固定格式拼进 agent 的“历史语境”。

5.4 群内 pending history

前面第 3 步里,群里没 @bot 的消息可能被记进一个 chatHistories 缓存。当终于有人 @bot 时,这些“背景发言”也会被整理进当前 prompt,让模型能看懂你们群里刚才在聊什么。

5.5 加系统提示 + 标注 message_id

最终拼装出来的 body,大概长这样(示意):

[message_id: om_xxx]
Alice: @OpenClaw 帮我总结下刚才的讨论

[System: 内容可能包含 <at user_id="...">name</at> 格式的 mention。]
[System: 如果 user_id 是 "<botOpenId>",那就是指你。]

这些都是 buildFeishuAgentBody() 干的事。

这一步的核心想法就一句话:

不是把“你刚发的一句话”原封不动丢给模型,而是把“这句话所处的全部上下文”一起送进去。


第 6 步:真正交给 agent / 模型

到了这一步,飞书插件把组装好的 payload 通过 gateway 的 routing 和 agent 机制送出去:

  • agent 决定用哪个模型(例如 minimax/MiniMax-M2.7
  • 走到对应的 provider 插件,例如 extensions/minimax/
  • provider 插件拿着你配好的 API Key 或 OAuth token 去打模型 API

关键分工可以这样理解:

组件职责
extensions/feishu/把飞书世界的消息翻译成 agent 的输入格式
OpenClaw gateway / agent挑模型、组 tool、维护 session、做 policy
extensions/minimax/用 provider 自己的协议去调模型 API

换句话说,飞书插件不懂什么是 MiniMax,MiniMax 插件也不懂什么是飞书。它们通过 gateway 的中间层解耦。


第 7 步:回复 — 从模型输出到你看到的那条消息

模型吐完结果后,就交给 reply-dispatcher

// extensions/feishu/src/reply-dispatcher.ts(节选)
export function createFeishuReplyDispatcher(params) {
  // ...
  const streamingEnabled =
    !threadReplyMode && account.config?.streaming !== false && renderMode !== "raw";
  // 根据是否开启流式卡片决定更新策略
}

根据配置,它会选一种回复方式:

  • 普通文本:一次性发一条;
  • 分段发送:超过 textChunkLimit 时拆;
  • 流式卡片:默认开启,bot 会像在“边想边说”,卡片内容逐步填充;
  • 线程回复 / reply-to:在某条消息下回复,而不是干扰正在讨论的话题;
  • 自动 @mention:如果你请求 bot 转发,bot 可以自动 @特定用户。

顺带一提:飞书卡片里你常看到的“正在输入”反应图标,就是 dispatcher 给原消息加上的一个 typing 反应,等正式内容出来再移除。

消息发出去以后,整条链路结束。


中间来一次“为什么内网也能跑”的小复盘

到这里你应该已经能回答开篇那个问题了。我们把关键点总结一下:

  • OpenClaw gateway 本身只在 127.0.0.1:18789 对本机暴露。 这只是给你浏览器 / CLI 用的,飞书根本访问不到。
  • 飞书消息进来不靠“飞书连你”,而是“你连飞书”。长连接是你本机主动建的,飞书只是借这条连接推事件。
  • 外发调用(MiniMax 等)也是从本机主动发出的,同样不需要任何反向入站权限。

所以你本机上 OpenClaw 其实同时做了两件事:

  1. 对内(本机):监听 127.0.0.1:18789,服务本地的浏览器 / CLI / UI;
  2. 对外(公网):主动发起连接,维护 OpenClaw -> 飞书云OpenClaw -> 各模型 API

两边完全分开,互不依赖。


一条消息的完整程序内部路径(代码视角)

把代码路径也画一张图,方便你自己跳源码:

flowchart TD
    S0["飞书云 WebSocket 推送"]
    S1["extensions/feishu/src/monitor.transport.ts<br/>monitorWebSocket()"]
    S2["extensions/feishu/src/monitor.account.ts<br/>EventDispatcher 分发"]

    subgraph BOT ["extensions/feishu/src/bot.ts"]
      direction TB
      B1["parseFeishuMessageEvent()<br/>结构化"]
      B2["policy.ts<br/>权限 / 群策略"]
      B3["resolveAgentRoute()<br/>会话路由"]
      B4["resolveFeishuMediaList()<br/>补媒体"]
      B5["getMessageFeishu()<br/>补引用 / thread"]
      B6["buildFeishuAgentBody()<br/>拼 prompt"]
      B1 --> B2 --> B3 --> B4 --> B5 --> B6
    end

    S3["gateway / agent<br/>(src/ 下的核心调度)"]
    S4["extensions/minimax/ 等 provider 插件<br/>调模型 API"]
    S5["extensions/feishu/src/reply-dispatcher.ts<br/>createFeishuReplyDispatcher()"]
    S6["飞书 Open API(发消息 / 更新卡片)"]
    S7["你在飞书看到回复"]

    S0 --> S1 --> S2 --> B1
    B6 --> S3 --> S4 --> S5 --> S6 --> S7

总结

回到最开始的问题:当你给飞书里的 OpenClaw 发一条消息后发生了什么?

可以用 7 个阶段概括:

  1. 飞书云通过一条已经由 OpenClaw 主动建立的 WebSocket 长连接,把事件推回本机;
  2. 插件把原始事件结构化,特别是判断 mentionedBot、剥掉 bot 自己的 mention;
  3. 根据 dmPolicy / groupPolicy / requireMention权限和触发判断,不是所有消息都会进模型;
  4. 根据聊天类型、thread、动态 agent 等做会话路由,算出 sessionKey
  5. 补齐引用消息、图片文件、线程历史、群内背景发言等上下文;
  6. 把拼好的 payload 交给 agent + provider 插件,让模型生成回复;
  7. 通过 reply-dispatcher 把回复流式 / 分段 / 线程回复回发给飞书云,最后推给你。

而所有这些能在内网本机运行,最根本的原因只有一个:

OpenClaw 用的是“本机主动向外建连”的模型,而不是“飞书主动打进你本机”的模型。

这一条也适用于仓库里的大部分其它 channel(Slack、Discord、Telegram 等),逻辑都是类似的:先连出去,再接收推送。如果你以后想自己写一个 channel 插件,按照这张流程图去套 extensions/<channel>/ 的目录结构,大部分骨架其实都能直接复用。


相关源码速查表

方便你自己 clone 仓库跳代码:

功能相对路径
飞书 WebSocket / Webhook 传输层extensions/feishu/src/monitor.transport.ts
事件分发extensions/feishu/src/monitor.account.ts
消息主处理逻辑extensions/feishu/src/bot.ts
权限 / 群策略extensions/feishu/src/policy.ts
回复分发 / 流式卡片extensions/feishu/src/reply-dispatcher.ts
飞书客户端 / SDK 封装extensions/feishu/src/client.ts
飞书通道官方文档docs/channels/feishu.md
MiniMax provider 插件extensions/minimax/

如果你顺着这张表把一条消息从头走到尾,对 OpenClaw 的 channel 架构基本就通了。