From 09dd15f5573d3cc51a4e9c10714fdef9c7d685d2 Mon Sep 17 00:00:00 2001 From: ooodc <549496103@qq.com> Date: Sun, 14 Jun 2026 14:15:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(feishu):=20=E6=94=AF=E6=8C=81=E5=BC=95?= =?UTF-8?q?=E7=94=A8=E5=9B=9E=E5=A4=8D=E6=B6=88=E6=81=AF=20-=20=E4=BD=BF?= =?UTF-8?q?=E7=94=A8=E9=A3=9E=E4=B9=A6=20reply=20API=20(POST=20/im/v1/mess?= =?UTF-8?q?ages/{id}/reply)=20-=20=E4=BB=85=20AssistantResponse=20?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=E5=9B=9E=E5=A4=8D=E6=8E=A5=E5=8F=A3=EF=BC=8C?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E6=B6=88=E6=81=AF=E8=B5=B0=E6=99=AE=E9=80=9A?= =?UTF-8?q?=E5=8F=91=E9=80=81=20-=20=E6=8F=90=E5=8F=96=E5=85=AC=E5=85=B1?= =?UTF-8?q?=E9=80=BB=E8=BE=91=E5=88=B0=20prepare=5Fpayload=20/=20check=5Fs?= =?UTF-8?q?end=5Fresponse=20-=20=E6=96=B0=E5=A2=9E=20reply=5Fto=5Ffeishu?= =?UTF-8?q?=5Fmessage=20/=20dispatch=5Fsend=20=E6=96=B9=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/channels/feishu.rs | 129 +++++++++++++++++++++++++++++++---------- 1 file changed, 97 insertions(+), 32 deletions(-) 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 }