Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,271 @@
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<IMessageWatcherEvents>;
private readonly config: ResolvedConfig;
private readonly parser: IMessageParser;
private lastRowId = 0;
private pollTimer: ReturnType<typeof setInterval> | 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<IMessageWatcherEvents>();
}
/** Register a listener (legacy API; delegates to the typed bus). */
public on<K extends keyof IMessageWatcherEvents>(
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<K extends keyof IMessageWatcherEvents>(
event: K,
listener: (payload: IMessageWatcherEvents[K]) => void
): void {
this.bus.off(event, listener);
}
public async start(
options: IMessageWatcherStartOptions = {}
): Promise<void> {
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<void> {
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<readonly ParsedIMessage[]> {
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[];
}
}