Skip to main content

rustant_tools/
imessage.rs

1//! iMessage tools — contact lookup and message sending via macOS Messages.app.
2//!
3//! These tools expose the iMessage channel functionality as agent tools,
4//! allowing the LLM to search contacts by name and send iMessages directly.
5//! macOS only.
6
7use 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
15// ── Contact Search Tool ────────────────────────────────────────────────────
16
17/// Tool that searches macOS Contacts by name and returns matching entries
18/// with phone numbers and email addresses.
19pub 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
99// ── Send Message Tool ──────────────────────────────────────────────────────
100
101/// Tool that sends an iMessage to a recipient via Messages.app.
102pub 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
173// ── Read Messages Tool ─────────────────────────────────────────────────────
174
175/// Tool that reads recent incoming iMessages from the Messages database.
176pub 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// ── AppleScript helpers ────────────────────────────────────────────────────
250
251/// A contact result from AppleScript Contacts search.
252#[derive(Debug)]
253struct ContactResult {
254    name: String,
255    phone: Option<String>,
256    email: Option<String>,
257}
258
259/// An incoming iMessage from the database.
260#[derive(Debug)]
261struct IncomingMessage {
262    sender: String,
263    text: String,
264}
265
266/// Search macOS Contacts via AppleScript.
267async 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
338/// Send an iMessage via AppleScript.
339async 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
365/// Read recent incoming iMessages using sqlite3 on the Messages database.
366async 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    // macOS Messages uses Apple's Core Data epoch (2001-01-01) in nanoseconds.
371    // We compute seconds-ago × 1e9 for the date comparison.
372    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        // Full Disk Access may be needed
395        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    // --- Security tests ---
501
502    #[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        // read tool has no required fields (minutes and limit are optional with defaults)
544        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}