import crypto from "node:crypto"; import type { EndReason, GetCallStatusInput, GetCallStatusResult, HangupCallInput, InitiateCallInput, InitiateCallResult, NormalizedEvent, PlayTtsInput, WebhookParseOptions, ProviderWebhookParseResult, StartListeningInput, StopListeningInput, WebhookContext, WebhookVerificationResult, } from "../types.js"; import type { VoiceCallProvider } from "./base.js"; /** * Mock voice call provider for local testing. * * Events are driven via webhook POST with JSON body: * - { events: NormalizedEvent[] } for bulk events * - { event: NormalizedEvent } for single event */ export class MockProvider implements VoiceCallProvider { readonly name = "mock" as const; verifyWebhook(_ctx: WebhookContext): WebhookVerificationResult { return { ok: true }; } parseWebhookEvent( ctx: WebhookContext, _options?: WebhookParseOptions, ): ProviderWebhookParseResult { try { const payload = JSON.parse(ctx.rawBody); const events: NormalizedEvent[] = []; if (Array.isArray(payload.events)) { for (const evt of payload.events) { const normalized = this.normalizeEvent(evt); if (normalized) { events.push(normalized); } } } else if (payload.event) { const normalized = this.normalizeEvent(payload.event); if (normalized) { events.push(normalized); } } return { events, statusCode: 200 }; } catch { return { events: [], statusCode: 400 }; } } private normalizeEvent(evt: Partial): NormalizedEvent | null { if (!evt.type || !evt.callId) { return null; } const base = { id: evt.id || crypto.randomUUID(), callId: evt.callId, providerCallId: evt.providerCallId, timestamp: evt.timestamp || Date.now(), }; switch (evt.type) { case "call.initiated": case "call.ringing": case "call.answered": case "call.active": return { ...base, type: evt.type }; case "call.speaking": { const payload = evt as Partial; return { ...base, type: evt.type, text: payload.text || "", }; } case "call.speech": { const payload = evt as Partial< NormalizedEvent & { transcript?: string; isFinal?: boolean; confidence?: number; } >; return { ...base, type: evt.type, transcript: payload.transcript || "", isFinal: payload.isFinal ?? true, confidence: payload.confidence, }; } case "call.silence": { const payload = evt as Partial; return { ...base, type: evt.type, durationMs: payload.durationMs || 0, }; } case "call.dtmf": { const payload = evt as Partial; return { ...base, type: evt.type, digits: payload.digits || "", }; } case "call.ended": { const payload = evt as Partial; return { ...base, type: evt.type, reason: payload.reason || "completed", }; } case "call.error": { const payload = evt as Partial; return { ...base, type: evt.type, error: payload.error || "unknown error", retryable: payload.retryable, }; } default: return null; } } async initiateCall(input: InitiateCallInput): Promise { return { providerCallId: `mock-${input.callId}`, status: "initiated", }; } async hangupCall(_input: HangupCallInput): Promise { // No-op for mock } async playTts(_input: PlayTtsInput): Promise { // No-op for mock } async startListening(_input: StartListeningInput): Promise { // No-op for mock } async stopListening(_input: StopListeningInput): Promise { // No-op for mock } async getCallStatus(input: GetCallStatusInput): Promise { const id = input.providerCallId.toLowerCase(); if (id.includes("stale") || id.includes("ended") || id.includes("completed")) { return { status: "completed", isTerminal: true }; } return { status: "in-progress", isTerminal: false }; } }