这篇文章想探讨一个我自己在本机跑 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;
}
这里有两个容易被忽略、但很关键的点:
-
mentionedBot会在这时就算好 后面判断“群里要不要响应”时,就不用再临时解析一次了。 -
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 其实同时做了两件事:
- 对内(本机):监听
127.0.0.1:18789,服务本地的浏览器 / CLI / UI; - 对外(公网):主动发起连接,维护
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 个阶段概括:
- 飞书云通过一条已经由 OpenClaw 主动建立的 WebSocket 长连接,把事件推回本机;
- 插件把原始事件结构化,特别是判断
mentionedBot、剥掉 bot 自己的 mention; - 根据
dmPolicy/groupPolicy/requireMention做权限和触发判断,不是所有消息都会进模型; - 根据聊天类型、thread、动态 agent 等做会话路由,算出
sessionKey; - 补齐引用消息、图片文件、线程历史、群内背景发言等上下文;
- 把拼好的 payload 交给 agent + provider 插件,让模型生成回复;
- 通过
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 架构基本就通了。