PicoBot/docs/INTERACTIVE_CHANNEL_DESIGN.md

15 KiB
Raw Blame History

PicoBot 跨渠道交互式消息规划

规划日期2026-06-16

背景

飞书交互式卡片可以让用户直接在消息卡片上点击按钮、提交选择或触发回调。这个能力很适合用于工具调用审批、快捷回复、任务确认、表单收集等 agent 交互。

参考项目调研结果:

  • reference/zeroclaw 已实现飞书/Lark 工具审批卡片:发送 Card JSON 2.0 按钮卡片,收到 card.action.trigger 后解析 approval_iddecision,唤醒等待中的 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 流程继续”的抽象。

目标

  1. 支持飞书交互式卡片按钮回调。
  2. 设计成跨渠道能力,后续 Slack、Telegram、Discord、CLI chat 等渠道可以复用同一套交互语义。
  3. 支持渠道降级:不支持按钮的渠道也能用纯文本命令完成同样操作。
  4. 保持 PicoBot 现有边界Channel 只做收发和渠道适配SessionManager 管会话AgentLoop 执行 LLM 和工具。
  5. 为工具调用审批、快捷回复和未来表单交互预留扩展点。

非目标:

  • 本阶段不立即实现完整功能。
  • 不把飞书卡片细节泄漏到 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>;

也可以先不改 traitsend() 内部判断 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 的识别:

  1. ACK 仍要尽快发送,飞书要求 3 秒内响应。
  2. 如果 event type 是 card.action.trigger,不要走普通消息解析。
  3. 从 event payload 中解析 interaction_idaction_idaction_value
  4. 构造 InteractionEvent 发布给统一处理器。
  5. 对未知、过期或重复 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 不够,需要落库。

工具审批流程

工具审批是第一批最适合落地的交互类型。

推荐流程:

  1. AgentLoop 准备执行需要审批的工具。
  2. 调用 InteractionManager::request_approval(...)
  3. InteractionManager 创建 InteractionPayload,通过 outbound 发送到原 channel/chat。
  4. AgentLoop 等待 oneshot带 timeout。
  5. 用户在飞书卡片上点击 Approve/Deny/Always。
  6. FeishuChannel 收到 card.action.trigger,发布 InteractionEvent
  7. InteractionManager resolve pending approval。
  8. AgentLoop 收到结果,继续执行或拒绝工具。
  9. 如果 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_idchat_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/ 模块。
  • 定义 InteractionPayloadInteractionActionInteractionEvent
  • 增加 fallback text renderer。
  • OutboundMessage 增加 interaction: Option<InteractionPayload>,或短期使用 metadata。
  • 增加单元测试覆盖序列化和 fallback 文本。

阶段 2Feishu 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.trigger payload。

阶段 3InteractionManager 和审批

  • 新增内存版 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。
  • 重复点击不会重复执行工具。
  • 非触发用户点击时按权限策略处理。

开放问题

  1. 工具审批应该由 AgentLoop 直接调用 InteractionManager还是通过 SessionManager 代理?
  2. 群聊中是否只允许原始触发者审批,还是允许配置中的所有 allowed user 审批?
  3. AlwaysApprove 的作用域是本次会话、本 dialog、本 chat还是全局工具策略
  4. 是否需要第一阶段就修改 OutboundMessage 结构,还是先用 metadata 降低改动面?
  5. 飞书 CardKit 流式输出是否要与交互卡片统一,还是继续保持普通回复卡片和交互卡片两套路径?