Skip to main content

rustant_core/channels/
imessage.rs

1//! iMessage channel via AppleScript bridge.
2//!
3//! Uses `osascript` to send messages via Messages.app and reads from the
4//! Messages SQLite database. macOS-only.
5
6use super::{
7    Channel, ChannelCapabilities, ChannelMessage, ChannelStatus, ChannelType, MessageId,
8    StreamingMode,
9};
10use crate::error::{ChannelError, RustantError};
11use async_trait::async_trait;
12use serde::{Deserialize, Serialize};
13
14/// Configuration for an iMessage channel.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct IMessageConfig {
17    pub enabled: bool,
18    pub polling_interval_ms: u64,
19}
20
21impl Default for IMessageConfig {
22    fn default() -> Self {
23        Self {
24            enabled: false,
25            polling_interval_ms: 5000,
26        }
27    }
28}
29
30/// A resolved macOS contact.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct ResolvedContact {
33    pub name: String,
34    pub phone: Option<String>,
35    pub email: Option<String>,
36}
37
38/// Trait for iMessage interactions, allowing test mocking.
39#[async_trait]
40pub trait IMessageBridge: Send + Sync {
41    async fn send_message(&self, recipient: &str, text: &str) -> Result<(), String>;
42    async fn receive_messages(&self) -> Result<Vec<IMessageIncoming>, String>;
43    async fn is_available(&self) -> Result<bool, String>;
44    /// Search macOS Contacts for a name, returning matching contacts with phone/email.
45    async fn resolve_contact(&self, query: &str) -> Result<Vec<ResolvedContact>, String>;
46}
47
48/// An incoming iMessage.
49#[derive(Debug, Clone)]
50pub struct IMessageIncoming {
51    pub sender: String,
52    pub text: String,
53    pub timestamp: u64,
54}
55
56/// iMessage channel.
57pub struct IMessageChannel {
58    config: IMessageConfig,
59    status: ChannelStatus,
60    bridge: Box<dyn IMessageBridge>,
61    name: String,
62}
63
64impl IMessageChannel {
65    pub fn new(config: IMessageConfig, bridge: Box<dyn IMessageBridge>) -> Self {
66        Self {
67            config,
68            status: ChannelStatus::Disconnected,
69            bridge,
70            name: "imessage".to_string(),
71        }
72    }
73
74    pub fn with_name(mut self, name: impl Into<String>) -> Self {
75        self.name = name.into();
76        self
77    }
78
79    /// Search macOS Contacts by name and return matching entries.
80    pub async fn resolve_contact(&self, query: &str) -> Result<Vec<ResolvedContact>, String> {
81        self.bridge.resolve_contact(query).await
82    }
83
84    /// Send an iMessage to a recipient (phone number or email) via the bridge.
85    /// This can be used directly without going through the Channel trait.
86    pub async fn send_imessage(&self, recipient: &str, text: &str) -> Result<(), RustantError> {
87        if self.status != ChannelStatus::Connected {
88            return Err(RustantError::Channel(ChannelError::NotConnected {
89                name: self.name.clone(),
90            }));
91        }
92        self.bridge
93            .send_message(recipient, text)
94            .await
95            .map_err(|e| {
96                RustantError::Channel(ChannelError::SendFailed {
97                    name: self.name.clone(),
98                    message: e,
99                })
100            })
101    }
102}
103
104#[async_trait]
105impl Channel for IMessageChannel {
106    fn name(&self) -> &str {
107        &self.name
108    }
109
110    fn channel_type(&self) -> ChannelType {
111        ChannelType::IMessage
112    }
113
114    async fn connect(&mut self) -> Result<(), RustantError> {
115        if !self.config.enabled {
116            return Err(RustantError::Channel(ChannelError::ConnectionFailed {
117                name: self.name.clone(),
118                message: "iMessage channel is not enabled".into(),
119            }));
120        }
121        let available = self.bridge.is_available().await.map_err(|e| {
122            RustantError::Channel(ChannelError::ConnectionFailed {
123                name: self.name.clone(),
124                message: e,
125            })
126        })?;
127        if !available {
128            return Err(RustantError::Channel(ChannelError::ConnectionFailed {
129                name: self.name.clone(),
130                message: "Messages.app not available".into(),
131            }));
132        }
133        self.status = ChannelStatus::Connected;
134        Ok(())
135    }
136
137    async fn disconnect(&mut self) -> Result<(), RustantError> {
138        self.status = ChannelStatus::Disconnected;
139        Ok(())
140    }
141
142    async fn send_message(&self, msg: ChannelMessage) -> Result<MessageId, RustantError> {
143        if self.status != ChannelStatus::Connected {
144            return Err(RustantError::Channel(ChannelError::NotConnected {
145                name: self.name.clone(),
146            }));
147        }
148        let text = msg.content.as_text().unwrap_or("");
149        self.bridge
150            .send_message(&msg.channel_id, text)
151            .await
152            .map_err(|e| {
153                RustantError::Channel(ChannelError::SendFailed {
154                    name: self.name.clone(),
155                    message: e,
156                })
157            })?;
158        Ok(MessageId::random())
159    }
160
161    async fn receive_messages(&self) -> Result<Vec<ChannelMessage>, RustantError> {
162        let incoming = self.bridge.receive_messages().await.map_err(|e| {
163            RustantError::Channel(ChannelError::ConnectionFailed {
164                name: self.name.clone(),
165                message: e,
166            })
167        })?;
168
169        let messages = incoming
170            .into_iter()
171            .map(|m| {
172                let sender = super::ChannelUser::new(&m.sender, ChannelType::IMessage);
173                ChannelMessage::text(ChannelType::IMessage, &m.sender, sender, &m.text)
174            })
175            .collect();
176
177        Ok(messages)
178    }
179
180    fn status(&self) -> ChannelStatus {
181        self.status
182    }
183
184    fn capabilities(&self) -> ChannelCapabilities {
185        ChannelCapabilities {
186            supports_threads: false,
187            supports_reactions: true,
188            supports_files: true,
189            supports_voice: false,
190            supports_video: false,
191            max_message_length: None,
192            supports_editing: false,
193            supports_deletion: false,
194        }
195    }
196
197    fn streaming_mode(&self) -> StreamingMode {
198        StreamingMode::Polling {
199            interval_ms: self.config.polling_interval_ms,
200        }
201    }
202}
203
204/// Real iMessage bridge using osascript (macOS only).
205#[cfg(target_os = "macos")]
206pub struct RealIMessageBridge;
207
208#[cfg(target_os = "macos")]
209impl Default for RealIMessageBridge {
210    fn default() -> Self {
211        Self
212    }
213}
214
215#[cfg(target_os = "macos")]
216impl RealIMessageBridge {
217    pub fn new() -> Self {
218        Self
219    }
220}
221
222#[cfg(target_os = "macos")]
223#[async_trait]
224impl IMessageBridge for RealIMessageBridge {
225    async fn send_message(&self, recipient: &str, text: &str) -> Result<(), String> {
226        let escaped_recipient = recipient.replace('"', "\\\"");
227        let escaped_text = text.replace('"', "\\\"");
228        let script = format!(
229            "tell application \"Messages\"\n\
230             \tset targetService to 1st service whose service type = iMessage\n\
231             \tset targetBuddy to buddy \"{}\" of targetService\n\
232             \tsend \"{}\" to targetBuddy\n\
233             end tell",
234            escaped_recipient, escaped_text,
235        );
236
237        let output = tokio::process::Command::new("osascript")
238            .args(["-e", &script])
239            .output()
240            .await
241            .map_err(|e| format!("Failed to run osascript: {e}"))?;
242
243        if !output.status.success() {
244            let stderr = String::from_utf8_lossy(&output.stderr);
245            return Err(format!("osascript failed: {}", stderr));
246        }
247
248        Ok(())
249    }
250
251    async fn receive_messages(&self) -> Result<Vec<IMessageIncoming>, String> {
252        let home = std::env::var("HOME").map_err(|_| "HOME not set".to_string())?;
253        let db_path = format!("{}/Library/Messages/chat.db", home);
254
255        let output = tokio::process::Command::new("sqlite3")
256            .args([
257                &db_path,
258                "-json",
259                "SELECT m.ROWID, m.text, h.id as sender, m.date \
260                 FROM message m \
261                 JOIN handle h ON m.handle_id = h.ROWID \
262                 WHERE m.is_from_me = 0 \
263                 AND m.date > strftime('%s', 'now', '-60 seconds') * 1000000000 \
264                 ORDER BY m.date DESC \
265                 LIMIT 20;",
266            ])
267            .output()
268            .await
269            .map_err(|e| format!("Failed to read Messages DB: {e}"))?;
270
271        let stdout = String::from_utf8_lossy(&output.stdout);
272        if stdout.trim().is_empty() {
273            return Ok(vec![]);
274        }
275
276        let rows: Vec<serde_json::Value> =
277            serde_json::from_str(&stdout).map_err(|e| format!("JSON parse error: {e}"))?;
278
279        let messages = rows
280            .iter()
281            .filter_map(|r| {
282                Some(IMessageIncoming {
283                    sender: r["sender"].as_str()?.to_string(),
284                    text: r["text"].as_str().unwrap_or("").to_string(),
285                    timestamp: r["date"].as_u64().unwrap_or(0),
286                })
287            })
288            .collect();
289
290        Ok(messages)
291    }
292
293    async fn is_available(&self) -> Result<bool, String> {
294        let output = tokio::process::Command::new("osascript")
295            .args([
296                "-e",
297                "tell application \"System Events\" to (name of processes) contains \"Messages\"",
298            ])
299            .output()
300            .await
301            .map_err(|e| format!("Failed to check Messages.app: {e}"))?;
302
303        let stdout = String::from_utf8_lossy(&output.stdout);
304        Ok(stdout.trim() == "true")
305    }
306
307    async fn resolve_contact(&self, query: &str) -> Result<Vec<ResolvedContact>, String> {
308        let escaped_query = query.replace('"', "\\\"");
309        let script = format!(
310            r#"tell application "Contacts"
311    set matchingPeople to every person whose name contains "{query}"
312    set output to ""
313    repeat with p in matchingPeople
314        set pName to name of p
315        set pPhone to ""
316        set pEmail to ""
317        try
318            set pPhone to value of phone 1 of p
319        end try
320        try
321            set pEmail to value of email 1 of p
322        end try
323        set output to output & pName & "||" & pPhone & "||" & pEmail & "%%"
324    end repeat
325    return output
326end tell"#,
327            query = escaped_query
328        );
329
330        let output = tokio::process::Command::new("osascript")
331            .args(["-e", &script])
332            .output()
333            .await
334            .map_err(|e| format!("Failed to run osascript: {e}"))?;
335
336        if !output.status.success() {
337            let stderr = String::from_utf8_lossy(&output.stderr);
338            return Err(format!("Contacts lookup failed: {}", stderr));
339        }
340
341        let stdout = String::from_utf8_lossy(&output.stdout);
342        let contacts = stdout
343            .trim()
344            .split("%%")
345            .filter(|s| !s.is_empty())
346            .filter_map(|entry| {
347                let parts: Vec<&str> = entry.split("||").collect();
348                if parts.is_empty() {
349                    return None;
350                }
351                let name = parts[0].trim().to_string();
352                if name.is_empty() {
353                    return None;
354                }
355                let phone = parts.get(1).and_then(|p| {
356                    let p = p.trim();
357                    if p.is_empty() {
358                        None
359                    } else {
360                        Some(p.to_string())
361                    }
362                });
363                let email = parts.get(2).and_then(|e| {
364                    let e = e.trim();
365                    if e.is_empty() {
366                        None
367                    } else {
368                        Some(e.to_string())
369                    }
370                });
371                Some(ResolvedContact { name, phone, email })
372            })
373            .collect();
374
375        Ok(contacts)
376    }
377}
378
379/// Create an iMessage channel with the real osascript bridge.
380#[cfg(target_os = "macos")]
381pub fn create_imessage_channel(config: IMessageConfig) -> IMessageChannel {
382    IMessageChannel::new(config, Box::new(RealIMessageBridge::new()))
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388
389    struct MockIMessageBridge {
390        available: bool,
391    }
392
393    impl MockIMessageBridge {
394        fn new(available: bool) -> Self {
395            Self { available }
396        }
397    }
398
399    #[async_trait]
400    impl IMessageBridge for MockIMessageBridge {
401        async fn send_message(&self, _recipient: &str, _text: &str) -> Result<(), String> {
402            Ok(())
403        }
404        async fn receive_messages(&self) -> Result<Vec<IMessageIncoming>, String> {
405            Ok(vec![])
406        }
407        async fn is_available(&self) -> Result<bool, String> {
408            Ok(self.available)
409        }
410        async fn resolve_contact(&self, query: &str) -> Result<Vec<ResolvedContact>, String> {
411            // Return a fake contact for testing
412            Ok(vec![ResolvedContact {
413                name: format!("Mock {}", query),
414                phone: Some("+1234567890".to_string()),
415                email: Some("mock@example.com".to_string()),
416            }])
417        }
418    }
419
420    #[test]
421    fn test_imessage_channel_creation() {
422        let ch = IMessageChannel::new(
423            IMessageConfig::default(),
424            Box::new(MockIMessageBridge::new(true)),
425        );
426        assert_eq!(ch.name(), "imessage");
427        assert_eq!(ch.channel_type(), ChannelType::IMessage);
428    }
429
430    #[test]
431    fn test_imessage_capabilities() {
432        let ch = IMessageChannel::new(
433            IMessageConfig::default(),
434            Box::new(MockIMessageBridge::new(true)),
435        );
436        let caps = ch.capabilities();
437        assert!(caps.supports_reactions);
438        assert!(caps.supports_files);
439        assert!(!caps.supports_threads);
440    }
441
442    #[test]
443    fn test_imessage_streaming_mode() {
444        let ch = IMessageChannel::new(
445            IMessageConfig::default(),
446            Box::new(MockIMessageBridge::new(true)),
447        );
448        assert_eq!(
449            ch.streaming_mode(),
450            StreamingMode::Polling { interval_ms: 5000 }
451        );
452    }
453
454    #[test]
455    fn test_imessage_status_disconnected() {
456        let ch = IMessageChannel::new(
457            IMessageConfig::default(),
458            Box::new(MockIMessageBridge::new(true)),
459        );
460        assert_eq!(ch.status(), ChannelStatus::Disconnected);
461        assert!(!ch.is_connected());
462    }
463
464    #[tokio::test]
465    async fn test_imessage_send_without_connect() {
466        let ch = IMessageChannel::new(
467            IMessageConfig::default(),
468            Box::new(MockIMessageBridge::new(true)),
469        );
470        let sender = super::super::ChannelUser::new("me", ChannelType::IMessage);
471        let msg = ChannelMessage::text(ChannelType::IMessage, "+1234", sender, "hi");
472        let result = ch.send_message(msg).await;
473        assert!(result.is_err());
474    }
475}