// ============================================================================ // iMESSAGE PLATFORM ADAPTER // ============================================================================ // // iMessage is unique — there's no API to call. Instead, we read directly // from the SQLite database at ~/Library/Messages/chat.db. // // Requires Full Disk Access (FDA) on macOS. // // The Tauri app handles this in Rust via rusqlite. This adapter documents // the database schema and normalization logic so the community can help // maintain it as Apple changes the schema across macOS versions. // // Write: via AppleScript (`tell application "Messages" to send...`) // Read: direct SQLite queries against chat.db // // Verified: 2026-06-19 on macOS 26.3 (Sequoia) // ============================================================================ import type { PlatformAdapter, ApiEndpoint, Conversation, Message, Participant, } from "../index.js"; // ── Database Schema ─────────────────────────────────────────────────────── /** * Key tables in ~/Library/Messages/chat.db: * * chat * - ROWID: integer primary key * - chat_identifier: string (phone number or email, e.g. "+1234567890") * - display_name: string (group chat name, often empty for DMs) * - style: integer (43 = DM, 45 = group) * - last_read_message_timestamp: integer (epoch nanoseconds / 1e9) * * message * - ROWID: integer primary key * - text: string (message body) * - handle_id: integer → handle.ROWID * - is_from_me: integer (0 or 1) * - date: integer (Core Data timestamp: seconds since 2001-01-01 + nanoseconds) * - date_read: integer * - cache_has_attachments: integer * - associated_message_type: integer (reactions, tapbacks) * * handle * - ROWID: integer primary key * - id: string (phone number or email) * - service: string ("iMessage" or "SMS") * * chat_message_join * - chat_id: integer → chat.ROWID * - message_id: integer → message.ROWID * * chat_handle_join * - chat_id: integer → chat.ROWID * - handle_id: integer → handle.ROWID * * attachment * - ROWID: integer * - filename: string (path to file, often ~/Library/Messages/Attachments/...) * - mime_type: string * * message_attachment_join * - message_id: integer → message.ROWID * - attachment_id: integer → attachment.ROWID */ /** SQL query for fetching recent conversations */ export const CONVERSATIONS_QUERY = ` SELECT c.ROWID as chat_id, c.chat_identifier, c.display_name, c.style, MAX(m.date) as last_message_date, COUNT(DISTINCT m.ROWID) as message_count FROM chat c JOIN chat_message_join cmj ON cmj.chat_id = c.ROWID JOIN message m ON m.ROWID = cmj.message_id WHERE m.date > :since_date GROUP BY c.ROWID ORDER BY last_message_date DESC LIMIT :limit `; /** SQL query for fetching messages in a chat */ export const MESSAGES_QUERY = ` SELECT m.ROWID as message_id, m.text, m.is_from_me, m.date, m.date_read, m.associated_message_type, h.id as handle_id, h.service FROM message m JOIN chat_message_join cmj ON cmj.message_id = m.ROWID LEFT JOIN handle h ON h.ROWID = m.handle_id WHERE cmj.chat_id = :chat_id AND m.text IS NOT NULL AND m.associated_message_type = 0 ORDER BY m.date DESC LIMIT :limit `; /** SQL query for getting participants of a chat */ export const PARTICIPANTS_QUERY = ` SELECT h.id, h.service FROM chat_handle_join chj JOIN handle h ON h.ROWID = chj.handle_id WHERE chj.chat_id = :chat_id `; /** * Convert a Core Data timestamp to a JS Date. * Core Data: seconds since 2001-01-01 00:00:00 UTC * With nanosecond component in the decimal part. */ export function coreDataTimestampToDate(timestamp: number): Date { // Core Data epoch offset from Unix epoch (2001-01-01 in seconds) const CORE_DATA_EPOCH = 978307200; // Timestamps after 2020 are in nanoseconds (Apple changed this) const ts = timestamp > 1e15 ? timestamp / 1e9 : timestamp; return new Date((ts + CORE_DATA_EPOCH) * 1000); } // ── Normalization ───────────────────────────────────────────────────────── /** * Normalize an iMessage chat row into a Conversation. * Contact name resolution happens in Rust (macOS Contacts framework). */ export function normalizeConversation(row: { chat_id: number; chat_identifier: string; display_name: string | null; style: number; last_message_date: number; }): Conversation { const isDM = row.style === 43; const isGroup = row.style === 45; return { platform: "imessage", conversationId: row.chat_identifier, conversationType: isGroup ? "group_dm" : "dm", name: row.display_name || row.chat_identifier, participants: [ { id: row.chat_identifier, name: row.display_name || row.chat_identifier, // Contact name resolution happens in Rust via the Contacts framework }, ], lastMessage: null, // Filled by message query lastActivityAt: coreDataTimestampToDate(row.last_message_date).toISOString(), unreadCount: 0, // Would need to compare date_read }; } // ── Adapter Definition ──────────────────────────────────────────────────── const listConversations: ApiEndpoint = { name: "SQLite: chat + message join", method: "GET", // Not really HTTP — SQLite query urlTemplate: "~/Library/Messages/chat.db", headers: {}, description: "Direct SQLite query against chat.db. Joins chat → chat_message_join → message " + "to get recent conversations sorted by last activity. Requires Full Disk Access.", rateLimiting: "N/A — local database. Can query as often as needed.", verification: { status: "live_verified", lastChecked: "2026-06-19", source: "local_platform", notes: "Hydra desktop reads this local SQLite source directly after Full Disk Access is granted.", }, }; const fetchMessages: ApiEndpoint = { name: "SQLite: message + chat_message_join", method: "GET", urlTemplate: "~/Library/Messages/chat.db", headers: {}, description: "Query messages for a specific chat_id. Joins message → chat_message_join, " + "left joins handle for sender info. Filters out reactions (associated_message_type = 0).", rateLimiting: "N/A — local database.", verification: { status: "live_verified", lastChecked: "2026-06-19", source: "local_platform", notes: "Hydra desktop local read path; macOS schema details can drift across OS versions.", }, }; const sendMessage: ApiEndpoint = { name: "AppleScript: Messages.app", method: "POST", urlTemplate: "osascript", headers: {}, bodyTemplate: { script: 'tell application "Messages" to send "{messageText}" to participant "{handle}" of (first conversation whose id = "{chatId}")', }, description: "Send via AppleScript. Requires Messages.app to be running. " + "The handle is the phone number or email address.", rateLimiting: "Low risk — local operation. But Messages.app can be slow to process.", verification: { status: "live_verified", lastChecked: "2026-06-25", source: "local_platform", notes: "Hydra executes this locally and updates the action ledger synchronously.", }, }; export const imessageAdapter: PlatformAdapter = { platform: "imessage", name: "iMessage", color: "#34C759", emoji: "💬", auth: { requiredTokens: [], // No tokens — requires Full Disk Access instead extractionMethod: "No browser auth needed. The Tauri app reads ~/Library/Messages/chat.db " + "directly via SQLite. Requires Full Disk Access (FDA) granted in " + "System Settings → Privacy & Security → Full Disk Access.", sources: [ { type: "local_storage", name: "chatDb", selector: "~/Library/Messages/chat.db", description: "SQLite database containing all iMessage/SMS history. " + "Locked behind FDA permission.", }, ], }, endpoints: { listConversations, fetchMessages, sendMessage, }, antiDetection: { minDelayMs: 0, maxDelayMs: 0, maxMessageThreads: 50, // Local DB, no limits notes: "No anti-detection needed — this is a local database read. " + "The only constraint is FDA permission.", }, buildDeepLink: (conv) => { const handle = conv.participants[0]?.id; if (handle) return `imessage://${handle}`; return undefined; }, lastVerified: "2026-06-19", changelog: [ { date: "2026-06-19", description: "Initial adapter. SQLite schema documented for macOS 26.3. " + "Core Data timestamp conversion, conversation + message queries.", }, ], };