Skip to main content

car_integrations/messages/
mod.rs

1//! Messages capability — enumerate Messages.app services/chats and send.
2//!
3//! macOS exposes no public Messages framework for third-party apps. This
4//! backend uses the public Apple Events automation surface of Messages.app,
5//! so callers must have Automation consent for the caller -> Messages.app
6//! pair.
7
8use serde::{Deserialize, Serialize};
9
10use super::{Availability, IntegrationError};
11
12/// Net-new inbound chat.db reader (`message` JOIN `handle`) + persisted ROWID
13/// watermark for the iMessage approval transport. The existing reader above
14/// only queries the `chat` table; this is the inbound read half.
15pub mod read;
16pub use read::{
17    decode_attributed_body, max_rowid_in_db, parse_inbound_rows, read_inbound_from_db,
18    resolve_body, InboundMessage, Watermark,
19};
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct MessageService {
23    pub id: String,
24    pub name: String,
25    pub service_type: Option<String>,
26    pub enabled: bool,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct Chat {
31    pub id: String,
32    pub name: Option<String>,
33    #[serde(default)]
34    pub participants: Vec<String>,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct ServiceListing {
39    #[serde(flatten)]
40    pub availability: Availability,
41    pub services: Vec<MessageService>,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct ChatListing {
46    #[serde(flatten)]
47    pub availability: Availability,
48    pub chats: Vec<Chat>,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct SendRequest {
53    /// Phone number, email address, buddy handle, or chat id.
54    pub recipient: String,
55    pub body: String,
56    /// Optional Messages service id/name, e.g. "iMessage".
57    pub service_id: Option<String>,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct SendResult {
62    #[serde(flatten)]
63    pub availability: Availability,
64    pub sent: bool,
65}
66
67pub fn list_services() -> Result<ServiceListing, IntegrationError> {
68    backend::list_services()
69}
70
71pub fn list_chats(limit: usize) -> Result<ChatListing, IntegrationError> {
72    backend::list_chats(limit)
73}
74
75pub fn send(req: SendRequest) -> Result<SendResult, IntegrationError> {
76    backend::send(req)
77}
78
79#[cfg(target_os = "macos")]
80mod backend {
81    use super::*;
82    use serde::Deserialize;
83    use std::path::PathBuf;
84    use std::process::Command;
85
86    const JXA: &str = r#"
87function app() {
88  const Messages = Application("/System/Applications/Messages.app");
89  Messages.includeStandardAdditions = true;
90  return Messages;
91}
92
93function normalizeService(service) {
94  let id = "";
95  let name = "";
96  let serviceType = null;
97  let enabled = true;
98  try { id = String(service.id()); } catch (e) {}
99  try { name = String(service.description()); } catch (e) {}
100  if (!name) { try { name = String(service.name()); } catch (e) {} }
101  try { serviceType = String(service.serviceType()); } catch (e) {}
102  try { enabled = !!service.enabled(); } catch (e) {}
103  return { id: id || name, name: name || id, service_type: serviceType, enabled: enabled };
104}
105
106function normalizeChat(chat) {
107  let id = "";
108  let name = null;
109  let participants = [];
110  try { id = String(chat.id()); } catch (e) {}
111  try { name = String(chat.name()); } catch (e) {}
112  try {
113    participants = chat.participants().map(p => {
114      try { return String(p.handle()); } catch (e) {}
115      try { return String(p.name()); } catch (e) {}
116      try { return String(p.id()); } catch (e) {}
117      return "";
118    }).filter(x => x.length > 0);
119  } catch (e) {}
120  return { id: id || name || "", name: name, participants: participants };
121}
122
123function pickService(Messages, serviceId) {
124  const services = Messages.accounts();
125  if (services.length === 0) return null;
126  if (!serviceId) return services[0];
127  for (let i = 0; i < services.length; i++) {
128    const normalized = normalizeService(services[i]);
129    if (normalized.id === serviceId || normalized.name === serviceId || normalized.service_type === serviceId) {
130      return services[i];
131    }
132  }
133  return services[0];
134}
135
136function run(argv) {
137  const mode = argv[0] || "services";
138  let Messages;
139  try {
140    Messages = app();
141  } catch (e) {
142    const reason = String(e);
143    return JSON.stringify({available:false, backend:"messages_app", reason:reason, services:[], chats:[], sent:false});
144  }
145
146  if (mode === "services") {
147    try {
148      return JSON.stringify({available:true, backend:"messages_app", reason:null, services: Messages.accounts().map(normalizeService)});
149    } catch (e) {
150      return JSON.stringify({available:false, backend:"messages_app", reason:String(e), services:[]});
151    }
152  }
153
154  if (mode === "chats") {
155    const limit = Number(argv[1] || "50");
156    try {
157      return JSON.stringify({available:true, backend:"messages_app", reason:null, chats: Messages.chats().slice(0, limit).map(normalizeChat)});
158    } catch (e) {
159      return JSON.stringify({available:false, backend:"messages_app", reason:String(e), chats:[]});
160    }
161  }
162
163  if (mode === "send") {
164    try {
165      const req = JSON.parse(argv[1] || "{}");
166      const target = String(req.recipient || "");
167      const body = String(req.body || "");
168      if (!target || !body) {
169        return JSON.stringify({available:false, backend:"messages_app", reason:"recipient and body are required", sent:false});
170      }
171      const service = pickService(Messages, req.service_id || null);
172      if (!service) {
173        return JSON.stringify({available:false, backend:"messages_app", reason:"Messages has no available services", sent:false});
174      }
175      let destination = null;
176      try { destination = Messages.chats.byId(target); destination.id(); } catch (e) { destination = null; }
177      if (!destination) {
178        try { destination = service.participants.byName(target); destination.name(); } catch (e) { destination = null; }
179      }
180      if (!destination) {
181        try { destination = service.participants.byId(target); destination.id(); } catch (e) { destination = null; }
182      }
183      if (!destination) {
184        return JSON.stringify({available:false, backend:"messages_app", reason:"recipient not found in Messages service", sent:false});
185      }
186      Messages.send(body, {to: destination});
187      return JSON.stringify({available:true, backend:"messages_app", reason:null, sent:true});
188    } catch (e) {
189      return JSON.stringify({available:false, backend:"messages_app", reason:String(e), sent:false});
190    }
191  }
192
193  return JSON.stringify({available:false, backend:"messages_app", reason:"unknown messages mode", services:[], chats:[], sent:false});
194}
195"#;
196
197    pub fn list_services() -> Result<ServiceListing, IntegrationError> {
198        let scripted: ServiceListing = run_jxa(&["services"]).unwrap_or_else(|e| ServiceListing {
199            availability: Availability::pending("messages_app", e.to_string()),
200            services: vec![],
201        });
202        if scripted.availability.available || !scripted.services.is_empty() {
203            return Ok(scripted);
204        }
205
206        let accounts = car_accounts::list().map_err(|e| {
207            IntegrationError::Backend(format!("messages account fallback failed: {e}"))
208        })?;
209        let services = accounts
210            .accounts
211            .into_iter()
212            .filter(|account| account.capabilities.iter().any(|cap| cap == "messages"))
213            .map(|account| MessageService {
214                id: account.id,
215                name: account.label,
216                service_type: Some(account.provider),
217                enabled: account.authenticated,
218            })
219            .collect::<Vec<_>>();
220
221        if services.is_empty() {
222            Ok(scripted)
223        } else {
224            Ok(ServiceListing {
225                availability: Availability::available("apple_internet_accounts"),
226                services,
227            })
228        }
229    }
230
231    pub fn list_chats(limit: usize) -> Result<ChatListing, IntegrationError> {
232        let limit = limit.to_string();
233        let scripted: ChatListing =
234            run_jxa(&["chats", limit.as_str()]).unwrap_or_else(|e| ChatListing {
235                availability: Availability::pending("messages_app", e.to_string()),
236                chats: vec![],
237            });
238        if scripted.availability.available || !scripted.chats.is_empty() {
239            return Ok(scripted);
240        }
241        list_chats_from_database(limit.as_str(), scripted.availability.reason)
242    }
243
244    pub fn send(req: SendRequest) -> Result<SendResult, IntegrationError> {
245        let req_json = serde_json::to_string(&req)
246            .map_err(|e| IntegrationError::Backend(format!("messages request json: {e}")))?;
247        run_jxa(&["send", req_json.as_str()])
248    }
249
250    fn run_jxa<T: serde::de::DeserializeOwned>(args: &[&str]) -> Result<T, IntegrationError> {
251        let output = Command::new("/usr/bin/osascript")
252            .arg("-l")
253            .arg("JavaScript")
254            .arg("-")
255            .args(args)
256            .stdin(std::process::Stdio::piped())
257            .stdout(std::process::Stdio::piped())
258            .stderr(std::process::Stdio::piped())
259            .spawn()
260            .and_then(|mut child| {
261                use std::io::Write;
262                if let Some(stdin) = child.stdin.as_mut() {
263                    stdin.write_all(JXA.as_bytes())?;
264                }
265                child.wait_with_output()
266            })
267            .map_err(|e| IntegrationError::Backend(format!("osascript: {e}")))?;
268
269        if !output.status.success() {
270            let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
271            return Err(IntegrationError::Backend(format!(
272                "messages osascript failed: {stderr}"
273            )));
274        }
275
276        serde_json::from_slice(&output.stdout)
277            .map_err(|e| IntegrationError::Backend(format!("messages json: {e}")))
278    }
279
280    #[derive(Deserialize)]
281    struct ChatDbRow {
282        id: String,
283        name: Option<String>,
284        participants: Option<String>,
285    }
286
287    fn list_chats_from_database(
288        limit: &str,
289        automation_reason: Option<String>,
290    ) -> Result<ChatListing, IntegrationError> {
291        let mut db = PathBuf::from(std::env::var_os("HOME").unwrap_or_default());
292        db.push("Library/Messages/chat.db");
293        let query = format!(
294            "SELECT chat.guid AS id, \
295             COALESCE(NULLIF(chat.display_name, ''), chat.chat_identifier) AS name, \
296             COALESCE(group_concat(handle.id, ', '), '') AS participants \
297             FROM chat \
298             LEFT JOIN chat_handle_join ON chat_handle_join.chat_id = chat.ROWID \
299             LEFT JOIN handle ON handle.ROWID = chat_handle_join.handle_id \
300             GROUP BY chat.ROWID \
301             ORDER BY chat.last_read_message_timestamp DESC, chat.ROWID DESC \
302             LIMIT {};",
303            limit.parse::<u32>().unwrap_or(50)
304        );
305        let output = Command::new("/usr/bin/sqlite3")
306            .arg("-json")
307            .arg(db)
308            .arg(query)
309            .output()
310            .map_err(|e| IntegrationError::Backend(format!("messages sqlite: {e}")))?;
311
312        if !output.status.success() {
313            let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
314            let reason = match automation_reason {
315                Some(prev) if !prev.is_empty() => {
316                    format!("{prev}; Messages chat database fallback failed: {stderr}")
317                }
318                _ => format!("Messages chat database fallback failed: {stderr}"),
319            };
320            return Ok(ChatListing {
321                availability: Availability::pending("messages_chat_db", reason),
322                chats: vec![],
323            });
324        }
325
326        let rows: Vec<ChatDbRow> = serde_json::from_slice(&output.stdout)
327            .map_err(|e| IntegrationError::Backend(format!("messages sqlite json: {e}")))?;
328        let chats = rows
329            .into_iter()
330            .map(|row| Chat {
331                id: row.id,
332                name: row.name,
333                participants: row
334                    .participants
335                    .unwrap_or_default()
336                    .split(", ")
337                    .filter(|participant| !participant.is_empty())
338                    .map(str::to_string)
339                    .collect(),
340            })
341            .collect();
342
343        Ok(ChatListing {
344            availability: Availability::available("messages_chat_db"),
345            chats,
346        })
347    }
348}
349
350#[cfg(not(target_os = "macos"))]
351mod backend {
352    use super::*;
353
354    pub fn list_services() -> Result<ServiceListing, IntegrationError> {
355        Ok(ServiceListing {
356            availability: current_backend_pending(),
357            services: vec![],
358        })
359    }
360
361    pub fn list_chats(_limit: usize) -> Result<ChatListing, IntegrationError> {
362        Ok(ChatListing {
363            availability: current_backend_pending(),
364            chats: vec![],
365        })
366    }
367
368    pub fn send(_req: SendRequest) -> Result<SendResult, IntegrationError> {
369        Ok(SendResult {
370            availability: current_backend_pending(),
371            sent: false,
372        })
373    }
374
375    fn current_backend_pending() -> Availability {
376        Availability::pending(
377            "messages_app",
378            "Messages integration is only available through Messages.app automation on macOS.",
379        )
380    }
381}