UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

305 lines (304 loc) 14.9 kB
import { c as normalizeOptionalString } from "./string-coerce-mnp54Vah.js"; import { r as logVerbose } from "./globals-GTrXU4s9.js"; import "./runtime-env-D_2q8-VK.js"; import "./string-coerce-runtime-CEGJWkQ_.js"; import { t as createPluginRuntimeStore } from "./runtime-store-uAKGMqTs.js"; import { createHash } from "node:crypto"; //#region extensions/imessage/src/runtime.ts const { clearRuntime: clearIMessageRuntime, getRuntime: getIMessageRuntime, setRuntime: setIMessageRuntime, tryGetRuntime: getOptionalIMessageRuntime } = createPluginRuntimeStore({ pluginId: "imessage", errorMessage: "iMessage runtime not initialized" }); //#endregion //#region extensions/imessage/src/monitor-reply-cache.ts const IMESSAGE_REPLY_CACHE_NAMESPACE = "imessage.reply-cache"; const IMESSAGE_REPLY_CACHE_MAX_ENTRIES = 2e3; const IMESSAGE_REPLY_CACHE_COUNTER_NAMESPACE = "imessage.reply-cache-counter"; const IMESSAGE_REPLY_CACHE_COUNTER_KEY = "short-id-counter"; const REPLY_CACHE_TTL_MS = 360 * 60 * 1e3; /** Recency window for the "react to the latest message" fallback. */ const LATEST_FALLBACK_MS = 600 * 1e3; let persistenceFailureLogged = false; function reportPersistenceFailure(scope, err) { if (persistenceFailureLogged) return; persistenceFailureLogged = true; logVerbose(`imessage reply-cache: ${scope} disabled after first failure: ${String(err)}`); } const imessageReplyCacheByMessageId = /* @__PURE__ */ new Map(); const imessageShortIdToUuid = /* @__PURE__ */ new Map(); const imessageUuidToShortId = /* @__PURE__ */ new Map(); let imessageShortIdCounter = 0; function resolveIMessageReplyCacheEntryKey(messageId) { return createHash("sha256").update(messageId, "utf8").digest("hex").slice(0, 32); } function openReplyCacheStore() { return getIMessageRuntime().state.openSyncKeyedStore({ namespace: IMESSAGE_REPLY_CACHE_NAMESPACE, maxEntries: IMESSAGE_REPLY_CACHE_MAX_ENTRIES }); } function openReplyCacheCounterStore() { return getIMessageRuntime().state.openSyncKeyedStore({ namespace: IMESSAGE_REPLY_CACHE_COUNTER_NAMESPACE, maxEntries: 1 }); } function remainingTtlMs(timestamp) { const remaining = REPLY_CACHE_TTL_MS - Math.max(0, Date.now() - timestamp); return remaining > 0 ? remaining : void 0; } let hydrated = false; function hydrateFromStoreOnce() { if (hydrated) return; hydrated = true; const cutoff = Date.now() - REPLY_CACHE_TTL_MS; let entries; try { const counter = openReplyCacheCounterStore().lookup(IMESSAGE_REPLY_CACHE_COUNTER_KEY); if (counter && Number.isSafeInteger(counter.counter) && counter.counter > 0) imessageShortIdCounter = Math.max(imessageShortIdCounter, counter.counter); entries = openReplyCacheStore().entries().map(({ value }) => value).filter((entry) => entry.timestamp >= cutoff).toSorted((a, b) => a.timestamp - b.timestamp).slice(-2e3); for (const entry of entries) { const numeric = Number.parseInt(entry.shortId, 10); if (Number.isFinite(numeric) && numeric > imessageShortIdCounter) imessageShortIdCounter = numeric; } } catch (err) { reportPersistenceFailure("read", err); return; } if (entries.length === 0) return; for (const entry of entries) { imessageReplyCacheByMessageId.set(entry.messageId, entry); imessageShortIdToUuid.set(entry.shortId, entry.messageId); imessageUuidToShortId.set(entry.messageId, entry.shortId); } } function persistReplyCacheEntry(entry) { const ttlMs = remainingTtlMs(entry.timestamp); if (!ttlMs) return; try { openReplyCacheStore().register(resolveIMessageReplyCacheEntryKey(entry.messageId), entry, { ttlMs }); } catch (err) { reportPersistenceFailure("write", err); } } function deleteReplyCacheEntry(messageId) { try { openReplyCacheStore().delete(resolveIMessageReplyCacheEntryKey(messageId)); } catch (err) { reportPersistenceFailure("delete", err); } } function persistReplyCacheCounter() { try { openReplyCacheCounterStore().register(IMESSAGE_REPLY_CACHE_COUNTER_KEY, { counter: imessageShortIdCounter }); } catch (err) { reportPersistenceFailure("counter", err); } } function buildReplyCacheEntry(entry, messageId, shortId) { return { accountId: entry.accountId, messageId, shortId, timestamp: entry.timestamp, ...typeof entry.chatGuid === "string" ? { chatGuid: entry.chatGuid } : {}, ...typeof entry.chatIdentifier === "string" ? { chatIdentifier: entry.chatIdentifier } : {}, ...typeof entry.chatId === "number" ? { chatId: entry.chatId } : {}, ...typeof entry.isFromMe === "boolean" ? { isFromMe: entry.isFromMe } : {} }; } function generateShortId() { imessageShortIdCounter += 1; persistReplyCacheCounter(); return String(imessageShortIdCounter); } function rememberIMessageReplyCache(entry) { hydrateFromStoreOnce(); const messageId = entry.messageId.trim(); if (!messageId) return { ...entry, shortId: "" }; let shortId = imessageUuidToShortId.get(messageId); if (!shortId) { shortId = generateShortId(); imessageShortIdToUuid.set(shortId, messageId); imessageUuidToShortId.set(messageId, shortId); } const fullEntry = buildReplyCacheEntry(entry, messageId, shortId); imessageReplyCacheByMessageId.delete(messageId); imessageReplyCacheByMessageId.set(messageId, fullEntry); const cutoff = Date.now() - REPLY_CACHE_TTL_MS; let evicted = false; const deletedMessageIds = []; for (const [key, value] of imessageReplyCacheByMessageId) { if (value.timestamp >= cutoff) break; imessageReplyCacheByMessageId.delete(key); deletedMessageIds.push(key); if (value.shortId) { imessageShortIdToUuid.delete(value.shortId); imessageUuidToShortId.delete(key); } evicted = true; } while (imessageReplyCacheByMessageId.size > IMESSAGE_REPLY_CACHE_MAX_ENTRIES) { const oldest = imessageReplyCacheByMessageId.keys().next().value; if (!oldest) break; const oldEntry = imessageReplyCacheByMessageId.get(oldest); imessageReplyCacheByMessageId.delete(oldest); deletedMessageIds.push(oldest); if (oldEntry?.shortId) { imessageShortIdToUuid.delete(oldEntry.shortId); imessageUuidToShortId.delete(oldest); } evicted = true; } if (evicted) for (const messageIdToDelete of deletedMessageIds) deleteReplyCacheEntry(messageIdToDelete); persistReplyCacheEntry(fullEntry); return fullEntry; } function hasChatScope(ctx) { if (!ctx) return false; return Boolean(normalizeOptionalString(ctx.chatGuid) || normalizeOptionalString(ctx.chatIdentifier) || typeof ctx.chatId === "number"); } /** * Strip the `iMessage;-;` / `SMS;-;` / `any;-;` service prefix that Messages * uses for direct chats. Different layers report direct DMs in different * forms — imsg's watch emits the bare handle plus an `any;-;…` chat_guid, * the action surface synthesizes `iMessage;-;…` from a phone-number target — * so comparing the raw strings would falsely flag the same chat as a * cross-chat target. Normalize both sides to the bare suffix. */ function normalizeDirectChatIdentifier(raw) { const trimmed = raw.trim(); const lowered = trimmed.toLowerCase(); for (const prefix of [ "imessage;-;", "sms;-;", "any;-;" ]) if (lowered.startsWith(prefix)) return trimmed.slice(prefix.length); return trimmed; } function isCrossChatMismatch(cached, ctx) { const cachedChatGuid = normalizeOptionalString(cached.chatGuid); const ctxChatGuid = normalizeOptionalString(ctx.chatGuid); if (cachedChatGuid && ctxChatGuid) { if (normalizeDirectChatIdentifier(cachedChatGuid) === normalizeDirectChatIdentifier(ctxChatGuid)) return false; return cachedChatGuid !== ctxChatGuid; } const cachedChatIdentifier = normalizeOptionalString(cached.chatIdentifier); const ctxChatIdentifier = normalizeOptionalString(ctx.chatIdentifier); if (cachedChatIdentifier && ctxChatIdentifier) { if (normalizeDirectChatIdentifier(cachedChatIdentifier) === normalizeDirectChatIdentifier(ctxChatIdentifier)) return false; return cachedChatIdentifier !== ctxChatIdentifier; } const cachedChatId = typeof cached.chatId === "number" ? cached.chatId : void 0; const ctxChatId = typeof ctx.chatId === "number" ? ctx.chatId : void 0; if (cachedChatId !== void 0 && ctxChatId !== void 0) return cachedChatId !== ctxChatId; const cachedFingerprint = cachedChatGuid ? normalizeDirectChatIdentifier(cachedChatGuid) : cachedChatIdentifier ? normalizeDirectChatIdentifier(cachedChatIdentifier) : void 0; const ctxFingerprint = ctxChatGuid ? normalizeDirectChatIdentifier(ctxChatGuid) : ctxChatIdentifier ? normalizeDirectChatIdentifier(ctxChatIdentifier) : void 0; if (cachedFingerprint && ctxFingerprint) return cachedFingerprint !== ctxFingerprint; return false; } function describeChatForError(values) { const parts = []; if (normalizeOptionalString(values.chatGuid)) parts.push("chatGuid=<redacted>"); if (normalizeOptionalString(values.chatIdentifier)) parts.push("chatIdentifier=<redacted>"); if (typeof values.chatId === "number") parts.push("chatId=<redacted>"); return parts.length === 0 ? "<unknown chat>" : parts.join(", "); } function describeMessageIdForError(inputId, inputKind) { if (inputKind === "short") return `<short:${inputId.length}-digit>`; return `<uuid:${inputId.slice(0, 8)}...>`; } function buildCrossChatError(inputId, inputKind, cached, ctx) { const remediation = inputKind === "short" ? "Retry with MessageSidFull to avoid cross-chat reactions/replies landing in the wrong conversation." : "Retry with the correct chat target."; return /* @__PURE__ */ new Error(`iMessage message id ${describeMessageIdForError(inputId, inputKind)} belongs to a different chat (${describeChatForError(cached)}) than the current call target (${describeChatForError(ctx)}). ${remediation}`); } function resolveIMessageMessageId(shortOrUuid, opts) { const trimmed = shortOrUuid.trim(); if (!trimmed) return trimmed; hydrateFromStoreOnce(); if (/^\d+$/.test(trimmed)) { const uuid = imessageShortIdToUuid.get(trimmed); if (uuid) { const cached = imessageReplyCacheByMessageId.get(uuid); if (opts?.chatContext && hasChatScope(opts.chatContext)) { if (cached && isCrossChatMismatch(cached, opts.chatContext)) throw buildCrossChatError(trimmed, "short", cached, opts.chatContext); } if (opts?.requireFromMe && cached?.isFromMe !== true) throw buildFromMeError(trimmed, "short"); return uuid; } if (opts?.requireKnownShortId && !hasChatScope(opts.chatContext)) throw new Error(`iMessage short message id ${describeMessageIdForError(trimmed, "short")} requires a chat scope (chatGuid / chatIdentifier / chatId or a target).`); if (opts?.requireKnownShortId) throw new Error(`iMessage short message id ${describeMessageIdForError(trimmed, "short")} is no longer available. Use MessageSidFull.`); return trimmed; } const cached = imessageReplyCacheByMessageId.get(trimmed); if (opts?.chatContext) { if (cached && isCrossChatMismatch(cached, opts.chatContext)) throw buildCrossChatError(trimmed, "uuid", cached, opts.chatContext); } if (opts?.requireFromMe && cached?.isFromMe !== true) throw buildFromMeError(trimmed, "uuid"); return trimmed; } function isKnownFromMeIMessageMessageId(messageId, ctx) { const trimmed = normalizeOptionalString(messageId); if (!trimmed || !ctx.accountId || !hasChatScope(ctx)) return false; hydrateFromStoreOnce(); const cached = imessageReplyCacheByMessageId.get(trimmed); if (!cached || cached.isFromMe !== true || cached.accountId !== ctx.accountId) return false; return isPositiveChatMatch(cached, ctx); } function buildFromMeError(inputId, inputKind) { return /* @__PURE__ */ new Error(`iMessage message id ${describeMessageIdForError(inputId, inputKind)} is not one this agent sent. edit and unsend can only target messages the gateway delivered itself; messages received from other participants cannot be modified.`); } /** * Return the most recent cached entry whose chat scope matches the supplied * context. Used as a fallback when an agent calls a per-message action (e.g. * `react`) without specifying a `messageId` — the natural intent is "react * to the message I just received in this chat." * * Strict semantics for safety: * - Caller must supply a chat scope. We refuse to "guess" the active chat. * - Cached entry must positively match on at least one identifier kind * (chatGuid, chatIdentifier, chatId, or normalized direct-DM fingerprint). * We do NOT fall through on "no overlapping identifier" — that's how a * cached entry from a foreign chat could be returned when the caller's * context didn't share any identifier kind with the cache. * - Caller must supply an accountId; we never cross account boundaries. * - We only consider entries newer than `LATEST_FALLBACK_MS`. The intent * of "react to the latest" is "the message I just received," not * "anything in this chat from any time." */ function findLatestIMessageEntryForChat(ctx) { if (!hasChatScope(ctx)) return; if (!ctx.accountId) return; const cutoff = Date.now() - LATEST_FALLBACK_MS; let best; for (const entry of imessageReplyCacheByMessageId.values()) { if (entry.accountId !== ctx.accountId) continue; if (entry.timestamp < cutoff) continue; if (!isPositiveChatMatch(entry, ctx)) continue; if (!best || entry.timestamp > best.timestamp) best = entry; } return best; } /** * Return true when the cached entry positively matches the caller's chat * context on at least one identifier kind. Unlike `isCrossChatMismatch`, * which returns false for "no overlap," this requires concrete agreement. */ function isPositiveChatMatch(entry, ctx) { const cachedChatGuid = normalizeOptionalString(entry.chatGuid); const ctxChatGuid = normalizeOptionalString(ctx.chatGuid); if (cachedChatGuid && ctxChatGuid && cachedChatGuid === ctxChatGuid) return true; const cachedChatIdentifier = normalizeOptionalString(entry.chatIdentifier); const ctxChatIdentifier = normalizeOptionalString(ctx.chatIdentifier); if (cachedChatIdentifier && ctxChatIdentifier && cachedChatIdentifier === ctxChatIdentifier) return true; if (typeof entry.chatId === "number" && typeof ctx.chatId === "number" && entry.chatId === ctx.chatId) return true; const cachedFingerprint = cachedChatGuid ? normalizeDirectChatIdentifier(cachedChatGuid) : cachedChatIdentifier ? normalizeDirectChatIdentifier(cachedChatIdentifier) : void 0; const ctxFingerprint = ctxChatGuid ? normalizeDirectChatIdentifier(ctxChatGuid) : ctxChatIdentifier ? normalizeDirectChatIdentifier(ctxChatIdentifier) : void 0; if (cachedFingerprint && ctxFingerprint && cachedFingerprint === ctxFingerprint) return true; return false; } //#endregion export { findLatestIMessageEntryForChat as a, resolveIMessageMessageId as c, getOptionalIMessageRuntime as d, setIMessageRuntime as f, IMESSAGE_REPLY_CACHE_NAMESPACE as i, resolveIMessageReplyCacheEntryKey as l, IMESSAGE_REPLY_CACHE_COUNTER_NAMESPACE as n, isKnownFromMeIMessageMessageId as o, IMESSAGE_REPLY_CACHE_MAX_ENTRIES as r, rememberIMessageReplyCache as s, IMESSAGE_REPLY_CACHE_COUNTER_KEY as t, getIMessageRuntime as u };