// Voice Call plugin module implements manager behavior. import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { VoiceCallConfig, VoiceCallCoreSessionConfig } from "./config.js"; import type { CallManagerContext, StreamSessionIssuer } from "./manager/context.js"; import { processEvent as processManagerEvent, type ProcessEventResult } from "./manager/events.js"; import { getCallByProviderCallId as getCallByProviderCallIdFromMaps } from "./manager/lookup.js"; import { continueCall as continueCallWithContext, endCall as endCallWithContext, initiateCall as initiateCallWithContext, sendDtmf as sendDtmfWithContext, speak as speakWithContext, speakInitialMessage as speakInitialMessageWithContext, type SpeakOptions, } from "./manager/outbound.js"; import { findCallMatchesInStore, getCallHistoryFromStore, loadActiveCallsFromStore, persistCallRecord, } from "./manager/store.js"; import { resolveVoiceCallSecondsTimerDelayMs } from "./manager/timer-delays.js"; import { startMaxDurationTimer } from "./manager/timers.js"; import type { VoiceCallProvider } from "./providers/base.js"; import { TerminalStates, type CallId, type CallRecord, type NormalizedEvent, type OutboundCallOptions, } from "./types.js"; import { resolveUserPath } from "./utils.js"; function markRestoredCallSkipped(call: CallRecord, endReason: "completed" | "timeout"): void { call.endedAt = Date.now(); call.endReason = endReason; call.state = endReason; } function incrementRestoreStatusCount( counts: Map, status: string | undefined, ): void { const key = normalizeOptionalString(status) ?? "terminal"; counts.set(key, (counts.get(key) ?? 0) + 1); } function resolveRestoredMaxDurationAnchor(call: CallRecord): number | undefined { return ( call.answeredAt ?? (call.state === "speaking" || call.state === "listening" ? call.startedAt : undefined) ); } function resolveDefaultStoreBase(config: VoiceCallConfig, storePath?: string): string { const rawOverride = storePath?.trim() || config.store?.trim(); if (rawOverride) { return resolveUserPath(rawOverride); } const preferred = path.join(os.homedir(), ".openclaw", "voice-calls"); const candidates = [preferred].map((dir) => resolveUserPath(dir)); const existing = candidates.find((dir) => { try { return fs.existsSync(path.join(dir, "calls.jsonl")) || fs.existsSync(dir); } catch { return false; } }) ?? resolveUserPath(preferred); return existing; } /** * Manages voice calls: state ownership and delegation to manager helper modules. */ export class CallManager { private activeCalls = new Map(); private providerCallIdMap = new Map(); private processedEventIds = new Set(); private rejectedProviderCallIds = new Set(); private provider: VoiceCallProvider | null = null; private config: VoiceCallConfig; private coreSession: VoiceCallCoreSessionConfig | undefined; private storePath: string; private webhookUrl: string | null = null; private activeTurnCalls = new Set(); private transcriptWaiters = new Map< CallId, { resolve: (text: string) => void; reject: (err: Error) => void; timeout: NodeJS.Timeout; } >(); private maxDurationTimers = new Map(); private initialMessageInFlight = new Set(); /** * Carrier-side stream session issuer. Wired by the runtime when realtime is * enabled so the manager can pre-issue stream URLs for providers (e.g. * Telnyx) that attach Media Streaming at dial or answer time. */ streamSessionIssuer: StreamSessionIssuer | undefined; constructor( config: VoiceCallConfig, storePath?: string, coreSession?: VoiceCallCoreSessionConfig, ) { this.config = config; this.coreSession = coreSession; this.storePath = resolveDefaultStoreBase(config, storePath); } /** * Initialize the call manager with a provider. * Verifies persisted calls with the provider and restarts timers. */ async initialize(provider: VoiceCallProvider, webhookUrl: string): Promise { this.provider = provider; this.webhookUrl = webhookUrl; fs.mkdirSync(this.storePath, { recursive: true }); const persisted = loadActiveCallsFromStore(this.storePath); this.processedEventIds = persisted.processedEventIds; this.rejectedProviderCallIds = persisted.rejectedProviderCallIds; const verified = await this.verifyRestoredCalls(provider, persisted.activeCalls); this.activeCalls = verified; // Rebuild providerCallIdMap from verified calls only this.providerCallIdMap = new Map(); for (const [callId, call] of verified) { if (call.providerCallId) { this.providerCallIdMap.set(call.providerCallId, callId); } } // Restart max-duration timers for restored calls that are past the answered/live state. let skippedAlreadyElapsedTimers = 0; for (const [callId, call] of verified) { const maxDurationAnchor = resolveRestoredMaxDurationAnchor(call); if (maxDurationAnchor !== undefined && !TerminalStates.has(call.state)) { const elapsed = Date.now() - maxDurationAnchor; const maxDurationMs = resolveVoiceCallSecondsTimerDelayMs(this.config.maxDurationSeconds); if (elapsed >= maxDurationMs) { // Already expired — remove instead of keeping verified.delete(callId); if (call.providerCallId) { this.providerCallIdMap.delete(call.providerCallId); } skippedAlreadyElapsedTimers += 1; continue; } if (call.answeredAt === undefined) { // Twilio streams can restore directly in speaking/listening without an // answered webhook; anchoring at startedAt preserves bounded duration. call.answeredAt = maxDurationAnchor; persistCallRecord(this.storePath, call); } startMaxDurationTimer({ ctx: this.getContext(), callId, timeoutMs: maxDurationMs - elapsed, onTimeout: async (id) => { await endCallWithContext(this.getContext(), id, { reason: "timeout" }); }, }); console.log(`[voice-call] Restarted max-duration timer for restored call ${callId}`); } } if (skippedAlreadyElapsedTimers > 0) { console.log( `[voice-call] Skipped ${skippedAlreadyElapsedTimers} restored call(s) whose max-duration timer already elapsed`, ); } if (verified.size > 0) { console.log(`[voice-call] Restored ${verified.size} active call(s) from store`); } } /** * Verify persisted calls with the provider before restoring. * Calls without providerCallId or older than maxDurationSeconds are skipped. * Transient provider errors keep the call (rely on timer fallback). */ private async verifyRestoredCalls( provider: VoiceCallProvider, candidates: Map, ): Promise> { if (candidates.size === 0) { return new Map(); } const maxAgeMs = resolveVoiceCallSecondsTimerDelayMs(this.config.maxDurationSeconds); const now = Date.now(); const verified = new Map(); const verifyTasks: Array<{ callId: CallId; call: CallRecord; promise: Promise }> = []; let skippedNoProviderCallId = 0; let skippedOlderThanMaxDuration = 0; const skippedTerminalStatuses = new Map(); let keptVerifiedActive = 0; let keptUnknownProviderStatus = 0; let keptVerificationFailures = 0; for (const [callId, call] of candidates) { // Skip calls without a provider ID — can't verify if (!call.providerCallId) { skippedNoProviderCallId += 1; continue; } // Skip calls older than maxDurationSeconds (time-based fallback) if (now - call.startedAt > maxAgeMs) { skippedOlderThanMaxDuration += 1; markRestoredCallSkipped(call, "timeout"); persistCallRecord(this.storePath, call); await provider .hangupCall({ callId, providerCallId: call.providerCallId, reason: "timeout", }) .catch((err: unknown) => { console.warn( `[voice-call] Failed to hang up expired restored call ${callId}:`, err instanceof Error ? err.message : String(err), ); }); continue; } const task = { callId, call, promise: provider .getCallStatus({ providerCallId: call.providerCallId }) .then((result) => { if (result.isTerminal) { incrementRestoreStatusCount(skippedTerminalStatuses, result.status); markRestoredCallSkipped(call, "completed"); persistCallRecord(this.storePath, call); } else if (result.isUnknown) { keptUnknownProviderStatus += 1; verified.set(callId, call); } else { keptVerifiedActive += 1; verified.set(callId, call); } }) .catch(() => { // Verification failed entirely — keep the call, rely on timer keptVerificationFailures += 1; verified.set(callId, call); }), }; verifyTasks.push(task); } await Promise.allSettled(verifyTasks.map((t) => t.promise)); if (skippedNoProviderCallId > 0) { console.log( `[voice-call] Skipped ${skippedNoProviderCallId} restored call(s) with no providerCallId`, ); } if (skippedOlderThanMaxDuration > 0) { console.log( `[voice-call] Skipped ${skippedOlderThanMaxDuration} restored call(s) older than maxDurationSeconds`, ); } for (const [status, count] of [...skippedTerminalStatuses].toSorted(([a], [b]) => a.localeCompare(b), )) { console.log(`[voice-call] Skipped ${count} restored call(s) with provider status: ${status}`); } if (keptVerifiedActive > 0) { console.log( `[voice-call] Kept ${keptVerifiedActive} restored call(s) confirmed active by provider`, ); } if (keptUnknownProviderStatus > 0) { console.log( `[voice-call] Kept ${keptUnknownProviderStatus} restored call(s) with unknown provider status (relying on timer)`, ); } if (keptVerificationFailures > 0) { console.log( `[voice-call] Kept ${keptVerificationFailures} restored call(s) after verification failure (relying on timer)`, ); } return verified; } /** * Get the current provider. */ getProvider(): VoiceCallProvider | null { return this.provider; } /** * Initiate an outbound call. */ async initiateCall( to: string, sessionKey?: string, options?: OutboundCallOptions | string, ): Promise<{ callId: CallId; success: boolean; error?: string }> { return initiateCallWithContext(this.getContext(), to, sessionKey, options); } /** * Speak to user in an active call. */ async speak( callId: CallId, text: string, options?: SpeakOptions, ): Promise<{ success: boolean; error?: string }> { return speakWithContext(this.getContext(), callId, text, options); } /** * Send DTMF digits to an active call. */ async sendDtmf(callId: CallId, digits: string): Promise<{ success: boolean; error?: string }> { return sendDtmfWithContext(this.getContext(), callId, digits); } /** * Speak the initial message for a call (called when media stream connects). */ async speakInitialMessage(providerCallId: string): Promise { return speakInitialMessageWithContext(this.getContext(), providerCallId); } /** * Continue call: speak prompt, then wait for user's final transcript. */ async continueCall( callId: CallId, prompt: string, ): Promise<{ success: boolean; transcript?: string; error?: string }> { return continueCallWithContext(this.getContext(), callId, prompt); } /** * End an active call. */ async endCall(callId: CallId): Promise<{ success: boolean; error?: string }> { return endCallWithContext(this.getContext(), callId); } private getContext(): CallManagerContext { return { activeCalls: this.activeCalls, providerCallIdMap: this.providerCallIdMap, processedEventIds: this.processedEventIds, rejectedProviderCallIds: this.rejectedProviderCallIds, provider: this.provider, config: this.config, coreSession: this.coreSession, storePath: this.storePath, webhookUrl: this.webhookUrl, activeTurnCalls: this.activeTurnCalls, transcriptWaiters: this.transcriptWaiters, maxDurationTimers: this.maxDurationTimers, initialMessageInFlight: this.initialMessageInFlight, onCallAnswered: (call) => { this.maybeSpeakInitialMessageOnAnswered(call); }, streamSessionIssuer: this.streamSessionIssuer, }; } /** * Process a webhook event. */ processEvent(event: NormalizedEvent): ProcessEventResult { return processManagerEvent(this.getContext(), event); } private shouldDeferConversationInitialMessageUntilStreamConnect(): boolean { if (!this.provider || this.provider.name !== "twilio" || !this.config.streaming.enabled) { return false; } const streamAwareProvider = this.provider as VoiceCallProvider & { isConversationStreamConnectEnabled?: () => boolean; }; if (typeof streamAwareProvider.isConversationStreamConnectEnabled !== "function") { return false; } return streamAwareProvider.isConversationStreamConnectEnabled(); } private maybeSpeakInitialMessageOnAnswered(call: CallRecord): void { const initialMessage = normalizeOptionalString(call.metadata?.initialMessage) ?? ""; if (!initialMessage) { return; } // Notify mode should speak as soon as the provider reports "answered". // Conversation mode should defer only when the Twilio stream-connect path // is actually available; otherwise speak immediately on answered. const mode = (call.metadata?.mode as string | undefined) ?? "conversation"; if (mode === "conversation") { if (this.config.realtime.enabled) { return; } const shouldWaitForStreamConnect = this.shouldDeferConversationInitialMessageUntilStreamConnect(); if (shouldWaitForStreamConnect) { return; } } else if (mode !== "notify") { return; } if (!this.provider || !call.providerCallId) { return; } void this.speakInitialMessage(call.providerCallId).catch((err: unknown) => { console.warn( `[voice-call] Failed to speak initial message for call ${call.callId}: ${formatErrorMessage(err)}`, ); }); } /** * Get an active call by ID. */ getCall(callId: CallId): CallRecord | undefined { return this.activeCalls.get(callId); } /** * Get an active call by provider call ID (e.g., Twilio CallSid). */ getCallByProviderCallId(providerCallId: string): CallRecord | undefined { return getCallByProviderCallIdFromMaps({ activeCalls: this.activeCalls, providerCallIdMap: this.providerCallIdMap, providerCallId, }); } /** * Get all active calls. */ getActiveCalls(): CallRecord[] { return Array.from(this.activeCalls.values()); } /** Resolve a status record from active state or the retained event store. */ async getCallFromMemoryOrStore(callId: CallId): Promise { const active = this.getCall(callId) ?? this.getCallByProviderCallId(callId); if (active) { return active; } const persisted = await findCallMatchesInStore(this.storePath, callId); // Active indexes are canonical for live calls and keep provider-id status // lookups off the retained-store path. Persisted ids are fallback-only. return persisted.byCallId ?? persisted.byProviderCallId; } /** * Get call history (from persisted logs). */ async getCallHistory(limit = 50): Promise { return getCallHistoryFromStore(this.storePath, limit); } }