diff --git a/extensions/voice-call/src/manager/lifecycle.ts b/extensions/voice-call/src/manager/lifecycle.ts index 93bd6242539a..952c0ccb54ad 100644 --- a/extensions/voice-call/src/manager/lifecycle.ts +++ b/extensions/voice-call/src/manager/lifecycle.ts @@ -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>; +/** Remove a provider-call mapping only when it still points at this call. */ function removeProviderCallMapping( providerCallIdMap: Map, call: Pick, @@ -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; diff --git a/extensions/voice-call/src/manager/lookup.ts b/extensions/voice-call/src/manager/lookup.ts index 1d3856ce5ff5..deefac593ba8 100644 --- a/extensions/voice-call/src/manager/lookup.ts +++ b/extensions/voice-call/src/manager/lookup.ts @@ -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; providerCallIdMap: Map; @@ -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; providerCallIdMap: Map; diff --git a/extensions/voice-call/src/manager/store.ts b/extensions/voice-call/src/manager/store.ts index 7e9e5a34e5d6..695260a4601c 100644 --- a/extensions/voice-call/src/manager/store.ts +++ b/extensions/voice-call/src/manager/store.ts @@ -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; chunks: PluginStateSyncKeyedStore; }; +/** 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 { await Promise.resolve(); } +/** Restore nonterminal active calls and provider/event indexes from persisted records. */ export function loadActiveCallsFromStore(storePath: string): { activeCalls: Map; providerCallIdMap: Map; @@ -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, diff --git a/extensions/voice-call/src/manager/timer-delays.ts b/extensions/voice-call/src/manager/timer-delays.ts index 3d279b42ea9b..fe0b536e75a2 100644 --- a/extensions/voice-call/src/manager/timer-delays.ts +++ b/extensions/voice-call/src/manager/timer-delays.ts @@ -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); } diff --git a/extensions/voice-call/src/manager/timers.ts b/extensions/voice-call/src/manager/timers.ts index 763cc53fed01..3c02516de7e0 100644 --- a/extensions/voice-call/src/manager/timers.ts +++ b/extensions/voice-call/src/manager/timers.ts @@ -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; +/** Clear and forget the max-duration timer for a call. */ export function clearMaxDurationTimer( ctx: Pick, 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, diff --git a/extensions/voice-call/src/manager/twiml.ts b/extensions/voice-call/src/manager/twiml.ts index 1e20d652b926..9365ae202589 100644 --- a/extensions/voice-call/src/manager/twiml.ts +++ b/extensions/voice-call/src/manager/twiml.ts @@ -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 ` @@ -8,6 +11,7 @@ export function generateNotifyTwiml(message: string, voice: string): string { `; } +/** Generate TwiML that plays DTMF digits before redirecting to a webhook URL. */ export function generateDtmfRedirectTwiml(digits: string, webhookUrl: string): string { return `