fix(feishu): preserve reply and thread semantics

This commit is contained in:
xiaoxixi 2026-07-19 15:52:53 +08:00
parent 0512d91729
commit 7e53fa658b
3 changed files with 254 additions and 151 deletions

View File

@ -243,7 +243,7 @@ WebUI/TUI 上传文件默认保存到 `~/.picobot/media/cli_chat`,单文件上
| `cli_chat` | Ratatui 终端客户端,通过 WebSocket 连接 Gateway | | `cli_chat` | Ratatui 终端客户端,通过 WebSocket 连接 Gateway |
| `feishu` | 飞书/Lark 消息、反应、文件上传下载和媒体引用 | | `feishu` | 飞书/Lark 消息、反应、文件上传下载和媒体引用 |
飞书默认只接受 `allow_from` 中的用户,且群聊消息必须明确 @ 机器人(可通过 `channels.feishu.require_mention=false` 关闭)。默认只发送终态结果;设置 `channels.feishu.live_updates=true` 后会创建一张卡片并持续编辑,`live_update_interval_ms` 默认 500ms运行时限制在 2505000ms。外部渠道始终不会收到模型 reasoning。 飞书默认只接受 `allow_from` 中的用户,且群聊消息必须明确 @ 机器人(可通过 `channels.feishu.require_mention=false` 关闭)。回复会使用飞书原生引用/话题语义保持在原消息位置。默认只发送终态结果;设置 `channels.feishu.live_updates=true` 后会创建一张卡片并持续编辑,`live_update_interval_ms` 默认 500ms运行时限制在 2505000ms。外部渠道始终不会收到模型 reasoning。
### 会话 ### 会话

View File

