diff --git a/src/channels/feishu.rs b/src/channels/feishu.rs index 9357714..bb07dc1 100644 --- a/src/channels/feishu.rs +++ b/src/channels/feishu.rs @@ -523,7 +523,7 @@ impl FeishuChannel { 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() @@ -543,10 +543,18 @@ impl FeishuChannel { image_key: String, } - let result: UploadResp = resp - .json() - .await - .map_err(|e| ChannelError::Other(format!("Parse upload response error: {}", e)))?; + let status = resp.status(); + let body = resp.text().await.map_err(|e| { + ChannelError::Other(format!("Read upload image response error: {}", e)) + })?; + let result: UploadResp = serde_json::from_str(&body).map_err(|e| { + ChannelError::Other(format!( + "Parse upload image response error: {} (status={}, body={})", + e, + status, + truncate_with_ellipsis(&body, 500) + )) + })?; if result.code != 0 { return Err(ChannelError::Other(format!( @@ -578,10 +586,10 @@ impl FeishuChannel { .to_lowercase(); let file_type = match extension.as_str() { - "mp3" | "m4a" | "wav" | "ogg" => "audio", + "mp3" | "m4a" | "wav" | "ogg" | "opus" => "opus", "mp4" | "mov" | "avi" | "mkv" => "video", "pdf" | "doc" | "docx" | "xls" | "xlsx" => "doc", - _ => "file", + _ => "stream", }; let file_data = tokio::fs::read(file_path) @@ -618,10 +626,18 @@ impl FeishuChannel { file_key: String, } - let result: UploadResp = resp - .json() - .await - .map_err(|e| ChannelError::Other(format!("Parse upload response error: {}", e)))?; + let status = resp.status(); + let body = resp.text().await.map_err(|e| { + ChannelError::Other(format!("Read upload file response error: {}", e)) + })?; + let result: UploadResp = serde_json::from_str(&body).map_err(|e| { + ChannelError::Other(format!( + "Parse upload file response error: {} (status={}, body={})", + e, + status, + truncate_with_ellipsis(&body, 500) + )) + })?; if result.code != 0 { return Err(ChannelError::Other(format!( @@ -2348,13 +2364,17 @@ impl Channel for FeishuChannel { "open_id" }; + let remove_reaction = async { + self.remove_reaction_from_metadata(&msg.metadata).await; + }; + // If no media, use smart format detection if msg.media.is_empty() { let content = msg.content.trim(); // Empty content if content.is_empty() { - self.remove_reaction_from_metadata(&msg.metadata).await; + remove_reaction.await; return Ok(()); } @@ -2366,7 +2386,7 @@ impl Channel for FeishuChannel { let result = self .send_message_to_feishu(receive_id, receive_id_type, "text", content) .await; - self.remove_reaction_from_metadata(&msg.metadata).await; + remove_reaction.await; return result; } MsgFormat::Post => { @@ -2375,7 +2395,7 @@ impl Channel for FeishuChannel { let result = self .send_message_to_feishu(receive_id, receive_id_type, "post", &post_body) .await; - self.remove_reaction_from_metadata(&msg.metadata).await; + remove_reaction.await; return result; } MsgFormat::Interactive => { @@ -2400,134 +2420,68 @@ impl Channel for FeishuChannel { content, ) .await; - self.remove_reaction_from_metadata(&msg.metadata).await; + remove_reaction.await; return result; } } - self.remove_reaction_from_metadata(&msg.metadata).await; + remove_reaction.await; return Ok(()); } } } - // Handle multimodal message - send with media - let token = self.get_tenant_access_token().await?; - - // Build content with media references - 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 { - format!( - "{}...\n\n[Content truncated due to length limit]", - &msg.content[..MAX_TEXT_LENGTH] - ) - } else { - msg.content.clone() - }; - content_parts.push(serde_json::json!({ - "tag": "text", - "text": truncated_text - })); + if !msg.content.trim().is_empty() { + self.send_message_to_feishu(receive_id, receive_id_type, "text", msg.content.trim()) + .await?; } - // Upload and add media + let mut sent_media = 0usize; 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"); - } - }, - "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"); - } - }, + let result = match media_item.media_type.as_str() { + "image" => { + let image_key = self.upload_image(path).await?; + self.send_message_to_feishu( + receive_id, + receive_id_type, + "image", + &serde_json::json!({ "image_key": image_key }).to_string(), + ) + .await + } + "audio" | "file" | "video" => { + let file_key = self.upload_file(path).await?; + self.send_message_to_feishu( + receive_id, + receive_id_type, + "file", + &serde_json::json!({ "file_key": file_key }).to_string(), + ) + .await + } _ => { tracing::warn!(media_type = %media_item.media_type, "Unsupported media type for sending"); + continue; + } + }; + + match result { + Ok(()) => sent_media += 1, + Err(error) => { + tracing::warn!(error = %error, path = %path, media_type = %media_item.media_type, "Failed to send media message to Feishu"); + return Err(error); } } } - // If no content parts after processing, just send empty text - 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; + if msg.content.trim().is_empty() && sent_media == 0 { + remove_reaction.await; + return Err(ChannelError::Other( + "No supported media items were sent to Feishu".to_string(), + )); } - // 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() { - "image" - } else { - "post" - }; - - let content = serde_json::json!({ - "content": content_parts - }) - .to_string(); - - let resp = self - .http_client - .post(format!( - "{}/im/v1/messages?receive_id_type={}", - FEISHU_API_BASE, receive_id_type - )) - .header("Content-Type", "application/json") - .header("Authorization", format!("Bearer {}", token)) - .json(&serde_json::json!({ - "receive_id": receive_id, - "msg_type": msg_type, - "content": content - })) - .send() - .await - .map_err(|e| { - ChannelError::ConnectionError(format!("Send multimodal message HTTP error: {}", e)) - })?; - - #[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)))?; - - if send_resp.code != 0 { - return Err(ChannelError::Other(format!( - "Send multimodal message failed: code={} msg={}", - send_resp.code, send_resp.msg - ))); - } - - // Remove pending reaction after successfully sending - self.remove_reaction_from_metadata(&msg.metadata).await; - + remove_reaction.await; Ok(()) } } diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs index 8de5c58..2e61079 100644 --- a/src/gateway/mod.rs +++ b/src/gateway/mod.rs @@ -19,6 +19,7 @@ pub mod session; pub mod session_factory; pub mod session_history; pub mod session_lifecycle; +pub mod session_message_sender; pub mod session_message_service; pub mod session_pool; pub mod tool_registry_factory; @@ -39,7 +40,8 @@ use crate::skills::SkillRuntime; use agent_task_executor::{AgentTaskExecutor, SchedulerMaintenanceService}; use outbound_dispatcher::OutboundDispatcher; use processor::InboundProcessor; -use runtime::build_session_manager; +use runtime::build_session_manager_with_sender; +use session_message_sender::BusSessionMessageSender; use session::SessionManager; pub struct GatewayState { @@ -64,8 +66,10 @@ impl GatewayState { let show_tool_results = config.gateway.show_tool_results; let skills = Arc::new(SkillRuntime::from_config(config.skills.clone())); + let channel_manager = ChannelManager::new(); + let bus = channel_manager.bus(); - let session_manager = build_session_manager( + let session_manager = build_session_manager_with_sender( session_ttl_hours, agent_prompt_reinject_every, show_tool_results, @@ -73,9 +77,8 @@ impl GatewayState { provider_config, provider_configs, skills, + Arc::new(BusSessionMessageSender::new(bus.clone())), )?; - let channel_manager = ChannelManager::new(); - let bus = channel_manager.bus(); Ok(Self { config, diff --git a/src/gateway/runtime.rs b/src/gateway/runtime.rs index b232f49..b3596d3 100644 --- a/src/gateway/runtime.rs +++ b/src/gateway/runtime.rs @@ -8,7 +8,7 @@ use crate::storage::{ ConversationRepository, MemoryRepository, PromptInjectionRepository, SchedulerJobRepository, SessionStore, SkillEventRepository, }; -use crate::tools::ToolRegistry; +use crate::tools::{NoopSessionMessageSender, SessionMessageSender, ToolRegistry}; use super::agent_factory::AgentFactory; use super::cli_session::CliSessionService; @@ -30,6 +30,28 @@ pub(crate) fn build_session_manager( provider_config: LLMProviderConfig, provider_configs: HashMap, skills: Arc, +) -> Result { + build_session_manager_with_sender( + session_ttl_hours, + agent_prompt_reinject_every, + show_tool_results, + default_timezone, + provider_config, + provider_configs, + skills, + Arc::new(NoopSessionMessageSender), + ) +} + +pub(crate) fn build_session_manager_with_sender( + session_ttl_hours: u64, + agent_prompt_reinject_every: u64, + show_tool_results: bool, + default_timezone: String, + provider_config: LLMProviderConfig, + provider_configs: HashMap, + skills: Arc, + session_message_sender: Arc, ) -> Result { let store = Arc::new( SessionStore::new() @@ -53,6 +75,7 @@ pub(crate) fn build_session_manager( memories, scheduler_jobs, skill_events.clone(), + session_message_sender, known_agents, default_timezone, ) diff --git a/src/gateway/session.rs b/src/gateway/session.rs index 27b3055..14056af 100644 --- a/src/gateway/session.rs +++ b/src/gateway/session.rs @@ -496,6 +496,7 @@ mod tests { use crate::bus::MessageBus; use crate::gateway::tool_registry_factory::ToolRegistryFactory; use crate::storage::MemoryRecord; + use crate::tools::NoopSessionMessageSender; use axum::http::StatusCode; use axum::{Json, Router, routing::post}; use serde_json::{Value, json}; @@ -537,6 +538,7 @@ mod tests { store.clone(), store.clone(), store.clone(), + Arc::new(NoopSessionMessageSender), HashSet::new(), "Asia/Shanghai".to_string(), ) @@ -581,6 +583,7 @@ mod tests { store.clone(), store.clone(), store.clone(), + Arc::new(NoopSessionMessageSender), HashSet::new(), "Asia/Shanghai".to_string(), ) @@ -1483,6 +1486,7 @@ mod tests { store.clone(), store.clone(), store.clone(), + Arc::new(NoopSessionMessageSender), HashSet::new(), "Asia/Shanghai".to_string(), ) @@ -1520,6 +1524,7 @@ mod tests { store.clone(), store.clone(), store.clone(), + Arc::new(NoopSessionMessageSender), HashSet::new(), "Asia/Shanghai".to_string(), ) @@ -1585,6 +1590,7 @@ mod tests { store.clone(), store.clone(), store.clone(), + Arc::new(NoopSessionMessageSender), HashSet::new(), "Asia/Shanghai".to_string(), ) @@ -1632,6 +1638,7 @@ mod tests { store.clone(), store.clone(), store, + Arc::new(NoopSessionMessageSender), HashSet::new(), "Asia/Shanghai".to_string(), ) diff --git a/src/gateway/session_message_sender.rs b/src/gateway/session_message_sender.rs new file mode 100644 index 0000000..029261d --- /dev/null +++ b/src/gateway/session_message_sender.rs @@ -0,0 +1,123 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use async_trait::async_trait; + +use crate::bus::{MessageBus, OutboundMessage}; +use crate::tools::{SessionMessageSender, SessionSendOutcome, SessionSendRequest, ToolContext}; + +pub(crate) struct BusSessionMessageSender { + bus: Arc, +} + +impl BusSessionMessageSender { + pub(crate) fn new(bus: Arc) -> Self { + Self { bus } + } +} + +#[async_trait] +impl SessionMessageSender for BusSessionMessageSender { + async fn send_to_current_session( + &self, + context: &ToolContext, + request: SessionSendRequest, + ) -> anyhow::Result { + let channel_name = context + .channel_name + .as_deref() + .ok_or_else(|| anyhow::anyhow!("missing channel_name in tool context"))?; + let chat_id = context + .chat_id + .as_deref() + .ok_or_else(|| anyhow::anyhow!("missing chat_id in tool context"))?; + + let metadata = HashMap::new(); + let mut published_messages = 0; + let text_sent = request + .text + .as_deref() + .map(str::trim) + .filter(|text| !text.is_empty()) + .is_some(); + + if let Some(text) = request.text.filter(|value| !value.trim().is_empty()) { + self.bus + .publish_outbound(OutboundMessage::assistant( + channel_name.to_string(), + chat_id.to_string(), + text, + None, + metadata.clone(), + )) + .await?; + published_messages += 1; + } + + let attachment_count = request.attachments.len(); + for attachment in request.attachments { + let mut outbound = OutboundMessage::assistant( + channel_name.to_string(), + chat_id.to_string(), + String::new(), + None, + metadata.clone(), + ); + outbound.media = vec![attachment]; + self.bus.publish_outbound(outbound).await?; + published_messages += 1; + } + + Ok(SessionSendOutcome { + published_messages, + text_sent, + attachment_count, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::bus::MediaItem; + + #[tokio::test] + async fn bus_sender_publishes_text_then_attachment() { + let bus = MessageBus::new(8); + let sender = BusSessionMessageSender::new(bus.clone()); + let context = ToolContext { + channel_name: Some("feishu".to_string()), + chat_id: Some("chat-1".to_string()), + ..ToolContext::default() + }; + + let outcome = sender + .send_to_current_session( + &context, + SessionSendRequest { + text: Some("hello".to_string()), + attachments: vec![MediaItem::new("/tmp/demo.png", "image")], + }, + ) + .await + .unwrap(); + + assert_eq!( + outcome, + SessionSendOutcome { + published_messages: 2, + text_sent: true, + attachment_count: 1, + } + ); + + let first = bus.consume_outbound().await; + assert_eq!(first.content, "hello"); + assert!(first.media.is_empty()); + + let second = bus.consume_outbound().await; + assert_eq!(second.content, ""); + assert_eq!(second.media.len(), 1); + assert_eq!(second.media[0].media_type, "image"); + } +} \ No newline at end of file diff --git a/src/gateway/tool_registry_factory.rs b/src/gateway/tool_registry_factory.rs index 4bb78b1..61b3c65 100644 --- a/src/gateway/tool_registry_factory.rs +++ b/src/gateway/tool_registry_factory.rs @@ -5,8 +5,9 @@ use crate::skills::SkillRuntime; use crate::storage::{MemoryRepository, SchedulerJobRepository, SkillEventRepository}; use crate::tools::{ BashTool, CalculatorTool, FileEditTool, FileReadTool, FileWriteTool, HttpRequestTool, - MemoryManageTool, MemorySearchTool, SchedulerManageTool, SkillActivateTool, SkillListTool, - SkillManageTool, TimeTool, ToolRegistry, WebFetchTool, + MemoryManageTool, MemorySearchTool, SchedulerManageTool, SessionMessageSender, + SessionSendTool, SkillActivateTool, SkillListTool, SkillManageTool, TimeTool, ToolRegistry, + WebFetchTool, }; pub(crate) struct ToolRegistryFactory { @@ -14,6 +15,7 @@ pub(crate) struct ToolRegistryFactory { memories: Arc, scheduler_jobs: Arc, skill_events: Arc, + session_message_sender: Arc, known_agents: HashSet, default_timezone: String, } @@ -24,6 +26,7 @@ impl ToolRegistryFactory { memories: Arc, scheduler_jobs: Arc, skill_events: Arc, + session_message_sender: Arc, known_agents: HashSet, default_timezone: String, ) -> Self { @@ -32,6 +35,7 @@ impl ToolRegistryFactory { memories, scheduler_jobs, skill_events, + session_message_sender, known_agents, default_timezone, } @@ -46,6 +50,7 @@ impl ToolRegistryFactory { registry.register(FileEditTool::new()); registry.register(MemorySearchTool::new(self.memories.clone())); registry.register(MemoryManageTool::new(self.memories.clone())); + registry.register(SessionSendTool::new(self.session_message_sender.clone())); registry.register(SchedulerManageTool::new( self.scheduler_jobs.clone(), self.known_agents.clone(), diff --git a/src/tools/mod.rs b/src/tools/mod.rs index 29487ca..e78a313 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -8,6 +8,7 @@ pub mod memory_manage; pub mod memory_search; pub mod registry; pub mod scheduler_manage; +pub mod session_send; pub mod schema; pub mod skill_activate; pub mod skill_manage; @@ -25,6 +26,10 @@ pub use memory_manage::MemoryManageTool; pub use memory_search::MemorySearchTool; pub use registry::ToolRegistry; pub use scheduler_manage::SchedulerManageTool; +pub use session_send::{ + NoopSessionMessageSender, SessionMessageSender, SessionSendOutcome, SessionSendRequest, + SessionSendTool, +}; pub use schema::{CleaningStrategy, SchemaCleanr}; pub use skill_activate::SkillActivateTool; pub use skill_manage::{SkillListTool, SkillManageTool}; diff --git a/src/tools/session_send.rs b/src/tools/session_send.rs new file mode 100644 index 0000000..8ce207e --- /dev/null +++ b/src/tools/session_send.rs @@ -0,0 +1,320 @@ +use std::path::Path; +use std::sync::Arc; + +use anyhow::anyhow; +use async_trait::async_trait; +use serde_json::json; + +use crate::bus::MediaItem; + +use super::traits::{Tool, ToolContext, ToolResult}; + +#[derive(Debug, Clone)] +pub struct SessionSendRequest { + pub text: Option, + pub attachments: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SessionSendOutcome { + pub published_messages: usize, + pub text_sent: bool, + pub attachment_count: usize, +} + +#[async_trait] +pub trait SessionMessageSender: Send + Sync + 'static { + async fn send_to_current_session( + &self, + context: &ToolContext, + request: SessionSendRequest, + ) -> anyhow::Result; +} + +pub struct NoopSessionMessageSender; + +#[async_trait] +impl SessionMessageSender for NoopSessionMessageSender { + async fn send_to_current_session( + &self, + _context: &ToolContext, + _request: SessionSendRequest, + ) -> anyhow::Result { + Err(anyhow!( + "session send tool is not configured with an outbound sender" + )) + } +} + +pub struct SessionSendTool { + sender: Arc, +} + +impl SessionSendTool { + pub fn new(sender: Arc) -> Self { + Self { sender } + } +} + +#[async_trait] +impl Tool for SessionSendTool { + fn name(&self) -> &str { + "send_session_message" + } + + fn description(&self) -> &str { + "Send a message to the current conversation through the normal channel reply path. You can send a text-only message, one or more local file attachments, or a text message followed by attachments. Use this when you need to proactively deliver content back to the current user." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "text": { + "type": "string", + "description": "Optional text to send to the current conversation. This tool can send a text-only message even when no attachments are provided." + }, + "attachments": { + "type": "array", + "description": "Optional list of local file paths to send to the current conversation.", + "items": { + "type": "string", + "description": "Absolute or workspace-relative local file path" + } + } + }, + "anyOf": [ + { "required": ["text"] }, + { "required": ["attachments"] } + ] + }) + } + + async fn execute(&self, _args: serde_json::Value) -> anyhow::Result { + Ok(error_result( + "send_session_message requires tool context for the current conversation", + )) + } + + async fn execute_with_context( + &self, + context: &ToolContext, + args: serde_json::Value, + ) -> anyhow::Result { + if let Err(err) = validate_context(context) { + return Ok(error_result(&err.to_string())); + } + + let text = args + .get("text") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned); + + let attachments = match args.get("attachments") { + Some(value) => match parse_attachments(value) { + Ok(attachments) => attachments, + Err(err) => return Ok(error_result(&err.to_string())), + }, + None => Vec::new(), + }; + + if text.is_none() && attachments.is_empty() { + return Ok(error_result( + "send_session_message requires non-empty text, attachments, or both", + )); + } + + let outcome = match self + .sender + .send_to_current_session( + context, + SessionSendRequest { + text, + attachments, + }, + ) + .await + { + Ok(outcome) => outcome, + Err(err) => return Ok(error_result(&err.to_string())), + }; + + Ok(ToolResult { + success: true, + output: format_success(outcome), + error: None, + }) + } +} + +fn validate_context(context: &ToolContext) -> anyhow::Result<()> { + if context.channel_name.as_deref().unwrap_or_default().is_empty() { + return Err(anyhow!( + "send_session_message requires channel_name in tool context" + )); + } + if context.chat_id.as_deref().unwrap_or_default().is_empty() { + return Err(anyhow!( + "send_session_message requires chat_id in tool context" + )); + } + Ok(()) +} + +fn parse_attachments(value: &serde_json::Value) -> anyhow::Result> { + let attachment_paths = value + .as_array() + .ok_or_else(|| anyhow!("attachments must be an array of local file paths"))?; + + let mut attachments = Vec::with_capacity(attachment_paths.len()); + for path_value in attachment_paths { + let raw_path = path_value + .as_str() + .ok_or_else(|| anyhow!("attachments entries must be strings"))? + .trim(); + if raw_path.is_empty() { + return Err(anyhow!("attachment paths must not be empty")); + } + + let metadata = std::fs::metadata(raw_path) + .map_err(|err| anyhow!("failed to access attachment '{}': {}", raw_path, err))?; + if !metadata.is_file() { + return Err(anyhow!("attachment path is not a file: {}", raw_path)); + } + if metadata.len() == 0 { + return Err(anyhow!("attachment file is empty: {}", raw_path)); + } + + let media_type = infer_media_type(raw_path); + let mut item = MediaItem::new(raw_path.to_string(), media_type); + item.mime_type = mime_guess::from_path(raw_path) + .first_raw() + .map(ToOwned::to_owned); + attachments.push(item); + } + + Ok(attachments) +} + +fn infer_media_type(path: &str) -> &'static str { + let mime = mime_guess::from_path(path).first_or_octet_stream(); + if mime.essence_str().starts_with("image/") { + return "image"; + } + + match Path::new(path) + .extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext.to_ascii_lowercase()) + .as_deref() + { + Some("mp3") | Some("wav") | Some("ogg") | Some("m4a") | Some("opus") => "audio", + Some("mp4") | Some("mov") | Some("avi") | Some("mkv") => "video", + _ => "file", + } +} + +fn format_success(outcome: SessionSendOutcome) -> String { + match (outcome.text_sent, outcome.attachment_count) { + (true, 0) => "Sent 1 text message to the current conversation.".to_string(), + (false, count) => format!("Sent {} attachment(s) to the current conversation.", count), + (true, count) => format!( + "Sent 1 text message and {} attachment(s) to the current conversation.", + count + ), + } +} + +fn error_result(message: &str) -> ToolResult { + ToolResult { + success: false, + output: String::new(), + error: Some(message.to_string()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::NamedTempFile; + + struct MockSender { + outcome: SessionSendOutcome, + } + + #[async_trait] + impl SessionMessageSender for MockSender { + async fn send_to_current_session( + &self, + _context: &ToolContext, + _request: SessionSendRequest, + ) -> anyhow::Result { + Ok(self.outcome) + } + } + + fn context() -> ToolContext { + ToolContext { + channel_name: Some("feishu".to_string()), + chat_id: Some("chat-1".to_string()), + ..ToolContext::default() + } + } + + #[tokio::test] + async fn send_session_message_supports_text_only() { + let tool = SessionSendTool::new(Arc::new(MockSender { + outcome: SessionSendOutcome { + published_messages: 1, + text_sent: true, + attachment_count: 0, + }, + })); + + let result = tool + .execute_with_context(&context(), json!({ "text": "hello" })) + .await + .unwrap(); + + assert!(result.success); + assert_eq!(result.output, "Sent 1 text message to the current conversation."); + } + + #[tokio::test] + async fn send_session_message_rejects_empty_request() { + let tool = SessionSendTool::new(Arc::new(MockSender { + outcome: SessionSendOutcome { + published_messages: 0, + text_sent: false, + attachment_count: 0, + }, + })); + + let result = tool + .execute_with_context(&context(), json!({})) + .await + .unwrap(); + + assert!(!result.success); + assert_eq!( + result.error.as_deref(), + Some("send_session_message requires non-empty text, attachments, or both") + ); + } + + #[test] + fn parse_attachments_infers_image_media_type() { + let file = NamedTempFile::new().unwrap(); + std::fs::write(file.path(), b"demo").unwrap(); + let image_path = file.path().with_extension("png"); + std::fs::rename(file.path(), &image_path).unwrap(); + + let attachments = parse_attachments(&json!([image_path.to_string_lossy().to_string()])) + .unwrap(); + + assert_eq!(attachments.len(), 1); + assert_eq!(attachments[0].media_type, "image"); + } +} \ No newline at end of file