diff --git a/.dockerignore b/.dockerignore index b45bd85..0391ce5 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,7 +3,9 @@ .gitignore # Build artifacts -target/ +target/* +!target/release/ +target/release/* !target/release/picobot # IDE diff --git a/Cargo.toml b/Cargo.toml index edc3170..e59fffd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "picobot" -version = "1.1.0" +version = "1.1.2" edition = "2024" [dependencies] diff --git a/Dockerfile b/Dockerfile index c28c148..793ce15 100644 --- a/Dockerfile +++ b/Dockerfile @@ -55,11 +55,8 @@ RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ && npm cache clean --force \ && rm -rf /var/lib/apt/lists/* -# Install himalaya (CLI email client) from local file -COPY docker_build/himalaya.x86_64-linux.tgz /tmp/himalaya.tgz -RUN tar -xzf /tmp/himalaya.tgz -C /usr/local/bin \ - && chmod +x /usr/local/bin/himalaya \ - && rm -f /tmp/himalaya.tgz +# Install himalaya (CLI email client) from the official pre-built binary release +RUN curl -sSL https://raw.githubusercontent.com/pimalaya/himalaya/master/install.sh | sh # Install fd (alternative to find) RUN curl -fsSL https://github.com/sharkdp/fd/releases/download/v9.0.0/fd-v9.0.0-x86_64-unknown-linux-gnu.tar.gz | \ diff --git a/docs/INTERACTIVE_CHANNEL_DESIGN.md b/docs/INTERACTIVE_CHANNEL_DESIGN.md new file mode 100644 index 0000000..7f21db3 --- /dev/null +++ b/docs/INTERACTIVE_CHANNEL_DESIGN.md @@ -0,0 +1,496 @@ +# 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 流式输出是否要与交互卡片统一,还是继续保持普通回复卡片和交互卡片两套路径? + diff --git a/src/agent/agent_loop.rs b/src/agent/agent_loop.rs index 11c69c7..b470a27 100644 --- a/src/agent/agent_loop.rs +++ b/src/agent/agent_loop.rs @@ -28,6 +28,10 @@ fn build_content_blocks( ) -> Vec { let mut blocks = Vec::new(); + if !text.is_empty() { + blocks.push(ContentBlock::text(text)); + } + if !media_refs.is_empty() { for mr in media_refs { if input_types.contains(&mr.media_type) { @@ -59,8 +63,6 @@ fn build_content_blocks( ))); } } - } else if !text.is_empty() { - blocks.push(ContentBlock::text(text)); } if blocks.is_empty() { @@ -858,6 +860,23 @@ mod tests { "calculator" ); } + + #[test] + fn test_build_content_blocks_keeps_text_with_media() { + let registry = MediaHandlerRegistry::new(); + let blocks = build_content_blocks( + "先看这段文字", + &[MediaRef { + path: "missing.png".to_string(), + media_type: "image".to_string(), + }], + &[], + ®istry, + ); + + assert!(matches!(blocks.first(), Some(ContentBlock::Text { text }) if text == "先看这段文字")); + assert!(matches!(blocks.get(1), Some(ContentBlock::Text { text }) if text.contains("用户发来了一个文件"))); + } } #[derive(Debug)] diff --git a/src/channels/feishu.rs b/src/channels/feishu.rs index 3e24b63..0eeda51 100644 --- a/src/channels/feishu.rs +++ b/src/channels/feishu.rs @@ -165,7 +165,7 @@ struct ParsedMessage { open_id: String, chat_id: String, content: String, - media: Option, + media: Vec, /// ID of the message this message is replying to (if any). /// Used to fetch quoted message content for display. parent_id: Option, @@ -1007,7 +1007,7 @@ impl FeishuChannel { } #[cfg(debug_assertions)] - if let Some(ref m) = media { + for m in &media { tracing::debug!(media_type = %m.media_type, media_path = %m.path, "Media downloaded successfully"); } @@ -1027,7 +1027,7 @@ impl FeishuChannel { msg_type: &str, content: &str, message_id: &str, - ) -> Result<(String, Option), ChannelError> { + ) -> Result<(String, Vec), ChannelError> { let (text, media) = match msg_type { "text" => { let text = if let Ok(parsed) = serde_json::from_str::(content) { @@ -1039,20 +1039,40 @@ impl FeishuChannel { } else { content.to_string() }; - (text, None) + (text, Vec::new()) + } + "post" => { + let text = parse_post_content(content); + let mut media = Vec::new(); + + for image_key in collect_post_image_keys(content) { + let content_json = serde_json::json!({ "image_key": image_key }); + match self + .download_media("image", &content_json, message_id) + .await + { + Ok((_text, Some(item))) => media.push(item), + Ok((_text, None)) => {} + Err(e) => { + tracing::warn!(error = %e, "Failed to download image from Feishu post message"); + } + } + } + + (text, media) } - "post" => (parse_post_content(content), None), "image" | "audio" | "file" | "media" => { if let Ok(content_json) = serde_json::from_str::(content) { match self .download_media(msg_type, &content_json, message_id) .await { - Ok((text, media)) => (text, media), - Err(_) => (format!("[{}: content unavailable]", msg_type), None), + Ok((text, Some(media))) => (text, vec![media]), + Ok((text, None)) => (text, Vec::new()), + Err(_) => (format!("[{}: content unavailable]", msg_type), Vec::new()), } } else { - (format!("[{}: content unavailable]", msg_type), None) + (format!("[{}: content unavailable]", msg_type), Vec::new()) } } "share_chat" => { @@ -1062,9 +1082,9 @@ impl FeishuChannel { .get("chat_id") .and_then(|v| v.as_str()) .unwrap_or("unknown"); - (format!("[shared chat: {}]", chat_id), None) + (format!("[shared chat: {}]", chat_id), Vec::new()) } else { - ("[shared chat]".to_string(), None) + ("[shared chat]".to_string(), Vec::new()) } } "share_user" => { @@ -1074,42 +1094,44 @@ impl FeishuChannel { .get("user_id") .and_then(|v| v.as_str()) .unwrap_or("unknown"); - (format!("[shared user: {}]", user_id), None) + (format!("[shared user: {}]", user_id), Vec::new()) } else { - ("[shared user]".to_string(), None) + ("[shared user]".to_string(), Vec::new()) } } "interactive" => { // Interactive card messages - extract text content match extract_interactive_content(content) { - Ok((text, media)) => (text, media), + Ok((text, Some(media))) => (text, vec![media]), + Ok((text, None)) => (text, Vec::new()), Err(e) => { tracing::warn!(error = %e, "Failed to extract interactive content"); - (content.to_string(), None) + (content.to_string(), Vec::new()) } } } "list" => { // List/bullet messages match parse_list_content(content) { - Ok((text, media)) => (text, media), - Err(_) => (content.to_string(), None), + Ok((text, Some(media))) => (text, vec![media]), + Ok((text, None)) => (text, Vec::new()), + Err(_) => (content.to_string(), Vec::new()), } } - "merge_forward" => ("[merged forward messages]".to_string(), None), + "merge_forward" => ("[merged forward messages]".to_string(), Vec::new()), "share_calendar_event" => { if let Ok(parsed) = serde_json::from_str::(content) { let event_key = parsed .get("event_key") .and_then(|v| v.as_str()) .unwrap_or("unknown"); - (format!("[shared calendar event: {}]", event_key), None) + (format!("[shared calendar event: {}]", event_key), Vec::new()) } else { - ("[shared calendar event]".to_string(), None) + ("[shared calendar event]".to_string(), Vec::new()) } } - "system" => ("[system message]".to_string(), None), - _ => (content.to_string(), None), + "system" => ("[system message]".to_string(), Vec::new()), + _ => (content.to_string(), Vec::new()), }; // Strip @_user_N placeholders from group chat @mentions @@ -1235,16 +1257,15 @@ impl FeishuChannel { let channel = self.clone(); let bus = bus.clone(); tokio::spawn(async move { - let media_count = if parsed.media.is_some() { 1 } else { 0 }; #[cfg(debug_assertions)] - tracing::debug!(open_id = %parsed.open_id, chat_id = %parsed.chat_id, content_len = %parsed.content.len(), media_count = %media_count, "Publishing message to bus"); + tracing::debug!(open_id = %parsed.open_id, chat_id = %parsed.chat_id, content_len = %parsed.content.len(), media_count = %parsed.media.len(), "Publishing message to bus"); let msg = crate::bus::InboundMessage { channel: "feishu".to_string(), sender_id: parsed.open_id.clone(), chat_id: parsed.chat_id.clone(), content: parsed.content.clone(), timestamp: crate::bus::message::current_timestamp(), - media: parsed.media.map(|m| vec![m]).unwrap_or_default(), + media: parsed.media.clone(), metadata: std::collections::HashMap::new(), forwarded_metadata, }; @@ -1333,6 +1354,52 @@ impl FeishuChannel { } } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn collect_post_image_keys_finds_nested_images() { + let content = serde_json::json!({ + "zh_cn": { + "title": "", + "content": [[ + {"tag": "img", "image_key": "img_v3_001"}, + {"tag": "text", "text": "这是哪里?"}, + {"tag": "img", "image_key": "img_v3_002"}, + {"tag": "img", "image_key": "img_v3_001"} + ]] + } + }) + .to_string(); + + assert_eq!( + collect_post_image_keys(&content), + vec!["img_v3_001".to_string(), "img_v3_002".to_string()] + ); + } + + #[test] + fn parse_post_content_preserves_image_positions() { + let content = serde_json::json!({ + "zh_cn": { + "title": "", + "content": [[ + {"tag": "text", "text": "这是一张图:"}, + {"tag": "img", "image_key": "img_v3_001"}, + {"tag": "text", "text": "看完继续说"} + ]] + } + }) + .to_string(); + + assert_eq!( + parse_post_content(&content), + "这是一张图:[image]看完继续说" + ); + } +} + fn parse_post_content(content: &str) -> String { /// Extract text from a single post element (text, link, at-mention). fn extract_element(el: &serde_json::Value, out: &mut Vec) { @@ -1359,6 +1426,9 @@ fn parse_post_content(content: &str) -> String { .unwrap_or("user"); out.push(format!("@{}", name)); } + "img" => { + out.push("[image]".to_string()); + } "code_block" => { let lang = el.get("language").and_then(|l| l.as_str()).unwrap_or(""); let code_text = el.get("text").and_then(|t| t.as_str()).unwrap_or(""); @@ -1449,6 +1519,38 @@ fn parse_post_content(content: &str) -> String { content.to_string() } +fn collect_post_image_keys(content: &str) -> Vec { + fn visit(value: &serde_json::Value, keys: &mut Vec) { + match value { + serde_json::Value::Object(map) => { + if let Some(image_key) = map.get("image_key").and_then(|v| v.as_str()) + && !keys.iter().any(|k| k == image_key) + { + keys.push(image_key.to_string()); + } + + for child in map.values() { + visit(child, keys); + } + } + serde_json::Value::Array(items) => { + for item in items { + visit(item, keys); + } + } + _ => {} + } + } + + let Ok(parsed) = serde_json::from_str::(content) else { + return Vec::new(); + }; + + let mut keys = Vec::new(); + visit(&parsed, &mut keys); + keys +} + /// Extract text content from interactive card messages fn extract_interactive_content(content: &str) -> Result<(String, Option), ChannelError> { let parsed = match serde_json::from_str::(content) { diff --git a/src/main.rs b/src/main.rs index 23772b5..299b177 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,7 +3,7 @@ use clap::{CommandFactory, Parser}; #[derive(Parser)] #[command(name = "picobot")] #[command(about = "A CLI chatbot", long_about = None)] -#[command(version = "1.1.0")] +#[command(version = "1.1.1")] enum Command { /// Connect to gateway Chat { diff --git a/src/providers/openai.rs b/src/providers/openai.rs index b89255a..cf55fc8 100644 --- a/src/providers/openai.rs +++ b/src/providers/openai.rs @@ -150,13 +150,20 @@ struct OpenAIChoice { message: OpenAIMessage, } +fn null_or_missing_tool_calls<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + Ok(Option::>::deserialize(deserializer)?.unwrap_or_default()) +} + #[derive(Deserialize)] struct OpenAIMessage { #[serde(default)] content: Option, #[serde(default)] reasoning_content: Option, - #[serde(default)] + #[serde(default, deserialize_with = "null_or_missing_tool_calls")] tool_calls: Vec, } @@ -418,4 +425,42 @@ mod tests { "{\"expression\":\"1+1\"}" ); } + + #[test] + fn test_decode_response_accepts_null_tool_calls() { + let text = r#"{ + "id": "d21abaa6552741949e2aba76bde59359", + "choices": [{ + "finish_reason": "stop", + "index": 0, + "message": { + "content": "你好!", + "role": "assistant", + "tool_calls": null, + "reasoning_content": "The user sent a greeting." + } + }], + "created": 1781622889, + "model": "mimo-v2.5", + "object": "chat.completion", + "usage": { + "completion_tokens": 65, + "prompt_tokens": 11741, + "total_tokens": 11806, + "completion_tokens_details": {"reasoning_tokens": 40}, + "prompt_tokens_details": {} + } + }"#; + + let response: OpenAIResponse = serde_json::from_str(text).unwrap(); + let message = &response.choices[0].message; + + assert_eq!(message.content.as_deref(), Some("你好!")); + assert_eq!( + message.reasoning_content.as_deref(), + Some("The user sent a greeting.") + ); + assert!(message.tool_calls.is_empty()); + assert_eq!(response.usage.total_tokens, 11806); + } }