两个 Hermes 机器人在同一个飞书群里协作,关键不是“看见对方的名字”,而是让发送方使用接收方自己确认过的 bot open_id 发出真正的 @,再由接收方按 mention 策略放行。


飞书里的四类身份

飞书消息事件中常见的身份字段有 open_iduser_idunion_id,机器人还多了一层应用身份:

身份 作用域 典型形式 适合用途
app_id 应用 cli_xxx 标识应用和申请 tenant access token,不是群消息中的 @ 目标
open_id 应用 ou_xxx 同一主体在不同应用下可能不同,消息事件和 @ 提及中最常用
user_id 租户 u_xxx 企业租户内的用户身份,是否返回取决于权限和事件配置
union_id 开发者 on_xxx 同一开发者主体下跨应用关联用户,适合做跨应用身份归并

open_id 的“应用作用域”是 A2A 最容易踩坑的地方。同一个人经过两个飞书应用观察,得到的 open_id 可能不同;机器人同样不能把 app_id、群成员接口中的某个 ID 和 mention 事件里的 open_id 淡化成一个概念。

飞书群里的 bot-to-bot @ 本质上是跨应用身份传递:发送方机器人属于应用 A,接收方机器人属于应用 B。应用 A 必须在消息内容里写入飞书能够解析为目标机器人的身份,而应用 B 收到事件后,还要判断 mentions[] 中的身份是否确实指向自己。


/bot/v3/info 为什么重要

每个 Hermes bot 都应该用自己的 FEISHU_APP_IDFEISHU_APP_SECRET 请求:

1
GET /open-apis/bot/v3/info

Hermes Feishu adapter 中的 probe_bot() 会优先通过飞书 SDK 调用该接口,SDK 不可用时再走原始 HTTP。接口成功后,adapter 从响应中提取:

1
2
3
4
{
"bot_name": "目标机器人名称",
"bot_open_id": "ou_xxx"
}

这里的 bot_open_id 不是 app_id。它是机器人自己的 app-scoped open_id,也是飞书在群消息 mentions[].id.open_id 中表示“@ 到这个机器人”时使用的身份。

Hermes 启动连接时还会执行 _hydrate_bot_identity()。它请求同一个接口,用实时结果更新 _bot_open_id_bot_name。即使 .env 中已有 FEISHU_BOT_OPEN_ID,只要接口返回了不同的 open_id,adapter 也会采用实时值,避免机器人迁移或重建应用后继续拿旧 ID 做 mention 判断。

因此,A2A 中最可靠的身份来源不是猜测,也不是让其他机器人自行换算,而是目标 bot 用自己的应用凭据查询后主动公布的 self bot open_id。


每个 Agent 如何查询自己的 open_id

每个 Hermes Agent 都应从当前 Profile 独立查询自己的 open_id,具体步骤如下:

  1. 从当前 Profile 的 .env 读取 FEISHU_APP_IDFEISHU_APP_SECRET
  2. 调用 Hermes 飞书适配器 hermes-agent/plugins/platforms/feishu/adapter.py 中的 probe_bot()
  3. probe_bot() 实际请求飞书 API:/open-apis/bot/v3/info
  4. 从实时响应中取得 bot_namebot_open_id,作为当前 Agent 对外公布的身份信息。

probe_bot() 会优先使用飞书 SDK,SDK 不可用时回退到原始 HTTP 请求。不要在多个 Profile 之间共用同一份查询结果,否则可能把另一个飞书应用的 open_id 当成自己。


其他机器人 @ 当前机器人时,事件如何进入 Hermes

飞书应用需要订阅消息接收事件:

1
im.message.receive_v1

接收其他机器人 @ 当前机器人的关键权限是:

1
im:message.group_at_msg.include_bot:readonly

它不是普通的群聊 @ 消息权限,而是明确包含“其他机器人 @ 当前机器人”消息的权限。应用还要具备以机器人身份发送消息所需的权限。配置完成后必须发布应用版本,否则新权限和事件订阅不会生效。

飞书官方的消息接收事件支持“其他机器人 @ 当前机器人”的场景。发送方不能只在纯文本里写 @机器人名称,而要发送带 at 标签的文本消息。最原始的频道消息内容可以写成:

1
2
3
{
"text": "<at user_id=\"ou_xxx\">P3-hermes</at> 请检查任务状态"
}

其中 ou_xxx 应替换成目标 bot 自己公布的 bot_open_id。标签中的显示名称方便人阅读,真正用于寻址的是 ID。

实测中,目标 bot 自己通过 /bot/v3/info 查询到的 self open_id 可以触发 @;群成员机器人列表返回的 bot_idmember_id 也可能触发 @;随机伪造的 ou_xxx 则不能触发。这说明飞书会校验 ID 是否对应真实、可解析的群成员机器人,而不是只看 at 标签中的显示文本。这也解释了为什么来自两个接口、看起来不同的机器人 ID 都可能有效。不过从来源明确性和长期稳定性考虑,A2A 仍应优先使用目标 bot 自己公布的 self bot_open_id

这件事不需要额外 CLI。Hermes 原始频道发送消息能力只要能提交上述飞书文本内容,就可以完成 A2A @;接收端仍通过正常的飞书消息事件进入 Gateway。


Hermes 如何判断“是否 @ 到自己”

Hermes Feishu adapter 的处理分为身份补全、准入和 mention 匹配三步。

