feat(send_message): support file sending via Feishu; fix upload endpoint and message format

- OutboundMessenger trait: add media: Vec<MediaItem> 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
This commit is contained in:
xiaoski 2026-05-13 12:06:55 +08:00
parent ebbf7e4036
commit d957f9c649
4 changed files with 160 additions and 63 deletions

View File

@ -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<Vec<serde_json::Value>> = 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)));

View File

@ -1540,6 +1540,7 @@ impl OutboundMessenger for SessionManager {
dialog_id: Option<&str>,
content: &str,
mut source: MessageSource,
media: Vec<MediaItem>,
) -> 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 <origin>]
// 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

View File

@ -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 <channel>:<chat_id>\
<channel>:<chat_id>:<dialog_id>"
<channel>:<chat_id>:<dialog_id>\
使 files "
}
fn parameters_schema(&self) -> serde_json::Value {
@ -76,6 +78,11 @@ target_chat_id 支持两种格式:<channel>:<chat_id>(发送到该聊天下
"origin": {
"type": "string",
"description": "可选。消息来源标识。不填则自动使用当前会话的完整 session_id (<channel>:<chat_id>:<dialog_id>)"
},
"files": {
"type": "array",
"items": { "type": "string" },
"description": "可选。要发送的文件路径列表,支持绝对路径和工作区相对路径"
}
},
"required": ["target_chat_id", "content"]
@ -118,9 +125,12 @@ target_chat_id 支持两种格式:<channel>:<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 支持两种格式:<channel>:<chat_id>(发送到该聊天下
}
}
/// Parse the "files" argument into a Vec<MediaItem>, auto-detecting media type
/// from file extension using mime_guess.
fn parse_files_arg(args: &serde_json::Value) -> Vec<MediaItem> {
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::*;

View File

@ -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<MediaItem>,
) -> Result<(), String>;
}