PicoBot/src/tools/send_message.rs

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());
}
}