From d957f9c64957bc89fcb1e673f3ff4f2545da77ef Mon Sep 17 00:00:00 2001 From: xiaoski Date: Wed, 13 May 2026 12:06:55 +0800 Subject: [PATCH] feat(send_message): support file sending via Feishu; fix upload endpoint and message format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OutboundMessenger trait: add media: Vec param to send_message() - send_message tool: add optional 'files' param with path-to-MediaItem conversion - SessionManager::send_message(): forward media to OutboundMessage - Feishu: fix image upload endpoint (/im/v1/images/upload → /im/v1/images) - Feishu: fix post image tag (image → img) - Feishu: fix file upload file_type mapping (use valid Feishu types) - Feishu: send files as separate messages (file/audio/media msg_type), not embedded in post - Feishu: add debug logging for upload/send responses - Skip [message from] prefix for pure file messages to same session --- src/channels/feishu.rs | 151 ++++++++++++++++++++++++-------------- src/session/session.rs | 14 +++- src/tools/send_message.rs | 55 ++++++++++++-- src/tools/traits.rs | 3 +- 4 files changed, 160 insertions(+), 63 deletions(-) diff --git a/src/channels/feishu.rs b/src/channels/feishu.rs index c6bb47e..232186f 100644 --- a/src/channels/feishu.rs +++ b/src/channels/feishu.rs @@ -481,13 +481,18 @@ impl FeishuChannel { .part("image", part); let resp = self.http_client - .post(format!("{}/im/v1/images/upload", FEISHU_API_BASE)) + .post(format!("{}/im/v1/images", FEISHU_API_BASE)) .header("Authorization", format!("Bearer {}", token)) .multipart(form) .send() .await .map_err(|e| ChannelError::ConnectionError(format!("Upload image HTTP error: {}", e)))?; + let status = resp.status(); + let body_text = resp.text().await + .map_err(|e| ChannelError::Other(format!("Failed to read upload response: {}", e)))?; + tracing::debug!(status = %status, body = %body_text, "Feishu upload image"); + #[derive(Deserialize)] struct UploadResp { code: i32, @@ -499,8 +504,8 @@ impl FeishuChannel { image_key: String, } - let result: UploadResp = resp.json().await - .map_err(|e| ChannelError::Other(format!("Parse upload response error: {}", e)))?; + let result: UploadResp = serde_json::from_str(&body_text) + .map_err(|e| ChannelError::Other(format!("Parse upload response error: {} | body: {}", e, &body_text)))?; if result.code != 0 { return Err(ChannelError::Other(format!( @@ -531,10 +536,13 @@ impl FeishuChannel { .to_lowercase(); let file_type = match extension.as_str() { - "mp3" | "m4a" | "wav" | "ogg" => "audio", - "mp4" | "mov" | "avi" | "mkv" => "video", - "pdf" | "doc" | "docx" | "xls" | "xlsx" => "doc", - _ => "file", + "opus" => "opus", + "mp4" | "mov" | "avi" | "mkv" => "mp4", + "pdf" => "pdf", + "doc" | "docx" => "doc", + "xls" | "xlsx" => "xls", + "ppt" | "pptx" => "ppt", + _ => "stream", }; let file_data = tokio::fs::read(file_path).await @@ -558,6 +566,11 @@ impl FeishuChannel { .await .map_err(|e| ChannelError::ConnectionError(format!("Upload file HTTP error: {}", e)))?; + let status = resp.status(); + let body_text = resp.text().await + .map_err(|e| ChannelError::Other(format!("Failed to read upload response: {}", e)))?; + tracing::debug!(status = %status, body = %body_text, "Feishu upload file"); + #[derive(Deserialize)] struct UploadResp { code: i32, @@ -569,8 +582,8 @@ impl FeishuChannel { file_key: String, } - let result: UploadResp = resp.json().await - .map_err(|e| ChannelError::Other(format!("Parse upload response error: {}", e)))?; + let result: UploadResp = serde_json::from_str(&body_text) + .map_err(|e| ChannelError::Other(format!("Parse upload response error: {} | body: {}", e, &body_text)))?; if result.code != 0 { return Err(ChannelError::Other(format!( @@ -2135,10 +2148,44 @@ impl Channel for FeishuChannel { // Handle multimodal message - send with media let token = self.get_tenant_access_token().await?; - // Build content with media references + // Separate images (can embed in post) from files (sent as separate messages) + let mut image_items = Vec::new(); + let mut file_items = Vec::new(); + for media_item in &msg.media { + match media_item.media_type.as_str() { + "image" => image_items.push(media_item), + "audio" | "video" | "file" => file_items.push(media_item), + _ => { + tracing::warn!(media_type = %media_item.media_type, "Unsupported media type for sending"); + } + } + } + + // Upload and send files as separate messages (one per file) + for item in &file_items { + match self.upload_file(&item.path).await { + Ok(file_key) => { + let file_msg_type = match item.media_type.as_str() { + "audio" => "audio", + "video" => "media", + _ => "file", + }; + let file_content = serde_json::json!({"file_key": file_key}).to_string(); + if let Err(e) = self.send_message_to_feishu( + receive_id, receive_id_type, file_msg_type, &file_content, + ).await { + tracing::warn!(error = %e, msg_type = file_msg_type, "Failed to send file message"); + } + } + Err(e) => { + tracing::warn!(error = %e, path = %item.path, "Failed to upload file"); + } + } + } + + // Build content parts for post (text + images) let mut content_parts = Vec::new(); - // Add text content if present (truncate if too long for Feishu) if !msg.content.is_empty() { const MAX_TEXT_LENGTH: usize = 60_000; let truncated_text = if msg.content.len() > MAX_TEXT_LENGTH { @@ -2152,61 +2199,52 @@ impl Channel for FeishuChannel { })); } - // Upload and add media - for media_item in &msg.media { - let path = &media_item.path; - match media_item.media_type.as_str() { - "image" => { - match self.upload_image(path).await { - Ok(image_key) => { - content_parts.push(serde_json::json!({ - "tag": "image", - "image_key": image_key - })); - } - Err(e) => { - tracing::warn!(error = %e, path = %path, "Failed to upload image"); - } - } + for item in &image_items { + match self.upload_image(&item.path).await { + Ok(image_key) => { + content_parts.push(serde_json::json!({ + "tag": "img", + "image_key": image_key + })); } - "audio" | "file" | "video" => { - match self.upload_file(path).await { - Ok(file_key) => { - content_parts.push(serde_json::json!({ - "tag": "file", - "file_key": file_key - })); - } - Err(e) => { - tracing::warn!(error = %e, path = %path, "Failed to upload file"); - } - } - } - _ => { - tracing::warn!(media_type = %media_item.media_type, "Unsupported media type for sending"); + Err(e) => { + tracing::warn!(error = %e, path = %item.path, "Failed to upload image"); } } } - // If no content parts after processing, just send empty text + // If no post content after processing (no text, no images), skip if content_parts.is_empty() { - let result = self.send_message_to_feishu(receive_id, receive_id_type, "text", "").await; - // Remove pending reaction after sending (using metadata propagated from inbound) self.remove_reaction_from_metadata(&msg.metadata).await; - return result; + return Ok(()); } - // Determine message type - let has_image = msg.media.iter().any(|m| m.media_type == "image"); - let msg_type = if has_image && msg.content.is_empty() { + // Determine message type and build content + let msg_type = if msg.content.is_empty() && image_items.len() == 1 { "image" } else { "post" }; - let content = serde_json::json!({ - "content": content_parts - }).to_string(); + let content = if msg_type == "image" { + // Image-only: content is just {"image_key": "..."} + let image_key = content_parts[0]["image_key"] + .as_str() + .unwrap_or(""); + serde_json::json!({"image_key": image_key}).to_string() + } else { + // Post with media: zh_cn wrapped post structure + let post_content: Vec> = content_parts + .into_iter() + .map(|part| vec![part]) + .collect(); + serde_json::json!({ + "zh_cn": { + "title": "", + "content": post_content + } + }).to_string() + }; let resp = self.http_client .post(format!("{}/im/v1/messages?receive_id_type={}", FEISHU_API_BASE, receive_id_type)) @@ -2221,14 +2259,19 @@ impl Channel for FeishuChannel { .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 = resp.json().await - .map_err(|e| ChannelError::Other(format!("Parse send response error: {}", e)))?; + 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))); diff --git a/src/session/session.rs b/src/session/session.rs index b144cd1..9cd174c 100644 --- a/src/session/session.rs +++ b/src/session/session.rs @@ -1540,6 +1540,7 @@ impl OutboundMessenger for SessionManager { dialog_id: Option<&str>, content: &str, mut source: MessageSource, + media: Vec, ) -> Result<(), String> { // Fill origin from current source session if not provided if source.from_session.is_none() { @@ -1560,10 +1561,17 @@ impl OutboundMessenger for SessionManager { }; // Build message prefix: [message from ] + // Skip prefix for pure file messages to the same session (no cross-session redirect). let origin = source.from_session.as_deref().unwrap_or("unknown"); let origin_id = source.from_session.clone(); - let prefix = format!("[message from {}] ", origin); - let marked_content = format!("{}\n{}", prefix, content); + let same_session = source.from_session.as_deref() + .map(|src| src == target_sid.to_string().as_str()) + .unwrap_or(false); + let marked_content = if content.trim().is_empty() && !media.is_empty() && same_session { + String::new() + } else { + format!("[message from {}] \n{}", origin, content) + }; // Write source-tagged assistant message to target session history { @@ -1588,7 +1596,7 @@ impl OutboundMessenger for SessionManager { chat_id: chat_id.to_string(), content: marked_content, reply_to: None, - media: vec![], + media, metadata: HashMap::new(), }; self.bus.publish_outbound(outbound).await diff --git a/src/tools/send_message.rs b/src/tools/send_message.rs index 91647e0..f16e82b 100644 --- a/src/tools/send_message.rs +++ b/src/tools/send_message.rs @@ -2,8 +2,9 @@ use std::sync::Arc; use std::collections::HashSet; use async_trait::async_trait; +use mime_guess::mime; -use crate::bus::{MessageSource, SourceKind}; +use crate::bus::{MediaItem, MessageSource, SourceKind}; use super::traits::{OutboundMessenger, Tool, ToolResult}; @@ -56,9 +57,10 @@ impl Tool for SendMessageTool { } fn description(&self) -> &str { - "向指定渠道的会话发送消息。用于在用户请求下向其他渠道发送内容。\ + "向指定渠道的会话发送消息,可附带文件。用于在用户请求下向其他渠道发送内容。\ target_chat_id 支持两种格式::(发送到该聊天下最新活跃会话)\ -或 ::(发送到指定会话,过期则自动激活)" +或 ::(发送到指定会话,过期则自动激活)。\ +如需发送文件,使用 files 参数指定文件路径列表。" } fn parameters_schema(&self) -> serde_json::Value { @@ -76,6 +78,11 @@ target_chat_id 支持两种格式::(发送到该聊天下 "origin": { "type": "string", "description": "可选。消息来源标识。不填则自动使用当前会话的完整 session_id (::)" + }, + "files": { + "type": "array", + "items": { "type": "string" }, + "description": "可选。要发送的文件路径列表,支持绝对路径和工作区相对路径" } }, "required": ["target_chat_id", "content"] @@ -118,9 +125,12 @@ target_chat_id 支持两种格式::(发送到该聊天下 task_id: None, }; - // 3. Send via messenger + // 3. Parse files into MediaItems + let media = parse_files_arg(&args); + + // 4. Send via messenger match self.messenger - .send_message(channel, chat_id, dialog_id, content, source) + .send_message(channel, chat_id, dialog_id, content, source, media) .await { Ok(()) => Ok(ToolResult { @@ -137,6 +147,41 @@ target_chat_id 支持两种格式::(发送到该聊天下 } } +/// Parse the "files" argument into a Vec, auto-detecting media type +/// from file extension using mime_guess. +fn parse_files_arg(args: &serde_json::Value) -> Vec { + let files = match args.get("files").and_then(|v| v.as_array()) { + Some(arr) => arr, + None => return Vec::new(), + }; + + files + .iter() + .filter_map(|v| v.as_str()) + .map(|path| path_to_media_item(path)) + .collect() +} + +/// Convert a file path to a MediaItem, detecting media_type and mime_type. +fn path_to_media_item(path: &str) -> MediaItem { + let mime = mime_guess::from_path(path).first_or_octet_stream(); + let media_type = if mime.type_() == mime::IMAGE { + "image" + } else if mime.type_() == mime::AUDIO { + "audio" + } else if mime.type_() == mime::VIDEO { + "video" + } else { + "file" + }; + MediaItem { + path: path.to_string(), + media_type: media_type.to_string(), + mime_type: Some(mime.to_string()), + original_key: None, + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/tools/traits.rs b/src/tools/traits.rs index 6ac5770..584054c 100644 --- a/src/tools/traits.rs +++ b/src/tools/traits.rs @@ -1,5 +1,5 @@ use async_trait::async_trait; -use crate::bus::MessageSource; +use crate::bus::{MediaItem, MessageSource}; #[derive(Debug, Clone)] pub struct ToolResult { @@ -40,5 +40,6 @@ pub trait OutboundMessenger: Send + Sync { dialog_id: Option<&str>, content: &str, source: MessageSource, + media: Vec, ) -> Result<(), String>; }