_hydrate_bot_identity() 在连接阶段请求 /open-apis/bot/v3/info,得到当前机器人的 _bot_open_id_bot_name。这是后续判断的身份基准。

消息到达后,_admit() 先识别发送者是不是 bot:

  • FEISHU_ALLOW_BOTS=none 时,所有其他 bot 消息都会被忽略,这是默认值。
  • FEISHU_ALLOW_BOTS=mentions 时,只接收明确 @ 当前 Hermes 的 bot 消息。
  • FEISHU_ALLOW_BOTS=all 时,其他 bot 消息即使没有 @ 也可进入处理链路。
  • 如果自身 ID 或发送方 ID 尚未识别,adapter 会拒绝消息,而不是冒险按名称放行。
  • adapter 还会比较发送方身份和自身身份,防止把自己的消息当作新的入站任务。

当策略要求 @ 时,_mentions_self() 检查消息中的 mention:

  • 优先比较 mentions[].id.open_id_bot_open_id
  • 在双方都提供 user_id 时,也可以比较 _bot_user_id
  • ID 已存在但不相等时,不会再因为机器人重名而按名称误判。
  • 只有一侧缺少 ID 时,才会回退到名称匹配。
  • adapter 也兼容 @_all 和经过消息标准化后的 mention 结果。

这套顺序很重要:ID 匹配优先,名称只作降级方案。群里出现两个同名机器人时,单靠显示名称无法安全区分它们。


推荐的 A2A 配置

A2A 的关键不是“发送方是谁”,而是“发送方要 @ 谁”。发送方无需先查询自己的 open_id,最重要的是拿到目标 bot 自己公布的 self bot_open_id;接收方则要在启动时通过 /bot/v3/info hydrate 自己的 _bot_open_id,并启用 FEISHU_ALLOW_BOTS=mentions

接收方 Hermes 建议使用下面的策略:

1
FEISHU_ALLOW_BOTS=mentions

如果应用事件使用 sender_id_type=user_id,还应按实际情况设置 FEISHU_BOT_USER_ID。正常情况下,Hermes 会在启动时自动查询自身 bot_open_id;只有网络或飞书 API 不可达、自动探测失败时,才需要手工设置:

1
2
FEISHU_BOT_OPEN_ID=ou_xxx
FEISHU_BOT_NAME=P3-hermes

一套稳定的 A2A 交互流程如下:

  • 每个 Hermes bot 启动后都用自身 app credentials 查询 /open-apis/bot/v3/info
  • bot 保存并公布查询得到的 self bot_open_id,同时标注对应的机器人名称和环境。
  • 发送方使用目标 bot 公布的 self bot_open_id 生成 <at user_id="...">...</at> 标签。
  • 接收方订阅 im.message.receive_v1,开通 im:message.group_at_msg.include_bot:readonly,并将 FEISHU_ALLOW_BOTS 设为 mentions
  • 接收方通过 _admit()_mentions_self() 确认消息来自其他 bot 且确实 @ 到自己。
  • 回复时继续显式 @ 目标 bot,并带上任务 ID、会话 ID 或幂等键,方便双方限制轮次和去重。

群成员列表中的 bot_idmember_id 可以帮助发现候选机器人,但不应当成为 A2A @ 的首选身份来源。不同接口的字段可能处在不同身份作用域,最稳妥的做法仍是让目标 bot 自己调用 /bot/v3/info,再把实时得到的 self open_id 交给发送方。


为什么不建议 FEISHU_ALLOW_BOTS=all

all 会让接收方处理群里所有 bot 消息。只要两个机器人都在自动回复,就可能形成这样的循环:

1
Bot A 发消息 → Bot B 回复 → Bot A 再回复 → Bot B 再回复

多人群里再加入第三个机器人后,循环会更难控制。因此默认使用 mentions,并同时增加以下约束:

  • 每条 A2A 消息只 @ 明确的目标 bot,不使用广播触发协作。
  • 消息携带任务 ID,并在接收端做幂等去重。
  • 为一次协作设置最大往返轮数和超时。
  • 回复内容不要无条件复述原始 at 标签。
  • 对可执行高风险操作的机器人增加群聊范围、命令和权限限制。

mentions 不是完整的防循环方案,但它把触发条件收窄到明确寻址,明显比 all 更适合作为生产环境默认值。


排查清单

A2A @ 没有触发时,可以按这条链路检查:

  • 目标 bot 调用 /open-apis/bot/v3/info 后是否拿到了非空的 bot_open_id
  • 发送方消息中的 <at user_id="..."> 是否使用了目标 bot 自己公布的 open_id,而不是 app_id
  • 接收方应用是否订阅 im.message.receive_v1,并已发布包含最新权限与事件配置的版本。
  • 接收方应用是否开通 im:message.group_at_msg.include_bot:readonly,而不是只有普通的群聊 @ 消息权限。
  • 接收方是否设置 FEISHU_ALLOW_BOTS=mentions,而不是默认的 none
  • Hermes 日志里是否出现 self_ids_unknownbot_not_mentionedbots_disabledgroup_policy_rejected 对应的拒绝原因。
  • 群聊规则是否仍要求 mention,以及该群是否被全局或单群策略禁用。
  • .env 中手工填写的 FEISHU_BOT_OPEN_ID 是否已经过期;应以启动时实时查询结果为准。

排查时不要把 FEISHU_APP_SECRET、tenant access token 或完整私密响应写进日志和文章。验证身份只需要记录机器人名称、脱敏后的 open_id 和接口是否成功。


参考资料