import crypto from "node:crypto"; import type { PlivoConfig, WebhookSecurityConfig } from "../config.js"; import { getHeader } from "../http-headers.js"; import type { HangupCallInput, InitiateCallInput, InitiateCallResult, NormalizedEvent, PlayTtsInput, ProviderWebhookParseResult, StartListeningInput, StopListeningInput, WebhookContext, WebhookParseOptions, WebhookVerificationResult, } from "../types.js"; import { escapeXml } from "../voice-mapping.js"; import { reconstructWebhookUrl, verifyPlivoWebhook } from "../webhook-security.js"; import type { VoiceCallProvider } from "./base.js"; import { guardedJsonApiRequest } from "./shared/guarded-json-api.js"; export interface PlivoProviderOptions { /** Override public URL origin for signature verification */ publicUrl?: string; /** Skip webhook signature verification (development only) */ skipVerification?: boolean; /** Outbound ring timeout in seconds */ ringTimeoutSec?: number; /** Webhook security options (forwarded headers/allowlist) */ webhookSecurity?: WebhookSecurityConfig; } type PendingSpeak = { text: string; locale?: string }; type PendingListen = { language?: string }; function createPlivoRequestDedupeKey(ctx: WebhookContext): string { const nonceV3 = getHeader(ctx.headers, "x-plivo-signature-v3-nonce"); if (nonceV3) { return `plivo:v3:${nonceV3}`; } const nonceV2 = getHeader(ctx.headers, "x-plivo-signature-v2-nonce"); if (nonceV2) { return `plivo:v2:${nonceV2}`; } return `plivo:fallback:${crypto.createHash("sha256").update(ctx.rawBody).digest("hex")}`; } export class PlivoProvider implements VoiceCallProvider { readonly name = "plivo" as const; private readonly authId: string; private readonly authToken: string; private readonly baseUrl: string; private readonly options: PlivoProviderOptions; private readonly apiHost: string; // Best-effort mapping between create-call request UUID and call UUID. private requestUuidToCallUuid = new Map(); // Used for transfer URLs and GetInput action URLs. private callIdToWebhookUrl = new Map(); private callUuidToWebhookUrl = new Map(); private pendingSpeakByCallId = new Map(); private pendingListenByCallId = new Map(); constructor(config: PlivoConfig, options: PlivoProviderOptions = {}) { if (!config.authId) { throw new Error("Plivo Auth ID is required"); } if (!config.authToken) { throw new Error("Plivo Auth Token is required"); } this.authId = config.authId; this.authToken = config.authToken; this.baseUrl = `https://api.plivo.com/v1/Account/${this.authId}`; this.apiHost = new URL(this.baseUrl).hostname; this.options = options; } private async apiRequest(params: { method: "GET" | "POST" | "DELETE"; endpoint: string; body?: Record; allowNotFound?: boolean; }): Promise { const { method, endpoint, body, allowNotFound } = params; return await guardedJsonApiRequest({ url: `${this.baseUrl}${endpoint}`, method, headers: { Authorization: `Basic ${Buffer.from(`${this.authId}:${this.authToken}`).toString("base64")}`, "Content-Type": "application/json", }, body, allowNotFound, allowedHostnames: [this.apiHost], auditContext: "voice-call.plivo.api", errorPrefix: "Plivo API error", }); } verifyWebhook(ctx: WebhookContext): WebhookVerificationResult { const result = verifyPlivoWebhook(ctx, this.authToken, { publicUrl: this.options.publicUrl, skipVerification: this.options.skipVerification, allowedHosts: this.options.webhookSecurity?.allowedHosts, trustForwardingHeaders: this.options.webhookSecurity?.trustForwardingHeaders, trustedProxyIPs: this.options.webhookSecurity?.trustedProxyIPs, remoteIP: ctx.remoteAddress, }); if (!result.ok) { console.warn(`[plivo] Webhook verification failed: ${result.reason}`); } return { ok: result.ok, reason: result.reason, isReplay: result.isReplay, verifiedRequestKey: result.verifiedRequestKey, }; } parseWebhookEvent( ctx: WebhookContext, options?: WebhookParseOptions, ): ProviderWebhookParseResult { const flow = typeof ctx.query?.flow === "string" ? ctx.query.flow.trim() : ""; const parsed = this.parseBody(ctx.rawBody); if (!parsed) { return { events: [], statusCode: 400 }; } // Keep providerCallId mapping for later call control. const callUuid = parsed.get("CallUUID") || undefined; if (callUuid) { const webhookBase = this.baseWebhookUrlFromCtx(ctx); if (webhookBase) { this.callUuidToWebhookUrl.set(callUuid, webhookBase); } } // Special flows that exist only to return Plivo XML (no events). if (flow === "xml-speak") { const callId = this.getCallIdFromQuery(ctx); const pending = callId ? this.pendingSpeakByCallId.get(callId) : undefined; if (callId) { this.pendingSpeakByCallId.delete(callId); } const xml = pending ? PlivoProvider.xmlSpeak(pending.text, pending.locale) : PlivoProvider.xmlKeepAlive(); return { events: [], providerResponseBody: xml, providerResponseHeaders: { "Content-Type": "text/xml" }, statusCode: 200, }; } if (flow === "xml-listen") { const callId = this.getCallIdFromQuery(ctx); const pending = callId ? this.pendingListenByCallId.get(callId) : undefined; if (callId) { this.pendingListenByCallId.delete(callId); } const actionUrl = this.buildActionUrl(ctx, { flow: "getinput", callId, }); const xml = actionUrl && callId ? PlivoProvider.xmlGetInputSpeech({ actionUrl, language: pending?.language, }) : PlivoProvider.xmlKeepAlive(); return { events: [], providerResponseBody: xml, providerResponseHeaders: { "Content-Type": "text/xml" }, statusCode: 200, }; } // Normal events. const callIdFromQuery = this.getCallIdFromQuery(ctx); const dedupeKey = options?.verifiedRequestKey ?? createPlivoRequestDedupeKey(ctx); const event = this.normalizeEvent(parsed, callIdFromQuery, dedupeKey); return { events: event ? [event] : [], providerResponseBody: flow === "answer" || flow === "getinput" ? PlivoProvider.xmlKeepAlive() : PlivoProvider.xmlEmpty(), providerResponseHeaders: { "Content-Type": "text/xml" }, statusCode: 200, }; } private normalizeEvent( params: URLSearchParams, callIdOverride?: string, dedupeKey?: string, ): NormalizedEvent | null { const callUuid = params.get("CallUUID") || ""; const requestUuid = params.get("RequestUUID") || ""; if (requestUuid && callUuid) { this.requestUuidToCallUuid.set(requestUuid, callUuid); } const direction = params.get("Direction"); const from = params.get("From") || undefined; const to = params.get("To") || undefined; const callStatus = params.get("CallStatus"); const baseEvent = { id: crypto.randomUUID(), dedupeKey, callId: callIdOverride || callUuid || requestUuid, providerCallId: callUuid || requestUuid || undefined, timestamp: Date.now(), direction: direction === "inbound" ? ("inbound" as const) : direction === "outbound" ? ("outbound" as const) : undefined, from, to, }; const digits = params.get("Digits"); if (digits) { return { ...baseEvent, type: "call.dtmf", digits }; } const transcript = PlivoProvider.extractTranscript(params); if (transcript) { return { ...baseEvent, type: "call.speech", transcript, isFinal: true, }; } // Call lifecycle. if (callStatus === "ringing") { return { ...baseEvent, type: "call.ringing" }; } if (callStatus === "in-progress") { return { ...baseEvent, type: "call.answered" }; } if ( callStatus === "completed" || callStatus === "busy" || callStatus === "no-answer" || callStatus === "failed" ) { return { ...baseEvent, type: "call.ended", reason: callStatus === "completed" ? "completed" : callStatus === "busy" ? "busy" : callStatus === "no-answer" ? "no-answer" : "failed", }; } // Plivo will call our answer_url when the call is answered; if we don't have // a CallStatus for some reason, treat it as answered so the call can proceed. if (params.get("Event") === "StartApp" && callUuid) { return { ...baseEvent, type: "call.answered" }; } return null; } async initiateCall(input: InitiateCallInput): Promise { const webhookUrl = new URL(input.webhookUrl); webhookUrl.searchParams.set("provider", "plivo"); webhookUrl.searchParams.set("callId", input.callId); const answerUrl = new URL(webhookUrl); answerUrl.searchParams.set("flow", "answer"); const hangupUrl = new URL(webhookUrl); hangupUrl.searchParams.set("flow", "hangup"); this.callIdToWebhookUrl.set(input.callId, input.webhookUrl); const ringTimeoutSec = this.options.ringTimeoutSec ?? 30; const result = await this.apiRequest({ method: "POST", endpoint: "/Call/", body: { from: PlivoProvider.normalizeNumber(input.from), to: PlivoProvider.normalizeNumber(input.to), answer_url: answerUrl.toString(), answer_method: "POST", hangup_url: hangupUrl.toString(), hangup_method: "POST", // Plivo's API uses `hangup_on_ring` for outbound ring timeout. hangup_on_ring: ringTimeoutSec, }, }); const requestUuid = Array.isArray(result.request_uuid) ? result.request_uuid[0] : result.request_uuid; if (!requestUuid) { throw new Error("Plivo call create returned no request_uuid"); } return { providerCallId: requestUuid, status: "initiated" }; } async hangupCall(input: HangupCallInput): Promise { const callUuid = this.requestUuidToCallUuid.get(input.providerCallId); if (callUuid) { await this.apiRequest({ method: "DELETE", endpoint: `/Call/${callUuid}/`, allowNotFound: true, }); return; } // Best-effort: try hangup (call UUID), then cancel (request UUID). await this.apiRequest({ method: "DELETE", endpoint: `/Call/${input.providerCallId}/`, allowNotFound: true, }); await this.apiRequest({ method: "DELETE", endpoint: `/Request/${input.providerCallId}/`, allowNotFound: true, }); } private resolveCallContext(params: { providerCallId: string; callId: string; operation: string; }): { callUuid: string; webhookBase: string; } { const callUuid = this.requestUuidToCallUuid.get(params.providerCallId) ?? params.providerCallId; const webhookBase = this.callUuidToWebhookUrl.get(callUuid) || this.callIdToWebhookUrl.get(params.callId); if (!webhookBase) { throw new Error("Missing webhook URL for this call (provider state missing)"); } if (!callUuid) { throw new Error(`Missing Plivo CallUUID for ${params.operation}`); } return { callUuid, webhookBase }; } private async transferCallLeg(params: { callUuid: string; webhookBase: string; callId: string; flow: "xml-speak" | "xml-listen"; }): Promise { const transferUrl = new URL(params.webhookBase); transferUrl.searchParams.set("provider", "plivo"); transferUrl.searchParams.set("flow", params.flow); transferUrl.searchParams.set("callId", params.callId); await this.apiRequest({ method: "POST", endpoint: `/Call/${params.callUuid}/`, body: { legs: "aleg", aleg_url: transferUrl.toString(), aleg_method: "POST", }, }); } async playTts(input: PlayTtsInput): Promise { const { callUuid, webhookBase } = this.resolveCallContext({ providerCallId: input.providerCallId, callId: input.callId, operation: "playTts", }); this.pendingSpeakByCallId.set(input.callId, { text: input.text, locale: input.locale, }); await this.transferCallLeg({ callUuid, webhookBase, callId: input.callId, flow: "xml-speak", }); } async startListening(input: StartListeningInput): Promise { const { callUuid, webhookBase } = this.resolveCallContext({ providerCallId: input.providerCallId, callId: input.callId, operation: "startListening", }); this.pendingListenByCallId.set(input.callId, { language: input.language, }); await this.transferCallLeg({ callUuid, webhookBase, callId: input.callId, flow: "xml-listen", }); } async stopListening(_input: StopListeningInput): Promise { // GetInput ends automatically when speech ends. } private static normalizeNumber(numberOrSip: string): string { const trimmed = numberOrSip.trim(); if (trimmed.toLowerCase().startsWith("sip:")) { return trimmed; } return trimmed.replace(/[^\d+]/g, ""); } private static xmlEmpty(): string { return ``; } private static xmlKeepAlive(): string { return ` `; } private static xmlSpeak(text: string, locale?: string): string { const language = locale || "en-US"; return ` ${escapeXml(text)} `; } private static xmlGetInputSpeech(params: { actionUrl: string; language?: string }): string { const language = params.language || "en-US"; return ` `; } private getCallIdFromQuery(ctx: WebhookContext): string | undefined { const callId = typeof ctx.query?.callId === "string" && ctx.query.callId.trim() ? ctx.query.callId.trim() : undefined; return callId || undefined; } private buildActionUrl( ctx: WebhookContext, opts: { flow: string; callId?: string }, ): string | null { const base = this.baseWebhookUrlFromCtx(ctx); if (!base) { return null; } const u = new URL(base); u.searchParams.set("provider", "plivo"); u.searchParams.set("flow", opts.flow); if (opts.callId) { u.searchParams.set("callId", opts.callId); } return u.toString(); } private baseWebhookUrlFromCtx(ctx: WebhookContext): string | null { try { const u = new URL( reconstructWebhookUrl(ctx, { allowedHosts: this.options.webhookSecurity?.allowedHosts, trustForwardingHeaders: this.options.webhookSecurity?.trustForwardingHeaders, trustedProxyIPs: this.options.webhookSecurity?.trustedProxyIPs, remoteIP: ctx.remoteAddress, }), ); return `${u.origin}${u.pathname}`; } catch { return null; } } private parseBody(rawBody: string): URLSearchParams | null { try { return new URLSearchParams(rawBody); } catch { return null; } } private static extractTranscript(params: URLSearchParams): string | null { const candidates = [ "Speech", "Transcription", "TranscriptionText", "SpeechResult", "RecognizedSpeech", "Text", ] as const; for (const key of candidates) { const value = params.get(key); if (value && value.trim()) { return value.trim(); } } return null; } } type PlivoCreateCallResponse = { api_id?: string; message?: string; request_uuid?: string | string[]; };