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