import { EventBus } from "@suaveplan/events"; import { IMessageDbQueryFailedError, IMessagePollAbortedError, IMessageWatcherAlreadyRunningError, IMessageWatcherNotRunningError, } from "../error-codes/error-codes.js"; import { recordAttachmentEmitted, recordMessageEmitted, recordPollDuration, recordPollError, } from "../otel/metrics.js"; import { IMessageParser } from "../parser/imessage-parser.js"; import { parseWatcherConfig } from "../schemas/schemas.js"; import { bunSqliteReader } from "../sqlite/bun-sqlite-reader.js"; import type { AttachmentRow, ChatRow, HandleRow, IMessageRow, IMessageWatcherConfig, IMessageWatcherEvents, ParsedIMessage, } from "../types/imessage.types.js"; import type { SqliteHandle, SqliteReader } from "../types/seams.types.js"; const DEFAULT_DB_PATH = "~/Library/Messages/chat.db"; const DEFAULT_POLL_INTERVAL = 5000; interface ResolvedConfig { readonly dbPath: string; readonly pollIntervalMs: number; readonly sinceDate: Date; readonly includeFromMe: boolean; readonly chatFilter: readonly string[]; readonly sqliteReader: SqliteReader; } export interface IMessageWatcherStartOptions { readonly signal?: AbortSignal; } export interface IMessagePollOptions { readonly signal?: AbortSignal; } /** * Polls Apple's iMessage chat.db SQLite database on a configurable * interval, projects new rows into `ParsedIMessage`, and emits them on * both a legacy `on/off` listener registry and the typed `EventBus` * exposed as `watcher.bus`. */ export class IMessageWatcher { public readonly bus: EventBus; private readonly config: ResolvedConfig; private readonly parser: IMessageParser; private lastRowId = 0; private pollTimer: ReturnType | null = null; private running = false; private abortListener: (() => void) | null = null; private currentSignal: AbortSignal | null = null; public constructor(input: IMessageWatcherConfig = {}) { const parsed = parseWatcherConfig(input); this.config = { dbPath: parsed.dbPath ?? DEFAULT_DB_PATH, pollIntervalMs: parsed.pollIntervalMs ?? DEFAULT_POLL_INTERVAL, sinceDate: parsed.sinceDate ?? new Date(), includeFromMe: parsed.includeFromMe ?? false, chatFilter: parsed.chatFilter ?? [], sqliteReader: parsed.sqliteReader ?? bunSqliteReader, }; this.parser = new IMessageParser(); this.bus = new EventBus(); } /** Register a listener (legacy API; delegates to the typed bus). */ public on( event: K, listener: (payload: IMessageWatcherEvents[K]) => void ): () => void { return this.bus.on(event, listener); } /** Remove a listener (legacy API; delegates to the typed bus). */ public off( event: K, listener: (payload: IMessageWatcherEvents[K]) => void ): void { this.bus.off(event, listener); } public async start( options: IMessageWatcherStartOptions = {} ): Promise { if (this.running) throw new IMessageWatcherAlreadyRunningError(); const signal = options.signal; if (signal?.aborted) { throw new IMessagePollAbortedError(); } this.running = true; this.currentSignal = signal ?? null; if (signal) { const handler = (): void => { void this.stop(); }; this.abortListener = handler; signal.addEventListener("abort", handler, { once: true }); } this.bus.emit("started", undefined); this.pollTimer = setInterval(() => { this.poll(signal ? { signal } : {}).catch((err: unknown) => { const error = err instanceof Error ? err : new Error(String(err)); this.bus.emit("error", error); }); }, this.config.pollIntervalMs); } public async stop(): Promise { if (!this.running) throw new IMessageWatcherNotRunningError(); this.running = false; if (this.pollTimer) { clearInterval(this.pollTimer); this.pollTimer = null; } if (this.currentSignal && this.abortListener) { this.currentSignal.removeEventListener("abort", this.abortListener); } this.abortListener = null; this.currentSignal = null; this.bus.emit("stopped", undefined); } public async poll( options: IMessagePollOptions = {} ): Promise { const signal = options.signal; if (signal?.aborted) { recordPollDuration(0, "aborted"); throw new IMessagePollAbortedError(); } const started = Date.now(); let handle: SqliteHandle | null = null; try { handle = this.config.sqliteReader.open(this.config.dbPath, { readonly: true, }); const rows = this.queryNewMessages(handle); const parsed: ParsedIMessage[] = []; for (const row of rows) { if (signal?.aborted) { recordPollDuration(Date.now() - started, "aborted"); throw new IMessagePollAbortedError(); } const handleRow = this.queryHandle(handle, row.handle_id); const chatRow = this.queryChat(handle, row.ROWID); const attachments: readonly AttachmentRow[] = row.cache_has_attachments ? this.queryAttachments(handle, row.ROWID) : []; const message = this.parser.parseMessage( row, handleRow, chatRow, attachments ); if (row.ROWID > this.lastRowId) { this.lastRowId = row.ROWID; } if (!this.config.includeFromMe && message.isFromMe) continue; if ( this.config.chatFilter.length > 0 && !this.config.chatFilter.includes(message.chatIdentifier) ) { continue; } parsed.push(message); this.bus.emit("message", message); recordMessageEmitted( message.isGroupChat ? "group" : "direct", message.isFromMe ); for (const attachment of message.attachments) { const family = attachment.mimeType.split("/")[0] ?? "other"; recordAttachmentEmitted(family); } } recordPollDuration(Date.now() - started, "ok"); return parsed; } catch (cause) { recordPollDuration(Date.now() - started, "error"); if (cause instanceof IMessagePollAbortedError) { throw cause; } const queryError = new IMessageDbQueryFailedError({ cause }); recordPollError(queryError.code); const err = cause instanceof Error ? cause : new Error(String(cause)); this.bus.emit("error", err); // Wrap-and-rethrow only when a caller explicitly polled (no // signal-driven internal scheduler); otherwise swallow to // mirror upstream behaviour where the loop keeps ticking. if (signal !== undefined) throw queryError; return []; } finally { if (handle) handle.close(); } } public getLastRowId(): number { return this.lastRowId; } public setLastRowId(rowId: number): void { if (!Number.isFinite(rowId) || rowId < 0) { throw new IMessageDbQueryFailedError({ message: "invalid lastRowId", context: { rowId }, }); } this.lastRowId = rowId; } public isRunning(): boolean { return this.running; } private queryNewMessages(handle: SqliteHandle): readonly IMessageRow[] { const stmt = handle.prepare( "SELECT * FROM message WHERE ROWID > ? ORDER BY ROWID ASC LIMIT 100" ); return stmt.all(this.lastRowId) as readonly IMessageRow[]; } private queryHandle( handle: SqliteHandle, handleId: number ): HandleRow | null { if (!handleId) return null; const stmt = handle.prepare("SELECT * FROM handle WHERE ROWID = ?"); const row = stmt.get(handleId); return (row as HandleRow | undefined) ?? null; } private queryChat( handle: SqliteHandle, messageRowId: number ): ChatRow | null { const stmt = handle.prepare( `SELECT c.* FROM chat c JOIN chat_message_join cmj ON c.ROWID = cmj.chat_id WHERE cmj.message_id = ?` ); const row = stmt.get(messageRowId); return (row as ChatRow | undefined) ?? null; } private queryAttachments( handle: SqliteHandle, messageRowId: number ): readonly AttachmentRow[] { const stmt = handle.prepare( `SELECT a.* FROM attachment a JOIN message_attachment_join maj ON a.ROWID = maj.attachment_id WHERE maj.message_id = ?` ); return stmt.all(messageRowId) as readonly AttachmentRow[]; } }