Skip to main content

chat_system/messengers/
imessage.rs

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