我用 100 行核心代码,做了一个能接入飞书的 Hermes 式智能体

4,748 字#rpi #Hermes #飞书 #Lark #Agent #Rust

很多人看到 Hermes Agent 的体验,第一反应是:

我也要把自己的 Agent 接进飞书。

然后就开始设计 Webhook、消息签名、事件回调、会话表、流式输出和重试队列。还没让 Agent 回第一句话,网关已经先写了一半。

其实,最简单的方式不是再造一个飞书机器人服务,而是直接让 rpi 接管这条长连接。

rpi 的 rpi-im-message 扩展已经把飞书接入层包起来:飞书消息通过官方 Rust SDK 的 WebSocket 长连接进入 rpi,rpi 负责调用 Agent,模型的最终回复再发回原来的会话。你需要配置飞书应用和模型,不需要从零实现一套消息网关。

这篇文章会分成两层:先用约 100 行代码把 Hermes 式 Agent 的核心循环讲清楚,再告诉你真正接入飞书时为什么不必自己写网关。代码用来理解原理,扩展用来减少接入成本。

先看最终效果

下面是实际运行截图。第一张用于展示 Agent 在飞书中的对话入口,第二张用于展示工具执行过程和最终回复。截图保留了手机端的完整上下文,读者可以重点看消息是否回到了原来的飞书会话,以及工具状态是否单独反馈。

rpi Agent 接入飞书后的对话截图

图 1:飞书消息进入 rpi Agent 后的实际回复。入口是飞书,任务执行仍发生在运行 rpi 的工作环境中。具体回复内容取决于当前模型、工作目录和任务。

rpi Agent 在飞书中反馈工具执行状态的截图

图 2:工具调用过程在飞书中单独反馈,最终回答完成后再发送结果。截图只证明当次运行中的显示结果,不等于完成了安全审计或性能测试。

先用 100 行代码看懂 Agent 核心

先说明代码的性质:下面不是从扩展里复制出来、可以直接编译的完整文件,而是根据 rpi-im-message 当前源码压缩出的 100 行阅读版。它保留真实的函数职责和调用顺序,省略了配置校验、ABI 字符串管理、线程同步、错误处理等外围代码。

const EVENT_TYPE_MESSAGE: &str = "im.message.receive_v1";

struct MessageHandler {
    state: Arc<RuntimeState>,
    commands: Sender<Command>,
}

impl EventHandler for MessageHandler {
    fn event_type(&self) -> &str {
        EVENT_TYPE_MESSAGE
    }

    fn handle(&self, event: Event) -> HandlerFuture {
        let state = self.state.clone();
        let commands = self.commands.clone();

        Box::pin(async move {
            let event = event.event.ok_or("empty event")?;
            let message: MessageEvent = serde_json::from_value(event)?;

            // 机器人自己的消息不能再次进入 Agent,否则会自我触发。
            if message.sender.sender_type.as_deref() == Some("app") {
                return Ok(None);
            }

            let chat_id = message.message.chat_id.clone().unwrap_or_default();
            if !state.profile.allow_chats.is_empty()
                && !state.profile.allow_chats.contains(&chat_id)
            {
                return Ok(None);
            }
            if state.profile.mention_required
                && message.message.mentions.as_ref().map_or(true, Vec::is_empty)
            {
                return Ok(None);
            }

            let text = extract_message_text(
                &message.message.message_type,
                &message.message.content.clone().unwrap_or_default(),
            );
            let message_id = message.message.message_id.clone().unwrap_or_default();

            // 先让用户知道消息已经收到。
            if state.profile.ack_reaction {
                commands.send(Command::React {
                    message_id: message_id.clone(),
                    emoji_type: random_ack_reaction(),
                })?;
            }

            // 把原始消息放入接收队列,供 receive 动作读取。
            state.push_message(json!({
                "type": "message",
                "provider": "feishu",
                "messageId": message_id,
                "conversationId": chat_id,
                "text": text,
            }));

            if state.profile.auto_reply && !text.trim().is_empty() {
                let runtime = state.host_runtime.ok_or("runtime unavailable")?;
                let chat = chat_id.clone();
                let model = state.profile.auto_reply_model.clone();
                let timeout = state.profile.auto_reply_timeout;

                tokio::spawn(async move {
                    let result = tokio::task::spawn_blocking(move || {
                        let _lock = AUTO_REPLY_LOCK.lock()?;
                        let _prompt_scope = FeishuPromptScope::enter();

                        // 优先进入当前 rpi Harness。
                        match invoke_runtime_action(
                            &runtime,
                            RuntimeActionId::SendUserMessage,
                            &json!({"text": text}),
                        ) {
                            Ok(result) => Ok(result),
                            Err(error) if error.contains("before harness was built") => {
                                // 没有 Harness 时,使用无 TUI 的 rpi 进程。
                                invoke_print_with_retry(
                                    &text, &chat, model.as_deref(), timeout,
                                )
                            }
                            Err(error) => Err(error),
                        }
                    }).await??;

                    if let Some(reply) = result["text"].as_str() {
                        commands.send(Command::SendAsync {
                            conversation_id: chat,
                            content: auto_reply_content(reply),
                        })?;
                    }
                    Ok::<_, anyhow::Error>(())
                });
            }
            Ok(None)
        })
    }
}

