233 lines
7.8 KiB
Rust
233 lines
7.8 KiB
Rust
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<dyn OutboundMessenger>,
|
|
available_channels: HashSet<String>,
|
|
}
|
|
|
|
impl SendMessageTool {
|
|
pub fn new(messenger: Arc<dyn OutboundMessenger>, available_channels: Vec<String>) -> 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: `<channel>:<chat_id>` → sends to latest active session for that chat
|
|
/// - Three-part: `<channel>:<chat_id>:<dialog_id>` → 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 <channel>:<chat_id> or <channel>:<chat_id>:<dialog_id>",
|
|
raw
|
|
)),
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Tool for SendMessageTool {
|
|
fn name(&self) -> &str {
|
|
"send_message"
|
|
}
|
|
|
|
fn description(&self) -> &str {
|
|
"向指定渠道的会话发送消息,可附带文件。用于在用户请求下向其他渠道发送内容。\
|
|
target_chat_id 支持两种格式:<channel>:<chat_id>(发送到该聊天下最新活跃会话)\
|
|
或 <channel>:<chat_id>:<dialog_id>(发送到指定会话,过期则自动激活)。\
|
|
如需发送文件,使用 files 参数指定文件路径列表。"
|
|
}
|
|
|
|
fn parameters_schema(&self) -> serde_json::Value {
|
|
serde_json::json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"target_chat_id": {
|
|
"type": "string",
|
|
"description": "目标会话ID。支持两种格式: 1) <channel>:<chat_id> 发送到该聊天下最新活跃会话, 无则自动创建; 2) <channel>:<chat_id>:<dialog_id> 发送到指定会话, 过期则自动激活。channel 可选值: feishu, cli_chat"
|
|
},
|
|
"content": {
|
|
"type": "string",
|
|
"description": "要发送的消息内容"
|
|
},
|
|
"origin": {
|
|
"type": "string",
|
|
"description": "可选。消息来源标识。不填则自动使用当前会话的完整 session_id (<channel>:<chat_id>:<dialog_id>)"
|
|
},
|
|
"files": {
|
|
"type": "array",
|
|
"items": { "type": "string" },
|
|
"description": "可选。要发送的文件路径列表,支持绝对路径和工作区相对路径"
|
|
}
|
|
},
|
|
"required": ["target_chat_id", "content"]
|
|
})
|
|
}
|
|
|
|
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
|
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::<Vec<_>>()
|
|
.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<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_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());
|
|
}
|
|
}
|