openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
366 lines (365 loc) • 17.1 kB
JavaScript
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 };