import { isRecordLike } from '@sim/utils/object' import { NextResponse } from 'next/server' import { verifyTwilioAuth } from '@/lib/webhooks/providers/twilio-signature' import type { AuthContext, FormatInputContext, FormatInputResult, WebhookProviderHandler, } from '@/lib/webhooks/providers/types' import { convertSquareBracketsToTwiML } from '@/lib/webhooks/utils' export const twilioVoiceHandler: WebhookProviderHandler = { verifyAuth(ctx: AuthContext) { return verifyTwilioAuth(ctx, 'Twilio Voice') }, /** * A call fires many independent callbacks against the same CallSid as it * progresses: CallStatus transitions (queued -> ringing -> in-progress -> * completed/...), repeated Gather turns while CallStatus stays * "in-progress" (differentiated only by Digits or SpeechResult), and * separate recording/transcription completions via RecordingStatus/ * TranscriptionStatus. The discriminator is built from field=value pairs * (not bare values) so callbacks of different kinds can never collide even * when they share a value — e.g. CallStatus=completed vs * RecordingStatus=completed are distinct strings — while a retried * delivery of the identical payload still produces the identical key. */ extractIdempotencyId(body: unknown) { if (!isRecordLike(body)) return null const sid = (body.MessageSid as string) || (body.CallSid as string) if (!sid) return null const discriminatorFields = [ 'CallStatus', 'Digits', 'SpeechResult', 'RecordingSid', 'RecordingStatus', 'TranscriptionSid', 'TranscriptionStatus', ] as const const discriminator = discriminatorFields .map((field) => { const value = body[field] return typeof value === 'string' && value ? `${field}=${value.toLowerCase()}` : null }) .filter(Boolean) .join('&') return discriminator ? `${sid}:${discriminator}` : sid }, formatSuccessResponse(providerConfig: Record) { const twimlResponse = (providerConfig.twimlResponse as string | undefined)?.trim() if (twimlResponse && twimlResponse.length > 0) { const convertedTwiml = convertSquareBracketsToTwiML(twimlResponse) return new NextResponse(convertedTwiml, { status: 200, headers: { 'Content-Type': 'text/xml; charset=utf-8', }, }) } const defaultTwiml = ` Your call is being processed. ` return new NextResponse(defaultTwiml, { status: 200, headers: { 'Content-Type': 'text/xml; charset=utf-8', }, }) }, async formatInput({ body }: FormatInputContext): Promise { const b = isRecordLike(body) ? body : {} return { input: { callSid: b.CallSid, accountSid: b.AccountSid, from: b.From, to: b.To, callStatus: b.CallStatus, direction: b.Direction, apiVersion: b.ApiVersion, callerName: b.CallerName, forwardedFrom: b.ForwardedFrom, digits: b.Digits, speechResult: b.SpeechResult, recordingUrl: b.RecordingUrl, recordingSid: b.RecordingSid, called: b.Called, caller: b.Caller, toCity: b.ToCity, toState: b.ToState, toZip: b.ToZip, toCountry: b.ToCountry, fromCity: b.FromCity, fromState: b.FromState, fromZip: b.FromZip, fromCountry: b.FromCountry, calledCity: b.CalledCity, calledState: b.CalledState, calledZip: b.CalledZip, calledCountry: b.CalledCountry, callerCity: b.CallerCity, callerState: b.CallerState, callerZip: b.CallerZip, callerCountry: b.CallerCountry, callToken: b.CallToken, raw: JSON.stringify(b), }, } }, formatQueueErrorResponse() { const errorTwiml = ` We're sorry, but an error occurred processing your call. Please try again later. ` return new NextResponse(errorTwiml, { status: 200, headers: { 'Content-Type': 'text/xml', }, }) }, }