diff --git a/src/channels/feishu.rs b/src/channels/feishu.rs index 1fbbe34..6c75bf9 100644 --- a/src/channels/feishu.rs +++ b/src/channels/feishu.rs @@ -866,32 +866,7 @@ impl FeishuChannel { ) -> Result<(), ChannelError> { let token = self.get_tenant_access_token().await?; - let payload_content = if msg_type == "text" { - let truncated = if char_count(content) > self.config.max_message_chars { - format!( - "{}\n\n[Content truncated due to length limit]", - truncate_with_ellipsis(content, self.config.max_message_chars) - ) - } else { - content.to_string() - }; - serde_json::json!({ "text": truncated }).to_string() - } else { - // For post messages, content is already JSON (from markdown_to_post) - // But we still need to check length - if char_count(content) > self.config.max_message_chars { - // Fallback to truncated text for post as well - serde_json::json!({ - "text": format!( - "{}\n\n[Content truncated due to length limit]", - truncate_with_ellipsis(content, self.config.max_message_chars) - ) - }) - .to_string() - } else { - content.to_string() - } - }; + let payload_content = self.prepare_payload(msg_type, content); let resp = self .http_client @@ -912,6 +887,70 @@ impl FeishuChannel { ChannelError::ConnectionError(format!("Send message HTTP error: {}", e)) })?; + Self::check_send_response(resp).await + } + + /// Reply to a specific message using POST /im/v1/messages/{message_id}/reply + async fn reply_to_feishu_message( + &self, + reply_to_message_id: &str, + msg_type: &str, + content: &str, + ) -> Result<(), ChannelError> { + let token = self.get_tenant_access_token().await?; + + let payload_content = self.prepare_payload(msg_type, content); + + let resp = self + .http_client + .post(format!( + "{}/im/v1/messages/{}/reply", + FEISHU_API_BASE, reply_to_message_id + )) + .header("Content-Type", "application/json") + .header("Authorization", format!("Bearer {}", token)) + .json(&serde_json::json!({ + "msg_type": msg_type, + "content": payload_content + })) + .send() + .await + .map_err(|e| { + ChannelError::ConnectionError(format!("Reply message HTTP error: {}", e)) + })?; + + Self::check_send_response(resp).await + } + + /// Prepare message payload with length truncation + fn prepare_payload(&self, msg_type: &str, content: &str) -> String { + if msg_type == "text" { + let truncated = if char_count(content) > self.config.max_message_chars { + format!( + "{}\n\n[Content truncated due to length limit]", + truncate_with_ellipsis(content, self.config.max_message_chars) + ) + } else { + content.to_string() + }; + serde_json::json!({ "text": truncated }).to_string() + } else { + if char_count(content) > self.config.max_message_chars { + serde_json::json!({ + "text": format!( + "{}\n\n[Content truncated due to length limit]", + truncate_with_ellipsis(content, self.config.max_message_chars) + ) + }) + .to_string() + } else { + content.to_string() + } + } + } + + /// Check the response from Feishu send/reply API + async fn check_send_response(resp: reqwest::Response) -> Result<(), ChannelError> { #[derive(Deserialize)] struct SendResp { code: i32, @@ -933,6 +972,22 @@ impl FeishuChannel { Ok(()) } + /// Dispatch: use reply API when reply_to is set, otherwise use send API + async fn dispatch_send( + &self, + receive_id: &str, + receive_id_type: &str, + msg_type: &str, + content: &str, + reply_to: Option<&str>, + ) -> Result<(), ChannelError> { + if let Some(parent_id) = reply_to { + self.reply_to_feishu_message(parent_id, msg_type, content).await + } else { + self.send_message_to_feishu(receive_id, receive_id_type, msg_type, content).await + } + } + /// Extract service_id from WebSocket URL query params fn extract_service_id(url: &str) -> i32 { url.split('?') @@ -2412,6 +2467,13 @@ impl Channel for FeishuChannel { return Ok(()); } + // Only use reply API for assistant content messages + let reply_to = if matches!(msg.event_kind, OutboundEventKind::AssistantResponse) { + msg.metadata.get("feishu.parent_id").map(|s| s.as_str()) + } else { + None + }; + let receive_id = if msg.chat_id.starts_with("oc_") { &msg.chat_id } else { @@ -2443,7 +2505,7 @@ impl Channel for FeishuChannel { MsgFormat::Text => { // Short plain text – send as simple text message let result = self - .send_message_to_feishu(receive_id, receive_id_type, "text", content) + .dispatch_send(receive_id, receive_id_type, "text", content, reply_to) .await; remove_reaction.await; return result; @@ -2452,7 +2514,7 @@ impl Channel for FeishuChannel { // Medium content with links – send as rich-text post let post_body = Self::markdown_to_post(content); let result = self - .send_message_to_feishu(receive_id, receive_id_type, "post", &post_body) + .dispatch_send(receive_id, receive_id_type, "post", &post_body, reply_to) .await; remove_reaction.await; return result; @@ -2472,11 +2534,12 @@ impl Channel for FeishuChannel { tracing::warn!(error = %e, "Failed to send interactive card, falling back to text"); // Fallback to plain text let result = self - .send_message_to_feishu( + .dispatch_send( receive_id, receive_id_type, "text", content, + reply_to, ) .await; remove_reaction.await; @@ -2490,7 +2553,7 @@ impl Channel for FeishuChannel { } if !msg.content.trim().is_empty() { - self.send_message_to_feishu(receive_id, receive_id_type, "text", msg.content.trim()) + self.dispatch_send(receive_id, receive_id_type, "text", msg.content.trim(), reply_to) .await?; } @@ -2500,21 +2563,23 @@ impl Channel for FeishuChannel { let result = match media_item.media_type.as_str() { "image" => { let image_key = self.upload_image(path).await?; - self.send_message_to_feishu( + self.dispatch_send( receive_id, receive_id_type, "image", &serde_json::json!({ "image_key": image_key }).to_string(), + reply_to, ) .await } "audio" | "file" | "video" => { let file_key = self.upload_file(path).await?; - self.send_message_to_feishu( + self.dispatch_send( receive_id, receive_id_type, "file", &serde_json::json!({ "file_key": file_key }).to_string(), + reply_to, ) .await }