15 KiB
PicoBot 跨渠道交互式消息规划
规划日期:2026-06-16
背景
飞书交互式卡片可以让用户直接在消息卡片上点击按钮、提交选择或触发回调。这个能力很适合用于工具调用审批、快捷回复、任务确认、表单收集等 agent 交互。
参考项目调研结果:
reference/zeroclaw已实现飞书/Lark 工具审批卡片:发送 Card JSON 2.0 按钮卡片,收到card.action.trigger后解析approval_id和decision,唤醒等待中的 approval future,并 PATCH 原卡片为已处理状态。reference/nanobot主要使用飞书 CardKit 做 agent 输出展示和流式更新,适合参考消息渲染体验,但没有完整的按钮回调驱动 agent 流程。reference/openlark是 SDK/API 封装,支持发送 interactive card 和 CardKit API,不包含完整 agent channel 编排。
PicoBot 当前飞书渠道已经会把普通 markdown 回复发送成 interactive card,但还缺少“用户在卡片上操作 -> 统一交互事件 -> Session/Agent/Tool 流程继续”的抽象。
目标
- 支持飞书交互式卡片按钮回调。
- 设计成跨渠道能力,后续 Slack、Telegram、Discord、CLI chat 等渠道可以复用同一套交互语义。
- 支持渠道降级:不支持按钮的渠道也能用纯文本命令完成同样操作。
- 保持 PicoBot 现有边界:Channel 只做收发和渠道适配,SessionManager 管会话,AgentLoop 执行 LLM 和工具。
- 为工具调用审批、快捷回复和未来表单交互预留扩展点。
非目标:
- 本阶段不立即实现完整功能。
- 不把飞书卡片细节泄漏到 AgentLoop 或工具层。
- 不要求所有渠道同时支持原生交互组件。
核心原则
交互语义和渠道渲染分离。
Agent、工具或 Session 层只表达“我要一个 approval/quick reply/form interaction”。具体是飞书卡片按钮、Slack Block Kit、Telegram inline keyboard,还是 CLI 里显示编号选项,由 Channel 根据能力渲染。
回调也要统一。
飞书的 card.action.trigger、Telegram 的 callback_query、Slack 的 interaction payload 都应归一化成 PicoBot 内部的 InteractionEvent,再交给统一的处理器。
数据模型
建议新增一个 interaction 模块,定义渠道无关的数据结构。
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub enum InteractionKind {
QuickReply,
Approval,
FormSubmit,
Command,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub enum InteractionStyle {
Default,
Primary,
Danger,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct InteractionAction {
pub id: String,
pub label: String,
pub value: String,
pub style: InteractionStyle,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct InteractionPayload {
pub interaction_id: String,
pub kind: InteractionKind,
pub title: Option<String>,
pub body: String,
pub actions: Vec<InteractionAction>,
pub expires_at: Option<i64>,
}
#[derive(Debug, Clone)]
pub struct InteractionEvent {
pub channel: String,
pub chat_id: String,
pub sender_id: String,
pub interaction_id: String,
pub action_id: String,
pub action_value: String,
pub timestamp: i64,
pub metadata: std::collections::HashMap<String, String>,
}
InteractionPayload 用于 outbound 渲染,InteractionEvent 用于 inbound 回调。
OutboundMessage 扩展
短期兼容方案:
- 继续使用
OutboundMessage.metadata携带交互描述。 - 例如:
interaction.kind = "approval"interaction.id = "<uuid>"interaction.actions = "<json>"
长期推荐方案:
pub struct OutboundMessage {
pub channel: String,
pub chat_id: String,
pub content: String,
pub reply_to: Option<String>,
pub media: Vec<MediaItem>,
pub metadata: HashMap<String, String>,
pub interaction: Option<InteractionPayload>,
}
推荐长期方案。它能避免把结构化交互塞进字符串 metadata,也让每个 Channel 的 send() 更清晰。
Channel 能力声明
给 Channel 增加可选能力声明:
#[derive(Debug, Clone, Default)]
pub struct ChannelCapabilities {
pub interactive_buttons: bool,
pub forms: bool,
pub message_update: bool,
pub markdown_cards: bool,
}
pub trait Channel {
fn capabilities(&self) -> ChannelCapabilities {
ChannelCapabilities::default()
}
}
渠道能力示例:
| 渠道 | 原生按钮 | 表单 | 更新原消息 | 降级策略 |
|---|---|---|---|---|
| Feishu | 是,interactive card | 可后续支持 | 是,PATCH message/card | 文本命令 |
| Slack | 是,Block Kit | 是 | 是 | 文本命令 |
| Telegram | 是,inline keyboard | 有限 | 可编辑消息 | 文本命令 |
| Discord | 是,components | 有限 | 可编辑消息 | 文本命令 |
| CLI chat | 否 | 否 | 局部可模拟 | 编号/命令输入 |
| Webhook/Email/SMS | 否 | 否 | 通常否 | 纯文本命令或链接 |
渲染策略
每个 channel 实现一个渠道内的渲染函数:
async fn send_interaction(
&self,
chat_id: &str,
payload: &InteractionPayload,
) -> Result<(), ChannelError>;
也可以先不改 trait,在 send() 内部判断 msg.interaction。
飞书渲染:
- 使用 Card JSON 2.0。
schema = "2.0"。- body 用 markdown 展示
payload.body。 - actions 渲染为 button。
- 每个按钮的 callback value 写入:
{
"interaction_id": "...",
"action_id": "...",
"action_value": "approve"
}
需要兼容飞书 Card 2.0 回调路径:
/action/value/action/behaviors/0/value
CLI 降级渲染:
需要确认:
Tool: bash
Args: cargo test --lib
可选操作:
1. Approve
2. Deny
3. Always approve
回复:
/_interaction <interaction_id> approve
/_interaction <interaction_id> deny
/_interaction <interaction_id> always
纯文本渠道都可以复用这个 fallback renderer。
Inbound 回调归一化
飞书 WebSocket 当前在 src/channels/feishu.rs 里处理 im.message.receive_v1。需要新增对 card.action.trigger 的识别:
- ACK 仍要尽快发送,飞书要求 3 秒内响应。
- 如果 event type 是
card.action.trigger,不要走普通消息解析。 - 从 event payload 中解析
interaction_id、action_id、action_value。 - 构造
InteractionEvent发布给统一处理器。 - 对未知、过期或重复 interaction 返回成功但记录日志,不应导致渠道重连或报错。
如果短期不新增 interaction bus,可以把回调转成特殊 InboundMessage:
content = "/_interaction <interaction_id> <action_value>"
metadata["event.kind"] = "interaction"
metadata["interaction.id"] = "<interaction_id>"
metadata["interaction.action_id"] = "<action_id>"
metadata["interaction.action_value"] = "<action_value>"
但必须由 SessionManager 或 InteractionManager 先拦截,不能把 /_interaction 当普通用户文本直接送进 LLM。
长期推荐新增 bus 通道:
pub enum InboundEvent {
Message(InboundMessage),
Interaction(InteractionEvent),
}
或者在 MessageBus 上增加 interaction_tx。
InteractionManager
建议新增 InteractionManager,集中管理 pending 交互状态,而不是让每个 Channel 各自维护。
职责:
- 生成
interaction_id。 - 保存 pending interaction。
- 处理超时和过期。
- 接收
InteractionEvent并解析成业务结果。 - 对重复点击、未知 interaction、过期 interaction 做幂等处理。
- 必要时通知 channel 更新原消息。
内部状态示例:
pub struct PendingInteraction {
pub id: String,
pub kind: InteractionKind,
pub channel: String,
pub chat_id: String,
pub sender_id: Option<String>,
pub session_id: Option<String>,
pub created_at: i64,
pub expires_at: Option<i64>,
pub status: InteractionStatus,
pub responder: InteractionResponder,
pub message_ref: Option<InteractionMessageRef>,
}
pub struct InteractionMessageRef {
pub channel: String,
pub chat_id: String,
pub message_id: String,
pub metadata: HashMap<String, String>,
}
InteractionResponder 可以先支持 oneshot:
pub enum InteractionResponder {
Approval(tokio::sync::oneshot::Sender<ApprovalDecision>),
InboundMessage,
}
后续如果需要持久化长期交互,oneshot 不够,需要落库。
工具审批流程
工具审批是第一批最适合落地的交互类型。
推荐流程:
- AgentLoop 准备执行需要审批的工具。
- 调用
InteractionManager::request_approval(...)。 - InteractionManager 创建
InteractionPayload,通过 outbound 发送到原 channel/chat。 - AgentLoop 等待 oneshot,带 timeout。
- 用户在飞书卡片上点击 Approve/Deny/Always。
- FeishuChannel 收到
card.action.trigger,发布InteractionEvent。 - InteractionManager resolve pending approval。
- AgentLoop 收到结果,继续执行或拒绝工具。
- 如果 channel 支持更新消息,InteractionManager 或 Channel 把原卡片更新成 resolved 状态。
审批 action 建议:
approve -> ApprovalDecision::Approve
deny -> ApprovalDecision::Deny
always -> ApprovalDecision::AlwaysApprove
DenyWithEdit 可后续支持,适合 ACP/Web/CLI 这类能输入文本的渠道。
快捷回复流程
快捷回复不是阻塞工具执行,而是把用户点击转成新的用户输入。
示例:
{
"kind": "QuickReply",
"body": "你想继续哪个操作?",
"actions": [
{ "label": "继续分析", "value": "继续分析" },
{ "label": "生成报告", "value": "生成报告" }
]
}
用户点击后:
InteractionEvent.action_value转成一条普通InboundMessage.content。sender_id和chat_id保留原用户和会话。- metadata 标记来源为 interaction,供审计或 UI 使用。
消息更新
支持原消息更新的渠道应在交互完成后更新 UI,避免重复点击。
飞书:
- 发送卡片后保存
data.message_id。 - resolve 后 PATCH
/im/v1/messages/{message_id}。 - 卡片 schema 发送和更新都使用 Card JSON 2.0,参考项目指出跨版本 PATCH 可能返回成功但客户端不重渲染。
不支持更新的渠道:
- 发送一条新消息提示“已批准/已拒绝”。
- 或仅在后台幂等拒绝重复点击。
安全和权限
交互回调必须校验:
interaction_id是否存在。- 是否已过期。
- 是否已处理。
- 点击用户是否允许处理该 interaction。
- 当前 channel/chat 是否匹配。
对于工具审批,默认建议只有触发该 agent turn 的用户或允许列表用户可以审批。群聊里要特别注意 sender_id,不能只看 chat_id。
日志中避免记录原始飞书回调敏感字段:
- callback token
- operator open_id/union_id/user_id/tenant_key
- open_chat_id/open_message_id
可以记录脱敏后的 payload shape,用于排查飞书回调字段变化。
持久化策略
第一阶段可以只做内存 pending map:
- 适合短时工具审批。
- 进程重启后旧按钮点击会变成 unknown/expired。
- 实现简单。
后续如果要支持长期任务或跨重启交互,需要持久化:
interactions表保存 id、kind、channel、chat_id、sender_id、status、payload、created_at、expires_at。interaction_actions可选,或直接 JSON 存在 payload 中。- resolve 时事务更新 status,防止重复点击竞态。
与现有架构的关系
现有数据流:
Channel -> MessageBus -> SessionManager -> AgentLoop -> tools -> SessionManager -> MessageBus -> OutboundDispatcher -> Channel
加入 interaction 后建议:
Outbound:
AgentLoop/Tool approval -> InteractionManager -> MessageBus outbound -> OutboundDispatcher -> Channel renderer
Inbound:
Channel callback -> InteractionEvent -> InteractionManager -> pending waiter / synthetic InboundMessage
Channel 仍然只做渠道协议适配:
- 飞书负责 Card JSON 和
card.action.trigger。 - Slack 负责 Block Kit 和 signing secret。
- Telegram 负责 callback query。
- CLI 负责文本命令 fallback。
InteractionManager 负责语义:
- 这是 approval 还是 quick reply。
- 是否过期。
- 是否有权限。
- 应该唤醒哪个等待者。
SessionManager/AgentLoop 不需要知道飞书卡片格式。
分阶段实施计划
阶段 1:模型和 fallback
- 新增
src/interaction/模块。 - 定义
InteractionPayload、InteractionAction、InteractionEvent。 - 增加 fallback text renderer。
- 为
OutboundMessage增加interaction: Option<InteractionPayload>,或短期使用 metadata。 - 增加单元测试覆盖序列化和 fallback 文本。
阶段 2:Feishu card action
- Feishu outbound 支持把
InteractionPayload渲染为 Card JSON 2.0。 - Feishu inbound 在 WebSocket frame 中识别
card.action.trigger。 - 解析
/action/value和/action/behaviors/0/value。 - 发布统一
InteractionEvent。 - 保存
message_id,支持完成后 PATCH resolved card。 - 增加 fixtures 测试真实/模拟的
card.action.triggerpayload。
阶段 3:InteractionManager 和审批
- 新增内存版
InteractionManager。 - 支持 request/resolve/timeout。
- 接入工具执行前审批点。
- 支持 Approve/Deny/AlwaysApprove。
- 未知、过期、重复点击保持幂等。
- 增加 agent/tool 审批单元测试。
阶段 4:其他渠道兼容
- CLI chat 支持
/_interaction <id> <value>fallback。 - 其他不支持原生按钮的渠道使用纯文本 fallback。
- 后续按需实现 Slack/Telegram/Discord 原生按钮。
阶段 5:持久化和高级交互
- 需要时落库 pending interaction。
- 支持 quick reply 生成 synthetic inbound message。
- 支持表单提交。
- 支持
DenyWithEdit。 - 支持长期任务交互和重启恢复。
测试计划
单元测试:
InteractionPayload序列化。- fallback text renderer 输出。
- Feishu Card JSON 包含正确 callback value。
- Feishu 回调同时支持
/action/value和/action/behaviors/0/value。 - unknown/expired interaction 不报错。
- 重复点击只 resolve 一次。
集成测试:
- 模拟 Feishu
card.action.trigger,验证 pending approval 被唤醒。 - 模拟超时,验证默认 deny。
- CLI fallback 输入
/_interaction,验证能 resolve。 - 不支持按钮的 channel 能收到可读 fallback 文本。
手工验证:
- 飞书群聊点击 Approve/Deny/Always。
- 私聊点击。
- 点击后原卡片更新为 resolved。
- 重复点击不会重复执行工具。
- 非触发用户点击时按权限策略处理。
开放问题
- 工具审批应该由 AgentLoop 直接调用 InteractionManager,还是通过 SessionManager 代理?
- 群聊中是否只允许原始触发者审批,还是允许配置中的所有 allowed user 审批?
AlwaysApprove的作用域是本次会话、本 dialog、本 chat,还是全局工具策略?- 是否需要第一阶段就修改
OutboundMessage结构,还是先用 metadata 降低改动面? - 飞书 CardKit 流式输出是否要与交互卡片统一,还是继续保持普通回复卡片和交互卡片两套路径?