1use crate::registry::Tool;
8use async_trait::async_trait;
9use rustant_core::error::ToolError;
10use rustant_core::types::{RiskLevel, ToolOutput};
11use serde_json::json;
12use std::time::Duration;
13use tracing::debug;
14
15pub struct IMessageContactsTool;
20
21#[async_trait]
22impl Tool for IMessageContactsTool {
23 fn name(&self) -> &str {
24 "imessage_contacts"
25 }
26
27 fn description(&self) -> &str {
28 "Search macOS Contacts by name. Returns matching contacts with phone numbers \
29 and email addresses. Use this to find the correct recipient before sending \
30 an iMessage."
31 }
32
33 fn parameters_schema(&self) -> serde_json::Value {
34 json!({
35 "type": "object",
36 "properties": {
37 "query": {
38 "type": "string",
39 "description": "Name or partial name to search for (e.g. 'John', 'Chaitu')"
40 }
41 },
42 "required": ["query"]
43 })
44 }
45
46 async fn execute(&self, args: serde_json::Value) -> Result<ToolOutput, ToolError> {
47 let query = args["query"]
48 .as_str()
49 .ok_or_else(|| ToolError::InvalidArguments {
50 name: "imessage_contacts".to_string(),
51 reason: "missing required 'query' parameter".to_string(),
52 })?;
53
54 debug!(query = query, "Searching contacts");
55
56 let contacts =
57 search_contacts_applescript(query)
58 .await
59 .map_err(|e| ToolError::ExecutionFailed {
60 name: "imessage_contacts".into(),
61 message: e,
62 })?;
63
64 if contacts.is_empty() {
65 return Ok(ToolOutput::text(format!(
66 "No contacts found matching '{}'.",
67 query
68 )));
69 }
70
71 let mut output = format!(
72 "Found {} contact(s) matching '{}':\n\n",
73 contacts.len(),
74 query
75 );
76 for (i, contact) in contacts.iter().enumerate() {
77 output.push_str(&format!("{}. {}\n", i + 1, contact.name));
78 if let Some(ref phone) = contact.phone {
79 output.push_str(&format!(" Phone: {}\n", phone));
80 }
81 if let Some(ref email) = contact.email {
82 output.push_str(&format!(" Email: {}\n", email));
83 }
84 output.push('\n');
85 }
86
87 Ok(ToolOutput::text(output))
88 }
89
90 fn risk_level(&self) -> RiskLevel {
91 RiskLevel::ReadOnly
92 }
93
94 fn timeout(&self) -> Duration {
95 Duration::from_secs(15)
96 }
97}
98
99pub struct IMessageSendTool;
103
104#[async_trait]
105impl Tool for IMessageSendTool {
106 fn name(&self) -> &str {
107 "imessage_send"
108 }
109
110 fn description(&self) -> &str {
111 "Send an iMessage to a recipient via macOS Messages.app. The recipient \
112 should be a phone number (e.g. '+1234567890') or Apple ID email. Use \
113 imessage_contacts first to find the correct phone number or email for \
114 a contact name."
115 }
116
117 fn parameters_schema(&self) -> serde_json::Value {
118 json!({
119 "type": "object",
120 "properties": {
121 "recipient": {
122 "type": "string",
123 "description": "Phone number (e.g. '+1234567890') or Apple ID email of the recipient"
124 },
125 "message": {
126 "type": "string",
127 "description": "The text message to send"
128 }
129 },
130 "required": ["recipient", "message"]
131 })
132 }
133
134 async fn execute(&self, args: serde_json::Value) -> Result<ToolOutput, ToolError> {
135 let recipient = args["recipient"]
136 .as_str()
137 .ok_or_else(|| ToolError::InvalidArguments {
138 name: "imessage_send".to_string(),
139 reason: "missing required 'recipient' parameter".to_string(),
140 })?;
141
142 let message = args["message"]
143 .as_str()
144 .ok_or_else(|| ToolError::InvalidArguments {
145 name: "imessage_send".to_string(),
146 reason: "missing required 'message' parameter".to_string(),
147 })?;
148
149 debug!(recipient = recipient, "Sending iMessage");
150
151 send_imessage_applescript(recipient, message)
152 .await
153 .map_err(|e| ToolError::ExecutionFailed {
154 name: "imessage_send".into(),
155 message: e,
156 })?;
157
158 Ok(ToolOutput::text(format!(
159 "iMessage sent successfully to {}.",
160 recipient
161 )))
162 }
163
164 fn risk_level(&self) -> RiskLevel {
165 RiskLevel::Write
166 }
167
168 fn timeout(&self) -> Duration {
169 Duration::from_secs(30)
170 }
171}
172
173pub struct IMessageReadTool;
177
178#[async_trait]
179impl Tool for IMessageReadTool {
180 fn name(&self) -> &str {
181 "imessage_read"
182 }
183
184 fn description(&self) -> &str {
185 "Read recent incoming iMessages. Returns the latest messages received \
186 in the past N minutes (default 5). Useful for checking replies."
187 }
188
189 fn parameters_schema(&self) -> serde_json::Value {
190 json!({
191 "type": "object",
192 "properties": {
193 "minutes": {
194 "type": "integer",
195 "description": "How far back to look in minutes (default: 5, max: 60)",
196 "default": 5
197 },
198 "limit": {
199 "type": "integer",
200 "description": "Maximum number of messages to return (default: 20)",
201 "default": 20
202 }
203 }
204 })
205 }
206
207 async fn execute(&self, args: serde_json::Value) -> Result<ToolOutput, ToolError> {
208 let minutes = args["minutes"].as_u64().unwrap_or(5).min(60);
209 let limit = args["limit"].as_u64().unwrap_or(20).min(100);
210
211 debug!(minutes = minutes, limit = limit, "Reading recent iMessages");
212
213 let messages = read_recent_imessages(minutes, limit).await.map_err(|e| {
214 ToolError::ExecutionFailed {
215 name: "imessage_read".into(),
216 message: e,
217 }
218 })?;
219
220 if messages.is_empty() {
221 return Ok(ToolOutput::text(format!(
222 "No incoming messages in the last {} minute(s).",
223 minutes
224 )));
225 }
226
227 let mut output = format!(
228 "Recent iMessages (last {} minute(s), {} message(s)):\n\n",
229 minutes,
230 messages.len()
231 );
232 for msg in &messages {
233 output.push_str(&format!("From: {}\n", msg.sender));
234 output.push_str(&format!("Text: {}\n\n", msg.text));
235 }
236
237 Ok(ToolOutput::text(output))
238 }
239
240 fn risk_level(&self) -> RiskLevel {
241 RiskLevel::ReadOnly
242 }
243
244 fn timeout(&self) -> Duration {
245 Duration::from_secs(15)
246 }
247}
248
249#[derive(Debug)]
253struct ContactResult {
254 name: String,
255 phone: Option<String>,
256 email: Option<String>,
257}
258
259#[derive(Debug)]
261struct IncomingMessage {
262 sender: String,
263 text: String,
264}
265
266async fn search_contacts_applescript(query: &str) -> Result<Vec<ContactResult>, String> {
268 let escaped = query.replace('"', "\\\"");
269 let script = format!(
270 r#"tell application "Contacts"
271 set matchingPeople to every person whose name contains "{query}"
272 set output to ""
273 repeat with p in matchingPeople
274 set pName to name of p
275 set pPhone to ""
276 set pEmail to ""
277 try
278 set pPhone to value of phone 1 of p
279 end try
280 try
281 set pEmail to value of email 1 of p
282 end try
283 set output to output & pName & "||" & pPhone & "||" & pEmail & "%%"
284 end repeat
285 return output
286end tell"#,
287 query = escaped
288 );
289
290 let output = tokio::process::Command::new("osascript")
291 .args(["-e", &script])
292 .output()
293 .await
294 .map_err(|e| format!("Failed to run osascript: {e}"))?;
295
296 if !output.status.success() {
297 let stderr = String::from_utf8_lossy(&output.stderr);
298 return Err(format!("Contacts lookup failed: {}", stderr));
299 }
300
301 let stdout = String::from_utf8_lossy(&output.stdout);
302 let contacts = stdout
303 .trim()
304 .split("%%")
305 .filter(|s| !s.is_empty())
306 .filter_map(|entry| {
307 let parts: Vec<&str> = entry.split("||").collect();
308 if parts.is_empty() {
309 return None;
310 }
311 let name = parts[0].trim().to_string();
312 if name.is_empty() {
313 return None;
314 }
315 let phone = parts.get(1).and_then(|p| {
316 let p = p.trim();
317 if p.is_empty() {
318 None
319 } else {
320 Some(p.to_string())
321 }
322 });
323 let email = parts.get(2).and_then(|e| {
324 let e = e.trim();
325 if e.is_empty() {
326 None
327 } else {
328 Some(e.to_string())
329 }
330 });
331 Some(ContactResult { name, phone, email })
332 })
333 .collect();
334
335 Ok(contacts)
336}
337
338async fn send_imessage_applescript(recipient: &str, text: &str) -> Result<(), String> {
340 let escaped_recipient = recipient.replace('"', "\\\"");
341 let escaped_text = text.replace('"', "\\\"");
342 let script = format!(
343 "tell application \"Messages\"\n\
344 \tset targetService to 1st service whose service type = iMessage\n\
345 \tset targetBuddy to buddy \"{}\" of targetService\n\
346 \tsend \"{}\" to targetBuddy\n\
347 end tell",
348 escaped_recipient, escaped_text,
349 );
350
351 let output = tokio::process::Command::new("osascript")
352 .args(["-e", &script])
353 .output()
354 .await
355 .map_err(|e| format!("Failed to run osascript: {e}"))?;
356
357 if !output.status.success() {
358 let stderr = String::from_utf8_lossy(&output.stderr);
359 return Err(format!("Failed to send iMessage: {}", stderr));
360 }
361
362 Ok(())
363}
364
365async fn read_recent_imessages(minutes: u64, limit: u64) -> Result<Vec<IncomingMessage>, String> {
367 let home = std::env::var("HOME").map_err(|_| "HOME not set".to_string())?;
368 let db_path = format!("{}/Library/Messages/chat.db", home);
369
370 let seconds = minutes * 60;
373 let query = format!(
374 "SELECT m.text, h.id as sender \
375 FROM message m \
376 JOIN handle h ON m.handle_id = h.ROWID \
377 WHERE m.is_from_me = 0 \
378 AND m.text IS NOT NULL \
379 AND m.date > (strftime('%s', 'now') - 978307200 - {seconds}) * 1000000000 \
380 ORDER BY m.date DESC \
381 LIMIT {limit};",
382 seconds = seconds,
383 limit = limit,
384 );
385
386 let output = tokio::process::Command::new("sqlite3")
387 .args([&db_path, "-json", &query])
388 .output()
389 .await
390 .map_err(|e| format!("Failed to read Messages DB: {e}"))?;
391
392 if !output.status.success() {
393 let stderr = String::from_utf8_lossy(&output.stderr);
394 return Err(format!(
396 "Cannot read Messages database: {}. \
397 Ensure your terminal has Full Disk Access in System Settings.",
398 stderr
399 ));
400 }
401
402 let stdout = String::from_utf8_lossy(&output.stdout);
403 if stdout.trim().is_empty() {
404 return Ok(vec![]);
405 }
406
407 let rows: Vec<serde_json::Value> =
408 serde_json::from_str(&stdout).map_err(|e| format!("JSON parse error: {e}"))?;
409
410 let messages = rows
411 .iter()
412 .filter_map(|r| {
413 let sender = r["sender"].as_str()?.to_string();
414 let text = r["text"].as_str().unwrap_or("").to_string();
415 if text.is_empty() {
416 return None;
417 }
418 Some(IncomingMessage { sender, text })
419 })
420 .collect();
421
422 Ok(messages)
423}
424
425#[cfg(test)]
426mod tests {
427 use super::*;
428
429 #[test]
430 fn test_imessage_contacts_tool_definition() {
431 let tool = IMessageContactsTool;
432 assert_eq!(tool.name(), "imessage_contacts");
433 assert_eq!(tool.risk_level(), RiskLevel::ReadOnly);
434 let schema = tool.parameters_schema();
435 assert!(schema["properties"]["query"].is_object());
436 }
437
438 #[test]
439 fn test_imessage_send_tool_definition() {
440 let tool = IMessageSendTool;
441 assert_eq!(tool.name(), "imessage_send");
442 assert_eq!(tool.risk_level(), RiskLevel::Write);
443 let schema = tool.parameters_schema();
444 assert!(schema["properties"]["recipient"].is_object());
445 assert!(schema["properties"]["message"].is_object());
446 }
447
448 #[test]
449 fn test_imessage_read_tool_definition() {
450 let tool = IMessageReadTool;
451 assert_eq!(tool.name(), "imessage_read");
452 assert_eq!(tool.risk_level(), RiskLevel::ReadOnly);
453 let schema = tool.parameters_schema();
454 assert!(schema["properties"]["minutes"].is_object());
455 assert!(schema["properties"]["limit"].is_object());
456 }
457
458 #[tokio::test]
459 async fn test_imessage_contacts_missing_query() {
460 let tool = IMessageContactsTool;
461 let result = tool.execute(json!({})).await;
462 assert!(result.is_err());
463 match result.unwrap_err() {
464 ToolError::InvalidArguments { name, reason } => {
465 assert_eq!(name, "imessage_contacts");
466 assert!(reason.contains("query"));
467 }
468 _ => panic!("Expected InvalidArguments error"),
469 }
470 }
471
472 #[tokio::test]
473 async fn test_imessage_send_missing_recipient() {
474 let tool = IMessageSendTool;
475 let result = tool.execute(json!({"message": "hello"})).await;
476 assert!(result.is_err());
477 match result.unwrap_err() {
478 ToolError::InvalidArguments { name, reason } => {
479 assert_eq!(name, "imessage_send");
480 assert!(reason.contains("recipient"));
481 }
482 _ => panic!("Expected InvalidArguments error"),
483 }
484 }
485
486 #[tokio::test]
487 async fn test_imessage_send_missing_message() {
488 let tool = IMessageSendTool;
489 let result = tool.execute(json!({"recipient": "+1234567890"})).await;
490 assert!(result.is_err());
491 match result.unwrap_err() {
492 ToolError::InvalidArguments { name, reason } => {
493 assert_eq!(name, "imessage_send");
494 assert!(reason.contains("message"));
495 }
496 _ => panic!("Expected InvalidArguments error"),
497 }
498 }
499
500 #[test]
503 fn test_imessage_send_tool_timeout() {
504 let tool = IMessageSendTool;
505 assert_eq!(tool.timeout(), Duration::from_secs(30));
506 }
507
508 #[test]
509 fn test_imessage_contacts_tool_timeout() {
510 let tool = IMessageContactsTool;
511 assert_eq!(tool.timeout(), Duration::from_secs(15));
512 }
513
514 #[test]
515 fn test_imessage_read_tool_timeout() {
516 let tool = IMessageReadTool;
517 assert_eq!(tool.timeout(), Duration::from_secs(15));
518 }
519
520 #[test]
521 fn test_imessage_send_schema_required_fields() {
522 let tool = IMessageSendTool;
523 let schema = tool.parameters_schema();
524 let required = schema["required"].as_array().unwrap();
525 assert!(required.contains(&json!("recipient")));
526 assert!(required.contains(&json!("message")));
527 assert_eq!(required.len(), 2);
528 }
529
530 #[test]
531 fn test_imessage_contacts_schema_required_fields() {
532 let tool = IMessageContactsTool;
533 let schema = tool.parameters_schema();
534 let required = schema["required"].as_array().unwrap();
535 assert!(required.contains(&json!("query")));
536 assert_eq!(required.len(), 1);
537 }
538
539 #[test]
540 fn test_imessage_read_no_required_fields() {
541 let tool = IMessageReadTool;
542 let schema = tool.parameters_schema();
543 assert!(schema.get("required").is_none());
545 }
546
547 #[tokio::test]
548 async fn test_imessage_send_both_params_missing() {
549 let tool = IMessageSendTool;
550 let result = tool.execute(json!({})).await;
551 assert!(result.is_err());
552 }
553
554 #[tokio::test]
555 async fn test_imessage_contacts_null_query() {
556 let tool = IMessageContactsTool;
557 let result = tool.execute(json!({"query": null})).await;
558 assert!(result.is_err());
559 }
560}