import crypto from "node:crypto"; import { TerminalStates, type CallId, type CallRecord, type OutboundCallOptions } from "../types.js"; import type { CallMode } from "../config.js"; import { mapVoiceToPolly } from "../voice-mapping.js"; import type { CallManagerContext } from "./context.js"; import { getCallByProviderCallId } from "./lookup.js"; import { generateNotifyTwiml } from "./twiml.js"; import { addTranscriptEntry, transitionState } from "./state.js"; import { persistCallRecord } from "./store.js"; import { clearMaxDurationTimer, clearTranscriptWaiter, rejectTranscriptWaiter, waitForFinalTranscript } from "./timers.js"; export async function initiateCall( ctx: CallManagerContext, to: string, sessionKey?: string, options?: OutboundCallOptions | string, ): Promise<{ callId: CallId; success: boolean; error?: string }> { const opts: OutboundCallOptions = typeof options === "string" ? { message: options } : (options ?? {}); const initialMessage = opts.message; const mode = opts.mode ?? ctx.config.outbound.defaultMode; if (!ctx.provider) { return { callId: "", success: false, error: "Provider not initialized" }; } if (!ctx.webhookUrl) { return { callId: "", success: false, error: "Webhook URL not configured" }; } if (ctx.activeCalls.size >= ctx.config.maxConcurrentCalls) { return { callId: "", success: false, error: `Maximum concurrent calls (${ctx.config.maxConcurrentCalls}) reached`, }; } const callId = crypto.randomUUID(); const from = ctx.config.fromNumber || (ctx.provider?.name === "mock" ? "+15550000000" : undefined); if (!from) { return { callId: "", success: false, error: "fromNumber not configured" }; } const callRecord: CallRecord = { callId, provider: ctx.provider.name, direction: "outbound", state: "initiated", from, to, sessionKey, startedAt: Date.now(), transcript: [], processedEventIds: [], metadata: { ...(initialMessage && { initialMessage }), mode, }, }; ctx.activeCalls.set(callId, callRecord); persistCallRecord(ctx.storePath, callRecord); try { // For notify mode with a message, use inline TwiML with . let inlineTwiml: string | undefined; if (mode === "notify" && initialMessage) { const pollyVoice = mapVoiceToPolly(ctx.config.tts.voice); inlineTwiml = generateNotifyTwiml(initialMessage, pollyVoice); console.log(`[voice-call] Using inline TwiML for notify mode (voice: ${pollyVoice})`); } const result = await ctx.provider.initiateCall({ callId, from, to, webhookUrl: ctx.webhookUrl, inlineTwiml, }); callRecord.providerCallId = result.providerCallId; ctx.providerCallIdMap.set(result.providerCallId, callId); persistCallRecord(ctx.storePath, callRecord); return { callId, success: true }; } catch (err) { callRecord.state = "failed"; callRecord.endedAt = Date.now(); callRecord.endReason = "failed"; persistCallRecord(ctx.storePath, callRecord); ctx.activeCalls.delete(callId); if (callRecord.providerCallId) { ctx.providerCallIdMap.delete(callRecord.providerCallId); } return { callId, success: false, error: err instanceof Error ? err.message : String(err), }; } } export async function speak( ctx: CallManagerContext, callId: CallId, text: string, ): Promise<{ success: boolean; error?: string }> { const call = ctx.activeCalls.get(callId); if (!call) return { success: false, error: "Call not found" }; if (!ctx.provider || !call.providerCallId) return { success: false, error: "Call not connected" }; if (TerminalStates.has(call.state)) return { success: false, error: "Call has ended" }; try { transitionState(call, "speaking"); persistCallRecord(ctx.storePath, call); addTranscriptEntry(call, "bot", text); await ctx.provider.playTts({ callId, providerCallId: call.providerCallId, text, voice: ctx.config.tts.voice, }); return { success: true }; } catch (err) { return { success: false, error: err instanceof Error ? err.message : String(err) }; } } export async function speakInitialMessage( ctx: CallManagerContext, providerCallId: string, ): Promise { const call = getCallByProviderCallId({ activeCalls: ctx.activeCalls, providerCallIdMap: ctx.providerCallIdMap, providerCallId, }); if (!call) { console.warn(`[voice-call] speakInitialMessage: no call found for ${providerCallId}`); return; } const initialMessage = call.metadata?.initialMessage as string | undefined; const mode = (call.metadata?.mode as CallMode) ?? "conversation"; if (!initialMessage) { console.log(`[voice-call] speakInitialMessage: no initial message for ${call.callId}`); return; } // Clear so we don't speak it again if the provider reconnects. if (call.metadata) { delete call.metadata.initialMessage; persistCallRecord(ctx.storePath, call); } console.log(`[voice-call] Speaking initial message for call ${call.callId} (mode: ${mode})`); const result = await speak(ctx, call.callId, initialMessage); if (!result.success) { console.warn(`[voice-call] Failed to speak initial message: ${result.error}`); return; } if (mode === "notify") { const delaySec = ctx.config.outbound.notifyHangupDelaySec; console.log(`[voice-call] Notify mode: auto-hangup in ${delaySec}s for call ${call.callId}`); setTimeout(async () => { const currentCall = ctx.activeCalls.get(call.callId); if (currentCall && !TerminalStates.has(currentCall.state)) { console.log(`[voice-call] Notify mode: hanging up call ${call.callId}`); await endCall(ctx, call.callId); } }, delaySec * 1000); } } export async function continueCall( ctx: CallManagerContext, callId: CallId, prompt: string, ): Promise<{ success: boolean; transcript?: string; error?: string }> { const call = ctx.activeCalls.get(callId); if (!call) return { success: false, error: "Call not found" }; if (!ctx.provider || !call.providerCallId) return { success: false, error: "Call not connected" }; if (TerminalStates.has(call.state)) return { success: false, error: "Call has ended" }; try { await speak(ctx, callId, prompt); transitionState(call, "listening"); persistCallRecord(ctx.storePath, call); await ctx.provider.startListening({ callId, providerCallId: call.providerCallId }); const transcript = await waitForFinalTranscript(ctx, callId); // Best-effort: stop listening after final transcript. await ctx.provider.stopListening({ callId, providerCallId: call.providerCallId }); return { success: true, transcript }; } catch (err) { return { success: false, error: err instanceof Error ? err.message : String(err) }; } finally { clearTranscriptWaiter(ctx, callId); } } export async function endCall( ctx: CallManagerContext, callId: CallId, ): Promise<{ success: boolean; error?: string }> { const call = ctx.activeCalls.get(callId); if (!call) return { success: false, error: "Call not found" }; if (!ctx.provider || !call.providerCallId) return { success: false, error: "Call not connected" }; if (TerminalStates.has(call.state)) return { success: true }; try { await ctx.provider.hangupCall({ callId, providerCallId: call.providerCallId, reason: "hangup-bot", }); call.state = "hangup-bot"; call.endedAt = Date.now(); call.endReason = "hangup-bot"; persistCallRecord(ctx.storePath, call); clearMaxDurationTimer(ctx, callId); rejectTranscriptWaiter(ctx, callId, "Call ended: hangup-bot"); ctx.activeCalls.delete(callId); if (call.providerCallId) ctx.providerCallIdMap.delete(call.providerCallId); return { success: true }; } catch (err) { return { success: false, error: err instanceof Error ? err.message : String(err) }; } }