UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

461 lines (460 loc) 18.4 kB
import { a as normalizeLowercaseStringOrEmpty, c as normalizeOptionalString, s as normalizeOptionalLowercaseString } from "./string-coerce-mnp54Vah.js"; import { C as resolveExpiresAtMsFromDurationMs, m as isFutureDateTimestampMs, o as asDateTimestampMs } from "./number-coercion-CJQ8TR--.js"; import { y as resolveStateDir } from "./paths-mvMm5bYV.js"; import { t as resolveGlobalMap } from "./global-singleton-PwlQSEal.js"; import { v as uniqueValues } from "./string-normalization-WNUDCpXX.js"; import { n as normalizeAccountId } from "./account-id-Df9e41E6.js"; import { t as getActivePluginChannelRegistryFromState } from "./runtime-channel-state-D79Ax0is.js"; import { a as normalizeAnyChannelId } from "./registry-9YoS2BJP.js"; import { t as loadJsonFile } from "./json-file-CVAOif1i.js"; import { r as saveJsonFile } from "./json-store-CWaMsrLM.js"; import fs from "node:fs"; import path from "node:path"; //#region src/acp/conversation-id.ts /** Normalizes ACP conversation identifiers from loose metadata values. */ function normalizeConversationText(value) { if (typeof value === "string") return value.trim(); if (typeof value === "number" || typeof value === "bigint" || typeof value === "boolean") return `${value}`.trim(); return ""; } //#endregion //#region src/infra/outbound/session-binding-normalization.ts /** * Normalizes conversation ids and drops self-referential parent ids. */ function normalizeConversationTargetRef(ref) { const conversationId = normalizeOptionalString(ref.conversationId) ?? ""; const parentConversationId = normalizeOptionalString(ref.parentConversationId); const { parentConversationId: _ignoredParentConversationId, ...rest } = ref; return { ...rest, conversationId, ...parentConversationId && parentConversationId !== conversationId ? { parentConversationId } : {} }; } /** * Normalizes a full conversation reference for stable binding keys. */ function normalizeConversationRef(ref) { return { ...normalizeConversationTargetRef(ref), channel: normalizeLowercaseStringOrEmpty(ref.channel), accountId: normalizeAccountId(ref.accountId) }; } /** * Builds the adapter registry key shared by channel/account scoped bindings. */ function buildChannelAccountKey(params) { return `${normalizeLowercaseStringOrEmpty(params.channel)}:${normalizeAccountId(params.accountId)}`; } //#endregion //#region src/infra/outbound/current-conversation-bindings.ts const CURRENT_BINDINGS_FILE_VERSION = 1; const CURRENT_BINDINGS_ID_PREFIX = "generic:"; let bindingsLoaded = false; const bindingsByConversationKey = /* @__PURE__ */ new Map(); function buildConversationKey(ref) { const normalized = normalizeConversationRef(ref); return [ normalized.channel, normalized.accountId, normalized.parentConversationId ?? "", normalized.conversationId ].join("␟"); } function buildBindingId(ref) { return `${CURRENT_BINDINGS_ID_PREFIX}${buildConversationKey(ref)}`; } function resolveBindingsFilePath(env = process.env) { return path.join(resolveStateDir(env), "bindings", "current-conversations.json"); } function isBindingExpired(record, now = Date.now()) { if (record.expiresAt === void 0) return false; const expiresAt = asDateTimestampMs(record.expiresAt); if (expiresAt === void 0) return true; const nowMs = asDateTimestampMs(now); return nowMs !== void 0 && !isFutureDateTimestampMs(expiresAt, { nowMs }); } function toPersistedFile() { return { version: CURRENT_BINDINGS_FILE_VERSION, bindings: [...bindingsByConversationKey.values()].filter((record) => !isBindingExpired(record)).toSorted((a, b) => a.bindingId.localeCompare(b.bindingId)) }; } function loadBindingsIntoMemory() { if (bindingsLoaded) return; bindingsLoaded = true; bindingsByConversationKey.clear(); const parsed = loadJsonFile(resolveBindingsFilePath()); const bindings = parsed?.version === CURRENT_BINDINGS_FILE_VERSION ? parsed.bindings : []; for (const record of bindings ?? []) { if (!record?.bindingId || !record?.conversation?.conversationId || isBindingExpired(record)) continue; const conversation = normalizeConversationRef(record.conversation); const targetSessionKey = record.targetSessionKey?.trim() ?? ""; if (!targetSessionKey) continue; bindingsByConversationKey.set(buildConversationKey(conversation), { ...record, bindingId: buildBindingId(conversation), targetSessionKey, conversation }); } } function persistBindingsToDisk() { saveJsonFile(resolveBindingsFilePath(), toPersistedFile()); } function pruneExpiredBinding(key) { loadBindingsIntoMemory(); const record = bindingsByConversationKey.get(key) ?? null; if (!record) return null; if (!isBindingExpired(record)) return record; bindingsByConversationKey.delete(key); persistBindingsToDisk(); return null; } function resolveChannelSupportsCurrentConversationBinding(channel) { const normalized = normalizeAnyChannelId(channel) ?? normalizeOptionalLowercaseString(normalizeConversationText(channel)); if (!normalized) return false; const matchesPluginId = (plugin) => plugin.id === normalized || (plugin.meta?.aliases ?? []).some((alias) => normalizeOptionalLowercaseString(alias) === normalized); if (((getActivePluginChannelRegistryFromState()?.channels ?? []).find((entry) => matchesPluginId(entry.plugin))?.plugin)?.conversationBindings?.supportsCurrentConversationBinding === true) return true; return false; } /** Reports generic current-conversation binding support for plugin-owned channels. */ function getGenericCurrentConversationBindingCapabilities(params) { params.accountId; if (!resolveChannelSupportsCurrentConversationBinding(params.channel)) return null; return { adapterAvailable: true, bindSupported: true, unbindSupported: true, placements: ["current"] }; } /** Stores or replaces the current-conversation binding for a normalized conversation ref. */ async function bindGenericCurrentConversation(input) { const conversation = normalizeConversationRef(input.conversation); const targetSessionKey = input.targetSessionKey.trim(); if (!conversation.channel || !conversation.conversationId || !targetSessionKey) return null; loadBindingsIntoMemory(); const rawNow = Date.now(); const now = asDateTimestampMs(rawNow); if (now === void 0) return null; const ttlMs = typeof input.ttlMs === "number" && Number.isFinite(input.ttlMs) ? Math.max(0, Math.floor(input.ttlMs)) : void 0; const expiresAt = ttlMs === void 0 ? void 0 : ttlMs === 0 ? now : resolveExpiresAtMsFromDurationMs(ttlMs, { nowMs: rawNow }); if (ttlMs !== void 0 && expiresAt === void 0) return null; const key = buildConversationKey(conversation); const existing = pruneExpiredBinding(key); const record = { bindingId: buildBindingId(conversation), targetSessionKey, targetKind: input.targetKind, conversation, status: "active", boundAt: now, ...expiresAt !== void 0 ? { expiresAt } : {}, metadata: { ...existing?.metadata, ...input.metadata, lastActivityAt: now } }; bindingsByConversationKey.set(key, record); persistBindingsToDisk(); return record; } /** Resolves a current-conversation binding and prunes it if its TTL has expired. */ function resolveGenericCurrentConversationBinding(ref) { return pruneExpiredBinding(buildConversationKey(ref)); } /** Lists non-expired current-conversation bindings owned by one target session. */ function listGenericCurrentConversationBindingsBySession(targetSessionKey) { loadBindingsIntoMemory(); const results = []; for (const key of bindingsByConversationKey.keys()) { const record = pruneExpiredBinding(key); if (!record || record.targetSessionKey !== targetSessionKey) continue; results.push(record); } return results; } /** Persists last-activity metadata for an existing generic current-conversation binding. */ function touchGenericCurrentConversationBinding(bindingId, at = Date.now()) { loadBindingsIntoMemory(); if (!bindingId.startsWith(CURRENT_BINDINGS_ID_PREFIX)) return; const key = bindingId.slice(8); const record = pruneExpiredBinding(key); if (!record) return; bindingsByConversationKey.set(key, { ...record, metadata: { ...record.metadata, lastActivityAt: at } }); persistBindingsToDisk(); } /** Removes generic current-conversation bindings by binding id or target session key. */ async function unbindGenericCurrentConversationBindings(input) { loadBindingsIntoMemory(); const removed = []; const normalizedBindingId = input.bindingId?.trim(); const normalizedTargetSessionKey = input.targetSessionKey?.trim(); if (normalizedBindingId?.startsWith(CURRENT_BINDINGS_ID_PREFIX)) { const key = normalizedBindingId.slice(8); const record = pruneExpiredBinding(key); if (record) { bindingsByConversationKey.delete(key); removed.push(record); persistBindingsToDisk(); } return removed; } if (!normalizedTargetSessionKey) return removed; for (const key of bindingsByConversationKey.keys()) { const record = pruneExpiredBinding(key); if (!record || record.targetSessionKey !== normalizedTargetSessionKey) continue; bindingsByConversationKey.delete(key); removed.push(record); } if (removed.length > 0) persistBindingsToDisk(); return removed; } const testing$1 = { resetCurrentConversationBindingsForTests(params) { bindingsLoaded = false; bindingsByConversationKey.clear(); if (params?.deletePersistedFile) { const filePath = resolveBindingsFilePath(params.env); try { fs.rmSync(filePath, { force: true }); } catch {} } }, resolveBindingsFilePath }; //#endregion //#region src/infra/outbound/session-binding-service.ts var SessionBindingError = class extends Error { constructor(code, message, details) { super(message); this.code = code; this.details = details; this.name = "SessionBindingError"; } }; function isSessionBindingError(error) { return error instanceof SessionBindingError; } function toAdapterKey(params) { return buildChannelAccountKey(params); } function normalizePlacement(raw) { return raw === "current" || raw === "child" ? raw : void 0; } function inferDefaultPlacement(ref) { return ref.conversationId ? "current" : "child"; } function resolveAdapterPlacements(adapter) { const placements = (adapter.capabilities?.placements?.map((value) => normalizePlacement(value)))?.filter((value) => Boolean(value)); if (placements && placements.length > 0) return uniqueValues(placements); return ["current", "child"]; } function resolveAdapterCapabilities(adapter) { if (!adapter) return { adapterAvailable: false, bindSupported: false, unbindSupported: false, placements: [] }; const bindSupported = adapter.capabilities?.bindSupported ?? Boolean(adapter.bind); return { adapterAvailable: true, bindSupported, unbindSupported: adapter.capabilities?.unbindSupported ?? Boolean(adapter.unbind), placements: bindSupported ? resolveAdapterPlacements(adapter) : [] }; } const ADAPTERS_BY_CHANNEL_ACCOUNT = resolveGlobalMap(Symbol.for("openclaw.sessionBinding.adapters")); function getActiveAdapterForKey(key) { return ADAPTERS_BY_CHANNEL_ACCOUNT.get(key)?.at(-1)?.normalizedAdapter ?? null; } function registerSessionBindingAdapter(adapter) { const normalizedAdapter = { ...adapter, ...normalizeConversationRef({ channel: adapter.channel, accountId: adapter.accountId, conversationId: "unused" }) }; const key = toAdapterKey({ channel: normalizedAdapter.channel, accountId: normalizedAdapter.accountId }); const existing = ADAPTERS_BY_CHANNEL_ACCOUNT.get(key); const registrations = existing ? [...existing] : []; registrations.push({ adapter, normalizedAdapter }); ADAPTERS_BY_CHANNEL_ACCOUNT.set(key, registrations); } function unregisterSessionBindingAdapter(params) { const key = toAdapterKey(params); const registrations = ADAPTERS_BY_CHANNEL_ACCOUNT.get(key); if (!registrations || registrations.length === 0) return; const nextRegistrations = [...registrations]; if (params.adapter) { const registrationIndex = nextRegistrations.findLastIndex((registration) => registration.adapter === params.adapter); if (registrationIndex < 0) return; nextRegistrations.splice(registrationIndex, 1); } else nextRegistrations.pop(); if (nextRegistrations.length === 0) { ADAPTERS_BY_CHANNEL_ACCOUNT.delete(key); return; } ADAPTERS_BY_CHANNEL_ACCOUNT.set(key, nextRegistrations); } function resolveAdapterForConversation(ref) { return resolveAdapterForChannelAccount({ channel: ref.channel, accountId: ref.accountId }); } function resolveAdapterForChannelAccount(params) { return getActiveAdapterForKey(toAdapterKey({ channel: params.channel, accountId: params.accountId })); } function getActiveRegisteredAdapters() { return [...ADAPTERS_BY_CHANNEL_ACCOUNT.values()].map((registrations) => registrations.at(-1)?.normalizedAdapter ?? null).filter((adapter) => Boolean(adapter)); } function dedupeBindings(records) { const byId = /* @__PURE__ */ new Map(); for (const record of records) { if (!record?.bindingId) continue; byId.set(record.bindingId, record); } return [...byId.values()]; } function createDefaultSessionBindingService() { return { bind: async (input) => { const normalizedConversation = normalizeConversationRef(input.conversation); const adapter = resolveAdapterForConversation(normalizedConversation); if (!adapter) { if (getGenericCurrentConversationBindingCapabilities({ channel: normalizedConversation.channel, accountId: normalizedConversation.accountId })?.bindSupported) { const placement = normalizePlacement(input.placement) ?? inferDefaultPlacement(normalizedConversation); if (placement !== "current") throw new SessionBindingError("BINDING_CAPABILITY_UNSUPPORTED", `Session binding placement "${placement}" is not supported for ${normalizedConversation.channel}:${normalizedConversation.accountId}`, { channel: normalizedConversation.channel, accountId: normalizedConversation.accountId, placement }); const bound = await bindGenericCurrentConversation({ ...input, conversation: normalizedConversation, placement }); if (!bound) throw new SessionBindingError("BINDING_CREATE_FAILED", "Session binding adapter failed to bind target conversation", { channel: normalizedConversation.channel, accountId: normalizedConversation.accountId, placement }); return bound; } throw new SessionBindingError("BINDING_ADAPTER_UNAVAILABLE", `Session binding adapter unavailable for ${normalizedConversation.channel}:${normalizedConversation.accountId}`, { channel: normalizedConversation.channel, accountId: normalizedConversation.accountId }); } if (!adapter.bind) throw new SessionBindingError("BINDING_CAPABILITY_UNSUPPORTED", `Session binding adapter does not support binding for ${normalizedConversation.channel}:${normalizedConversation.accountId}`, { channel: normalizedConversation.channel, accountId: normalizedConversation.accountId }); const placement = normalizePlacement(input.placement) ?? inferDefaultPlacement(normalizedConversation); if (!resolveAdapterPlacements(adapter).includes(placement)) throw new SessionBindingError("BINDING_CAPABILITY_UNSUPPORTED", `Session binding placement "${placement}" is not supported for ${normalizedConversation.channel}:${normalizedConversation.accountId}`, { channel: normalizedConversation.channel, accountId: normalizedConversation.accountId, placement }); const bound = await adapter.bind({ ...input, conversation: normalizedConversation, placement }); if (!bound) throw new SessionBindingError("BINDING_CREATE_FAILED", "Session binding adapter failed to bind target conversation", { channel: normalizedConversation.channel, accountId: normalizedConversation.accountId, placement }); return bound; }, getCapabilities: (params) => { const adapter = resolveAdapterForChannelAccount({ channel: params.channel, accountId: params.accountId }); if (!adapter) return getGenericCurrentConversationBindingCapabilities(params) ?? { adapterAvailable: false, bindSupported: false, unbindSupported: false, placements: [] }; return resolveAdapterCapabilities(adapter); }, listBySession: (targetSessionKey) => { const key = targetSessionKey.trim(); if (!key) return []; const results = []; for (const adapter of getActiveRegisteredAdapters()) { const entries = adapter.listBySession(key); if (entries.length > 0) results.push(...entries); } results.push(...listGenericCurrentConversationBindingsBySession(key)); return dedupeBindings(results); }, resolveByConversation: (ref) => { const normalized = normalizeConversationRef(ref); if (!normalized.channel || !normalized.conversationId) return null; const adapter = resolveAdapterForConversation(normalized); if (!adapter) return resolveGenericCurrentConversationBinding(normalized); return adapter.resolveByConversation(normalized); }, touch: (bindingId, at) => { const normalizedBindingId = bindingId.trim(); if (!normalizedBindingId) return; for (const adapter of getActiveRegisteredAdapters()) adapter.touch?.(normalizedBindingId, at); touchGenericCurrentConversationBinding(normalizedBindingId, at); }, unbind: async (input) => { const removed = []; for (const adapter of getActiveRegisteredAdapters()) { if (!adapter.unbind) continue; const entries = await adapter.unbind(input); if (entries.length > 0) removed.push(...entries); } removed.push(...await unbindGenericCurrentConversationBindings(input)); return dedupeBindings(removed); } }; } const DEFAULT_SESSION_BINDING_SERVICE = createDefaultSessionBindingService(); function getSessionBindingService() { return DEFAULT_SESSION_BINDING_SERVICE; } const testing = { resetSessionBindingAdaptersForTests() { ADAPTERS_BY_CHANNEL_ACCOUNT.clear(); testing$1.resetCurrentConversationBindingsForTests({ deletePersistedFile: true }); }, getRegisteredAdapterKeys() { return [...ADAPTERS_BY_CHANNEL_ACCOUNT.keys()]; } }; //#endregion export { testing as a, normalizeConversationTargetRef as c, registerSessionBindingAdapter as i, normalizeConversationText as l, getSessionBindingService as n, unregisterSessionBindingAdapter as o, isSessionBindingError as r, normalizeConversationRef as s, SessionBindingError as t };