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

这篇文章想探讨一个我自己在本机跑 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 插件
monitorWebSocket() 保持长连接
解析事件 / 判权限 / 会话路由"] C --> D["OpenClaw Gateway
会话 / agent / 工具调度"] D -->|公网 API 调用| E["模型(MiniMax 等)"] E -->|模型回复| F["OpenClaw reply-dispatcher
组织文本 / 卡片 / 流式"] 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 里:

ts 复制代码
// 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

ts 复制代码
// 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()

ts 复制代码
// 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

默认值来自:

ts 复制代码
// 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

典型决策逻辑:

ts 复制代码
// 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、哪个会话?"

简化后的核心几行:

ts 复制代码
// 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)

ts 复制代码
// 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 媒体(图片 / 文件)

ts 复制代码
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,大概长这样(示意):

text 复制代码
[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

ts 复制代码
// 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
monitorWebSocket()"] S2["extensions/feishu/src/monitor.account.ts
EventDispatcher 分发"] subgraph BOT ["extensions/feishu/src/bot.ts"] direction TB B1["parseFeishuMessageEvent()
结构化"] B2["policy.ts
权限 / 群策略"] B3["resolveAgentRoute()
会话路由"] B4["resolveFeishuMediaList()
补媒体"] B5["getMessageFeishu()
补引用 / thread"] B6["buildFeishuAgentBody()
拼 prompt"] B1 --> B2 --> B3 --> B4 --> B5 --> B6 end S3["gateway / agent
(src/ 下的核心调度)"] S4["extensions/minimax/ 等 provider 插件
调模型 API"] S5["extensions/feishu/src/reply-dispatcher.ts
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 架构基本就通了。

相关推荐
淡定o3 小时前
Redis List 换成 Streams,以为能睡安稳觉了——结果消息还是在丢
架构
沛沛rh454 小时前
用 Rust 实现用户态调试器:mini-debugger项目原理剖析与工程复盘
开发语言·c++·后端·架构·rust·系统架构
SamDeepThinking4 小时前
Spring AOP记录日志,生产环境的代码长什么样
java·后端·架构
陈天伟教授5 小时前
四川省中小学和职业院校教师校长省级培训专家库专家名单
人工智能·安全·架构
亚马逊云开发者6 小时前
【Bedrock AgentCore】Multi-Agent 架构实战:用 6 个 Agent 打通零售供应链数据→洞察→行动全链路
大数据·架构·零售
踩着两条虫6 小时前
VTJ:技术架构概述
前端·架构·ai编程
超级无敌攻城狮6 小时前
Agent 到底是怎么跑起来的
前端·后端·架构
无心水7 小时前
14、企业级表格|AWS Textract 扫描件表格自动结构化
架构·pdf·云计算·aws·pdf解析·pdf抽取·aws textract
预知同行7 小时前
深入解析 MCP 协议:从架构设计到生产级安全防护实战指南
架构