mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
docs: document voice call manager helpers
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user