更新版本号至1.1.2,优化Dockerfile中的himalaya安装方式,调整内容块构建逻辑以保留文本,增强Feishu通道的媒体处理,新增交互式消息设计文档

This commit is contained in:
xiaoxixi 2026-06-17 22:22:41 +08:00
parent e707774175
commit fe2bc3dfd3
8 changed files with 696 additions and 35 deletions

View File

@ -3,7 +3,9 @@
.gitignore
# Build artifacts
target/
target/*
!target/release/
target/release/*
!target/release/picobot
# IDE

View File

@ -1,6 +1,6 @@
[package]
name = "picobot"
version = "1.1.0"
version = "1.1.2"
edition = "2024"
[dependencies]

View File

@ -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 | \

View File

@ -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<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>"`
长期推荐方案:
```rust
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` 增加可选能力声明:
```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 <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_id``action_id``action_value`
4. 构造 `InteractionEvent` 发布给统一处理器。
5. 对未知、过期或重复 interaction 返回成功但记录日志,不应导致渠道重连或报错。
如果短期不新增 interaction bus可以把回调转成特殊 `InboundMessage`
```text
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 通道:
```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<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
```rust
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 建议:
```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<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 流式输出是否要与交互卡片统一,还是继续保持普通回复卡片和交互卡片两套路径?

View File

@ -28,6 +28,10 @@ fn build_content_blocks(
) -> Vec<ContentBlock> {
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(),
}],
&[],
&registry,
);
assert!(matches!(blocks.first(), Some(ContentBlock::Text { text }) if text == "先看这段文字"));
assert!(matches!(blocks.get(1), Some(ContentBlock::Text { text }) if text.contains("用户发来了一个文件")));
}
}
#[derive(Debug)]

View File

@ -165,7 +165,7 @@ struct ParsedMessage {
open_id: String,
chat_id: String,
content: String,
media: Option<MediaItem>,
media: Vec<MediaItem>,
/// ID of the message this message is replying to (if any).
/// Used to fetch quoted message content for display.
parent_id: Option<String>,
@ -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<MediaItem>), ChannelError> {
) -> Result<(String, Vec<MediaItem>), ChannelError> {
let (text, media) = match msg_type {
"text" => {
let text = if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(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::<serde_json::Value>(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::<serde_json::Value>(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<String>) {
@ -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<String> {
fn visit(value: &serde_json::Value, keys: &mut Vec<String>) {
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::<serde_json::Value>(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<MediaItem>), ChannelError> {
let parsed = match serde_json::from_str::<serde_json::Value>(content) {

View File

@ -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 {

View File

@ -150,13 +150,20 @@ struct OpenAIChoice {
message: OpenAIMessage,
}
fn null_or_missing_tool_calls<'de, D>(deserializer: D) -> Result<Vec<OpenAIToolCall>, D::Error>
where
D: serde::Deserializer<'de>,
{
Ok(Option::<Vec<OpenAIToolCall>>::deserialize(deserializer)?.unwrap_or_default())
}
#[derive(Deserialize)]
struct OpenAIMessage {
#[serde(default)]
content: Option<String>,
#[serde(default)]
reasoning_content: Option<String>,
#[serde(default)]
#[serde(default, deserialize_with = "null_or_missing_tool_calls")]
tool_calls: Vec<OpenAIToolCall>,
}
@ -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);
}
}