async fn run_server(profile: Profile) -> Result<()> {
    let client = Client::new(Config::builder(
        &profile.app_id, &profile.app_secret
    ).base_url(FEISHU_BASE_URL).build())?;

    let dispatcher = EventDispatcher::new(EventDispatcherConfig::new(), noop_logger());
    dispatcher.register_handler(Box::new(MessageHandler {
        state: state.clone(),
        commands: command_sender.clone(),
    })).await;

    let stream = client.stream()
        .stream_config(StreamConfig::new()
            .auto_reconnect(profile.auto_reconnect)
            .reconnect_interval(profile.reconnect_interval)
            .reconnect_count(-1))
        .event_dispatcher(dispatcher)
        .build()?;

    tokio::select! {
        result = stream.start() => result?,
        command = commands.recv() => handle_command(&client, command).await?,
    }
    Ok(())
}

这段代码对应的真实源码并不只做“收消息、发回复”两件事。它还处理了会话选择、工具进度、自动重连、消息队列、飞书 Markdown/Card 格式和失败重试。

但核心闭环可以压缩成一句话:

飞书事件 → MessageHandler → rpi Agent → 工具进度 → SendAsync → 飞书回复

模型并没有直接操作飞书,也没有直接读取项目文件。扩展负责把飞书事件转换成 rpi 能理解的用户消息,再把 Agent 的结果转换回飞书消息。这正是 Hermes 式智能体最关键的“渠道层 + Agent 层”分工。

源码里的真实链路比上面的 100 行更完整。可以按下面几个位置阅读:

  • MessageHandler::handle:接收 im.message.receive_v1,过滤机器人自己发出的消息、允许的群聊和是否必须 @,然后提取文本。
  • autoReply 分支:如果开启自动回复,优先通过 ABI v2 的 RuntimeActionId::SendUserMessage 把文本交给当前 rpi Agent;如果 Harness 尚未准备好,则退回到受限的 rpi --print --no-extensions 子进程。
  • Progress::event:把工具开始、成功、失败和未完成状态转换成简短的飞书文本。
  • run_server:创建飞书 SDK 客户端、注册事件分发器、建立 WebSocket 长连接,并消费发送、进度更新和停止命令。
  • send_message:根据 text、markdown 或 card 选择飞书消息格式。

这也解释了为什么文章要把“100 行核心代码”和“已有扩展”分开:前者展示 Agent 如何工作,后者解决生产接入中的长连接、队列、会话、重试和消息格式问题。

对应源码:

代码写明白了,飞书接入交给已有扩展

飞书机器人常见的接入方式有两类:Webhook 回调和长连接。

Webhook 需要你提供一个公网可访问的服务端点,处理飞书发来的 HTTP 请求,再自己维护签名校验、响应时限和异步任务。它适合已经有后端服务的团队,但对于“我只是想把 Agent 放进飞书”这个目标来说,外围工作比较多。

rpi-im-message 走的是飞书官方 SDK 支持的 WebSocket 长连接。rpi 进程主动连接飞书,不需要先部署一个公网 HTTP 接收端点,消息接收和发送都由扩展处理。

这并不意味着长连接自动解决了所有问题:进程需要持续运行,飞书应用仍然需要配置权限和事件订阅,模型调用也需要自己的凭据。但接入边界清楚了:

