Initial Commit
This commit is contained in:
@@ -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[];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user