UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

366 lines (365 loc) 17.1 kB
import { d as executeSqliteQuerySync, f as executeSqliteQueryTakeFirstSync, i as openOpenClawStateDatabase, o as runOpenClawStateWriteTransaction, p as getNodeSqliteKysely } from "./openclaw-state-db-ULtH_b7x.js"; import { randomUUID } from "node:crypto"; //#region src/channels/message/ingress-queue.ts /** * Durable channel ingress queue. * * Stores, claims, completes, and tombstones inbound channel events in OpenClaw state. */ function normalizePart(value, fallback) { const normalized = value?.trim(); return normalized ? normalized : fallback; } function openStateDatabase(stateDir) { return openOpenClawStateDatabase({ env: stateDir ? { ...process.env, OPENCLAW_STATE_DIR: stateDir } : process.env }); } function getChannelIngressKysely(db) { return getNodeSqliteKysely(db); } function affectedRows(result) { return Number(result.numAffectedRows ?? 0n); } function parseJson(value) { return JSON.parse(value); } function baseRecord(row) { return { id: row.event_id, channelId: row.channel_id, accountId: row.account_id, queueName: row.queue_name, payload: parseJson(row.payload_json), ...row.metadata_json === null ? {} : { metadata: parseJson(row.metadata_json) }, receivedAt: row.received_at, updatedAt: row.updated_at, ...row.lane_key === null ? {} : { laneKey: row.lane_key }, attempts: row.attempts, ...row.last_attempt_at === null ? {} : { lastAttemptAt: row.last_attempt_at }, ...row.last_error === null ? {} : { lastError: row.last_error } }; } function claimedRecord(row) { return { ...baseRecord(row), claim: { token: row.claim_token ?? "", ownerId: row.claim_owner ?? "", claimedAt: row.claimed_at ?? 0 } }; } function completedRecord(row) { return { id: row.event_id, channelId: row.channel_id, accountId: row.account_id, queueName: row.queue_name, completedAt: row.completed_at ?? row.updated_at, ...row.completed_metadata_json === null ? {} : { metadata: parseJson(row.completed_metadata_json) } }; } function failedRecord(row) { return { id: row.event_id, channelId: row.channel_id, accountId: row.account_id, queueName: row.queue_name, failedAt: row.failed_at ?? row.updated_at, reason: row.failed_reason ?? "failed", ...row.last_error === null ? {} : { message: row.last_error } }; } function selectRow(db, queueName, id) { return executeSqliteQueryTakeFirstSync(db, getChannelIngressKysely(db).selectFrom("channel_ingress_events").selectAll().where("queue_name", "=", queueName).where("event_id", "=", id)); } function idFrom(idOrRecord) { const id = normalizePart(typeof idOrRecord === "string" ? idOrRecord : idOrRecord.id, ""); if (!id) throw new Error("Channel ingress event id cannot be empty"); return id; } function claimTokenFrom(idOrClaim) { return typeof idOrClaim === "string" ? null : idOrClaim.claim?.token ?? null; } function rowToEnqueueResult(row) { if (row.status === "completed") return { kind: "completed", duplicate: true, record: completedRecord(row) }; if (row.status === "failed") return { kind: "failed", duplicate: true, record: failedRecord(row) }; if (row.status === "claimed") return { kind: "claimed", duplicate: true, record: claimedRecord(row) }; return { kind: "pending", duplicate: true, record: baseRecord(row) }; } function normalizeLimit(limit) { return limit === "all" ? Number.MAX_SAFE_INTEGER : Math.max(1, Math.floor(limit ?? 100)); } function normalizeMaxEntries(value) { return value === void 0 ? null : Math.max(0, Math.floor(value)); } function normalizedProtectedIds(ids) { return [...ids ?? []].map((id) => id.trim()).filter(Boolean); } function queueNameForParts(channelId, accountId) { return JSON.stringify([channelId, accountId]); } /** Creates a durable channel/account-scoped ingress queue backed by the OpenClaw state database. */ function createChannelIngressQueue(options) { const channelId = normalizePart(options.channelId, "unknown"); const accountId = normalizePart(options.accountId, "default"); const queueName = queueNameForParts(channelId, accountId); const now = options.now ?? Date.now; const enqueue = async (id, payload, enqueueOptions) => { const eventId = normalizePart(id, ""); if (!eventId) throw new Error("Channel ingress event id cannot be empty"); const receivedAt = enqueueOptions?.receivedAt ?? now(); const updatedAt = now(); return runOpenClawStateWriteTransaction((tx) => { const kysely = getChannelIngressKysely(tx.db); const insert = executeSqliteQuerySync(tx.db, kysely.insertInto("channel_ingress_events").values({ queue_name: queueName, event_id: eventId, channel_id: channelId, account_id: accountId, status: "pending", lane_key: enqueueOptions?.laneKey ?? null, payload_json: JSON.stringify(payload), metadata_json: enqueueOptions?.metadata === void 0 ? null : JSON.stringify(enqueueOptions.metadata), received_at: receivedAt, updated_at: updatedAt, attempts: 0 }).onConflict((conflict) => conflict.columns(["queue_name", "event_id"]).doNothing())); const row = selectRow(tx.db, queueName, eventId); if (!row) throw new Error(`Failed to read channel ingress event ${queueName}/${eventId}`); if (affectedRows(insert) > 0) return { kind: "accepted", duplicate: false, record: baseRecord(row) }; return rowToEnqueueResult(row); }, { path: openStateDatabase(options.stateDir).path }); }; const listPending = async (listOptions) => { const { db } = openStateDatabase(options.stateDir); const baseQuery = getChannelIngressKysely(db).selectFrom("channel_ingress_events").selectAll().where("queue_name", "=", queueName).where("status", "=", "pending").limit(normalizeLimit(listOptions?.limit)); return executeSqliteQuerySync(db, listOptions?.orderBy === "id" ? baseQuery.orderBy("event_id", "asc") : baseQuery.orderBy("received_at", "asc").orderBy("event_id", "asc")).rows.map((row) => baseRecord(row)); }; const listClaims = async () => { const { db } = openStateDatabase(options.stateDir); return executeSqliteQuerySync(db, getChannelIngressKysely(db).selectFrom("channel_ingress_events").selectAll().where("queue_name", "=", queueName).where("status", "=", "claimed").orderBy("claimed_at", "asc").orderBy("received_at", "asc").orderBy("event_id", "asc")).rows.map((row) => claimedRecord(row)); }; const recoverStaleClaims = async (recoverOptions) => { const staleMs = Math.max(0, Math.floor(recoverOptions?.staleMs ?? 0)); const cutoff = (recoverOptions?.now ?? now()) - staleMs; const claims = (await listClaims()).filter((claim) => claim.claim.claimedAt <= cutoff); let recovered = 0; for (const claim of claims) { if (recoverOptions?.shouldRecover && !await recoverOptions.shouldRecover(claim)) continue; if (await release(claim, { releasedAt: recoverOptions?.now ?? now() })) recovered += 1; } return recovered; }; const claimNext = async (claimOptions) => { if (claimOptions?.staleMs !== void 0) await recoverStaleClaims({ staleMs: claimOptions.staleMs }); const blocked = new Set([...claimOptions?.blockedLaneKeys ?? []].map((key) => key.trim()).filter(Boolean)); return runOpenClawStateWriteTransaction((tx) => { const kysely = getChannelIngressKysely(tx.db); const baseSelect = kysely.selectFrom("channel_ingress_events").select(["event_id", "lane_key"]).where("queue_name", "=", queueName).where("status", "=", "pending"); const select = blocked.size === 0 ? baseSelect : baseSelect.where((eb) => eb.or([eb("lane_key", "is", null), eb("lane_key", "not in", [...blocked])])); const selected = executeSqliteQueryTakeFirstSync(tx.db, select.orderBy("received_at", "asc").orderBy("event_id", "asc").limit(1)); if (!selected) return null; const token = randomUUID(); const claimedAt = now(); const ownerId = normalizePart(claimOptions?.ownerId, `${process.pid}`); if (affectedRows(executeSqliteQuerySync(tx.db, kysely.updateTable("channel_ingress_events").set({ status: "claimed", claim_token: token, claim_owner: ownerId, claimed_at: claimedAt, updated_at: claimedAt }).where("queue_name", "=", queueName).where("event_id", "=", selected.event_id).where("status", "=", "pending"))) === 0) return null; const row = selectRow(tx.db, queueName, selected.event_id); return row ? claimedRecord(row) : null; }, { path: openStateDatabase(options.stateDir).path }); }; const claim = async (id, claimOptions) => { const eventId = normalizePart(id, ""); if (!eventId) throw new Error("Channel ingress event id cannot be empty"); return runOpenClawStateWriteTransaction((tx) => { const kysely = getChannelIngressKysely(tx.db); const token = randomUUID(); const claimedAt = now(); const ownerId = normalizePart(claimOptions?.ownerId, `${process.pid}`); if (affectedRows(executeSqliteQuerySync(tx.db, kysely.updateTable("channel_ingress_events").set({ status: "claimed", claim_token: token, claim_owner: ownerId, claimed_at: claimedAt, updated_at: claimedAt }).where("queue_name", "=", queueName).where("event_id", "=", eventId).where("status", "=", "pending"))) === 0) return null; const row = selectRow(tx.db, queueName, eventId); return row ? claimedRecord(row) : null; }, { path: openStateDatabase(options.stateDir).path }); }; const complete = async (idOrClaim, completeOptions) => { const eventId = idFrom(idOrClaim); const token = claimTokenFrom(idOrClaim); const completedAt = completeOptions?.completedAt ?? now(); return runOpenClawStateWriteTransaction((tx) => { const kysely = getChannelIngressKysely(tx.db); const baseUpdate = kysely.updateTable("channel_ingress_events").set({ status: "completed", completed_at: completedAt, completed_metadata_json: completeOptions?.metadata === void 0 ? null : JSON.stringify(completeOptions.metadata), payload_json: "null", metadata_json: null, claim_token: null, claim_owner: null, claimed_at: null, last_attempt_at: null, last_error: null, updated_at: completedAt }).where("queue_name", "=", queueName).where("event_id", "=", eventId); const update = token === null ? baseUpdate.where("status", "=", "pending") : baseUpdate.where("status", "=", "claimed").where("claim_token", "=", token); if (affectedRows(executeSqliteQuerySync(tx.db, update)) > 0) return true; if (token !== null) return false; return affectedRows(executeSqliteQuerySync(tx.db, kysely.insertInto("channel_ingress_events").values({ queue_name: queueName, event_id: eventId, channel_id: channelId, account_id: accountId, status: "completed", lane_key: null, payload_json: "null", metadata_json: null, received_at: completedAt, updated_at: completedAt, attempts: 0, completed_at: completedAt, completed_metadata_json: completeOptions?.metadata === void 0 ? null : JSON.stringify(completeOptions.metadata) }).onConflict((conflict) => conflict.columns(["queue_name", "event_id"]).doNothing()))) > 0; }, { path: openStateDatabase(options.stateDir).path }); }; const release = async (idOrClaim, releaseOptions) => { const eventId = idFrom(idOrClaim); const token = claimTokenFrom(idOrClaim); const releasedAt = releaseOptions?.releasedAt ?? now(); return runOpenClawStateWriteTransaction((tx) => { const baseUpdate = getChannelIngressKysely(tx.db).updateTable("channel_ingress_events").set((eb) => ({ status: "pending", claim_token: null, claim_owner: null, claimed_at: null, attempts: eb("attempts", "+", 1), last_attempt_at: releasedAt, ...releaseOptions?.lastError === void 0 ? {} : { last_error: releaseOptions.lastError }, updated_at: releasedAt })).where("queue_name", "=", queueName).where("event_id", "=", eventId); const update = token === null ? baseUpdate.where("status", "=", "pending") : baseUpdate.where("status", "=", "claimed").where("claim_token", "=", token); return affectedRows(executeSqliteQuerySync(tx.db, update)) > 0; }, { path: openStateDatabase(options.stateDir).path }); }; const fail = async (idOrClaim, failOptions) => { const eventId = idFrom(idOrClaim); const token = claimTokenFrom(idOrClaim); const failedAt = failOptions.failedAt ?? now(); return runOpenClawStateWriteTransaction((tx) => { const baseUpdate = getChannelIngressKysely(tx.db).updateTable("channel_ingress_events").set({ status: "failed", failed_at: failedAt, failed_reason: failOptions.reason, last_error: failOptions.message ?? null, payload_json: "null", metadata_json: null, claim_token: null, claim_owner: null, claimed_at: null, updated_at: failedAt }).where("queue_name", "=", queueName).where("event_id", "=", eventId); const update = token === null ? baseUpdate.where("status", "=", "pending") : baseUpdate.where("status", "=", "claimed").where("claim_token", "=", token); return affectedRows(executeSqliteQuerySync(tx.db, update)) > 0; }, { path: openStateDatabase(options.stateDir).path }); }; const deleteEntry = async (idOrRecord) => { const eventId = idFrom(idOrRecord); const token = claimTokenFrom(idOrRecord); return runOpenClawStateWriteTransaction((tx) => { const baseDelete = getChannelIngressKysely(tx.db).deleteFrom("channel_ingress_events").where("queue_name", "=", queueName).where("event_id", "=", eventId); const deleteQuery = token === null ? baseDelete.where("status", "=", "pending") : baseDelete.where("status", "=", "claimed").where("claim_token", "=", token); return affectedRows(executeSqliteQuerySync(tx.db, deleteQuery)) > 0; }, { path: openStateDatabase(options.stateDir).path }); }; const prune = async (pruneOptions) => { const current = pruneOptions?.now ?? now(); const pendingCutoff = pruneOptions?.pendingTtlMs === void 0 ? null : current - pruneOptions.pendingTtlMs; const completedCutoff = pruneOptions?.completedTtlMs === void 0 ? null : current - pruneOptions.completedTtlMs; const failedCutoff = pruneOptions?.failedTtlMs === void 0 ? null : current - pruneOptions.failedTtlMs; const pendingMaxEntries = normalizeMaxEntries(pruneOptions?.pendingMaxEntries); const completedMaxEntries = normalizeMaxEntries(pruneOptions?.completedMaxEntries); const failedMaxEntries = normalizeMaxEntries(pruneOptions?.failedMaxEntries); const protectIds = normalizedProtectedIds(pruneOptions?.protectIds); if (pendingCutoff === null && completedCutoff === null && failedCutoff === null && pendingMaxEntries === null && completedMaxEntries === null && failedMaxEntries === null) return 0; return runOpenClawStateWriteTransaction((tx) => { const kysely = getChannelIngressKysely(tx.db); let deleted = 0; if (pendingCutoff !== null) { let deleteQuery = kysely.deleteFrom("channel_ingress_events").where("queue_name", "=", queueName).where("status", "=", "pending").where("updated_at", "<", pendingCutoff); if (protectIds.length > 0) deleteQuery = deleteQuery.where("event_id", "not in", protectIds); deleted += affectedRows(executeSqliteQuerySync(tx.db, deleteQuery)); } if (completedCutoff !== null) { let deleteQuery = kysely.deleteFrom("channel_ingress_events").where("queue_name", "=", queueName).where("status", "=", "completed").where("completed_at", "<", completedCutoff); if (protectIds.length > 0) deleteQuery = deleteQuery.where("event_id", "not in", protectIds); deleted += affectedRows(executeSqliteQuerySync(tx.db, deleteQuery)); } if (failedCutoff !== null) { let deleteQuery = kysely.deleteFrom("channel_ingress_events").where("queue_name", "=", queueName).where("status", "=", "failed").where("failed_at", "<", failedCutoff); if (protectIds.length > 0) deleteQuery = deleteQuery.where("event_id", "not in", protectIds); deleted += affectedRows(executeSqliteQuerySync(tx.db, deleteQuery)); } const pruneMaxEntries = (status, maxEntries) => { if (maxEntries === null) return; const batchSize = 500; const protectedSet = new Set(protectIds); while (true) { const ids = executeSqliteQuerySync(tx.db, kysely.selectFrom("channel_ingress_events").select("event_id").where("queue_name", "=", queueName).where("status", "=", status).orderBy("updated_at", "desc").orderBy("event_id", "desc").limit(maxEntries + batchSize)).rows.slice(maxEntries).map((row) => row.event_id).filter((id) => !protectedSet.has(id)); if (ids.length === 0) return; deleted += affectedRows(executeSqliteQuerySync(tx.db, kysely.deleteFrom("channel_ingress_events").where("queue_name", "=", queueName).where("status", "=", status).where("event_id", "in", ids))); } }; pruneMaxEntries("pending", pendingMaxEntries); pruneMaxEntries("completed", completedMaxEntries); pruneMaxEntries("failed", failedMaxEntries); return deleted; }, { path: openStateDatabase(options.stateDir).path }); }; return { enqueue, listPending, listClaims, claimNext, claim, complete, release, fail, delete: deleteEntry, recoverStaleClaims, prune }; } //#endregion export { createChannelIngressQueue as t };