飞书开放平台:负责应用身份和消息权限
rpi-im-message:负责飞书长连接与消息收发
rpi Agent:负责上下文、模型和工具循环
模型服务:负责生成下一步决策和回复

第一步:准备飞书应用

登录飞书开放平台,创建一个企业自建应用,并为应用启用机器人能力。

需要准备两项信息:

  • App ID,通常形如 cli_xxx。
  • App Secret,用于应用鉴权。

然后在事件订阅中选择 长连接,订阅接收消息事件 im.message.receive_v1,并为机器人开通发送和接收消息所需的权限。

不同租户、版本和管理员策略下,权限名称可能略有差异。不要只复制一份配置文件就认为接入完成:启动 rpi 后,如果连接成功但收不到消息,优先回到飞书开放平台检查机器人是否启用、事件是否订阅,以及当前账号是否有权使用该应用。

建议先在一个测试群里验证,不要一开始就把机器人加入所有业务群。

第二步:安装 rpi 的飞书扩展

安装 rpi-im-message:

rpi install rpi-im-message

扩展的职责不是创建一个新的 Agent,而是把飞书接入到现有 rpi 运行时。它提供消息服务、状态查看、收发消息等动作;打开自动回复后,收到的文本会交给当前 rpi Agent。

如果你的环境需要固定版本,建议根据当前扩展仓库和本地 rpi 版本指定版本号,不要盲目复制旧文章里的版本。扩展仓库地址是:

https://github.com/pi-rust/rpi-package/tree/main/packages/rpi-im-message

第三步:写一份最小配置

可以把配置放在项目的 .rpi/im.json。下面是一份最小示例:

{
  "defaultProfile": "feishu-main",
  "profiles": {
    "feishu-main": {
      "provider": "feishu",
      "domain": "feishu",
      "appId": "cli_xxx",
      "appSecretEnv": "RPI_FEISHU_APP_SECRET",
      "transport": "long_connection",
      "mentionRequired": true,
      "autoReply": true,
      "autoReconnect": true
    }
  }
}

把密钥放到环境变量中:

export RPI_FEISHU_APP_SECRET='替换成飞书应用的 App Secret'

Windows PowerShell 可以这样设置:

$env:RPI_FEISHU_APP_SECRET = "替换成飞书应用的 App Secret"

不要把 appSecret 直接提交到 Git。扩展支持直接写入配置,但共享机器和团队仓库更适合使用 appSecretEnv。

配置中的几个字段分别表示:

字段作用
appId飞书应用 ID
appSecretEnv从哪个环境变量读取应用密钥
transport使用飞书长连接
mentionRequired群聊中是否必须 @ 机器人
autoReply是否把收到的消息交给 Agent 自动回复
autoReconnect长连接断开后是否自动重连

启动:让 Agent 留在飞书里工作

用无 TUI 的服务模式启动:

rpi --im-message-server

如果配置了多个飞书应用,可以指定 profile:

rpi --im-message-server --im-profile feishu-main

看到长连接建立后,把机器人加入测试群,@ 它发送一句简单的问题:

你好,请告诉我你现在能做什么?

第一轮不要直接测试复杂的代码修改。先确认三件事:

  1. 飞书应用能收到消息。
  2. rpi 能把消息交给模型。
  3. 模型回复能回到同一个飞书会话。

这三步打通后,再打开文件工具、命令工具或项目 Skills。否则遇到问题时,很难判断是飞书权限、模型配置还是工具执行失败。

它为什么有点像 Hermes

Hermes 让人印象深刻的地方,不只是“可以在聊天软件里问 AI”,而是聊天消息背后仍然是一个会执行任务的 Agent:它能调用工具,维持上下文,处理长任务,还可以从多个渠道进入同一套运行环境。

用 rpi 接入飞书,最先复用的是这条核心路径:

飞书消息
  ↓
rpi-im-message 长连接
  ↓
rpi Agent 上下文
  ↓
模型决定是否调用工具
  ↓
rpi 执行工具并回传结果
  ↓
最终回复发送回飞书

在工具执行期间,扩展还可以把工具调用状态作为普通文本消息发送,并在工具成功、失败或中断时更新状态。最终回答单独发送,避免用户只看到一条“处理中”然后不知道发生了什么。

这就是推广 rpi 时最值得讲的一点:你接入的不是一个只会回固定文本的机器人,而是一个可以沿用 rpi 工具和会话能力的 Agent 入口。

