openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
387 lines (386 loc) • 13.8 kB
JavaScript
import { At as boolean, Bt as discriminatedUnion, Et as array, Nn as record, Rn as string, Tn as object, Zn as unknown, dn as literal, wn as number, yt as _enum } from "./schemas-6cH6bZ7o.js";
import { t as createPluginRuntimeStore } from "./runtime-store-uAKGMqTs.js";
import path from "node:path";
import { createHash, randomUUID } from "node:crypto";
//#region extensions/voice-call/src/types.ts
const ProviderNameSchema = _enum([
"telnyx",
"twilio",
"plivo",
"mock"
]);
const CallStateSchema = _enum([
"initiated",
"ringing",
"answered",
"active",
"speaking",
"listening",
"completed",
"hangup-user",
"hangup-bot",
"timeout",
"error",
"failed",
"no-answer",
"busy",
"voicemail"
]);
const TerminalStates = new Set([
"completed",
"hangup-user",
"hangup-bot",
"timeout",
"error",
"failed",
"no-answer",
"busy",
"voicemail"
]);
const EndReasonSchema = _enum([
"completed",
"hangup-user",
"hangup-bot",
"timeout",
"error",
"failed",
"no-answer",
"busy",
"voicemail"
]);
const BaseEventSchema = object({
id: string(),
dedupeKey: string().optional(),
callId: string(),
providerCallId: string().optional(),
timestamp: number(),
turnToken: string().optional(),
direction: _enum(["inbound", "outbound"]).optional(),
from: string().optional(),
to: string().optional()
});
discriminatedUnion("type", [
BaseEventSchema.extend({ type: literal("call.initiated") }),
BaseEventSchema.extend({ type: literal("call.ringing") }),
BaseEventSchema.extend({ type: literal("call.answered") }),
BaseEventSchema.extend({ type: literal("call.active") }),
BaseEventSchema.extend({
type: literal("call.speaking"),
text: string()
}),
BaseEventSchema.extend({
type: literal("call.speech"),
transcript: string(),
isFinal: boolean(),
confidence: number().min(0).max(1).optional()
}),
BaseEventSchema.extend({
type: literal("call.silence"),
durationMs: number()
}),
BaseEventSchema.extend({
type: literal("call.dtmf"),
digits: string()
}),
BaseEventSchema.extend({
type: literal("call.ended"),
reason: EndReasonSchema
}),
BaseEventSchema.extend({
type: literal("call.error"),
error: string(),
retryable: boolean().optional()
})
]);
const CallDirectionSchema = _enum(["outbound", "inbound"]);
const TranscriptEntrySchema = object({
timestamp: number(),
speaker: _enum(["bot", "user"]),
text: string(),
isFinal: boolean().default(true)
});
const CallRecordSchema = object({
callId: string(),
providerCallId: string().optional(),
provider: ProviderNameSchema,
direction: CallDirectionSchema,
state: CallStateSchema,
from: string(),
to: string(),
sessionKey: string().optional(),
startedAt: number(),
answeredAt: number().optional(),
endedAt: number().optional(),
endReason: EndReasonSchema.optional(),
transcript: array(TranscriptEntrySchema).default([]),
processedEventIds: array(string()).default([]),
metadata: record(string(), unknown()).optional()
});
//#endregion
//#region extensions/voice-call/src/runtime-state.ts
const { setRuntime: setVoiceCallStateRuntime, clearRuntime: clearVoiceCallStateRuntime, tryGetRuntime: getOptionalVoiceCallStateRuntime } = createPluginRuntimeStore({
pluginId: "voice-call-state",
errorMessage: "Voice Call state runtime not initialized"
});
//#endregion
//#region extensions/voice-call/src/manager/store.ts
/** Plugin state namespace for call record event metadata. */
const CALL_RECORD_EVENTS_NAMESPACE = "call-record-events";
/** Plugin state namespace for base64 call record event chunks. */
const CALL_RECORD_EVENT_CHUNKS_NAMESPACE = "call-record-event-chunks";
/** Maximum retained call record events. */
const MAX_CALL_RECORD_EVENTS = 1e3;
/** Extra metadata entries retained so pruning can safely trim oldest rows. */
const CALL_RECORD_EVENT_META_MAX_ENTRIES = 1100;
const CALL_RECORD_CHUNK_MAX_ENTRIES = 48048;
/** Raw UTF-8 bytes stored per call record chunk before base64 encoding. */
const RAW_CALL_RECORD_CHUNK_BYTES = 47 * 1024;
let callRecordEventSequence = 0;
/** Return the pre-SQLite JSONL call log path for migration/compat checks. */
function resolveVoiceCallLegacyCallLogPath(storePath) {
return path.join(storePath, "calls.jsonl");
}
/** Build env for plugin state stores rooted at the voice-call store path. */
function resolvePluginStateEnv(storePath) {
return {
...process.env,
OPENCLAW_STATE_DIR: storePath
};
}
/** Open the plugin state stores when the runtime is available. */
function createCallRecordStateStores(storePath) {
const runtime = getOptionalVoiceCallStateRuntime();
if (!runtime) return null;
const env = resolvePluginStateEnv(storePath);
return {
events: runtime.state.openSyncKeyedStore({
namespace: CALL_RECORD_EVENTS_NAMESPACE,
maxEntries: CALL_RECORD_EVENT_META_MAX_ENTRIES,
env
}),
chunks: runtime.state.openSyncKeyedStore({
namespace: CALL_RECORD_EVENT_CHUNKS_NAMESPACE,
maxEntries: CALL_RECORD_CHUNK_MAX_ENTRIES,
env
})
};
}
/** Open call stores and log failures instead of breaking restore paths. */
function tryCreateCallRecordStateStores(storePath) {
try {
return createCallRecordStateStores(storePath);
} catch (err) {
console.error("[voice-call] Failed to open SQLite call record store:", err);
return null;
}
}
/** Build the stable storage key for one chunk of an event. */
function buildChunkKey(eventKey, index) {
return `${eventKey}:chunk:${String(index).padStart(4, "0")}`;
}
/** Build a deterministic key for one legacy JSONL line. */
function buildVoiceCallLegacyJsonlEventKey(line, index) {
return `jsonl:${String(index).padStart(8, "0")}:${createHash("sha256").update(line).digest("hex")}`;
}
/** Allocate monotonic ordering metadata for newly persisted call records. */
function nextCallRecordOrder() {
const sequence = callRecordEventSequence;
callRecordEventSequence = (callRecordEventSequence + 1) % 1e6;
return {
persistedAt: Date.now(),
sequence
};
}
/** Build a unique event key that preserves timestamp and sequence ordering. */
function buildNewEventKey(order) {
return `event:${order.persistedAt.toString(36)}:${String(order.sequence).padStart(6, "0")}:${randomUUID()}`;
}
/** Recover the sequence segment from newer event keys. */
function parseEventKeySequence(key) {
const match = /^event:[^:]+:(\d+):/.exec(key);
return match ? Number.parseInt(match[1], 10) : 0;
}
/** Parse a stored call record line from v2 envelope or legacy raw-call JSON. */
function parseVoiceCallRecordLine(line, sequence = 0) {
if (!line.trim()) return null;
try {
const parsed = JSON.parse(line);
if (parsed && typeof parsed === "object" && parsed.version === 2) {
const envelope = parsed;
return {
call: CallRecordSchema.parse(envelope.call),
persistedAt: typeof envelope.persistedAt === "number" && Number.isFinite(envelope.persistedAt) ? envelope.persistedAt : 0,
sequence: typeof envelope.sequence === "number" && Number.isFinite(envelope.sequence) ? envelope.sequence : sequence,
orderKey: ""
};
}
return {
call: CallRecordSchema.parse(parsed),
persistedAt: 0,
sequence,
orderKey: ""
};
} catch {
return null;
}
}
/** Count storage chunks needed for a call record. */
function countCallRecordChunks(call) {
return Math.max(1, Math.ceil(Buffer.byteLength(JSON.stringify(call), "utf8") / RAW_CALL_RECORD_CHUNK_BYTES));
}
/** Truncate oversized call records to fit the bounded plugin state chunk budget. */
function prepareVoiceCallRecordForStorage(call) {
if (countCallRecordChunks(call) <= 48) return call;
const transcriptEntries = call.transcript.length;
const metadata = {
...call.metadata,
voiceCallPersistence: {
transcriptTruncated: true,
originalTranscriptEntries: transcriptEntries
}
};
const candidateInputs = [
{
transcript: call.transcript.slice(-20),
metadata
},
{
transcript: [],
metadata
},
{
transcript: [],
metadata: { voiceCallPersistence: {
transcriptTruncated: true,
originalTranscriptEntries: transcriptEntries,
metadataTruncated: true
} }
}
];
for (const candidateInput of candidateInputs) {
const candidate = CallRecordSchema.parse({
...call,
...candidateInput
});
if (countCallRecordChunks(candidate) <= 48) return candidate;
}
return call;
}
/** Register a serialized call record event and its chunks, then prune old events. */
function registerCallRecordEvent(stores, eventKey, call, order) {
const serialized = JSON.stringify(prepareVoiceCallRecordForStorage(call));
const buffer = Buffer.from(serialized, "utf8");
const chunkCount = Math.max(1, Math.ceil(buffer.byteLength / RAW_CALL_RECORD_CHUNK_BYTES));
if (chunkCount > 48) throw new Error(`voice-call record exceeds SQLite chunk limit (${chunkCount}/48)`);
for (let index = 0; index < chunkCount; index += 1) {
const chunk = buffer.subarray(index * RAW_CALL_RECORD_CHUNK_BYTES, (index + 1) * RAW_CALL_RECORD_CHUNK_BYTES);
stores.chunks.register(buildChunkKey(eventKey, index), {
index,
dataBase64: chunk.toString("base64")
});
}
stores.events.register(eventKey, {
chunkCount,
byteLength: buffer.byteLength,
persistedAt: order?.persistedAt,
sequence: order?.sequence
});
pruneCallRecordEvents(stores);
}
/** Delete metadata and all chunk rows for one call record event. */
function deleteCallRecordEventRows(stores, eventKey) {
const meta = stores.events.lookup(eventKey);
stores.events.delete(eventKey);
if (!meta) return;
for (let index = 0; index < meta.chunkCount; index += 1) stores.chunks.delete(buildChunkKey(eventKey, index));
}
/** Keep only the newest bounded call record events. */
function pruneCallRecordEvents(stores) {
const rows = stores.events.entries();
if (rows.length <= 1e3) return;
const sorted = rows.toSorted((a, b) => a.createdAt - b.createdAt || a.key.localeCompare(b.key));
for (const row of sorted.slice(0, rows.length - MAX_CALL_RECORD_EVENTS)) deleteCallRecordEventRows(stores, row.key);
}
/** Read and reassemble one chunked call record event. */
function readCallRecordEvent(stores, eventKey) {
const meta = stores.events.lookup(eventKey);
if (!meta) return null;
const chunks = [];
for (let index = 0; index < meta.chunkCount; index += 1) {
const chunk = stores.chunks.lookup(buildChunkKey(eventKey, index));
if (!chunk || chunk.index !== index) return null;
chunks.push(Buffer.from(chunk.dataBase64, "base64"));
}
return parseVoiceCallRecordLine(Buffer.concat(chunks, meta.byteLength).toString("utf8"))?.call ?? null;
}
/** Read all persisted call records in stable persisted order. */
function readCallRecordEvents(stores) {
return stores.events.entries().toSorted((a, b) => a.createdAt - b.createdAt || a.key.localeCompare(b.key)).map((entry) => {
const call = readCallRecordEvent(stores, entry.key);
return call ? {
call,
persistedAt: entry.value.persistedAt ?? entry.createdAt,
sequence: entry.value.sequence ?? parseEventKeySequence(entry.key),
orderKey: entry.key
} : null;
}).filter((entry) => entry !== null).toSorted((a, b) => a.persistedAt - b.persistedAt || a.sequence - b.sequence || a.orderKey.localeCompare(b.orderKey)).map((entry) => entry.call);
}
/** Persist one call record event to plugin state. */
function persistCallRecord(storePath, call) {
try {
const stores = createCallRecordStateStores(storePath);
if (!stores) throw new Error("Voice Call state runtime not initialized");
const order = nextCallRecordOrder();
registerCallRecordEvent(stores, buildNewEventKey(order), call, order);
} catch (err) {
console.error("[voice-call] Failed to persist call record:", err);
throw err;
}
}
/** Restore nonterminal active calls and provider/event indexes from persisted records. */
function loadActiveCallsFromStore(storePath) {
const stores = tryCreateCallRecordStateStores(storePath);
let calls = [];
try {
calls = stores ? readCallRecordEvents(stores) : [];
} catch (err) {
console.error("[voice-call] Failed to read SQLite call records:", err);
}
if (calls.length === 0) return {
activeCalls: /* @__PURE__ */ new Map(),
providerCallIdMap: /* @__PURE__ */ new Map(),
processedEventIds: /* @__PURE__ */ new Set(),
rejectedProviderCallIds: /* @__PURE__ */ new Set()
};
const callMap = /* @__PURE__ */ new Map();
for (const call of calls) callMap.set(call.callId, call);
const activeCalls = /* @__PURE__ */ new Map();
const providerCallIdMap = /* @__PURE__ */ new Map();
const processedEventIds = /* @__PURE__ */ new Set();
const rejectedProviderCallIds = /* @__PURE__ */ new Set();
for (const [callId, call] of callMap) {
for (const eventId of call.processedEventIds) processedEventIds.add(eventId);
if (TerminalStates.has(call.state)) continue;
activeCalls.set(callId, call);
if (call.providerCallId) providerCallIdMap.set(call.providerCallId, callId);
}
return {
activeCalls,
providerCallIdMap,
processedEventIds,
rejectedProviderCallIds
};
}
/** Return the newest persisted call history rows up to the requested limit. */
async function getCallHistoryFromStore(storePath, limit = 50) {
if (limit <= 0) return [];
const stores = tryCreateCallRecordStateStores(storePath);
if (stores) try {
return readCallRecordEvents(stores).slice(-limit);
} catch (err) {
console.error("[voice-call] Failed to read SQLite call history:", err);
}
return [];
}
//#endregion
export { MAX_CALL_RECORD_EVENTS as a, getCallHistoryFromStore as c, persistCallRecord as d, prepareVoiceCallRecordForStorage as f, TerminalStates as h, CALL_RECORD_EVENT_META_MAX_ENTRIES as i, loadActiveCallsFromStore as l, setVoiceCallStateRuntime as m, CALL_RECORD_EVENTS_NAMESPACE as n, RAW_CALL_RECORD_CHUNK_BYTES as o, resolveVoiceCallLegacyCallLogPath as p, CALL_RECORD_EVENT_CHUNKS_NAMESPACE as r, buildVoiceCallLegacyJsonlEventKey as s, CALL_RECORD_CHUNK_MAX_ENTRIES as t, parseVoiceCallRecordLine as u };