Skip to main content

microclaw_engine/tools/
export_chat.rs

1use std::sync::Arc;
2
3use async_trait::async_trait;
4use serde_json::json;
5
6use super::{authorize_chat_access, schema_object, Tool, ToolResult};
7use crate::internal::storage::db::{call_blocking, Database};
8use microclaw_core::llm_types::ToolDefinition;
9
10pub struct ExportChatTool {
11    db: Arc<Database>,
12    data_dir: String,
13}
14
15impl ExportChatTool {
16    pub fn new(db: Arc<Database>, data_dir: &str) -> Self {
17        ExportChatTool {
18            db,
19            data_dir: data_dir.to_string(),
20        }
21    }
22}
23
24#[async_trait]
25impl Tool for ExportChatTool {
26    fn name(&self) -> &str {
27        "export_chat"
28    }
29
30    fn definition(&self) -> ToolDefinition {
31        ToolDefinition {
32            name: "export_chat".into(),
33            description: "Export chat history to a markdown file. Returns the file path.".into(),
34            input_schema: schema_object(
35                json!({
36                    "chat_id": {
37                        "type": "integer",
38                        "description": "The chat ID to export"
39                    },
40                    "path": {
41                        "type": "string",
42                        "description": "Optional output file path. Defaults to data/exports/{chat_id}_{timestamp}.md"
43                    }
44                }),
45                &["chat_id"],
46            ),
47        }
48    }
49
50    async fn execute(&self, input: serde_json::Value) -> ToolResult {
51        let chat_id = match input.get("chat_id").and_then(|v| v.as_i64()) {
52            Some(id) => id,
53            None => return ToolResult::error("Missing required parameter: chat_id".into()),
54        };
55        if let Err(e) = authorize_chat_access(&input, chat_id) {
56            return ToolResult::error(e);
57        }
58
59        let messages =
60            match call_blocking(self.db.clone(), move |db| db.get_all_messages(chat_id)).await {
61                Ok(msgs) => msgs,
62                Err(e) => return ToolResult::error(format!("Failed to load messages: {e}")),
63            };
64
65        if messages.is_empty() {
66            return ToolResult::error(format!("No messages found for chat {chat_id}."));
67        }
68
69        let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
70        let default_path = format!("{}/exports/{}_{}.md", self.data_dir, chat_id, timestamp);
71        let path = input
72            .get("path")
73            .and_then(|v| v.as_str())
74            .unwrap_or(&default_path);
75
76        // Build markdown
77        let mut md = format!("# Chat Export: {chat_id}\n\n");
78        md.push_str(&format!(
79            "Exported at: {}\n\n---\n\n",
80            chrono::Utc::now().to_rfc3339()
81        ));
82
83        for msg in &messages {
84            let sender = if msg.is_from_bot {
85                "**Bot**"
86            } else {
87                &msg.sender_name
88            };
89            md.push_str(&format!(
90                "**{}** ({})\n\n{}\n\n---\n\n",
91                sender, msg.timestamp, msg.content
92            ));
93        }
94
95        // Write file
96        let path = std::path::Path::new(path);
97        if let Some(parent) = path.parent() {
98            if let Err(e) = std::fs::create_dir_all(parent) {
99                return ToolResult::error(format!("Failed to create directory: {e}"));
100            }
101        }
102        match std::fs::write(path, &md) {
103            Ok(_) => ToolResult::success(format!(
104                "Exported {} messages to {}",
105                messages.len(),
106                path.display()
107            )),
108            Err(e) => ToolResult::error(format!("Failed to write file: {e}")),
109        }
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116    use crate::internal::storage::db::{Database, StoredMessage};
117
118    fn test_db() -> (Arc<Database>, std::path::PathBuf) {
119        let dir = std::env::temp_dir().join(format!("microclaw_export_{}", uuid::Uuid::new_v4()));
120        let db = Arc::new(Database::new(dir.to_str().unwrap()).unwrap());
121        (db, dir)
122    }
123
124    fn cleanup(dir: &std::path::Path) {
125        let _ = std::fs::remove_dir_all(dir);
126    }
127
128    #[tokio::test]
129    async fn test_export_empty_chat() {
130        let (db, dir) = test_db();
131        let tool = ExportChatTool::new(db, dir.to_str().unwrap());
132        let result = tool.execute(json!({"chat_id": 999})).await;
133        assert!(result.is_error);
134        assert!(result.content.contains("No messages"));
135        cleanup(&dir);
136    }
137
138    #[tokio::test]
139    async fn test_export_chat_success() {
140        let (db, dir) = test_db();
141        db.store_message(&StoredMessage {
142            id: "m1".into(),
143            chat_id: 100,
144            sender_name: "alice".into(),
145            content: "hello".into(),
146            is_from_bot: false,
147            timestamp: "2024-01-01T00:00:01Z".into(),
148        })
149        .unwrap();
150        db.store_message(&StoredMessage {
151            id: "m2".into(),
152            chat_id: 100,
153            sender_name: "bot".into(),
154            content: "hi there!".into(),
155            is_from_bot: true,
156            timestamp: "2024-01-01T00:00:02Z".into(),
157        })
158        .unwrap();
159
160        let out_path = dir.join("test_export.md");
161        let tool = ExportChatTool::new(db, dir.to_str().unwrap());
162        let result = tool
163            .execute(json!({"chat_id": 100, "path": out_path.to_str().unwrap()}))
164            .await;
165        assert!(!result.is_error, "Error: {}", result.content);
166        assert!(result.content.contains("2 messages"));
167
168        let content = std::fs::read_to_string(&out_path).unwrap();
169        assert!(content.contains("alice"));
170        assert!(content.contains("hello"));
171        assert!(content.contains("**Bot**"));
172        assert!(content.contains("hi there!"));
173        cleanup(&dir);
174    }
175
176    #[tokio::test]
177    async fn test_export_chat_permission_denied() {
178        let (db, dir) = test_db();
179        db.store_message(&StoredMessage {
180            id: "m1".into(),
181            chat_id: 200,
182            sender_name: "alice".into(),
183            content: "hello".into(),
184            is_from_bot: false,
185            timestamp: "2024-01-01T00:00:01Z".into(),
186        })
187        .unwrap();
188
189        let tool = ExportChatTool::new(db, dir.to_str().unwrap());
190        let result = tool
191            .execute(json!({
192                "chat_id": 200,
193                "__microclaw_auth": {
194                    "caller_chat_id": 100,
195                    "control_chat_ids": []
196                }
197            }))
198            .await;
199        assert!(result.is_error);
200        assert!(result.content.contains("Permission denied"));
201        cleanup(&dir);
202    }
203
204    #[tokio::test]
205    async fn test_export_chat_allowed_for_control_chat_cross_chat() {
206        let (db, dir) = test_db();
207        db.store_message(&StoredMessage {
208            id: "m1".into(),
209            chat_id: 200,
210            sender_name: "alice".into(),
211            content: "hello".into(),
212            is_from_bot: false,
213            timestamp: "2024-01-01T00:00:01Z".into(),
214        })
215        .unwrap();
216        let out_path = dir.join("control_export.md");
217        let tool = ExportChatTool::new(db, dir.to_str().unwrap());
218        let result = tool
219            .execute(json!({
220                "chat_id": 200,
221                "path": out_path.to_str().unwrap(),
222                "__microclaw_auth": {
223                    "caller_chat_id": 100,
224                    "control_chat_ids": [100]
225                }
226            }))
227            .await;
228        assert!(!result.is_error, "{}", result.content);
229        let content = std::fs::read_to_string(out_path).unwrap();
230        assert!(content.contains("hello"));
231        cleanup(&dir);
232    }
233}