272 lines
9.5 KiB
TypeScript
272 lines
9.5 KiB
TypeScript
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[];
|
|
}
|
|
}
|