飞书里的会话怎么处理

自动回复模式下,扩展会为飞书会话维护对应的 rpi session。常用命令包括:

/new       创建并切换到一个新会话
/sessions  查看当前聊天最近的会话
/session   查看当前会话和帮助
/session ID 切换到指定会话

这让飞书不只是一个消息入口,也能成为一个轻量的 Agent 工作台:

  • 一个群聊用于项目日常问答。
  • 一个会话用于排查线上问题。
  • 另一个会话用于整理发布清单。

如果要切换模型,可以使用 rpi 支持的模型配置;如果要切换工作方式,可以通过项目里的规则文件和 Skills 约束 Agent。

但要注意,会话隔离不等于权限隔离。不同会话仍可能共享同一个工作目录和工具权限。涉及代码修改、生产文件或敏感数据时,仍然要配置允许范围和人工审批。

适合用它做什么

这个接入方式适合几类场景。

1. 团队内部的项目助手

把 Agent 放在项目群里,成员可以直接问:

  • 最近一次构建失败的原因是什么?
  • 这个模块的入口在哪里?
  • 帮我整理一下当前 TODO。
  • 读取变更记录,生成一份发布摘要。

机器人不需要把整个代码库预先塞进 prompt,而是根据任务调用读取和检查工具。

2. 远程处理长任务

Agent 在服务器上运行,开发者通过飞书发起任务、查看进度。即使人不在电脑前,也能知道它正在读取什么、执行什么、最后得到什么结果。

这也是“把 Agent 接进飞书”比“给飞书加一个问答机器人”更有价值的地方:飞书成为入口,真正的执行环境仍然在 rpi 所在的机器上。

3. 把内部流程变成 Skill

例如把发布流程写成 skills/release-checklist.md:

1. 检查工作区是否有未提交改动
2. 读取 CHANGELOG.md
3. 运行项目指定的检查命令
4. 汇总结果,等待人工确认后再发布

以后在飞书里说“按发布清单检查一下”,Agent 就可以按这套工作方法执行。

Skill 负责描述流程,工具负责真正执行,飞书只负责让人更容易发起和跟踪任务。三者不要混在一起。

这不是装完就能自动工作的魔法

为了让推广内容可信,有几件事必须说清楚。

第一,仍然需要配置飞书应用。 App ID、App Secret、机器人能力、事件订阅和消息权限缺一不可。

第二,仍然需要配置模型。 rpi-im-message 负责把消息接进来,不会替你提供模型账号,也不会让模型调用成本消失。

第三,长连接进程必须持续运行。 本地测试可以直接启动;团队使用时,需要用服务管理器、容器或其他可靠方式守护进程。

第四,插件接口不是沙箱。 如果 Agent 能读写文件或执行命令,它就拥有对应运行环境的能力。飞书群里的任何成员都不应该默认拥有生产环境操作权限。

第五,自动回复不是自动审批。 涉及删除、发布、发送外部消息等副作用操作,应在工具层增加权限判断和人工确认。

这些限制不会削弱这个方案,反而说明它的边界比较清楚:rpi 负责 Agent 运行机制,飞书负责消息入口,权限和部署由使用者决定。

结尾:真正的反差不是“代码少”

很多人想把 Agent 接进飞书,先想到的是写一个机器人服务;真正应该先问的是:消息接进来以后,谁负责上下文、工具调用、会话和取消?

如果这些能力已经在 rpi 里,飞书接入就不必重新实现一遍 Agent。安装扩展、配置应用、启动长连接,先让机器人在群里回复起来,再逐步增加工具和 Skills。

Hermes 式体验的核心并不在于界面有多复杂,而在于聊天消息背后有一个真正能工作的 Agent。

这次最简单的接入路径可以压缩成三步:

安装 rpi-im-message
→ 配置飞书 App 和长连接
→ 启动 rpi --im-message-server

如果你已经在使用 rpi,最短路径就是:

  1. 安装 rpi-im-message。
  2. 配置飞书 App ID、App Secret 和长连接事件。
  3. 打开 autoReply。
  4. 启动 rpi --im-message-server。

先在测试群里让机器人回复一句话,再逐步开放文件、命令和项目工具。这样可以先验证消息链路,再决定要不要把它变成团队里的项目助手。

参考资料