car_integrations/messages/
mod.rs1use serde::{Deserialize, Serialize};
9
10use super::{Availability, IntegrationError};
11
12pub 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 pub recipient: String,
55 pub body: String,
56 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}