car_integrations/messages/
mod.rs1use 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 pub recipient: String,
46 pub body: String,
47 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}