@ -125,7 +125,7 @@ sequenceDiagram
- 每个 session 有一条容量为 32 的队列,同一 session 串行处理,不同 session 的 worker 可并发执行。 - 每个 session 有一条容量为 32 的队列,同一 session 串行处理,不同 session 的 worker 可并发执行。
- 队列满时明确拒绝新消息,不允许无界积压。 - 队列满时明确拒绝新消息,不允许无界积压。
- Slash command 不进入 Agent 队列,由 `SessionManager` 直接执行,因此 `/stop` 等控制操作不会排在长模型调用之后。 - Slash command 不进入 Agent 队列,由 `SessionManager` 直接执行,因此 `/stop` 等控制操作不会排在长模型调用之后。
- `InboundMessage` 只保存规范化输入:`sender_id``received_at`、媒体和一个 `ChannelContext`。核心只解释其中的 `reply_to`reaction/message ID 等平台字段作为 `private` 不透明传到对应 Turn/普通回复,不能散落为核心层 magic key。持久化的用户消息保留真实接收时间和 `UserInput` 来源,客户端历史投影不暴露来源中的平台用户 ID。 - `InboundMessage` 只保存规范化输入:`sender_id``received_at`、媒体和一个 `ChannelContext`。核心只解释其中的 `reply_to`,其语义是本轮出站应回复的当前入站消息被用户引用的父消息只用于补充模型上下文。reaction/message ID、话题 root/thread 等平台字段作为 `private` 不透明传到对应 Turn/普通回复,不能散落为核心层 magic key。持久化的用户消息保留真实接收时间和 `UserInput` 来源,客户端历史投影不暴露来源中的平台用户 ID。
- Session 为每个 Agent 请求创建一个 `TurnController`。Provider 向 AgentLoop 发 deltaAgentLoop 发结构化 TurnEvent只有 TurnController 能把事件归约为有序 block 和单调 revision 的完整快照。 - Session 为每个 Agent 请求创建一个 `TurnController`。Provider 向 AgentLoop 发 deltaAgentLoop 发结构化 TurnEvent只有 TurnController 能把事件归约为有序 block 和单调 revision 的完整快照。
- Turn 快照经 Tokio `watch` 发布,语义为 latest-wins慢客户端或慢渠道跳过中间状态不反压 Provider。终态明确编码在快照中不依赖 sender 关闭。 - Turn 快照经 Tokio `watch` 发布,语义为 latest-wins慢客户端或慢渠道跳过中间状态不反压 Provider。终态明确编码在快照中不依赖 sender 关闭。
- Agent 本轮消息原子持久化成功后才发布 `Completed`。取消或失败若已有可见正文,则保存为 `cancelled`/`interrupted` partial只有 reasoning 时不创建 assistant 历史。 - Agent 本轮消息原子持久化成功后才发布 `Completed`。取消或失败若已有可见正文,则保存为 `cancelled`/`interrupted` partial只有 reasoning 时不创建 assistant 历史。
@ -140,7 +140,7 @@ sequenceDiagram
- `TurnDeliveryService` 返回可等待的终态句柄sink 生命周期启动不等于终态已送达。Session 在终态重试最终失败时通过普通出站路径兜底一次。 - `TurnDeliveryService` 返回可等待的终态句柄sink 生命周期启动不等于终态已送达。Session 在终态重试最终失败时通过普通出站路径兜底一次。
- `cli_chat` 将同一 `turn_updated` 快照发给 TUI 和 WebUI。客户端只保留当前 session 中 revision 更新的 `active_turn`,终态随后由持久化历史校准。 - `cli_chat` 将同一 `turn_updated` 快照发给 TUI 和 WebUI。客户端只保留当前 session 中 revision 更新的 `active_turn`,终态随后由持久化历史校准。
- 飞书对每个 DATA 帧先在 2 秒硬期限内 ACK再进行有界分片重组并把完整事件交给容量 32 的连接内处理队列;媒体下载和引用查询不占用正常的 WebSocket 读循环。队列饱和时当前事件在连接任务中同步处理而不丢弃。连接异常采用有上限的指数退避持续重连,不因累计故障永久停止。 - 飞书对每个 DATA 帧先在 2 秒硬期限内 ACK再进行有界分片重组并把完整事件交给容量 32 的连接内处理队列;媒体下载和引用查询不占用正常的 WebSocket 读循环。队列饱和时当前事件在连接任务中同步处理而不丢弃。连接异常采用有上限的指数退避持续重连,不因累计故障永久停止。
- 飞书在协议解析阶段按 `allow_from` 拒绝未授权用户;群聊默认必须明确 @ 运行时解析出的机器人身份,身份解析失败时安全地忽略群消息。飞书默认 `FinalOnly`;开启 `live_updates`第一个可见快照创建卡片后续编辑同一卡片终态编辑失败则发送完整结果兜底。reaction 清理在 finish、abort 和 Gateway shutdown 中幂等执行。 - 飞书在协议解析阶段按 `allow_from` 拒绝未授权用户;群聊默认必须明确 @ 运行时解析出的机器人身份,身份解析失败时安全地忽略群消息。飞书把当前消息 ID 作为 `reply_to`,并在私有 metadata 中携带 root/thread 信息Sink 使用原生 reply API 及 `reply_in_thread` 保持客户端引用和话题位置。飞书默认 `FinalOnly`;开启 `live_updates`第一个可见快照创建卡片后续编辑同一卡片终态编辑失败则发送完整结果兜底。reaction 清理在 finish、abort 和 Gateway shutdown 中幂等执行。
- DeliveryCoordinator 与 OutboundDispatcher 共享 `(channel, chat_id)` 写锁,避免活动 Turn 终态与独立消息并发写入同一目标。 - DeliveryCoordinator 与 OutboundDispatcher 共享 `(channel, chat_id)` 写锁,避免活动 Turn 终态与独立消息并发写入同一目标。
### 出站投递 ### 出站投递

View File

