microclaw_engine/tools/
export_chat.rs1use 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 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 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}