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:
parent
ebbf7e4036
commit
d957f9c649
@ -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)));
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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::*;
|
||||
|
||||
@ -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>;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user