car-integrations 0.12.0

OS-native account-bound integrations (Calendar, Contacts, Mail) for CAR
Documentation
//! Messages capability — enumerate Messages.app services/chats and send.
//!
//! macOS exposes no public Messages framework for third-party apps. This
//! backend uses the public Apple Events automation surface of Messages.app,
//! so callers must have Automation consent for the caller -> Messages.app
//! pair.

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 {
    /// Phone number, email address, buddy handle, or chat id.
    pub recipient: String,
    pub body: String,
    /// Optional Messages service id/name, e.g. "iMessage".
    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.",
        )
    }
}