openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
297 lines (296 loc) • 11.9 kB
JavaScript
import { u as getIMessageRuntime } from "./monitor-reply-cache-DUH5oJzE.js";
import { createHash } from "node:crypto";
//#region extensions/imessage/src/monitor/catchup.ts
const DEFAULT_MAX_AGE_MINUTES = 120;
const MAX_MAX_AGE_MINUTES = 720;
const DEFAULT_PER_RUN_LIMIT = 50;
const MAX_PER_RUN_LIMIT = 500;
const DEFAULT_FIRST_RUN_LOOKBACK_MINUTES = 30;
const DEFAULT_MAX_FAILURE_RETRIES = 10;
const MAX_MAX_FAILURE_RETRIES = 1e3;
const MAX_FAILURE_RETRY_MAP_SIZE = 512;
const MAX_FAILURE_RETRY_MAP_JSON_BYTES = 48e3;
const textEncoder = new TextEncoder();
const IMESSAGE_CATCHUP_CURSOR_NAMESPACE = "imessage.catchup-cursors";
const cursorWriteQueues = /* @__PURE__ */ new Map();
function resolveIMessageCatchupCursorKey(accountId) {
return createHash("sha256").update(accountId, "utf8").digest("hex").slice(0, 32);
}
function openCatchupCursorStore() {
return getIMessageRuntime().state.openSyncKeyedStore({
namespace: IMESSAGE_CATCHUP_CURSOR_NAMESPACE,
maxEntries: 256
});
}
function updateCatchupCursorStore(key, updateValue) {
const store = openCatchupCursorStore();
if (!store.update) throw new Error("iMessage catchup cursor persistence requires atomic plugin-state update.");
return store.update(key, updateValue);
}
function enqueueCursorWrite(accountId, fn) {
const key = resolveIMessageCatchupCursorKey(accountId);
const next = (cursorWriteQueues.get(key) ?? Promise.resolve()).then(fn, fn);
cursorWriteQueues.set(key, next);
next.finally(() => {
if (cursorWriteQueues.get(key) === next) cursorWriteQueues.delete(key);
}).catch(() => {});
return next;
}
function sanitizeFailureRetriesInput(raw) {
if (!raw || typeof raw !== "object") return {};
const out = {};
for (const [guid, count] of Object.entries(raw)) {
if (!guid || typeof guid !== "string") continue;
if (typeof count !== "number" || !Number.isFinite(count) || count <= 0) continue;
out[guid] = Math.floor(count);
}
return out;
}
function normalizeIMessageCatchupCursor(value) {
if (!value || typeof value !== "object") return null;
const raw = value;
if (typeof raw.lastSeenMs !== "number" || !Number.isFinite(raw.lastSeenMs)) return null;
if (typeof raw.lastSeenRowid !== "number" || !Number.isFinite(raw.lastSeenRowid)) return null;
const failureRetries = sanitizeFailureRetriesInput(raw.failureRetries);
const hasRetries = Object.keys(failureRetries).length > 0;
return {
lastSeenMs: raw.lastSeenMs,
lastSeenRowid: raw.lastSeenRowid,
updatedAt: typeof raw.updatedAt === "number" ? raw.updatedAt : 0,
...hasRetries ? { failureRetries } : {}
};
}
function readIMessageCatchupCursor(accountId) {
return normalizeIMessageCatchupCursor(openCatchupCursorStore().lookup(resolveIMessageCatchupCursorKey(accountId)));
}
async function loadIMessageCatchupCursor(accountId) {
return readIMessageCatchupCursor(accountId);
}
function buildIMessageCatchupCursor(next) {
const sanitized = sanitizeFailureRetriesInput(next.failureRetries);
const hasRetries = Object.keys(sanitized).length > 0;
return {
lastSeenMs: next.lastSeenMs,
lastSeenRowid: next.lastSeenRowid,
updatedAt: Date.now(),
...hasRetries ? { failureRetries: sanitized } : {}
};
}
async function saveIMessageCatchupCursor(accountId, next, options = {}) {
const cursor = buildIMessageCatchupCursor(next);
updateCatchupCursorStore(resolveIMessageCatchupCursorKey(accountId), (existingValue) => {
const existing = normalizeIMessageCatchupCursor(existingValue);
if (existing && cursor.lastSeenRowid < existing.lastSeenRowid) {
if (!options.allowCursorRewindForRetries) return;
return buildIMessageCatchupCursor({
lastSeenMs: cursor.lastSeenMs,
lastSeenRowid: cursor.lastSeenRowid,
failureRetries: {
...existing.failureRetries,
...cursor.failureRetries
}
});
}
return cursor;
});
}
/**
* Bound the retry map so a pathological storm of unique failing GUIDs
* cannot grow the cursor file without limit. Keeps the `maxSize` entries
* with the highest counts (closest to give-up) when over the bound.
*/
function capFailureRetriesMap(map, maxSize = MAX_FAILURE_RETRY_MAP_SIZE, maxBytes = MAX_FAILURE_RETRY_MAP_JSON_BYTES) {
const entries = Object.entries(map);
if (entries.length <= maxSize && textEncoder.encode(JSON.stringify(map)).byteLength <= maxBytes) return map;
entries.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
const capped = {};
for (let i = 0; i < entries.length && i < maxSize; i++) {
const [guid, count] = entries[i];
capped[guid] = count;
if (textEncoder.encode(JSON.stringify(capped)).byteLength > maxBytes) {
delete capped[guid];
break;
}
}
return capped;
}
function clampInt(value, min, max, fallback) {
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
return Math.min(max, Math.max(min, Math.floor(value)));
}
function resolveCatchupConfig(raw) {
return {
enabled: Boolean(raw?.enabled),
maxAgeMinutes: clampInt(raw?.maxAgeMinutes, 1, MAX_MAX_AGE_MINUTES, DEFAULT_MAX_AGE_MINUTES),
perRunLimit: clampInt(raw?.perRunLimit, 1, MAX_PER_RUN_LIMIT, DEFAULT_PER_RUN_LIMIT),
firstRunLookbackMinutes: clampInt(raw?.firstRunLookbackMinutes, 1, MAX_MAX_AGE_MINUTES, DEFAULT_FIRST_RUN_LOOKBACK_MINUTES),
maxFailureRetries: clampInt(raw?.maxFailureRetries, 1, MAX_MAX_FAILURE_RETRIES, DEFAULT_MAX_FAILURE_RETRIES)
};
}
async function advanceIMessageCatchupCursor(accountId, next, config) {
if (!Number.isFinite(next.lastSeenMs) || !Number.isFinite(next.lastSeenRowid)) return false;
return await enqueueCursorWrite(accountId, async () => {
let advanced = false;
updateCatchupCursorStore(resolveIMessageCatchupCursorKey(accountId), (existingValue) => {
const cursor = normalizeIMessageCatchupCursor(existingValue);
if (cursor && next.lastSeenRowid <= cursor.lastSeenRowid) return;
if (Object.values(cursor?.failureRetries ?? {}).some((count) => count < config.maxFailureRetries)) return;
advanced = true;
return buildIMessageCatchupCursor({
lastSeenMs: Math.max(cursor?.lastSeenMs ?? next.lastSeenMs, next.lastSeenMs),
lastSeenRowid: next.lastSeenRowid,
failureRetries: cursor?.failureRetries
});
});
return advanced;
});
}
/**
* One catchup pass. Loads the cursor, fetches `messages.history`, replays
* each row through `dispatch`, advances the cursor on success / give-up,
* persists the cursor, returns a summary.
*
* The fetch and dispatch functions are injected so this loop is unit-testable
* without standing up an `imsg` daemon. The wiring in `monitor-provider.ts`
* passes the live `client.request("messages.history", ...)` adapter as
* `fetch` and the `evaluateIMessageInbound` + `dispatchInboundMessage`
* pipeline as `dispatch`.
*/
async function performIMessageCatchup(params) {
const now = params.now ?? Date.now();
const cfg = params.config;
const cursor = await loadIMessageCatchupCursor(params.accountId);
const lookbackMs = cursor === null ? cfg.firstRunLookbackMinutes * 6e4 : cfg.maxAgeMinutes * 6e4;
const ageBoundMs = now - cfg.maxAgeMinutes * 6e4;
const windowStartMs = Math.max(cursor?.lastSeenMs ?? now - lookbackMs, ageBoundMs);
const windowEndMs = now;
const sinceRowid = cursor?.lastSeenRowid ?? 0;
const summary = {
querySucceeded: false,
fullyCaughtUp: false,
fetchedCount: 0,
replayed: 0,
skippedFromMe: 0,
skippedPreCursor: 0,
skippedGivenUp: 0,
failed: 0,
givenUp: 0,
cursorBefore: cursor ? {
lastSeenMs: cursor.lastSeenMs,
lastSeenRowid: cursor.lastSeenRowid
} : null,
cursorAfter: {
lastSeenMs: cursor?.lastSeenMs ?? windowStartMs,
lastSeenRowid: cursor?.lastSeenRowid ?? 0
},
windowStartMs,
windowEndMs
};
let fetchResult;
try {
fetchResult = await params.fetch({
sinceMs: windowStartMs,
sinceRowid,
limit: cfg.perRunLimit
});
} catch (err) {
params.warn?.(`imessage catchup: fetch failed: ${String(err)}`);
return summary;
}
if (!fetchResult.resolved) {
params.warn?.(`imessage catchup: fetch returned unresolved result`);
return summary;
}
summary.querySucceeded = true;
summary.fullyCaughtUp = fetchResult.fullyCaughtUp !== false;
summary.fetchedCount = fetchResult.rows.length;
const rows = fetchResult.rows.toSorted((a, b) => a.rowid - b.rowid);
const failureRetries = { ...cursor?.failureRetries };
const cursorBeforeMs = cursor?.lastSeenMs ?? windowStartMs;
const cursorBeforeRowid = cursor?.lastSeenRowid ?? 0;
let highWatermarkMs = cursorBeforeMs;
let highWatermarkRowid = cursorBeforeRowid;
let earliestHeldFailureRow = null;
for (const row of rows) {
if (row.rowid <= sinceRowid) {
summary.skippedPreCursor += 1;
continue;
}
if (row.date < ageBoundMs) {
summary.skippedPreCursor += 1;
highWatermarkMs = Math.max(highWatermarkMs, row.date);
highWatermarkRowid = Math.max(highWatermarkRowid, row.rowid);
continue;
}
if (row.isFromMe) {
try {
await params.observeSkippedFromMe?.(row);
} catch (err) {
params.warn?.(`imessage catchup: from-me observer failed for guid=${row.guid}: ${String(err)}`);
}
summary.skippedFromMe += 1;
highWatermarkMs = Math.max(highWatermarkMs, row.date);
highWatermarkRowid = Math.max(highWatermarkRowid, row.rowid);
continue;
}
const priorCount = failureRetries[row.guid] ?? 0;
if (priorCount >= cfg.maxFailureRetries) {
summary.skippedGivenUp += 1;
highWatermarkMs = Math.max(highWatermarkMs, row.date);
highWatermarkRowid = Math.max(highWatermarkRowid, row.rowid);
continue;
}
let dispatched;
try {
dispatched = await params.dispatch(row);
} catch (err) {
params.warn?.(`imessage catchup: dispatch threw for guid=${row.guid}: ${String(err)}`);
dispatched = { ok: false };
}
if (dispatched.ok) {
summary.replayed += 1;
delete failureRetries[row.guid];
highWatermarkMs = Math.max(highWatermarkMs, row.date);
highWatermarkRowid = Math.max(highWatermarkRowid, row.rowid);
continue;
}
const nextCount = priorCount + 1;
failureRetries[row.guid] = nextCount;
summary.failed += 1;
if (nextCount >= cfg.maxFailureRetries) {
summary.givenUp += 1;
params.warn?.(`imessage catchup: giving up on guid=${row.guid} after ${nextCount} failures; advancing cursor past it`);
highWatermarkMs = Math.max(highWatermarkMs, row.date);
highWatermarkRowid = Math.max(highWatermarkRowid, row.rowid);
continue;
}
if (earliestHeldFailureRow === null || row.rowid < earliestHeldFailureRow.rowid) earliestHeldFailureRow = row;
}
if (earliestHeldFailureRow === null) {
if (typeof fetchResult.highWatermarkMs === "number") highWatermarkMs = Math.max(highWatermarkMs, fetchResult.highWatermarkMs);
if (typeof fetchResult.highWatermarkRowid === "number") highWatermarkRowid = Math.max(highWatermarkRowid, fetchResult.highWatermarkRowid);
}
let lastSeenMs;
let lastSeenRowid;
if (earliestHeldFailureRow !== null) {
lastSeenMs = Math.max(cursorBeforeMs, earliestHeldFailureRow.date - 1);
lastSeenRowid = Math.max(cursorBeforeRowid, earliestHeldFailureRow.rowid - 1);
} else {
lastSeenMs = highWatermarkMs;
lastSeenRowid = highWatermarkRowid;
}
const capped = capFailureRetriesMap(failureRetries);
summary.cursorAfter = {
lastSeenMs,
lastSeenRowid
};
await saveIMessageCatchupCursor(params.accountId, {
lastSeenMs,
lastSeenRowid,
failureRetries: capped
}, { allowCursorRewindForRetries: earliestHeldFailureRow !== null });
if (summary.replayed > 0 || summary.failed > 0 || summary.givenUp > 0) params.log?.(`imessage catchup: replayed=${summary.replayed} skippedFromMe=${summary.skippedFromMe} skippedGivenUp=${summary.skippedGivenUp} failed=${summary.failed} givenUp=${summary.givenUp} fetchedCount=${summary.fetchedCount}`);
return summary;
}
//#endregion
export { resolveCatchupConfig as a, performIMessageCatchup as i, advanceIMessageCatchupCursor as n, resolveIMessageCatchupCursorKey as o, capFailureRetriesMap as r, IMESSAGE_CATCHUP_CURSOR_NAMESPACE as t };