Skip to main content

chat_system/messengers/
imessage.rs

1//! iMessage messenger — macOS Messages.app integration.
2
3use crate::{Message, Messenger};
4use anyhow::{anyhow, Context, Result};
5use async_trait::async_trait;
6use std::path::PathBuf;
7use tokio::sync::Mutex;
8
9#[cfg(target_os = "macos")]
10use rusqlite::{params, Connection};
11
12pub struct IMessageMessenger {
13    name: String,
14    chat_db_path: PathBuf,
15    last_seen_rowid: Mutex<Option<i64>>,
16    connected: bool,
17}
18
19impl IMessageMessenger {
20    pub fn new(name: impl Into<String>) -> Self {
21        Self {
22            name: name.into(),
23            chat_db_path: default_chat_db_path(),
24            last_seen_rowid: Mutex::new(None),
25            connected: false,
26        }
27    }
28
29    pub fn with_chat_db_path(mut self, path: impl Into<PathBuf>) -> Self {
30        self.chat_db_path = path.into();
31        self
32    }
33
34    #[cfg(target_os = "macos")]
35    fn max_rowid(path: &PathBuf) -> Result<Option<i64>> {
36        let conn = Connection::open(path)
37            .with_context(|| format!("Failed to open iMessage database at {}", path.display()))?;
38        let rowid = conn.query_row("SELECT MAX(ROWID) FROM message", [], |row| row.get(0))?;
39        Ok(rowid)
40    }
41
42    #[cfg(target_os = "macos")]
43    fn fetch_messages(path: &PathBuf, since_rowid: i64, own_name: &str) -> Result<(Vec<Message>, Option<i64>)> {
44        let conn = Connection::open(path)
45            .with_context(|| format!("Failed to open iMessage database at {}", path.display()))?;
46
47        let mut stmt = conn.prepare(
48            "SELECT
49                m.ROWID,
50                COALESCE(m.guid, printf('imessage:%lld', m.ROWID)) AS guid,
51                COALESCE(m.text, '') AS text,
52                COALESCE(h.id, '') AS sender_handle,
53                COALESCE(c.chat_identifier, h.id, '') AS channel_id,
54                COALESCE(c.display_name, '') AS display_name,
55                COALESCE(m.is_from_me, 0) AS is_from_me,
56                COALESCE(m.thread_originator_guid, '') AS reply_to,
57                CASE
58                    WHEN m.date > 1000000000000 THEN (m.date / 1000000000) + 978307200
59                    WHEN m.date > 0 THEN m.date + 978307200
60                    ELSE strftime('%s','now')
61                END AS unix_ts
62             FROM message m
63             LEFT JOIN handle h ON h.ROWID = m.handle_id
64             LEFT JOIN chat_message_join cmj ON cmj.message_id = m.ROWID
65             LEFT JOIN chat c ON c.ROWID = cmj.chat_id
66             WHERE m.ROWID > ?1 AND COALESCE(m.text, '') <> ''
67             GROUP BY m.ROWID
68             ORDER BY m.ROWID ASC",
69        )?;
70
71        let mut rows = stmt.query(params![since_rowid])?;
72        let mut messages = Vec::new();
73        let mut max_rowid = None;
74
75        while let Some(row) = rows.next()? {
76            let rowid: i64 = row.get(0)?;
77            let guid: String = row.get(1)?;
78            let text: String = row.get(2)?;
79            let sender_handle: String = row.get(3)?;
80            let channel_id: String = row.get(4)?;
81            let display_name: String = row.get(5)?;
82            let is_from_me: i64 = row.get(6)?;
83            let reply_to: String = row.get(7)?;
84            let unix_ts: i64 = row.get(8)?;
85
86            max_rowid = Some(rowid);
87            messages.push(Message {
88                id: guid,
89                sender: if is_from_me != 0 {
90                    own_name.to_string()
91                } else if sender_handle.is_empty() {
92                    "unknown".to_string()
93                } else {
94                    sender_handle.clone()
95                },
96                content: text,
97                timestamp: unix_ts,
98                channel: if channel_id.is_empty() { None } else { Some(channel_id) },
99                reply_to: if reply_to.is_empty() { None } else { Some(reply_to) },
100                media: None,
101                is_direct: display_name.is_empty(),
102                reactions: None,
103            });
104        }
105
106        Ok((messages, max_rowid))
107    }
108
109    #[cfg(target_os = "macos")]
110    async fn send_via_applescript(&self, recipient: &str, content: &str) -> Result<String> {
111        let script = format!(
112            r#"tell application "Messages"
113    set targetService to 1st service whose service type = iMessage
114    set targetBuddy to buddy "{}" of targetService
115    send "{}" to targetBuddy
116end tell"#,
117            escape_applescript_string(recipient),
118            escape_applescript_string(content)
119        );
120
121        let output = tokio::process::Command::new("osascript")
122            .arg("-e")
123            .arg(&script)
124            .output()
125            .await
126            .context("Failed to launch osascript for iMessage send")?;
127
128        if output.status.success() {
129            Ok(format!("imessage:{}", chrono::Utc::now().timestamp_millis()))
130        } else {
131            let stderr = String::from_utf8_lossy(&output.stderr);
132            anyhow::bail!("iMessage AppleScript failed: {}", stderr.trim());
133        }
134    }
135}
136
137#[async_trait]
138impl Messenger for IMessageMessenger {
139    fn name(&self) -> &str {
140        &self.name
141    }
142
143    fn messenger_type(&self) -> &str {
144        "imessage"
145    }
146
147    async fn initialize(&mut self) -> Result<()> {
148        #[cfg(target_os = "macos")]
149        {
150            let path = self.chat_db_path.clone();
151            if !path.exists() {
152                anyhow::bail!(
153                    "iMessage database not found at {}. Open Messages.app and allow Full Disk Access if needed.",
154                    path.display()
155                );
156            }
157
158            let max_rowid = tokio::task::spawn_blocking(move || Self::max_rowid(&path))
159                .await
160                .map_err(|e| anyhow!("Failed to join iMessage initialization task: {e}"))??;
161            *self.last_seen_rowid.lock().await = max_rowid;
162            self.connected = true;
163            Ok(())
164        }
165        #[cfg(not(target_os = "macos"))]
166        {
167            anyhow::bail!("iMessage is only supported on macOS");
168        }
169    }
170
171    async fn send_message(&self, recipient: &str, content: &str) -> Result<String> {
172        #[cfg(target_os = "macos")]
173        {
174            self.send_via_applescript(recipient, content).await
175        }
176        #[cfg(not(target_os = "macos"))]
177        {
178            let _ = (recipient, content);
179            anyhow::bail!("iMessage is only supported on macOS");
180        }
181    }
182
183    async fn receive_messages(&self) -> Result<Vec<Message>> {
184        #[cfg(target_os = "macos")]
185        {
186            if !self.connected {
187                return Ok(Vec::new());
188            }
189
190            let since_rowid = self.last_seen_rowid.lock().await.unwrap_or(0);
191            let path = self.chat_db_path.clone();
192            let own_name = self.name.clone();
193            let (messages, max_rowid) = tokio::task::spawn_blocking(move || {
194                Self::fetch_messages(&path, since_rowid, &own_name)
195            })
196            .await
197            .map_err(|e| anyhow!("Failed to join iMessage receive task: {e}"))??;
198            if let Some(max_rowid) = max_rowid {
199                *self.last_seen_rowid.lock().await = Some(max_rowid);
200            }
201            Ok(messages)
202        }
203        #[cfg(not(target_os = "macos"))]
204        {
205            Ok(Vec::new())
206        }
207    }
208
209    fn is_connected(&self) -> bool {
210        self.connected
211    }
212
213    async fn disconnect(&mut self) -> Result<()> {
214        self.connected = false;
215        Ok(())
216    }
217}
218
219fn default_chat_db_path() -> PathBuf {
220    std::env::var_os("HOME")
221        .map(PathBuf::from)
222        .unwrap_or_default()
223        .join("Library")
224        .join("Messages")
225        .join("chat.db")
226}
227
228fn escape_applescript_string(value: &str) -> String {
229    value.replace('\\', "\\\\").replace('"', "\\\"")
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    #[test]
237    fn escape_applescript_quotes_and_backslashes() {
238        let escaped = escape_applescript_string(r#"a\b"c"#);
239        assert_eq!(escaped, r#"a\\b\"c"#);
240    }
241
242    #[test]
243    fn default_chat_db_path_points_to_messages_db() {
244        let path = default_chat_db_path();
245        assert!(path.ends_with(PathBuf::from("Library/Messages/chat.db")));
246    }
247}