Skip to main content

robit_chatbot/tool/
send_file.rs

1//! `send_file` tool — sends a local file or image to the chat user.
2//!
3//! Only works on chatbot platforms (QQ, Feishu). The tool retrieves the
4//! `PlatformExt` capability from `ToolContext.extensions` via the
5//! `"chatbot.platform_ext"` key.
6
7use async_trait::async_trait;
8use serde::Deserialize;
9use serde_json::Value;
10
11use robit_agent::error::Result;
12use robit_agent::tool::{resolve_path, Tool, ToolContext, ToolResult};
13
14use crate::extensions::keys;
15use crate::extensions::PlatformExtWrapper;
16
17/// Tool that sends a local file/image to the chat user.
18pub struct SendFileTool;
19
20#[derive(Debug, Deserialize)]
21struct SendFileArgs {
22    /// Path to the file (absolute or relative to working directory).
23    file_path: String,
24    /// Optional media type override: "image" or "file".
25    /// When omitted, the type is inferred from the file extension.
26    #[serde(default)]
27    media_type: Option<String>,
28}
29
30#[async_trait]
31impl Tool for SendFileTool {
32    fn name(&self) -> &str {
33        "send_file"
34    }
35
36    fn description(&self) -> &str {
37        "Send a local image or file to the chat user. Use this tool when the user asks you \
38         to send them a file. Provide the full path to the file (absolute or relative to \
39         the working directory). Supported image formats: jpg, jpeg, png, gif, bmp, webp. \
40         Other file types (pdf, txt, zip, etc.) are also supported."
41    }
42
43    fn parameters_schema(&self) -> Value {
44        serde_json::json!({
45            "type": "object",
46            "properties": {
47                "file_path": {
48                    "type": "string",
49                    "description": "Path to the file to send (absolute or relative to working directory)"
50                },
51                "media_type": {
52                    "type": "string",
53                    "description": "Optional: 'image' or 'file'. When omitted, inferred from extension.",
54                    "enum": ["image", "file"]
55                }
56            },
57            "required": ["file_path"]
58        })
59    }
60
61    fn requires_confirmation(&self) -> bool {
62        false
63    }
64
65    async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<ToolResult> {
66        let parsed: SendFileArgs = match serde_json::from_value(args) {
67            Ok(a) => a,
68            Err(e) => return Ok(ToolResult::error(format!("Invalid arguments: {}", e))),
69        };
70
71        // Resolve path
72        let file_path = resolve_path(&parsed.file_path, &ctx.working_dir);
73        let file_path_str = match file_path.to_str() {
74            Some(p) => p,
75            None => return Ok(ToolResult::error("Invalid file path encoding".to_string())),
76        };
77
78        // Validate file exists
79        if !file_path.exists() {
80            return Ok(ToolResult::error(format!("File not found: {}", file_path_str)));
81        }
82        if !file_path.is_file() {
83            return Ok(ToolResult::error(format!("Not a file: {}", file_path_str)));
84        }
85
86        // Determine media type
87        let media_type = parsed
88            .media_type
89            .unwrap_or_else(|| classify_media_type(file_path_str).to_string());
90
91        // Get platform extension
92        let ext = match ctx
93            .extensions
94            .get(keys::PLATFORM_EXT)
95            .and_then(|e| e.downcast_ref::<PlatformExtWrapper>())
96        {
97            Some(wrapper) => &wrapper.0,
98            None => {
99                return Ok(ToolResult::error(
100                    "send_file is only available on chat platforms (QQ, Feishu)".to_string(),
101                ));
102            }
103        };
104
105        // Upload file
106        let upload_result = match ext.upload_file(file_path_str, &media_type).await {
107            Ok(r) => r,
108            Err(e) => return Ok(ToolResult::error(format!("Failed to upload file: {}", e))),
109        };
110
111        // Send media message
112        let file_name = file_path
113            .file_name()
114            .and_then(|n| n.to_str())
115            .unwrap_or("file");
116
117        match ext
118            .send_media_message(&upload_result.url, file_name, &media_type)
119            .await
120        {
121            Ok(_) => Ok(ToolResult::success(format!("File sent: {}", file_name))),
122            Err(e) => Ok(ToolResult::error(format!("Failed to send file: {}", e))),
123        }
124    }
125}
126
127/// Classify a file path as "image" or "file" based on its extension.
128fn classify_media_type(path: &str) -> &str {
129    let ext = std::path::Path::new(path)
130        .extension()
131        .and_then(|e| e.to_str())
132        .unwrap_or("")
133        .to_lowercase();
134
135    match ext.as_str() {
136        "jpg" | "jpeg" | "png" | "gif" | "bmp" | "webp" | "svg" | "ico" | "tiff" | "tif" => {
137            "image"
138        }
139        _ => "file",
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn test_classify_image() {
149        assert_eq!(classify_media_type("/path/to/photo.jpg"), "image");
150        assert_eq!(classify_media_type("C:\\img.PNG"), "image");
151        assert_eq!(classify_media_type("/tmp/anim.gif"), "image");
152    }
153
154    #[test]
155    fn test_classify_file() {
156        assert_eq!(classify_media_type("/path/to/doc.pdf"), "file");
157        assert_eq!(classify_media_type("C:\\data.zip"), "file");
158        assert_eq!(classify_media_type("/tmp/notes.txt"), "file");
159    }
160
161    #[test]
162    fn test_send_file_schema() {
163        let tool = SendFileTool;
164        assert_eq!(tool.name(), "send_file");
165        let schema = tool.parameters_schema();
166        assert!(schema["required"][0].as_str() == Some("file_path"));
167    }
168}