chat_system/messengers/
imessage.rs1#[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}