UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

297 lines (296 loc) 11.9 kB
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 };