# 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 流程继续”的抽象。 ## 目标 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` 模块,定义渠道无关的数据结构。 ```rust #[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, pub body: String, pub actions: Vec, pub expires_at: Option, } #[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, } ``` `InteractionPayload` 用于 outbound 渲染,`InteractionEvent` 用于 inbound 回调。 ## OutboundMessage 扩展 短期兼容方案: - 继续使用 `OutboundMessage.metadata` 携带交互描述。 - 例如: - `interaction.kind = "approval"` - `interaction.id = ""` - `interaction.actions = ""` 长期推荐方案: ```rust pub struct OutboundMessage { pub channel: String, pub chat_id: String, pub content: String, pub reply_to: Option, pub media: Vec, pub metadata: HashMap, pub interaction: Option, } ``` 推荐长期方案。它能避免把结构化交互塞进字符串 metadata,也让每个 Channel 的 `send()` 更清晰。 ## Channel 能力声明 给 `Channel` 增加可选能力声明: ```rust #[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 实现一个渠道内的渲染函数: ```rust 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 写入: ```json { "interaction_id": "...", "action_id": "...", "action_value": "approve" } ``` 需要兼容飞书 Card 2.0 回调路径: - `/action/value` - `/action/behaviors/0/value` CLI 降级渲染: ```text 需要确认: Tool: bash Args: cargo test --lib 可选操作: 1. Approve 2. Deny 3. Always approve 回复: /_interaction approve /_interaction deny /_interaction 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_id`、`action_id`、`action_value`。 4. 构造 `InteractionEvent` 发布给统一处理器。 5. 对未知、过期或重复 interaction 返回成功但记录日志,不应导致渠道重连或报错。 如果短期不新增 interaction bus,可以把回调转成特殊 `InboundMessage`: ```text content = "/_interaction " metadata["event.kind"] = "interaction" metadata["interaction.id"] = "" metadata["interaction.action_id"] = "" metadata["interaction.action_value"] = "" ``` 但必须由 SessionManager 或 InteractionManager 先拦截,不能把 `/_interaction` 当普通用户文本直接送进 LLM。 长期推荐新增 bus 通道: ```rust pub enum InboundEvent { Message(InboundMessage), Interaction(InteractionEvent), } ``` 或者在 `MessageBus` 上增加 `interaction_tx`。 ## InteractionManager 建议新增 `InteractionManager`,集中管理 pending 交互状态,而不是让每个 Channel 各自维护。 职责: - 生成 `interaction_id`。 - 保存 pending interaction。 - 处理超时和过期。 - 接收 `InteractionEvent` 并解析成业务结果。 - 对重复点击、未知 interaction、过期 interaction 做幂等处理。 - 必要时通知 channel 更新原消息。 内部状态示例: ```rust pub struct PendingInteraction { pub id: String, pub kind: InteractionKind, pub channel: String, pub chat_id: String, pub sender_id: Option, pub session_id: Option, pub created_at: i64, pub expires_at: Option, pub status: InteractionStatus, pub responder: InteractionResponder, pub message_ref: Option, } pub struct InteractionMessageRef { pub channel: String, pub chat_id: String, pub message_id: String, pub metadata: HashMap, } ``` `InteractionResponder` 可以先支持 oneshot: ```rust pub enum InteractionResponder { Approval(tokio::sync::oneshot::Sender), 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 建议: ```rust approve -> ApprovalDecision::Approve deny -> ApprovalDecision::Deny always -> ApprovalDecision::AlwaysApprove ``` `DenyWithEdit` 可后续支持,适合 ACP/Web/CLI 这类能输入文本的渠道。 ## 快捷回复流程 快捷回复不是阻塞工具执行,而是把用户点击转成新的用户输入。 示例: ```json { "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,防止重复点击竞态。 ## 与现有架构的关系 现有数据流: ```text Channel -> MessageBus -> SessionManager -> AgentLoop -> tools -> SessionManager -> MessageBus -> OutboundDispatcher -> Channel ``` 加入 interaction 后建议: ```text 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`,或短期使用 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.trigger` payload。 ### 阶段 3:InteractionManager 和审批 - 新增内存版 `InteractionManager`。 - 支持 request/resolve/timeout。 - 接入工具执行前审批点。 - 支持 Approve/Deny/AlwaysApprove。 - 未知、过期、重复点击保持幂等。 - 增加 agent/tool 审批单元测试。 ### 阶段 4:其他渠道兼容 - CLI chat 支持 `/_interaction ` 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 流式输出是否要与交互卡片统一,还是继续保持普通回复卡片和交互卡片两套路径?