问题背景
飞书平台有一个限制:bot 无法看到其他 bot 发送的消息。因此在群聊中,当 Bot A 发送包含 @Bot B 的消息时,Feishu 不会向 Bot B 推送 im.message.receive_v1 事件,Bot B 也就无法感知自己被 @。
解决方案概述
在同一个 OpenClaw 进程内,利用 bot 注册表 + 出站拦截 + 合成事件的模式,绕过飞书平台的限制:
Bot A 的 AI 生成包含 @Bot B 的回复
│
▼
reply-dispatcher 提取 <at> 标签为 MentionInfo[]
│
▼
sendMessageFeishu 发送消息后,检测 mentions 中是否有 bot
│
▼
triggerBotToBotMessage 构造合成 FeishuMessageEvent
│
▼
直接调用 Bot B 的 im.message.receive_v1 handler
│
▼
Bot B 像收到真实消息一样处理并回复
五个阶段详解
阶段 1:Bot 注册表
问题: 运行时需要知道哪些 open_id 是 bot,以及每个 bot 的 WebSocket handlers 在哪里。
实现: 在 LarkClient 类上添加两个静态 Map:
// accountId -> botOpenId
private static _botOpenIdRegistry: Map<string, string> = new Map();
// accountId -> { 'im.message.receive_v1': (data) => Promise<void>, ... }
private static _handlersRegistry: Map<string, Record<string, (data: unknown) => Promise<void>>> = new Map();
注册时机:
lark-client.ts的probe()获取到botOpenId后调用LarkClient.registerBotOpenId()monitor.ts的monitorSingleAccount()在startWS()前调用LarkClient.registerBotHandlers()
阶段 2:Mention 提取
问题: AI 生成的文本中包含 <at user_id="ou_xxx">name</at> 标签,但这是纯文本,不是结构化的 MentionInfo[]。
实现: 在 mention.ts 中新增 extractAtMentionsFromText():
export function extractAtMentionsFromText(text: string): MentionInfo[] {
const regex = /<at\s+user_id="([^"]+)">([^<]*)<\/at>/g;
// ... 解析为 MentionInfo[]
}
调用点:reply-dispatcher.ts 的 deliver 回调中,解析一次后传递给所有 sendMessageFeishu 和 sendMarkdownCardFeishu 调用。
同时在 send.ts 中加了去重逻辑——如果文本中已有内联 <at> 标签,就不再重复 prepend mentions。
阶段 3:出站拦截
问题: sendMessageFeishu 需要在消息成功发送后,检查 mentions 中是否有其他 bot。
实现: 在 sendMessageFeishu 的两个返回路径(reply 和 create)中,都加了跨 bot 检测:
if (mentions && mentions.length > 0) {
const mentionedBotOpenIds = mentions
.filter((m) => isBotOpenId(m.openId))
.map((m) => m.openId);
if (mentionedBotOpenIds.length > 0) {
void triggerBotToBotMessage({ ... }).catch(...);
}
}
关键:void + .catch() 表示异步触发、不阻塞主流程。
阶段 4:合成事件
问题: Bot B 的 handler 期望收到标准的 FeishuMessageEvent。
实现: createSyntheticMessageEvent() 构造一个模拟真实 WebSocket 事件的对象:
{
sender: {
sender_id: { open_id: senderBotOpenId },
sender_type: 'app',
},
message: {
message_id: messageId, // 真实飞书消息 ID(用于 reply)
chat_id: chatId,
chat_type: 'group',
message_type: messageType,
content,
create_time: Date.now().toString(), // 毫秒级时间戳
mentions: [{ key, id: { open_id: targetBotOpenId }, name }],
},
}
阶段 5:事件校验放行
问题: event-handlers.ts 的 isEventOwnershipValid 会校验 app_id,合成事件没有合法 app_id。
实现: 以 synthetic_om_ 前缀作为放行标记(真实飞书消息 ID 不会以这个前缀开头)。
合成事件踩坑实录
在实现合成事件的过程中,遇到了三个紧密相关的坑。这三个问题环环相扣,每一个都会导致跨Bot流程失败。
时间戳格式错误导致消息过期
现象: 合成消息被判定为 expired,日志显示 message synthetic_om_xxx expired, discarding。
原因: 构造合成事件时,create_time 用了 Math.floor(Date.now() / 1000)(秒级时间戳),但 isMessageExpired() 函数期望的是毫秒级时间戳。
差值约为 1.77 万亿毫秒,远超 30 分钟的过期阈值。
修复: 改为 Date.now().toString() 直接返回毫秒级时间戳。
mentions 数组指向错误
现象: 目标 Bot 收到合成事件后,日志显示 rejected: no bot mention in group,拒绝处理消息。
原因: 合成事件的 mentions 数组错误地指向了 senderBotOpenId(发送者 Bot)。但 handler 的逻辑是检查"有没有 @ 我(当前 Bot)",所以目标 Bot 认为自己没有被 @。
修复: 把 mentions 中的 open_id 从 senderBotOpenId 改为 targetBotOpenId(被 @ 的目标 Bot)。
合成消息 ID 无法用于回复
现象: 目标 Bot 生成了回复内容,但调用飞书 API 发送时失败:not a valid {open_message_id}, Invalid ids: [synthetic_om_xxx]。
原因: 目标 Bot 的 replyToMessageId 是合成的假 ID(synthetic_om_xxx),飞书 API 不认识这个 ID,无法将其作为回复目标。
修复: 将发送 Bot 成功发送消息后返回的真实飞书消息 ID(result.messageId)传入合成事件,替代合成的假 ID。这样目标 Bot 回复的是一个真实存在的消息,飞书 API 就能正常处理了。
小结: 合成事件需要模拟真实飞书消息的所有关键字段——时间戳格式要精确匹配(毫秒级)、mentions 数组要指向正确的目标、消息 ID 必须使用真实的飞书消息 ID。
让 Agent 学会 @ 其他 Bot
前五个阶段解决了 Bot 之间的消息传递问题,但还有一个关键问题:Agent 怎么知道有哪些 Bot 可以 @?
问题分析
Agent 在生成回复时,需要知道当前系统注册了哪些 Bot,以及每个 Bot 的 open_id。否则即使 Agent 想要 @ 另一个 Bot,也无法知道正确的 user_id 参数。
解决方案
添加 feishu_list_bots 工具,让 Agent 可以查询当前注册的所有 Bot 身份。
实现: 新增 src/tools/list-bots.ts
export function registerListBotsTool(api: OpenClawPluginApi): void {
api.registerTool({
name: 'feishu_list_bots',
label: 'List Feishu Bots',
description:
'List all registered Feishu bot identities (open_id, name, account). ' +
'You can direct another bot to work by @mentioning it in your reply using `<at user_id="open_id">name</at>`.',
parameters: Type.Object({}),
async execute() {
const openIdMap = LarkClient.getAllBotOpenIds();
const bots = [];
for (const [accountId, openId] of openIdMap.entries()) {
const account = getLarkAccount(cfg, accountId);
const name = LarkClient.getBotName(accountId) ?? account.name ?? accountId;
bots.push({ accountId, openId, name });
}
return formatToolResult({ bots });
},
});
}
关键设计:
-
工具描述即文档:在 description 中直接告诉 Agent 如何使用
<at user_id="open_id">name</at>格式进行 @ 提及。 -
Bot 名称注册:在
LarkClient中新增_botNameRegistry,在 probe 成功后保存 Bot 名称:private static _botNameRegistry: Map<string, string> = new Map(); static getBotName(accountId: string): string | undefined { return LarkClient._botNameRegistry.get(accountId); } -
返回信息完整:工具返回每个 Bot 的
accountId、openId和name,Agent 拿到后可以直接使用。
使用流程
- Agent 收到用户请求,需要其他 Bot 协助
- Agent 调用
feishu_list_bots工具查询可用 Bot - 工具返回 Bot 列表,例如
{ bots: [{ accountId: "bot-a", openId: "ou_xxx", name: "助手A" }] } - Agent 在回复中使用
<at user_id="ou_xxx">助手A</at>格式 @ 目标 Bot - 后续的跨 Bot 提及流程接管,触发目标 Bot 处理