docs: document voice call manager helpers

This commit is contained in:
Peter Steinberger
2026-06-04 01:42:32 -04:00
parent ae5376a599
commit 5dd026f3f7
6 changed files with 58 additions and 0 deletions

View File

@@ -4,12 +4,15 @@ import { transitionState } from "./state.js";
import { persistCallRecord } from "./store.js";
import { clearMaxDurationTimer, rejectTranscriptWaiter } from "./timers.js";
// Shared call finalization path for manager and webhook lifecycle exits.
type CallLifecycleContext = Pick<
CallManagerContext,
"activeCalls" | "providerCallIdMap" | "storePath"
> &
Partial<Pick<CallManagerContext, "transcriptWaiters" | "maxDurationTimers">>;
/** Remove a provider-call mapping only when it still points at this call. */
function removeProviderCallMapping(
providerCallIdMap: Map<string, string>,
call: Pick<CallRecord, "callId" | "providerCallId">,
@@ -23,6 +26,7 @@ function removeProviderCallMapping(
}
}
/** Persist terminal state, clean timers/waiters, and remove active call indexes. */
export function finalizeCall(params: {
ctx: CallLifecycleContext;
call: CallRecord;

View File

@@ -1,5 +1,8 @@
import type { CallId, CallRecord } from "../types.js";
// Lookup helpers for active calls by internal or provider call ids.
/** Resolve an active call from provider call id with map lookup plus stale-map fallback scan. */
export function getCallByProviderCallId(params: {
activeCalls: Map<CallId, CallRecord>;
providerCallIdMap: Map<string, CallId>;
@@ -18,6 +21,7 @@ export function getCallByProviderCallId(params: {
return undefined;
}
/** Resolve an active call by internal call id or provider call id. */
export function findCall(params: {
activeCalls: Map<CallId, CallRecord>;
providerCallIdMap: Map<string, CallId>;

View File

@@ -4,16 +4,25 @@ import type { PluginStateSyncKeyedStore } from "openclaw/plugin-sdk/plugin-state
import { getOptionalVoiceCallStateRuntime } from "../runtime-state.js";
import { CallRecordSchema, TerminalStates, type CallId, type CallRecord } from "../types.js";
// Persistent voice-call event store backed by plugin state chunk records.
/** Plugin state namespace for call record event metadata. */
export const CALL_RECORD_EVENTS_NAMESPACE = "call-record-events";
/** Plugin state namespace for base64 call record event chunks. */
export const CALL_RECORD_EVENT_CHUNKS_NAMESPACE = "call-record-event-chunks";
/** Maximum retained call record events. */
export const MAX_CALL_RECORD_EVENTS = 1000;
/** Extra metadata entries retained so pruning can safely trim oldest rows. */
export const CALL_RECORD_EVENT_META_MAX_ENTRIES = MAX_CALL_RECORD_EVENTS + 100;
/** Maximum chunks allowed for one persisted call record event. */
export const MAX_CHUNKS_PER_CALL_RECORD_EVENT = 48;
export const CALL_RECORD_CHUNK_MAX_ENTRIES =
MAX_CALL_RECORD_EVENTS * MAX_CHUNKS_PER_CALL_RECORD_EVENT + MAX_CHUNKS_PER_CALL_RECORD_EVENT;
/** Raw UTF-8 bytes stored per call record chunk before base64 encoding. */
export const RAW_CALL_RECORD_CHUNK_BYTES = 47 * 1024;
let callRecordEventSequence = 0;
/** Metadata row for a chunked call record event. */
type CallRecordEventMeta = {
chunkCount: number;
byteLength: number;
@@ -21,11 +30,13 @@ type CallRecordEventMeta = {
sequence?: number;
};
/** One base64 chunk for a serialized call record event. */
type CallRecordEventChunk = {
index: number;
dataBase64: string;
};
/** Call record plus stable ordering metadata read from persistence. */
export type PersistedCallRecord = {
call: CallRecord;
persistedAt: number;
@@ -33,19 +44,23 @@ export type PersistedCallRecord = {
orderKey: string;
};
/** Pair of plugin state stores used for call record events. */
type CallRecordStateStores = {
events: PluginStateSyncKeyedStore<CallRecordEventMeta>;
chunks: PluginStateSyncKeyedStore<CallRecordEventChunk>;
};
/** Return the pre-SQLite JSONL call log path for migration/compat checks. */
export function resolveVoiceCallLegacyCallLogPath(storePath: string): string {
return path.join(storePath, "calls.jsonl");
}
/** Build env for plugin state stores rooted at the voice-call store path. */
function resolvePluginStateEnv(storePath: string): NodeJS.ProcessEnv {
return { ...process.env, OPENCLAW_STATE_DIR: storePath };
}
/** Open the plugin state stores when the runtime is available. */
function createCallRecordStateStores(storePath: string): CallRecordStateStores | null {
const runtime = getOptionalVoiceCallStateRuntime();
if (!runtime) {
@@ -66,6 +81,7 @@ function createCallRecordStateStores(storePath: string): CallRecordStateStores |
};
}
/** Open call stores and log failures instead of breaking restore paths. */
function tryCreateCallRecordStateStores(storePath: string): CallRecordStateStores | null {
try {
return createCallRecordStateStores(storePath);
@@ -75,29 +91,35 @@ function tryCreateCallRecordStateStores(storePath: string): CallRecordStateStore
}
}
/** Build the stable storage key for one chunk of an event. */
function buildChunkKey(eventKey: string, index: number): string {
return `${eventKey}:chunk:${String(index).padStart(4, "0")}`;
}
/** Build a deterministic key for one legacy JSONL line. */
export function buildVoiceCallLegacyJsonlEventKey(line: string, index: number): string {
return `jsonl:${String(index).padStart(8, "0")}:${createHash("sha256").update(line).digest("hex")}`;
}
/** Allocate monotonic ordering metadata for newly persisted call records. */
function nextCallRecordOrder(): { persistedAt: number; sequence: number } {
const sequence = callRecordEventSequence;
callRecordEventSequence = (callRecordEventSequence + 1) % 1_000_000;
return { persistedAt: Date.now(), sequence };
}
/** Build a unique event key that preserves timestamp and sequence ordering. */
function buildNewEventKey(order: { persistedAt: number; sequence: number }): string {
return `event:${order.persistedAt.toString(36)}:${String(order.sequence).padStart(6, "0")}:${randomUUID()}`;
}
/** Recover the sequence segment from newer event keys. */
function parseEventKeySequence(key: string): number {
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. */
export function parseVoiceCallRecordLine(line: string, sequence = 0): PersistedCallRecord | null {
if (!line.trim()) {
return null;
@@ -135,6 +157,7 @@ export function parseVoiceCallRecordLine(line: string, sequence = 0): PersistedC
}
}
/** Count storage chunks needed for a call record. */
function countCallRecordChunks(call: CallRecord): number {
return Math.max(
1,
@@ -142,6 +165,7 @@ function countCallRecordChunks(call: CallRecord): number {
);
}
/** Truncate oversized call records to fit the bounded plugin state chunk budget. */
export function prepareVoiceCallRecordForStorage(call: CallRecord): CallRecord {
if (countCallRecordChunks(call) <= MAX_CHUNKS_PER_CALL_RECORD_EVENT) {
return call;
@@ -180,6 +204,7 @@ export function prepareVoiceCallRecordForStorage(call: CallRecord): CallRecord {
return call;
}
/** Register a serialized call record event and its chunks, then prune old events. */
function registerCallRecordEvent(
stores: CallRecordStateStores,
eventKey: string,
@@ -213,6 +238,7 @@ function registerCallRecordEvent(
pruneCallRecordEvents(stores);
}
/** Delete metadata and all chunk rows for one call record event. */
function deleteCallRecordEventRows(stores: CallRecordStateStores, eventKey: string): void {
const meta = stores.events.lookup(eventKey);
stores.events.delete(eventKey);
@@ -224,6 +250,7 @@ function deleteCallRecordEventRows(stores: CallRecordStateStores, eventKey: stri
}
}
/** Keep only the newest bounded call record events. */
function pruneCallRecordEvents(stores: CallRecordStateStores): void {
const rows = stores.events.entries();
if (rows.length <= MAX_CALL_RECORD_EVENTS) {
@@ -235,6 +262,7 @@ function pruneCallRecordEvents(stores: CallRecordStateStores): void {
}
}
/** Read and reassemble one chunked call record event. */
function readCallRecordEvent(stores: CallRecordStateStores, eventKey: string): CallRecord | null {
const meta = stores.events.lookup(eventKey);
if (!meta) {
@@ -252,6 +280,7 @@ function readCallRecordEvent(stores: CallRecordStateStores, eventKey: string): C
return parseVoiceCallRecordLine(serialized)?.call ?? null;
}
/** Read all persisted call records in stable persisted order. */
function readCallRecordEvents(stores: CallRecordStateStores): CallRecord[] {
const sqliteCalls: PersistedCallRecord[] = stores.events
.entries()
@@ -278,6 +307,7 @@ function readCallRecordEvents(stores: CallRecordStateStores): CallRecord[] {
.map((entry) => entry.call);
}
/** Persist one call record event to plugin state. */
export function persistCallRecord(storePath: string, call: CallRecord): void {
try {
const stores = createCallRecordStateStores(storePath);
@@ -292,10 +322,12 @@ export function persistCallRecord(storePath: string, call: CallRecord): void {
}
}
/** Test hook for older async persistence call sites. */
export async function flushPendingCallRecordWritesForTest(): Promise<void> {
await Promise.resolve();
}
/** Restore nonterminal active calls and provider/event indexes from persisted records. */
export function loadActiveCallsFromStore(storePath: string): {
activeCalls: Map<CallId, CallRecord>;
providerCallIdMap: Map<string, CallId>;
@@ -343,6 +375,7 @@ export function loadActiveCallsFromStore(storePath: string): {
return { activeCalls, providerCallIdMap, processedEventIds, rejectedProviderCallIds };
}
/** Return the newest persisted call history rows up to the requested limit. */
export async function getCallHistoryFromStore(
storePath: string,
limit = 50,

View File

@@ -1,5 +1,8 @@
import { MAX_TIMER_TIMEOUT_MS, resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
// Timer delay normalization helpers for voice-call lifecycle timers.
/** Convert seconds to a safe timeout delay in milliseconds. */
export function resolveVoiceCallSecondsTimerDelayMs(seconds: number, minMs = 1): number {
if (!Number.isFinite(seconds)) {
return resolveTimerTimeoutMs(MAX_TIMER_TIMEOUT_MS, MAX_TIMER_TIMEOUT_MS, minMs);
@@ -12,6 +15,7 @@ export function resolveVoiceCallSecondsTimerDelayMs(seconds: number, minMs = 1):
);
}
/** Normalize a millisecond timeout delay with fallback behavior. */
export function resolveVoiceCallTimerDelayMs(timeoutMs: number, fallbackMs = 1): number {
return resolveTimerTimeoutMs(timeoutMs, fallbackMs);
}

View File

@@ -6,6 +6,8 @@ import {
resolveVoiceCallTimerDelayMs,
} from "./timer-delays.js";
// Max-duration and transcript-waiter timers for active voice calls.
type TimerContext = Pick<
CallManagerContext,
"activeCalls" | "maxDurationTimers" | "config" | "storePath" | "transcriptWaiters"
@@ -16,6 +18,7 @@ type MaxDurationTimerContext = Pick<
>;
type TranscriptWaiterContext = Pick<TimerContext, "transcriptWaiters">;
/** Clear and forget the max-duration timer for a call. */
export function clearMaxDurationTimer(
ctx: Pick<MaxDurationTimerContext, "maxDurationTimers">,
callId: CallId,
@@ -27,6 +30,7 @@ export function clearMaxDurationTimer(
}
}
/** Start or replace the max-duration timer for a call. */
export function startMaxDurationTimer(params: {
ctx: MaxDurationTimerContext;
callId: CallId;
@@ -53,6 +57,7 @@ export function startMaxDurationTimer(params: {
);
call.endReason = "timeout";
persistCallRecord(params.ctx.storePath, call);
// Provider-specific timeout handling owns the actual hangup after state persistence.
await params.onTimeout(params.callId);
}
})();
@@ -61,6 +66,7 @@ export function startMaxDurationTimer(params: {
params.ctx.maxDurationTimers.set(params.callId, timer);
}
/** Clear and forget a pending final-transcript waiter. */
export function clearTranscriptWaiter(ctx: TranscriptWaiterContext, callId: CallId): void {
const waiter = ctx.transcriptWaiters.get(callId);
if (!waiter) {
@@ -70,6 +76,7 @@ export function clearTranscriptWaiter(ctx: TranscriptWaiterContext, callId: Call
ctx.transcriptWaiters.delete(callId);
}
/** Reject a pending transcript waiter during call finalization or error paths. */
export function rejectTranscriptWaiter(
ctx: TranscriptWaiterContext,
callId: CallId,
@@ -83,6 +90,7 @@ export function rejectTranscriptWaiter(
waiter.reject(new Error(reason));
}
/** Resolve a transcript waiter when the matching turn's final transcript arrives. */
export function resolveTranscriptWaiter(
ctx: TranscriptWaiterContext,
callId: CallId,
@@ -101,6 +109,7 @@ export function resolveTranscriptWaiter(
return true;
}
/** Wait for the next final transcript for a call, optionally scoped to a turn token. */
export function waitForFinalTranscript(
ctx: TimerContext,
callId: CallId,

View File

@@ -1,5 +1,8 @@
import { escapeXml } from "../voice-mapping.js";
// TwiML builders for manager-initiated notify and DTMF redirect flows.
/** Generate TwiML that speaks one notification and hangs up. */
export function generateNotifyTwiml(message: string, voice: string): string {
return `<?xml version="1.0" encoding="UTF-8"?>
<Response>
@@ -8,6 +11,7 @@ export function generateNotifyTwiml(message: string, voice: string): string {
</Response>`;
}
/** Generate TwiML that plays DTMF digits before redirecting to a webhook URL. */
export function generateDtmfRedirectTwiml(digits: string, webhookUrl: string): string {
return `<?xml version="1.0" encoding="UTF-8"?>
<Response>