use std::collections::HashSet; use std::sync::Arc; use async_trait::async_trait; use mime_guess::mime; use crate::bus::{MediaItem, MessageSource, SourceKind}; use super::traits::{OutboundDelivery, OutboundMessenger, Tool, ToolResult}; pub struct SendMessageTool { messenger: Arc, available_channels: HashSet, } impl SendMessageTool { pub fn new(messenger: Arc, available_channels: Vec) -> Self { Self { messenger, available_channels: available_channels.into_iter().collect(), } } } /// Parse target_chat_id into (channel, chat_id, optional dialog_id). /// Accepts two formats: /// - Two-part: `:` → sends to latest active session for that chat /// - Three-part: `::` → sends to specific session fn parse_target_chat_id(raw: &str) -> Result<(&str, &str, Option<&str>), String> { let parts: Vec<&str> = raw.split(':').collect(); match parts.len() { 2 => { if parts[0].is_empty() || parts[1].is_empty() { Err(format!( "Invalid target_chat_id format '{}': channel and chat_id must not be empty", raw )) } else { Ok((parts[0], parts[1], None)) } } 3 => { if parts[0].is_empty() || parts[1].is_empty() || parts[2].is_empty() { Err(format!( "Invalid target_chat_id format '{}': all three parts must not be empty", raw )) } else { Ok((parts[0], parts[1], Some(parts[2]))) } } _ => Err(format!( "Invalid target_chat_id format '{}'. Expected : or ::", raw )), } } #[async_trait] impl Tool for SendMessageTool { fn name(&self) -> &str { "send_message" } fn description(&self) -> &str { "向指定渠道的会话发送消息,可附带文件。用于在用户请求下向其他渠道发送内容。\ target_chat_id 支持两种格式::(发送到该聊天下最新活跃会话)\ 或 ::(发送到指定会话,过期则自动激活)。\ 如需发送文件,使用 files 参数指定文件路径列表。" } fn parameters_schema(&self) -> serde_json::Value { serde_json::json!({ "type": "object", "properties": { "target_chat_id": { "type": "string", "description": "目标会话ID。支持两种格式: 1) : 发送到该聊天下最新活跃会话, 无则自动创建; 2) :: 发送到指定会话, 过期则自动激活。channel 可选值: feishu, cli_chat" }, "content": { "type": "string", "description": "要发送的消息内容" }, "origin": { "type": "string", "description": "可选。消息来源标识。不填则自动使用当前会话的完整 session_id (::)" }, "files": { "type": "array", "items": { "type": "string" }, "description": "可选。要发送的文件路径列表,支持绝对路径和工作区相对路径" } }, "required": ["target_chat_id", "content"] }) } async fn execute(&self, args: serde_json::Value) -> anyhow::Result { let raw_id = args["target_chat_id"] .as_str() .ok_or_else(|| anyhow::anyhow!("missing target_chat_id"))?; let content = args["content"] .as_str() .ok_or_else(|| anyhow::anyhow!("missing content"))?; // 1. Parse target_chat_id let (channel, chat_id, dialog_id) = parse_target_chat_id(raw_id).map_err(|e| anyhow::anyhow!(e))?; // 2. Validate channel if !self.available_channels.contains(channel) { return Ok(ToolResult { success: false, output: String::new(), error: Some(format!( "Channel '{}' is not available. Available channels: {}", channel, self.available_channels .iter() .cloned() .collect::>() .join(", ") )), }); } let from_session = args["origin"].as_str().map(|s| s.to_string()); let source = MessageSource { kind: SourceKind::CrossChannel, from_channel: Some("tool".to_string()), from_session, from_user_id: None, system_name: None, task_id: None, }; // 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, media) .await { Ok(OutboundDelivery::Delivered) => Ok(ToolResult { success: true, output: "消息已发送".to_string(), error: None, }), Ok(OutboundDelivery::AttachedToCurrentTurn) => Ok(ToolResult { success: true, output: "附件已加入当前回复".to_string(), error: None, }), Err(e) => Ok(ToolResult { success: false, output: String::new(), error: Some(e), }), } } } /// 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_to_media_item) .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::*; #[test] fn test_parse_target_chat_id_two_part() { let (ch, cid, did) = parse_target_chat_id("feishu:oc_abc123").unwrap(); assert_eq!(ch, "feishu"); assert_eq!(cid, "oc_abc123"); assert!(did.is_none()); } #[test] fn test_parse_target_chat_id_three_part() { let (ch, cid, did) = parse_target_chat_id("feishu:oc_abc123:dialog1").unwrap(); assert_eq!(ch, "feishu"); assert_eq!(cid, "oc_abc123"); assert_eq!(did, Some("dialog1")); } #[test] fn test_parse_target_chat_id_invalid_one_part() { assert!(parse_target_chat_id("feishu").is_err()); } #[test] fn test_parse_target_chat_id_empty_parts() { assert!(parse_target_chat_id("feishu:").is_err()); assert!(parse_target_chat_id(":chat_id").is_err()); assert!(parse_target_chat_id("feishu::dialog").is_err()); } }