use serde::{Deserialize, Serialize};
use super::{Availability, IntegrationError};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MessageService {
pub id: String,
pub name: String,
pub service_type: Option<String>,
pub enabled: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Chat {
pub id: String,
pub name: Option<String>,
#[serde(default)]
pub participants: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceListing {
#[serde(flatten)]
pub availability: Availability,
pub services: Vec<MessageService>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatListing {
#[serde(flatten)]
pub availability: Availability,
pub chats: Vec<Chat>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SendRequest {
pub recipient: String,
pub body: String,
pub service_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SendResult {
#[serde(flatten)]
pub availability: Availability,
pub sent: bool,
}
pub fn list_services() -> Result<ServiceListing, IntegrationError> {
backend::list_services()
}
pub fn list_chats(limit: usize) -> Result<ChatListing, IntegrationError> {
backend::list_chats(limit)
}
pub fn send(req: SendRequest) -> Result<SendResult, IntegrationError> {
backend::send(req)
}
#[cfg(target_os = "macos")]
mod backend {
use super::*;
use serde::Deserialize;
use std::path::PathBuf;
use std::process::Command;
const JXA: &str = r#"
function app() {
const Messages = Application("/System/Applications/Messages.app");
Messages.includeStandardAdditions = true;
return Messages;
}
function normalizeService(service) {
let id = "";
let name = "";
let serviceType = null;
let enabled = true;
try { id = String(service.id()); } catch (e) {}
try { name = String(service.description()); } catch (e) {}
if (!name) { try { name = String(service.name()); } catch (e) {} }
try { serviceType = String(service.serviceType()); } catch (e) {}
try { enabled = !!service.enabled(); } catch (e) {}
return { id: id || name, name: name || id, service_type: serviceType, enabled: enabled };
}
function normalizeChat(chat) {
let id = "";
let name = null;
let participants = [];
try { id = String(chat.id()); } catch (e) {}
try { name = String(chat.name()); } catch (e) {}
try {
participants = chat.participants().map(p => {
try { return String(p.handle()); } catch (e) {}
try { return String(p.name()); } catch (e) {}
try { return String(p.id()); } catch (e) {}
return "";
}).filter(x => x.length > 0);
} catch (e) {}
return { id: id || name || "", name: name, participants: participants };
}
function pickService(Messages, serviceId) {
const services = Messages.accounts();
if (services.length === 0) return null;
if (!serviceId) return services[0];
for (let i = 0; i < services.length; i++) {
const normalized = normalizeService(services[i]);
if (normalized.id === serviceId || normalized.name === serviceId || normalized.service_type === serviceId) {
return services[i];
}
}
return services[0];
}
function run(argv) {
const mode = argv[0] || "services";
let Messages;
try {
Messages = app();
} catch (e) {
const reason = String(e);
return JSON.stringify({available:false, backend:"messages_app", reason:reason, services:[], chats:[], sent:false});
}
if (mode === "services") {
try {
return JSON.stringify({available:true, backend:"messages_app", reason:null, services: Messages.accounts().map(normalizeService)});
} catch (e) {
return JSON.stringify({available:false, backend:"messages_app", reason:String(e), services:[]});
}
}
if (mode === "chats") {
const limit = Number(argv[1] || "50");
try {
return JSON.stringify({available:true, backend:"messages_app", reason:null, chats: Messages.chats().slice(0, limit).map(normalizeChat)});
} catch (e) {
return JSON.stringify({available:false, backend:"messages_app", reason:String(e), chats:[]});
}
}
if (mode === "send") {
try {
const req = JSON.parse(argv[1] || "{}");
const target = String(req.recipient || "");
const body = String(req.body || "");
if (!target || !body) {
return JSON.stringify({available:false, backend:"messages_app", reason:"recipient and body are required", sent:false});
}
const service = pickService(Messages, req.service_id || null);
if (!service) {
return JSON.stringify({available:false, backend:"messages_app", reason:"Messages has no available services", sent:false});
}
let destination = null;
try { destination = Messages.chats.byId(target); destination.id(); } catch (e) { destination = null; }
if (!destination) {
try { destination = service.participants.byName(target); destination.name(); } catch (e) { destination = null; }
}
if (!destination) {
try { destination = service.participants.byId(target); destination.id(); } catch (e) { destination = null; }
}
if (!destination) {
return JSON.stringify({available:false, backend:"messages_app", reason:"recipient not found in Messages service", sent:false});
}
Messages.send(body, {to: destination});
return JSON.stringify({available:true, backend:"messages_app", reason:null, sent:true});
} catch (e) {
return JSON.stringify({available:false, backend:"messages_app", reason:String(e), sent:false});
}
}
return JSON.stringify({available:false, backend:"messages_app", reason:"unknown messages mode", services:[], chats:[], sent:false});
}
"#;
pub fn list_services() -> Result<ServiceListing, IntegrationError> {
let scripted: ServiceListing = run_jxa(&["services"]).unwrap_or_else(|e| ServiceListing {
availability: Availability::pending("messages_app", e.to_string()),
services: vec![],
});
if scripted.availability.available || !scripted.services.is_empty() {
return Ok(scripted);
}
let accounts = car_accounts::list().map_err(|e| {
IntegrationError::Backend(format!("messages account fallback failed: {e}"))
})?;
let services = accounts
.accounts
.into_iter()
.filter(|account| account.capabilities.iter().any(|cap| cap == "messages"))
.map(|account| MessageService {
id: account.id,
name: account.label,
service_type: Some(account.provider),
enabled: account.authenticated,
})
.collect::<Vec<_>>();
if services.is_empty() {
Ok(scripted)
} else {
Ok(ServiceListing {
availability: Availability::available("apple_internet_accounts"),
services,
})
}
}
pub fn list_chats(limit: usize) -> Result<ChatListing, IntegrationError> {
let limit = limit.to_string();
let scripted: ChatListing =
run_jxa(&["chats", limit.as_str()]).unwrap_or_else(|e| ChatListing {
availability: Availability::pending("messages_app", e.to_string()),
chats: vec![],
});
if scripted.availability.available || !scripted.chats.is_empty() {
return Ok(scripted);
}
list_chats_from_database(limit.as_str(), scripted.availability.reason)
}
pub fn send(req: SendRequest) -> Result<SendResult, IntegrationError> {
let req_json = serde_json::to_string(&req)
.map_err(|e| IntegrationError::Backend(format!("messages request json: {e}")))?;
run_jxa(&["send", req_json.as_str()])
}
fn run_jxa<T: serde::de::DeserializeOwned>(args: &[&str]) -> Result<T, IntegrationError> {
let output = Command::new("/usr/bin/osascript")
.arg("-l")
.arg("JavaScript")
.arg("-")
.args(args)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.and_then(|mut child| {
use std::io::Write;
if let Some(stdin) = child.stdin.as_mut() {
stdin.write_all(JXA.as_bytes())?;
}
child.wait_with_output()
})
.map_err(|e| IntegrationError::Backend(format!("osascript: {e}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
return Err(IntegrationError::Backend(format!(
"messages osascript failed: {stderr}"
)));
}
serde_json::from_slice(&output.stdout)
.map_err(|e| IntegrationError::Backend(format!("messages json: {e}")))
}
#[derive(Deserialize)]
struct ChatDbRow {
id: String,
name: Option<String>,
participants: Option<String>,
}
fn list_chats_from_database(
limit: &str,
automation_reason: Option<String>,
) -> Result<ChatListing, IntegrationError> {
let mut db = PathBuf::from(std::env::var_os("HOME").unwrap_or_default());
db.push("Library/Messages/chat.db");
let query = format!(
"SELECT chat.guid AS id, \
COALESCE(NULLIF(chat.display_name, ''), chat.chat_identifier) AS name, \
COALESCE(group_concat(handle.id, ', '), '') AS participants \
FROM chat \
LEFT JOIN chat_handle_join ON chat_handle_join.chat_id = chat.ROWID \
LEFT JOIN handle ON handle.ROWID = chat_handle_join.handle_id \
GROUP BY chat.ROWID \
ORDER BY chat.last_read_message_timestamp DESC, chat.ROWID DESC \
LIMIT {};",
limit.parse::<u32>().unwrap_or(50)
);
let output = Command::new("/usr/bin/sqlite3")
.arg("-json")
.arg(db)
.arg(query)
.output()
.map_err(|e| IntegrationError::Backend(format!("messages sqlite: {e}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let reason = match automation_reason {
Some(prev) if !prev.is_empty() => {
format!("{prev}; Messages chat database fallback failed: {stderr}")
}
_ => format!("Messages chat database fallback failed: {stderr}"),
};
return Ok(ChatListing {
availability: Availability::pending("messages_chat_db", reason),
chats: vec![],
});
}
let rows: Vec<ChatDbRow> = serde_json::from_slice(&output.stdout)
.map_err(|e| IntegrationError::Backend(format!("messages sqlite json: {e}")))?;
let chats = rows
.into_iter()
.map(|row| Chat {
id: row.id,
name: row.name,
participants: row
.participants
.unwrap_or_default()
.split(", ")
.filter(|participant| !participant.is_empty())
.map(str::to_string)
.collect(),
})
.collect();
Ok(ChatListing {
availability: Availability::available("messages_chat_db"),
chats,
})
}
}
#[cfg(not(target_os = "macos"))]
mod backend {
use super::*;
pub fn list_services() -> Result<ServiceListing, IntegrationError> {
Ok(ServiceListing {
availability: current_backend_pending(),
services: vec![],
})
}
pub fn list_chats(_limit: usize) -> Result<ChatListing, IntegrationError> {
Ok(ChatListing {
availability: current_backend_pending(),
chats: vec![],
})
}
pub fn send(_req: SendRequest) -> Result<SendResult, IntegrationError> {
Ok(SendResult {
availability: current_backend_pending(),
sent: false,
})
}
fn current_backend_pending() -> Availability {
Availability::pending(
"messages_app",
"Messages integration is only available through Messages.app automation on macOS.",
)
}
}