@ -159,6 +159,10 @@ struct LarkMessage {
#[serde(default)] #[serde(default)]
parent_id: Option<String>, parent_id: Option<String>,
#[serde(default)] #[serde(default)]
root_id: Option<String>,
#[serde(default)]
thread_id: Option<String>,
#[serde(default)]
mentions: Vec<serde_json::Value>, mentions: Vec<serde_json::Value>,
} }
@ -197,6 +201,77 @@ struct ParsedMessage {
/// ID of the message this message is replying to (if any). /// ID of the message this message is replying to (if any).
/// Used to fetch quoted message content for display. /// Used to fetch quoted message content for display.
parent_id: Option<String>, parent_id: Option<String>,
root_id: Option<String>,
thread_id: Option<String>,
chat_type: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct FeishuSendTarget {
receive_id: String,
receive_id_type: &'static str,
reply_to: Option<String>,
reply_in_thread: bool,
}
impl FeishuSendTarget {
fn from_message(
chat_id: String,
reply_to: Option<String>,
metadata: &HashMap<String, String>,
) -> Self {
let thread_id = metadata
.get("feishu.thread_id")
.filter(|value| !value.is_empty());
if reply_to.is_none()
&& let Some(thread_id) = thread_id
{
return Self {
receive_id: thread_id.clone(),
receive_id_type: "thread_id",
reply_to: None,
reply_in_thread: false,
};
}
let receive_id_type = if chat_id.starts_with("oc_") {
"chat_id"
} else {
"open_id"
};
Self {
receive_id: chat_id,
receive_id_type,
reply_to,
reply_in_thread: thread_id.is_some(),
}
}
fn request(&self, msg_type: &str, content: &str) -> (String, serde_json::Value) {
let uuid = uuid::Uuid::new_v4().to_string();
if let Some(message_id) = &self.reply_to {
return (
format!("{}/im/v1/messages/{message_id}/reply", FEISHU_API_BASE),
serde_json::json!({
"msg_type": msg_type,
"content": content,
"reply_in_thread": self.reply_in_thread,
"uuid": uuid
}),
);
}
(
format!(
"{}/im/v1/messages?receive_id_type={}",
FEISHU_API_BASE, self.receive_id_type
),
serde_json::json!({
"receive_id": self.receive_id,
"msg_type": msg_type,
"content": content,
"uuid": uuid
}),
)
}
} }
impl FeishuChannel { impl FeishuChannel {
@ -733,7 +808,10 @@ impl FeishuChannel {
/// Add a reaction emoji to a message and store the reaction_id for later removal. /// Add a reaction emoji to a message and store the reaction_id for later removal.
/// Returns the reaction_id if successful, None otherwise. /// Returns the reaction_id if successful, None otherwise.
async fn add_reaction(&self, message_id: &str) -> Result<Option<String>, ChannelError> { async fn add_reaction(&self, message_id: &str) -> Result<Option<String>, ChannelError> {
let emoji = self.config.reaction_emoji.as_str(); let emoji = self.config.reaction_emoji.trim();
if emoji.is_empty() {
return Ok(None);
}
let token = self.get_tenant_access_token().await?; let token = self.get_tenant_access_token().await?;
let resp = self let resp = self
@ -923,7 +1001,10 @@ impl FeishuChannel {
} }
let text = if text.len() > Self::REPLY_CONTEXT_MAX_LEN { let text = if text.len() > Self::REPLY_CONTEXT_MAX_LEN {
format!("{}...", &text[..Self::REPLY_CONTEXT_MAX_LEN]) format!(
"{}...",
&text[..text.floor_char_boundary(Self::REPLY_CONTEXT_MAX_LEN)]
)
} else { } else {
text text
}; };
@ -931,30 +1012,22 @@ impl FeishuChannel {
Some(format!("[Reply to: {}]", text)) Some(format!("[Reply to: {}]", text))
} }
/// Send a message to Feishu chat with specified message type and content. /// Deliver one native Feishu message using create or reply semantics.
/// Content is passed as-is (already a JSON string for file/media, or plain text for fallback). async fn send_raw_message(
async fn send_message_to_feishu(
&self, &self,
receive_id: &str, target: &FeishuSendTarget,
receive_id_type: &str,
msg_type: &str, msg_type: &str,
content: &str, content: &str,
) -> Result<(), ChannelError> { ) -> Result<String, ChannelError> {
let token = self.get_tenant_access_token().await?; let token = self.get_tenant_access_token().await?;
let (url, body) = target.request(msg_type, content);
let resp = self let resp = self
.http_client .http_client
.post(format!( .post(url)
"{}/im/v1/messages?receive_id_type={}",
FEISHU_API_BASE, receive_id_type
))
.header("Content-Type", "application/json") .header("Content-Type", "application/json")
.header("Authorization", format!("Bearer {}", token)) .header("Authorization", format!("Bearer {}", token))
.json(&serde_json::json!({ .json(&body)
"receive_id": receive_id,
"msg_type": msg_type,
"content": content
}))
.send() .send()
.await .await
.map_err(|e| { .map_err(|e| {
@ -965,6 +1038,11 @@ impl FeishuChannel {
struct SendResp { struct SendResp {
code: i32, code: i32,
msg: String, msg: String,
data: Option<SendData>,
}
#[derive(Deserialize)]
struct SendData {
message_id: String,
} }
let send_resp: SendResp = resp let send_resp: SendResp = resp
@ -972,6 +1050,42 @@ impl FeishuChannel {
.await .await
.map_err(|e| ChannelError::Other(format!("Parse send response error: {}", e)))?; .map_err(|e| ChannelError::Other(format!("Parse send response error: {}", e)))?;
if matches!(send_resp.code, 230011 | 231003)
&& target.reply_to.is_some()
&& !target.reply_in_thread
{
tracing::warn!(
code = send_resp.code,
"Feishu reply target is unavailable; falling back to a new chat message"
);
let mut create_target = target.clone();
create_target.reply_to = None;
let (url, body) = create_target.request(msg_type, content);
let response = self
.http_client
.post(url)
.bearer_auth(self.get_tenant_access_token().await?)
.json(&body)
.send()
.await
.map_err(|error| {
ChannelError::ConnectionError(format!(
"Fallback send message HTTP error: {error}"
))
})?;
let fallback: SendResp = response.json().await.map_err(|error| {
ChannelError::Other(format!("Parse fallback send response error: {error}"))
})?;
if fallback.code != 0 {
return Err(ChannelError::Other(format!(
"Fallback send failed: code={} msg={}",
fallback.code, fallback.msg
)));
}
return Ok(fallback
.data
.map_or_else(String::new, |data| data.message_id));
}
if send_resp.code != 0 { if send_resp.code != 0 {
return Err(ChannelError::Other(format!( return Err(ChannelError::Other(format!(
"Send message failed: code={} msg={}", "Send message failed: code={} msg={}",
@ -979,7 +1093,9 @@ impl FeishuChannel {
))); )));
} }
Ok(()) Ok(send_resp
.data
.map_or_else(String::new, |data| data.message_id))
} }
/// Extract service_id from WebSocket URL query params /// Extract service_id from WebSocket URL query params
@ -1069,6 +1185,9 @@ impl FeishuChannel {
let msg_type = msg.message_type.as_str(); let msg_type = msg.message_type.as_str();
let raw_content = msg.content.clone(); let raw_content = msg.content.clone();
let parent_id = msg.parent_id.clone(); let parent_id = msg.parent_id.clone();
let root_id = msg.root_id.clone();
let thread_id = msg.thread_id.clone().or_else(|| root_id.clone());
let chat_type = msg.chat_type.clone();
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
tracing::debug!(msg_type = %msg_type, chat_id = %chat_id, open_id = %open_id, "Parsing message content"); tracing::debug!(msg_type = %msg_type, chat_id = %chat_id, open_id = %open_id, "Parsing message content");
@ -1101,6 +1220,9 @@ impl FeishuChannel {
content, content,
media, media,
parent_id, parent_id,
root_id,
thread_id,
chat_type,
})) }))
} }
@ -1244,7 +1366,17 @@ impl FeishuChannel {
}; };
let mut private_context = HashMap::new(); let mut private_context = HashMap::new();
private_context.insert("feishu.message_id".to_string(), message_id); private_context.insert("feishu.message_id".to_string(), message_id.clone());
private_context.insert("feishu.chat_type".to_string(), parsed.chat_type);
if let Some(parent_id) = parsed.parent_id.as_ref() {
private_context.insert("feishu.parent_id".to_string(), parent_id.clone());
}
if let Some(root_id) = parsed.root_id {
private_context.insert("feishu.root_id".to_string(), root_id);
}
if let Some(thread_id) = parsed.thread_id {
private_context.insert("feishu.thread_id".to_string(), thread_id);
}
if let Some(reaction_id) = reaction_id { if let Some(reaction_id) = reaction_id {
private_context.insert("feishu.reaction_id".to_string(), reaction_id); private_context.insert("feishu.reaction_id".to_string(), reaction_id);
} }
@ -1257,7 +1389,7 @@ impl FeishuChannel {
received_at: crate::bus::message::current_timestamp(), received_at: crate::bus::message::current_timestamp(),
media: parsed.media, media: parsed.media,
channel_context: crate::bus::ChannelContext { channel_context: crate::bus::ChannelContext {
reply_to: parsed.parent_id, reply_to: Some(message_id),
private: private_context, private: private_context,
}, },
}; };
@ -2144,59 +2276,19 @@ impl FeishuChannel {
/// Send an interactive card message to Feishu. /// Send an interactive card message to Feishu.
async fn send_interactive_card( async fn send_interactive_card(
&self, &self,
receive_id: &str, target: &FeishuSendTarget,
receive_id_type: &str,
card_content: &str, card_content: &str,
) -> Result<String, ChannelError> { ) -> Result<String, ChannelError> {
let token = self.get_tenant_access_token().await?; let message_id = self
.send_raw_message(target, "interactive", card_content)
let resp = self .await?;
.http_client if message_id.is_empty() {
.post(format!( Err(ChannelError::Other(
"{}/im/v1/messages?receive_id_type={}", "Feishu send response has no message_id".into(),
FEISHU_API_BASE, receive_id_type
)) ))
.header("Content-Type", "application/json") } else {
.header("Authorization", format!("Bearer {}", token)) Ok(message_id)
.json(&serde_json::json!({
"receive_id": receive_id,
"msg_type": "interactive",
"content": card_content
}))
.send()
.await
.map_err(|e| {
ChannelError::ConnectionError(format!("Send interactive card HTTP error: {}", e))
})?;
#[derive(Deserialize)]
struct SendResp {
code: i32,
msg: String,
data: Option<SendData>,
} }
#[derive(Deserialize)]
struct SendData {
message_id: String,
}
let send_resp: SendResp = resp.json().await.map_err(|e| {
ChannelError::Other(format!("Parse send interactive card response error: {}", e))
})?;
if send_resp.code != 0 {
return Err(ChannelError::Other(format!(
"Send interactive card failed: code={} msg={}",
send_resp.code, send_resp.msg
)));
}
send_resp
.data
.map(|data| data.message_id)
.filter(|message_id| !message_id.is_empty())
.ok_or_else(|| ChannelError::Other("Feishu send response has no message_id".into()))
} }
async fn update_interactive_card( async fn update_interactive_card(
@ -2246,8 +2338,7 @@ trait FeishuTurnApi: Send {
struct FeishuTurnBackend { struct FeishuTurnBackend {
channel: FeishuChannel, channel: FeishuChannel,
receive_id: String, target: FeishuSendTarget,
receive_id_type: &'static str,
metadata: HashMap<String, String>, metadata: HashMap<String, String>,
} }
@ -2256,7 +2347,7 @@ impl FeishuTurnApi for FeishuTurnBackend {
async fn create_card(&mut self, markdown: &str) -> Result<String, ChannelError> { async fn create_card(&mut self, markdown: &str) -> Result<String, ChannelError> {
let card = FeishuChannel::build_card_content(markdown); let card = FeishuChannel::build_card_content(markdown);
self.channel self.channel
.send_interactive_card(&self.receive_id, self.receive_id_type, &card) .send_interactive_card(&self.target, &card)
.await .await
} }
@ -2587,30 +2678,21 @@ impl Channel for FeishuChannel {
} }
async fn open_turn(&self, target: TurnTarget) -> Result<Box<dyn TurnSink>, ChannelError> { async fn open_turn(&self, target: TurnTarget) -> Result<Box<dyn TurnSink>, ChannelError> {
let (receive_id, receive_id_type) = if target.chat_id.starts_with("oc_") { let send_target =
(target.chat_id, "chat_id") FeishuSendTarget::from_message(target.chat_id, target.reply_to, &target.metadata);
} else {
(target.reply_to.unwrap_or(target.chat_id), "open_id")
};
Ok(Box::new(FeishuTurnSink::new(Box::new(FeishuTurnBackend { Ok(Box::new(FeishuTurnSink::new(Box::new(FeishuTurnBackend {
channel: self.clone(), channel: self.clone(),
receive_id, target: send_target,
receive_id_type,
metadata: target.metadata, metadata: target.metadata,
})))) }))))
} }
async fn send(&self, msg: OutboundMessage) -> Result<(), ChannelError> { async fn send(&self, msg: OutboundMessage) -> Result<(), ChannelError> {
let receive_id = if msg.chat_id.starts_with("oc_") { let target = FeishuSendTarget::from_message(
&msg.chat_id msg.chat_id.clone(),
} else { msg.reply_to.clone(),
msg.reply_to.as_ref().unwrap_or(&msg.chat_id) &msg.metadata,
}; );
let receive_id_type = if msg.chat_id.starts_with("oc_") {
"chat_id"
} else {
"open_id"
};
// If no media, send as interactive card with raw markdown // If no media, send as interactive card with raw markdown
if msg.media.is_empty() { if msg.media.is_empty() {
@ -2625,15 +2707,13 @@ impl Channel for FeishuChannel {
let chunks = Self::split_markdown_chunks(content); let chunks = Self::split_markdown_chunks(content);
for chunk in &chunks { for chunk in &chunks {
let card = Self::build_card_content(chunk); let card = Self::build_card_content(chunk);
if let Err(e) = self if let Err(e) = self.send_interactive_card(&target, &card).await {
.send_interactive_card(receive_id, receive_id_type, &card)
.await
{
tracing::warn!(error = %e, "Failed to send interactive card, falling back to text"); tracing::warn!(error = %e, "Failed to send interactive card, falling back to text");
let text_content = serde_json::json!({ "text": chunk }).to_string(); let text_content = serde_json::json!({ "text": chunk }).to_string();
let result = self let result = self
.send_message_to_feishu(receive_id, receive_id_type, "text", &text_content) .send_raw_message(&target, "text", &text_content)
.await; .await
.map(|_| ());
self.remove_reaction_from_metadata(&msg.metadata).await; self.remove_reaction_from_metadata(&msg.metadata).await;
return result; return result;
} }
@ -2643,8 +2723,6 @@ impl Channel for FeishuChannel {
} }
// Handle multimodal message - send with media // Handle multimodal message - send with media
let token = self.get_tenant_access_token().await?;
// Separate images (can embed in post) from files (sent as separate messages) // Separate images (can embed in post) from files (sent as separate messages)
let mut image_items = Vec::new(); let mut image_items = Vec::new();
let mut file_items = Vec::new(); let mut file_items = Vec::new();
@ -2669,12 +2747,7 @@ impl Channel for FeishuChannel {
}; };
let file_content = serde_json::json!({"file_key": file_key}).to_string(); let file_content = serde_json::json!({"file_key": file_key}).to_string();
if let Err(e) = self if let Err(e) = self
.send_message_to_feishu( .send_raw_message(&target, file_msg_type, &file_content)
receive_id,
receive_id_type,
file_msg_type,
&file_content,
)
.await .await
{ {
tracing::warn!(error = %e, msg_type = file_msg_type, "Failed to send file message"); tracing::warn!(error = %e, msg_type = file_msg_type, "Failed to send file message");
@ -2749,51 +2822,7 @@ impl Channel for FeishuChannel {
.to_string() .to_string()
}; };
let resp = self self.send_raw_message(&target, msg_type, &content).await?;
.http_client
.post(format!(
"{}/im/v1/messages?receive_id_type={}",
FEISHU_API_BASE, receive_id_type
))
.header("Content-Type", "application/json")
.header("Authorization", format!("Bearer {}", token))
.json(&serde_json::json!({
"receive_id": receive_id,
"msg_type": msg_type,
"content": content
}))
.send()
.await
.map_err(|e| {
ChannelError::ConnectionError(format!("Send multimodal message HTTP error: {}", e))
})?;
let send_status = resp.status();
let send_body = resp
.text()
.await
.map_err(|e| ChannelError::Other(format!("Failed to read send response: {}", e)))?;
tracing::debug!(status = %send_status, body = %send_body, msg_type = %msg_type, "Feishu send message");
#[derive(Deserialize)]
struct SendResp {
code: i32,
msg: String,
}
let send_resp: SendResp = serde_json::from_str(&send_body).map_err(|e| {
ChannelError::Other(format!(
"Parse send response error: {} | body: {}",
e, &send_body
))
})?;
if send_resp.code != 0 {
return Err(ChannelError::Other(format!(
"Send multimodal message failed: code={} msg={}",
send_resp.code, send_resp.msg
)));
}
// Remove pending reaction after successfully sending // Remove pending reaction after successfully sending
self.remove_reaction_from_metadata(&msg.metadata).await; self.remove_reaction_from_metadata(&msg.metadata).await;
@ -2960,6 +2989,52 @@ mod tests {
assert_eq!(parsed.content, "ask @Alice"); assert_eq!(parsed.content, "ask @Alice");
} }
#[tokio::test]
async fn inbound_context_replies_to_current_message_and_preserves_thread_metadata() {
let mut channel = test_channel();
channel.config.reaction_emoji.clear();
*channel.bot_open_id.write().await = Some("ou_bot".to_string());
let mut frame = inbound_frame(
"om_current",
"ou_user",
"group",
"@_user_1 hello",
serde_json::json!([
{"key": "@_user_1", "id": {"open_id": "ou_bot"}, "name": "PicoBot"}
]),
);
let mut payload: serde_json::Value =
serde_json::from_slice(frame.payload.as_deref().unwrap()).unwrap();
payload["event"]["message"]["root_id"] = serde_json::json!("om_root");
payload["event"]["message"]["thread_id"] = serde_json::json!("omt_thread");
frame.payload = Some(payload.to_string().into_bytes());
let bus = Arc::new(MessageBus::new(4));
channel.process_event_frame(&bus, &frame).await;
let inbound = bus.consume_inbound().await.unwrap();
assert_eq!(
inbound.channel_context.reply_to.as_deref(),
Some("om_current")
);
assert_eq!(
inbound
.channel_context
.private
.get("feishu.thread_id")
.map(String::as_str),
Some("omt_thread")
);
assert_eq!(
inbound
.channel_context
.private
.get("feishu.root_id")
.map(String::as_str),
Some("om_root")
);
}
#[test] #[test]
fn post_mentions_can_gate_group_messages_when_top_level_mentions_are_absent() { fn post_mentions_can_gate_group_messages_when_top_level_mentions_are_absent() {
let message = LarkMessage { let message = LarkMessage {
@ -2972,6 +3047,8 @@ mod tests {
}) })
.to_string(), .to_string(),
parent_id: None, parent_id: None,
root_id: None,
thread_id: None,
mentions: vec![], mentions: vec![],
}; };
@ -3056,6 +3133,32 @@ mod tests {
assert_eq!(reconnect_delay(100), Duration::from_secs(64)); assert_eq!(reconnect_delay(100), Duration::from_secs(64));
} }
#[test]
fn send_target_uses_reply_endpoint_and_thread_flag() {
let metadata = HashMap::from([("feishu.thread_id".to_string(), "omt_thread".to_string())]);
let target = FeishuSendTarget::from_message(
"oc_chat".to_string(),
Some("om_current".to_string()),
&metadata,
);
let (url, body) = target.request("interactive", "{}");
assert!(url.ends_with("/im/v1/messages/om_current/reply"));
assert_eq!(body["reply_in_thread"], true);
assert_eq!(body["msg_type"], "interactive");
assert!(body.get("receive_id").is_none());
}
#[test]
fn proactive_thread_target_creates_inside_thread() {
let metadata = HashMap::from([("feishu.thread_id".to_string(), "omt_thread".to_string())]);
let target = FeishuSendTarget::from_message("oc_chat".to_string(), None, &metadata);
let (url, body) = target.request("text", "{}");
assert!(url.ends_with("/im/v1/messages?receive_id_type=thread_id"));
assert_eq!(body["receive_id"], "omt_thread");
}
#[tokio::test] #[tokio::test]
async fn turn_sink_creates_once_updates_same_card_and_cleans_up_at_finish() { async fn turn_sink_creates_once_updates_same_card_and_cleans_up_at_finish() {
let state = Arc::new(Mutex::new(MockTurnState::default())); let state = Arc::new(Mutex::new(MockTurnState::default()));