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

问题背景

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

解决方案概述

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

less 复制代码
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:

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

typescript 复制代码
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 检测:

typescript 复制代码
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 事件的对象:

typescript 复制代码
{
  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

typescript 复制代码
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 名称:

    typescript 复制代码
    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 处理
相关推荐
2501_9209538618 小时前
工业4.0时代,制造企业精益管理咨询的标准化实施步骤
大数据·人工智能·制造
~央千澈~18 小时前
《2026鸿蒙NEXT纯血开发与AI辅助》第四章 对鸿蒙next项目结构目录详解以及实战解决一个最初的依赖安装的报错·卓伊凡
人工智能
xinlianyq18 小时前
2026企业流量破局:四大主流短视频矩阵获客系统深度解析与选型指南
人工智能·矩阵
workflower19 小时前
用硬件换时间”与“用算法降成本”之间的博弈
人工智能·算法·安全·集成测试·无人机·ai编程
Cx330❀20 小时前
一文吃透Linux System V共享内存:原理+实操+避坑指南
大数据·linux·运维·服务器·人工智能
OPHKVPS20 小时前
Anthropic 为 Claude Code 推出“自动模式”:AI 编码工具迈向更高自主性
网络·人工智能·安全·ai
Allen_LVyingbo20 小时前
斯坦福HAI官网完整版《2025 AI Index Report》全面解读
人工智能·数学建模·开源·云计算·知识图谱
金融小师妹20 小时前
基于AI通胀预期建模与能源冲击传导机制的政策分析:高频信号下的风险再评估
人工智能·svn·能源
胡摩西20 小时前
当大模型遇上毫米级定位:机器人将拥有“空间思维”?
人工智能·机器人·slam·gps·室内定位·roomaps
摇滚侠20 小时前
搭建前端开发环境 安装 nodejs 设置淘宝镜像 最简化最标准版本 不使用 NVM NVM 高版本无法安装低版本 nodejs
java·开发语言·node.js