openclaw-lark 的 Bot@Bot 跨Bot提及功能 - 开发经验分享

14 阅读5分钟

问题背景

飞书平台有一个限制:bot 无法看到其他 bot 发送的消息。因此在群聊中,当 Bot A 发送包含 @Bot B 的消息时,Feishu 不会向 Bot B 推送 im.message.receive_v1 事件,Bot B 也就无法感知自己被 @。

解决方案概述

在同一个 OpenClaw 进程内,利用 bot 注册表 + 出站拦截 + 合成事件的模式,绕过飞书平台的限制:

Bot AAI 生成包含 @Bot B 的回复
        │
        ▼
reply-dispatcher 提取 <at> 标签为 MentionInfo[]
        │
        ▼
sendMessageFeishu 发送消息后,检测 mentions 中是否有 bot
        │
        ▼
triggerBotToBotMessage 构造合成 FeishuMessageEvent
        │
        ▼
直接调用 Bot Bim.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.tsprobe() 获取到 botOpenId 后调用 LarkClient.registerBotOpenId()
  • monitor.tsmonitorSingleAccount()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.tsdeliver 回调中,解析一次后传递给所有 sendMessageFeishusendMarkdownCardFeishu 调用。

同时在 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.tsisEventOwnershipValid 会校验 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_idsenderBotOpenId 改为 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 成功发送消息后返回的真实飞书消息 IDresult.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 });
    },
  });
}

关键设计:

  1. 工具描述即文档:在 description 中直接告诉 Agent 如何使用 <at user_id="open_id">name</at> 格式进行 @ 提及。

  2. Bot 名称注册:在 LarkClient 中新增 _botNameRegistry,在 probe 成功后保存 Bot 名称:

    private static _botNameRegistry: Map<string, string> = new Map();
    
    static getBotName(accountId: string): string | undefined {
      return LarkClient._botNameRegistry.get(accountId);
    }
    
  3. 返回信息完整:工具返回每个 Bot 的 accountIdopenIdname,Agent 拿到后可以直接使用。

使用流程

  1. Agent 收到用户请求,需要其他 Bot 协助
  2. Agent 调用 feishu_list_bots 工具查询可用 Bot
  3. 工具返回 Bot 列表,例如 { bots: [{ accountId: "bot-a", openId: "ou_xxx", name: "助手A" }] }
  4. Agent 在回复中使用 <at user_id="ou_xxx">助手A</at> 格式 @ 目标 Bot
  5. 后续的跨 Bot 提及流程接管,触发目标 Bot 处理