chat_system/messengers/
imessage.rs1use 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}