From 628b27def6b41a7a12563e4d091fd2e1b07c1d6a Mon Sep 17 00:00:00 2001 From: Madhu Ramasubramanian Date: Thu, 1 Oct 2026 13:49:47 -0400 Subject: [PATCH 1/2] VAPI-4041: send Twilio's action params from /bw/continue - Dial: map transferComplete to DialCallStatus/DialBridged, and join the dialed leg's transferDisconnect (new /bw/transfer-leg) for DialCallSid and DialCallDuration, waiting up to transferLegWaitMs (default 400 ms). - Record: send RecordingUrl, RecordingSid, RecordingDuration from recordComplete and register the recording with the Recordings facade. - Gather: on no digits or speech, resume the document after that Gather instead of requesting the action, unless actionOnEmptyResult="true". --- AGENTS.md | 21 +- src/matrix/twilio-voice.json | 20 +- src/server/app.ts | 191 +++++++++++-- src/server/call-store.ts | 31 +++ src/translator/translate.ts | 79 +++++- src/twilio/egress.ts | 67 +++++ test/server-continue.test.ts | 459 ++++++++++++++++++++++++++++++++ test/server-inbound.test.ts | 3 +- test/translate-continue.test.ts | 128 +++++++++ 9 files changed, 958 insertions(+), 41 deletions(-) create mode 100644 test/server-continue.test.ts create mode 100644 test/translate-continue.test.ts diff --git a/AGENTS.md b/AGENTS.md index 0c73757..01c98ee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -106,9 +106,24 @@ Translation is a fixed rulebook (`src/matrix/twilio-voice.json`), not a guess. - `Reject` — maps to `Hangup`, but Bandwidth answers before hanging up, so the caller may be billed for a short call. - `Dial` — `Number`/`Sip` nouns map to `Transfer`, `Conference` noun maps to - `Conference`; the `Queue` and `Client` nouns are unsupported, and deep Dial - semantics (`answerOnBridge`, child-call status propagation) are not - replicated. + `Conference`; the `Queue` and `Client` nouns are unsupported, and + `answerOnBridge` is not replicated. The Dial `action` receives Twilio's + `DialCallStatus` and `DialBridged` mapped from Bandwidth's transfer result; + `DialCallSid` and `DialCallDuration` come from the dialed leg's + `transferDisconnect` event, which the translator receives at + `/bw/transfer-leg` and joins to the parent's `transferComplete` (waiting up + to `transferLegWaitMs`, default 400 ms, since Bandwidth documents no + ordering between the two). As on Twilio, a caller hangup during the + transfer ends the session without requesting the action. + - `Gather` — a Gather that ends with no digits or speech does **not** request + the action (Twilio semantics): the translator remembers each call's last + TwiML document and re-translates the verbs after that Gather. Set + `actionOnEmptyResult="true"` to request the action with `Digits=""` instead. + On a translator instance that has not seen the document (restart, second + replica) the action is requested as a fallback. + - `Record` — the `action` receives `RecordingUrl`, `RecordingSid`, and + `RecordingDuration` from Bandwidth's `recordComplete`; Bandwidth's event has + no equivalent of Twilio's `Digits` (the key that stopped the recording). - `Start` — the `Transcription` noun maps to `StartTranscription`; the `Siprec` and `VirtualAgent` nouns are unsupported. - `Refer` — Bandwidth only honors `Refer` on inbound SIP URI calls, so a PSTN diff --git a/src/matrix/twilio-voice.json b/src/matrix/twilio-voice.json index 806911e..4b7ee9a 100644 --- a/src/matrix/twilio-voice.json +++ b/src/matrix/twilio-voice.json @@ -29,7 +29,11 @@ "notes": "DTMF and speech both supported. Per dev.bandwidth.com, BW Gather input accepts dtmf/speech/dtmf_speech.", "docsUrl": "https://dev.bandwidth.com/docs/voice/programmable-voice/bxml/gather", "attributes": { - "action": { "bxml": "gatherUrl", "status": "supported" }, + "action": { + "bxml": "gatherUrl", + "status": "supported", + "notes": "No digits or speech does not request the action; the verbs after the Gather run instead (Twilio semantics). actionOnEmptyResult=\"true\" requests the action with Digits=\"\"." + }, "numDigits": { "bxml": "maxDigits", "status": "supported" }, "timeout": { "bxml": "firstDigitTimeout", "status": "supported" }, "finishOnKey": { "bxml": "terminatingDigits", "status": "supported" }, @@ -72,7 +76,11 @@ "notes": "Recording callback payloads differ; translator normalizes.", "docsUrl": "https://dev.bandwidth.com/docs/voice/bxml/record", "attributes": { - "action": { "bxml": "recordCompleteUrl", "status": "supported" }, + "action": { + "bxml": "recordCompleteUrl", + "status": "supported", + "notes": "The callback carries RecordingUrl, RecordingSid, RecordingDuration. Bandwidth's event has no equivalent of Twilio's Digits." + }, "recordingStatusCallback": { "bxml": "recordingAvailableUrl", "status": "supported", "notes": "Async 'recording ready' webhook. Translator receives the BW recordingAvailableUrl event, reshapes it into Twilio recordingStatusCallback params, and forwards it; RecordingUrl points back at the translator's recording facade. recordingStatusCallbackMethod=GET is not honored (forwarded via POST)." }, "maxLength": { "bxml": "maxDuration", "status": "supported" }, "finishOnKey": { "bxml": "terminatingDigits", "status": "supported" }, @@ -83,12 +91,16 @@ "Dial": { "bxml": "Transfer", "status": "partial", - "notes": "Number/Sip nouns map to Transfer; Conference noun maps to Conference; Queue and Client nouns are unsupported. Deep Dial semantics (answerOnBridge, child-call status propagation) are not replicated in P0. When the record attribute is set to a recording-enabled value, a BW StartRecording verb is emitted immediately before the Transfer. Dual-channel variants (record-from-answer-dual, record-from-ringing-dual) emit StartRecording with multiChannel=true.", + "notes": "Number/Sip nouns map to Transfer; Conference noun maps to Conference; Queue and Client nouns are unsupported. answerOnBridge is not replicated. The Dial action receives DialCallStatus and DialBridged mapped from Bandwidth's transfer result, and DialCallSid/DialCallDuration from the dialed leg's transferDisconnect event when it has arrived. When the record attribute is set to a recording-enabled value, a BW StartRecording verb is emitted immediately before the Transfer. Dual-channel variants (record-from-answer-dual, record-from-ringing-dual) emit StartRecording with multiChannel=true.", "docsUrl": "https://dev.bandwidth.com/docs/voice/bxml/transfer", "attributes": { "callerId": { "bxml": "transferCallerId", "status": "supported" }, "timeout": { "bxml": "callTimeout", "status": "supported" }, - "action": { "bxml": "transferCompleteUrl", "status": "supported" }, + "action": { + "bxml": "transferCompleteUrl", + "status": "supported", + "notes": "The callback carries DialCallStatus, DialBridged, and (from the dialed leg's transferDisconnect) DialCallSid and DialCallDuration. As on Twilio, a caller hangup during the transfer does not request the action." + }, "record": { "bxml": "StartRecording", "status": "partial", diff --git a/src/server/app.ts b/src/server/app.ts index fc36ec1..4edae02 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -4,7 +4,12 @@ import type { IncomingMessage } from "node:http"; import type { Duplex } from "node:stream"; import { randomUUID } from "node:crypto"; import { WebSocketServer } from "ws"; -import { translateTwiml, type UrlKind } from "../translator/translate.js"; +import { + translateTwiml, + type UrlKind, + type RewriteContext, + type TranslateOptions, +} from "../translator/translate.js"; import { TwilioStreamBridge, customParametersFromBwStart } from "../streams/bridge.js"; import { BwWebSocketSource } from "../streams/bw-source.js"; import { bxmlDocument } from "../xml/build-xml.js"; @@ -13,6 +18,9 @@ import { gatherParams, statusParams, recordingStatusParams, + dialActionParams, + recordActionParams, + bwCauseToDialCallStatus, postToCustomer, } from "../twilio/egress.js"; import { EgressBlockedError, assertPublicUrl } from "../twilio/egress-guard.js"; @@ -53,6 +61,10 @@ export interface ServerConfig { streamBotConnectTimeoutMs?: number; /** Passed to TwilioStreamBridge.playoutLatencyPadMs for every stream. Default 0. */ streamPlayoutLatencyPadMs?: number; + /** How long a transferComplete waits for the dialed leg's transferDisconnect + * event before answering the Dial action without DialCallSid/DialCallDuration. + * Bandwidth documents no ordering between the two. Default 400. */ + transferLegWaitMs?: number; } export interface ServerDeps { @@ -67,9 +79,35 @@ interface BwEvent { to?: string; direction?: string; digits?: string; + terminatingDigit?: string; text?: string; // BW gather event speech transcription startTime?: string; // BW call answer/start time (for status-callback duration) + answerTime?: string; endTime?: string; // BW call end time + cause?: string; // transferComplete / transferDisconnect / disconnect + parentCallId?: string; // transferDisconnect: the call that ran the + transferTo?: string; + recordingId?: string; // recordComplete + duration?: string; // recordComplete, ISO-8601 + channels?: number; +} + +/** Bandwidth's speech gather reports a timeout as this text rather than "". */ +const SPEECH_TIMEOUT_TEXT = /^speech timeout elapsed/i; + +/** True when a gather event carries no usable input, i.e. Twilio's "no digits or speech" case. */ +function gatherIsEmpty(event: BwEvent): boolean { + const hasDigits = typeof event.digits === "string" && event.digits.length > 0; + const hasSpeech = + typeof event.text === "string" && event.text.length > 0 && !SPEECH_TIMEOUT_TEXT.test(event.text); + return !hasDigits && !hasSpeech; +} + +/** Whole seconds between two ISO timestamps, or undefined if either is missing/invalid. */ +function secondsBetween(start?: string, end?: string): number | undefined { + if (!start || !end) return undefined; + const ms = Date.parse(end) - Date.parse(start); + return Number.isFinite(ms) ? Math.max(0, Math.round(ms / 1000)) : undefined; } interface BwRecordingEvent { @@ -132,13 +170,20 @@ export function buildApp(config: ServerConfig, deps: ServerDeps): FastifyInstanc return u.toString().replace(/\/$/, ""); })(); - const rewriter = (base: string) => (url: string, kind: UrlKind) => { + const rewriter = (base: string) => (url: string, kind: UrlKind, ctx?: RewriteContext) => { const absolute = new URL(url, base).toString(); // Recording-available events are async, fire-and-forget (no BXML continuation), // so they route to a dedicated egress endpoint rather than /bw/continue. if (kind === "recordingStatus") { return `${config.publicBaseUrl}/bw/recording-status?cb=${encodeURIComponent(absolute)}`; } + // A Gather's action carries its document position so /bw/continue can resume + // the document after it when Bandwidth reports no input (Twilio semantics), + // plus the flag that says the customer wants the action even then. + if (kind === "action" && ctx?.gatherIndex !== undefined) { + const onEmpty = ctx.actionOnEmptyResult ? "&onEmpty=1" : ""; + return `${config.publicBaseUrl}/bw/continue?next=${encodeURIComponent(absolute)}&gather=${ctx.gatherIndex}${onEmpty}`; + } // A Twilio is the customer's bot. Bandwidth speaks its own // StartStream protocol, so the stream must come to us first; /bw/stream // bridges it to the bot in Twilio's Media Streams protocol. @@ -265,10 +310,35 @@ export function buildApp(config: ServerConfig, deps: ServerDeps): FastifyInstanc ]); } + /** Translate a TwiML document already in hand (no customer round-trip). */ + function replyWithTranslation( + twiml: string, + documentUrl: string, + reply: FastifyReply, + extra: Pick = {}, + ) { + const result = translateTwiml(twiml, { + rewriteUrl: rewriter(documentUrl), + callbackAuth: { username: config.webhookUser, password: config.webhookPassword }, + transferLegUrl: `${config.publicBaseUrl}/bw/transfer-leg`, + ...extra, + }); + if (result.hasErrors) { + const verbs = [ + ...new Set(result.findings.filter((f) => f.severity === "error").map((f) => f.verb)), + ]; + app.log.error({ findings: result.findings }, "unsupported TwiML"); + return reply.type("application/xml").send(errorBxml(verbs)); + } + return reply.type("application/xml").send(result.bxml); + } + async function fetchAndTranslate( customerUrl: string, params: Record, reply: FastifyReply, + /** When given, the fetched TwiML is remembered on the record for Gather resume. */ + record?: CallRecord, ) { // Split the per-turn latency into the customer webhook round-trip (network, // not ours) and the TwiML→BXML translation tax (CPU, ours). With TRANSLATOR_LOG=1 @@ -301,11 +371,13 @@ export function buildApp(config: ServerConfig, deps: ServerDeps): FastifyInstanc app.log.error({ err }, "twiml capture failed"); } } + if (record) { + record.lastTwiml = twiml; + record.lastTwimlUrl = customerUrl; + store.put(record.bwCallId, record); + } const translateStart = performance.now(); - const result = translateTwiml(twiml, { - rewriteUrl: rewriter(customerUrl), - callbackAuth: { username: config.webhookUser, password: config.webhookPassword }, - }); + const out = replyWithTranslation(twiml, customerUrl, reply); app.log.info( { customerUrl, @@ -314,14 +386,7 @@ export function buildApp(config: ServerConfig, deps: ServerDeps): FastifyInstanc }, "fetchAndTranslate timing", ); - if (result.hasErrors) { - const verbs = [ - ...new Set(result.findings.filter((f) => f.severity === "error").map((f) => f.verb)), - ]; - app.log.error({ findings: result.findings }, "unsupported TwiML"); - return reply.type("application/xml").send(errorBxml(verbs)); - } - return reply.type("application/xml").send(result.bxml); + return out; } app.post("/bw/initiate", async (req, reply) => { @@ -339,13 +404,27 @@ export function buildApp(config: ServerConfig, deps: ServerDeps): FastifyInstanc voiceUrl, }; store.put(event.callId, record); - return fetchAndTranslate(voiceUrl, initiateParams(record, config.accountSid), reply); + return fetchAndTranslate(voiceUrl, initiateParams(record, config.accountSid), reply, record); }); + /** Wait briefly for the dialed leg's transferDisconnect to land before the action fires. */ + async function awaitTransferLeg(parentCallId: string) { + const deadline = Date.now() + (config.transferLegWaitMs ?? 400); + for (;;) { + const leg = store.takeTransferLeg(parentCallId); + if (leg || Date.now() >= deadline) return leg; + await new Promise((r) => setTimeout(r, 25)); + } + } + + // Synchronous BXML continuations: Bandwidth posts the verb's completion event + // here (via a rewritten action URL) and executes whatever BXML we answer with. + // Each event type is reshaped into the params Twilio would send to that verb's + // action, so the customer's handler branches the way it did on Twilio. app.post("/bw/continue", async (req, reply) => { const event = req.body as BwEvent; if (!event || !isSafeBwId(event.callId)) return reply.code(400).send(serverErrors.invalidParam("callId")); - const query = req.query as { next?: string }; + const query = req.query as { next?: string; gather?: string; onEmpty?: string }; if (!query.next) return reply.code(400).send(serverErrors.missingParam("next")); const record = store.get(event.callId) ?? @@ -357,11 +436,81 @@ export function buildApp(config: ServerConfig, deps: ServerDeps): FastifyInstanc direction: "inbound", voiceUrl: config.voiceUrl, } satisfies CallRecord); - const params = - event.eventType === "gather" && (event.digits !== undefined || event.text !== undefined) - ? gatherParams(record, config.accountSid, { digits: event.digits, speech: event.text }) - : { ...initiateParams(record, config.accountSid), CallStatus: "in-progress" }; - return fetchAndTranslate(query.next, params, reply); + + let params: Record; + switch (event.eventType) { + case "gather": { + if (gatherIsEmpty(event) && query.onEmpty !== "1") { + // Twilio does not request the action on no input; it continues with the + // verbs after the . Bandwidth has already discarded them, so + // re-translate the document from just past this Gather. + const gatherIndex = Number(query.gather); + if (record.lastTwiml && record.lastTwimlUrl && Number.isInteger(gatherIndex) && gatherIndex > 0) { + return replyWithTranslation(record.lastTwiml, record.lastTwimlUrl, reply, { + resumeAfterGather: gatherIndex, + }); + } + // No document to resume (fresh instance, or an old-style URL): the only + // way to keep the call alive is to ask the action anyway. + app.log.warn({ callId: event.callId }, "empty gather with no document to resume; requesting action"); + } + params = gatherParams(record, config.accountSid, { + digits: event.digits ?? "", + speech: event.text !== undefined && !SPEECH_TIMEOUT_TEXT.test(event.text) ? event.text : undefined, + }); + break; + } + case "transferComplete": { + const leg = await awaitTransferLeg(event.callId); + // The leg's own cause is authoritative for how the dialed call ended; + // the parent's cause is the fallback when the leg event has not arrived. + const cause = leg?.cause ?? event.cause; + const bridged = leg ? Boolean(leg.answerTime) : cause === "hangup"; + params = dialActionParams(record, config.accountSid, { + dialCallStatus: bwCauseToDialCallStatus(cause), + bridged, + dialCallSid: leg ? toCallSid(leg.bwCallId) : undefined, + durationSec: leg ? (bridged ? secondsBetween(leg.answerTime, leg.endTime) ?? 0 : 0) : undefined, + }); + break; + } + case "recordComplete": { + if (event.recordingId && isSafeBwId(event.recordingId)) { + const recordingSid = toRecordingSid(event.recordingId); + store.putRecording(recordingSid, { bwCallId: event.callId, bwRecordingId: event.recordingId }); + params = recordActionParams(record, config.accountSid, { + recordingSid, + recordingUrl: `${config.publicBaseUrl}/2010-04-01/Accounts/${config.accountSid}/Recordings/${recordingSid}`, + durationSec: event.duration ? Number(iso8601DurationToSeconds(event.duration)) : 0, + }); + } else { + params = { ...initiateParams(record, config.accountSid), CallStatus: "in-progress" }; + } + break; + } + default: + params = { ...initiateParams(record, config.accountSid), CallStatus: "in-progress" }; + } + return fetchAndTranslate(query.next, params, reply, record); + }); + + // Async: Bandwidth reports how a dialed (B) leg ended. Held until the parent + // call's transferComplete arrives, which turns it into Dial action params. + app.post("/bw/transfer-leg", async (req, reply) => { + const event = req.body as BwEvent; + if (!event || !isSafeBwId(event.callId)) return reply.code(400).send(serverErrors.invalidParam("callId")); + if (!isSafeBwId(event.parentCallId)) return reply.code(400).send(serverErrors.invalidParam("parentCallId")); + if (event.eventType === "transferDisconnect") { + store.putTransferLeg(event.parentCallId, { + bwCallId: event.callId, + cause: event.cause ?? "unknown", + startTime: event.startTime, + answerTime: event.answerTime, + endTime: event.endTime, + transferTo: event.transferTo, + }); + } + return reply.code(204).send(); }); app.post("/bw/disconnect", async (req, reply) => { diff --git a/src/server/call-store.ts b/src/server/call-store.ts index 4f75033..a498c1c 100644 --- a/src/server/call-store.ts +++ b/src/server/call-store.ts @@ -10,6 +10,11 @@ export interface CallRecord { statusCallback?: string; /** HTTP method the customer requested for the status callback (default POST). */ statusCallbackMethod?: string; + /** The most recent TwiML document fetched for this call and the URL it came + * from. Kept so a that ends with no input can resume the document + * after that Gather, as Twilio does, instead of requesting the action. */ + lastTwiml?: string; + lastTwimlUrl?: string; } /** Resolves a Twilio recording SID back to the BW call + recording it maps to. */ @@ -18,10 +23,23 @@ export interface RecordingRef { bwRecordingId: string; } +/** What Bandwidth's transferDisconnect event said about one dialed (B) leg. */ +export interface TransferLegOutcome { + /** Bandwidth call id of the dialed leg. */ + bwCallId: string; + cause: string; + startTime?: string; + answerTime?: string; + endTime?: string; + transferTo?: string; +} + export class CallStore { private byBwId = new Map(); private bySid = new Map(); private recordingsBySid = new Map(); + /** Dialed-leg outcomes waiting for their parent's transferComplete, by parent call id. */ + private transferLegsByParent = new Map(); put(bwCallId: string, record: CallRecord): void { this.byBwId.set(bwCallId, record); this.bySid.set(record.sid, record); @@ -38,4 +56,17 @@ export class CallStore { getRecording(recordingSid: string): RecordingRef | undefined { return this.recordingsBySid.get(recordingSid); } + putTransferLeg(parentBwCallId: string, leg: TransferLegOutcome): void { + const legs = this.transferLegsByParent.get(parentBwCallId) ?? []; + legs.push(leg); + this.transferLegsByParent.set(parentBwCallId, legs); + } + /** Remove and return the oldest pending dialed-leg outcome for a parent call. */ + takeTransferLeg(parentBwCallId: string): TransferLegOutcome | undefined { + const legs = this.transferLegsByParent.get(parentBwCallId); + if (!legs || legs.length === 0) return undefined; + const leg = legs.shift(); + if (legs.length === 0) this.transferLegsByParent.delete(parentBwCallId); + return leg; + } } diff --git a/src/translator/translate.ts b/src/translator/translate.ts index 3ad9e3f..65b7ffd 100644 --- a/src/translator/translate.ts +++ b/src/translator/translate.ts @@ -12,11 +12,30 @@ export interface Finding { docsUrl?: string; } +/** Extra facts about the verb a URL belongs to, for rewriters that need them. */ +export interface RewriteContext { + /** 1-based position of this among all Gathers in the document. */ + gatherIndex?: number; + /** Twilio actionOnEmptyResult="true": request the action even with no input. */ + actionOnEmptyResult?: boolean; +} + +export type RewriteUrl = (url: string, kind: UrlKind, ctx?: RewriteContext) => string; + export interface TranslateOptions { - rewriteUrl?: (url: string, kind: UrlKind) => string; + rewriteUrl?: RewriteUrl; /** Basic-auth credentials to stamp onto emitted BXML callback verbs so Bandwidth * authenticates its continuation callbacks (e.g. /bw/continue). */ callbackAuth?: { username: string; password: string }; + /** Absolute URL that receives Bandwidth's transferDisconnect event for every + * dialed leg (the translator's /bw/transfer-leg). Set by the server so a Dial + * action can report DialCallSid/DialCallDuration/DialBridged, which Bandwidth's + * transferComplete event does not carry. Standalone BXML generation leaves it unset. */ + transferLegUrl?: string; + /** Emit only the verbs after the Nth (1-based, document order). Used + * to resume a document when a Gather ends with no input: Twilio continues + * with the following verbs, Bandwidth expects fresh BXML from the gatherUrl. */ + resumeAfterGather?: number; } export interface TranslateResult { @@ -173,6 +192,7 @@ const CALLBACK_URL_ATTRS = [ "recordCompleteUrl", "recordingAvailableUrl", "transferCompleteUrl", + "transferDisconnectUrl", "referCompleteUrl", ] as const; @@ -208,11 +228,21 @@ function stampCallbackAuth(els: XmlEl[], auth: { username: string; password: str export function translateTwiml(twiml: string, opts: TranslateOptions = {}): TranslateResult { bxmlByteBudget = MAX_TOTAL_BXML_BYTES; connectStreamSeq = 0; + gatherSeq = 0; + currentTransferLegUrl = opts.transferLegUrl; const root = parseTwiml(twiml); const findings: Finding[] = []; - const rewrite = opts.rewriteUrl ?? ((u: string) => u); + const rewrite: RewriteUrl = opts.rewriteUrl ?? ((u: string) => u); const els: XmlEl[] = []; + // Resuming after a Gather: skip everything through the Nth Gather. Skipped + // Gathers still advance gatherSeq so the ones we do emit keep their document + // positions, and a later resume lands in the right place. + let skipThroughGather = opts.resumeAfterGather ?? 0; for (const node of root.children) { + if (skipThroughGather > 0) { + if (node.name === "Gather" && ++gatherSeq === skipThroughGather) skipThroughGather = 0; + continue; + } const el = translateVerb(node, findings, rewrite); if (el) els.push(...el); } @@ -374,7 +404,7 @@ function translateVerb( function translateGather( node: TwimlNode, findings: Finding[], - rewrite: (u: string, k: UrlKind) => string, + rewrite: RewriteUrl, ): XmlEl[] | null { const attrs: Record = { maxDigits: node.attrs.numDigits, @@ -396,7 +426,15 @@ function translateGather( ); } } - if (node.attrs.action) attrs.gatherUrl = rewrite(node.attrs.action, "action"); + // Document position and the empty-result policy travel with the URL so the + // server can resume after this Gather when Bandwidth reports no input + // (Twilio continues with the next verb unless actionOnEmptyResult="true"). + const gatherIndex = ++gatherSeq; + if (node.attrs.action) + attrs.gatherUrl = rewrite(node.attrs.action, "action", { + gatherIndex, + actionOnEmptyResult: node.attrs.actionOnEmptyResult === "true", + }); else warn( "Gather", @@ -411,10 +449,16 @@ function translateGather( return [{ name: "Gather", attrs, children }]; } +// Per-document counter of verbs in document order; reset per +// translateTwiml call (same module-state caveat as bxmlByteBudget). +let gatherSeq = 0; +// The server's transferDisconnect endpoint for the current translation, if any. +let currentTransferLegUrl: string | undefined; + function translateRecord( node: TwimlNode, findings: Finding[], - rewrite: (u: string, k: UrlKind) => string, + rewrite: RewriteUrl, ): XmlEl[] | null { const attrs: Record = { maxDuration: node.attrs.maxLength, @@ -463,7 +507,7 @@ const UNSUPPORTED_DIAL_ATTRS: Record = { function translateDial( node: TwimlNode, findings: Finding[], - rewrite: (u: string, k: UrlKind) => string, + rewrite: RewriteUrl, ): XmlEl[] | null { const conference = node.children.find((c) => c.name === "Conference"); if (conference) { @@ -506,7 +550,10 @@ function translateDial( warn( "Dial", - "Child-call status propagation is not fully replicated in P0; validate call-progress behavior.", + "Dial action receives DialCallStatus/DialBridged mapped from Bandwidth's transfer result; " + + "DialCallSid and DialCallDuration come from the dialed leg's disconnect event and are omitted " + + "if it has not arrived when the action fires. As on Twilio, a caller hangup during the " + + "transfer ends the session without requesting the action.", findings, ); // Twilio Dial attributes the translator cannot map to BXML Transfer. Surfacing @@ -519,7 +566,15 @@ function translateDial( transferCallerId: node.attrs.callerId, callTimeout: node.attrs.timeout, }; - if (node.attrs.action) attrs.transferCompleteUrl = rewrite(node.attrs.action, "transfer"); + if (node.attrs.action) { + attrs.transferCompleteUrl = rewrite(node.attrs.action, "transfer"); + // Bandwidth's transferComplete names only the original call. The dialed + // leg's id, answer time, and end time arrive on its own transferDisconnect + // event, which the server joins to the action callback for DialCallSid, + // DialCallDuration, and DialBridged. + if (currentTransferLegUrl) + for (const t of targets) t.attrs = { ...t.attrs, transferDisconnectUrl: currentTransferLegUrl }; + } // Handle Twilio Dial record attribute → prepend StartRecording before Transfer. // Twilio values that trigger recording: record-from-answer, record-from-ringing, @@ -644,7 +699,7 @@ function streamToStartStream( stream: TwimlNode, mode: "bidirectional" | "unidirectional", findings: Finding[], - rewrite: (u: string, k: UrlKind) => string, + rewrite: RewriteUrl, ): XmlEl[] | null { if (!stream.attrs.url) return unsupported(stream, findings, "Stream requires a url attribute."); warn("Stream", matrix.verbs.Stream.notes, findings); @@ -676,7 +731,7 @@ let connectStreamSeq = 0; function translateConnect( node: TwimlNode, findings: Finding[], - rewrite: (u: string, k: UrlKind) => string, + rewrite: RewriteUrl, ): XmlEl[] | null { const stream = node.children.find((c) => c.name === "Stream"); if (!stream) @@ -737,7 +792,7 @@ function translateStop(node: TwimlNode, findings: Finding[]): XmlEl[] | null { function translateRefer( node: TwimlNode, findings: Finding[], - rewrite: (u: string, k: UrlKind) => string, + rewrite: RewriteUrl, ): XmlEl[] | null { const sip = node.children.find((c) => c.name === "Sip"); if (!sip || !sip.text) @@ -760,7 +815,7 @@ function translateRefer( function translateStart( node: TwimlNode, findings: Finding[], - rewrite: (u: string, k: UrlKind) => string, + rewrite: RewriteUrl, ): XmlEl[] | null { // is a unidirectional fork (audio out to the bot only). const startStream = node.children.find((c) => c.name === "Stream"); diff --git a/src/twilio/egress.ts b/src/twilio/egress.ts index 9fc7658..3a6b1e4 100644 --- a/src/twilio/egress.ts +++ b/src/twilio/egress.ts @@ -61,6 +61,73 @@ export function gatherParams( return p; } +/** + * Twilio DialCallStatus from a Bandwidth transfer cause. The dialed leg's own + * transferDisconnect cause is preferred; the parent's transferComplete cause + * is the fallback. Bandwidth documents the list as non-exhaustive, so anything + * unrecognized is "failed", which is what Twilio reports for an unroutable call. + */ +export function bwCauseToDialCallStatus(cause: string | undefined): string { + switch (cause) { + case "hangup": + return "completed"; + case "busy": + case "rejected": + return "busy"; + case "timeout": + return "no-answer"; + case "cancel": + return "canceled"; + default: + return "failed"; + } +} + +/** + * Twilio params, from Bandwidth's transferComplete (parent call) + * joined with the dialed leg's transferDisconnect when it has arrived. Without + * the leg, DialCallSid and DialCallDuration are omitted rather than invented. + */ +export function dialActionParams( + call: CallRecord, + accountSid: string, + result: { + dialCallStatus: string; + bridged: boolean; + dialCallSid?: string; + durationSec?: number; + }, +): Record { + const p: Record = { + ...baseParams(call, accountSid), + CallStatus: "in-progress", + DialCallStatus: result.dialCallStatus, + DialBridged: result.bridged ? "true" : "false", + }; + if (result.dialCallSid) p.DialCallSid = result.dialCallSid; + if (result.durationSec !== undefined) p.DialCallDuration = String(result.durationSec); + return p; +} + +/** + * Twilio params from Bandwidth's recordComplete. Twilio also + * sends Digits (the key that stopped the recording); Bandwidth's event has no + * such field, so it is omitted. + */ +export function recordActionParams( + call: CallRecord, + accountSid: string, + rec: { recordingSid: string; recordingUrl: string; durationSec: number }, +): Record { + return { + ...baseParams(call, accountSid), + CallStatus: "in-progress", + RecordingUrl: rec.recordingUrl, + RecordingSid: rec.recordingSid, + RecordingDuration: String(rec.durationSec), + }; +} + export function statusParams( call: CallRecord, accountSid: string, diff --git a/test/server-continue.test.ts b/test/server-continue.test.ts new file mode 100644 index 0000000..695f777 --- /dev/null +++ b/test/server-continue.test.ts @@ -0,0 +1,459 @@ +/** + * VAPI-4041: /bw/continue answers each verb's action the way Twilio would. + * + * - transferComplete -> Dial action params (DialCallStatus, DialBridged, and, + * once the dialed leg's transferDisconnect has landed on /bw/transfer-leg, + * DialCallSid and DialCallDuration). + * - recordComplete -> Record action params (RecordingUrl, RecordingSid, + * RecordingDuration) and the recording becomes fetchable by SID. + * - gather with no input -> resume the document after that Gather instead of + * requesting the action, unless actionOnEmptyResult="true". + */ +import { describe, it, expect, vi } from "vitest"; +import { buildApp } from "../src/server/app.js"; +import { toCallSid, toRecordingSid } from "../src/twilio/call-sid.js"; + +const webhookAuth = "Basic " + Buffer.from("u:p").toString("base64"); +const restAuth = "Basic " + Buffer.from("AC123:tok").toString("base64"); + +function config(extra: Record = {}) { + return { + accountSid: "AC123", + authToken: "tok", + publicBaseUrl: "https://translator.test", + voiceUrl: "https://customer.test/voice", + allowPrivateEgress: true, + webhookUser: "u", + webhookPassword: "p", + transferLegWaitMs: 150, + ...extra, + }; +} + +function makeApp(twimlByUrl: Record, cfg = config()) { + const fetchImpl = vi.fn(async (url: any) => { + const twiml = twimlByUrl[String(url)]; + return twiml ? new Response(twiml, { status: 200 }) : new Response("not found", { status: 404 }); + }) as unknown as typeof fetch; + const bwClient = { + createCall: vi.fn(), + modifyCall: vi.fn(), + getCall: vi.fn(), + listRecordings: vi.fn(), + getRecording: vi.fn(async (callId: string, recordingId: string) => ({ + recordingId, + callId, + to: "+2", + from: "+1", + direction: "inbound", + channels: 1, + duration: "PT12S", + startTime: "2026-09-29T10:00:00Z", + endTime: "2026-09-29T10:00:12Z", + fileFormat: "wav", + status: "complete", + })), + getRecordingMedia: vi.fn(), + updateRecording: vi.fn(), + }; + const app = buildApp(cfg, { fetchImpl, bwClient }); + /** Form body of the Nth customer POST, decoded. */ + const customerPost = (n: number) => { + const call = (fetchImpl as any).mock.calls[n]; + if (!call) return undefined; + const [url, init] = call; + return { url: String(url), params: Object.fromEntries(new URLSearchParams(String(init.body))) }; + }; + const posts = () => (fetchImpl as any).mock.calls.length; + return { app, customerPost, posts }; +} + +const bwEvent = (callId: string, extra: Record) => ({ + callId, + from: "+15550001111", + to: "+15552223333", + direction: "inbound", + ...extra, +}); + +async function initiate(app: any, callId: string) { + return app.inject({ + method: "POST", + url: "/bw/initiate", + headers: { authorization: webhookAuth }, + payload: { eventType: "initiate", ...bwEvent(callId, {}) }, + }); +} + +const continueUrl = (next: string, extra = "") => `/bw/continue?next=${encodeURIComponent(next)}${extra}`; + +// ─── Dial action ───────────────────────────────────────────────────────────── + +describe("/bw/continue transferComplete -> Dial action", () => { + const AFTER = "https://customer.test/after"; + const docs = { + "https://customer.test/voice": `+15552223333`, + [AFTER]: `After dial`, + }; + + it("emits transferDisconnectUrl on the dialed number pointing at /bw/transfer-leg with creds", async () => { + const { app } = makeApp(docs); + const res = await initiate(app, "c-d1"); + expect(res.body).toMatch( + /\+15552223333<\/PhoneNumber>/, + ); + }); + + it("joins the dialed leg's disconnect to the action: completed, bridged, DialCallSid, DialCallDuration", async () => { + const { app, customerPost } = makeApp(docs); + await initiate(app, "c-d2"); + + // The B leg ended (answered at :20, hung up at :84) and Bandwidth told /bw/transfer-leg first. + const leg = await app.inject({ + method: "POST", + url: "/bw/transfer-leg", + headers: { authorization: webhookAuth }, + payload: bwEvent("c-d2-bleg", { + eventType: "transferDisconnect", + parentCallId: "c-d2", + cause: "hangup", + startTime: "2026-09-29T10:00:15Z", + answerTime: "2026-09-29T10:00:20Z", + endTime: "2026-09-29T10:01:24Z", + transferTo: "+15552223333", + }), + }); + expect(leg.statusCode).toBe(204); + + const res = await app.inject({ + method: "POST", + url: continueUrl(AFTER), + headers: { authorization: webhookAuth }, + payload: bwEvent("c-d2", { eventType: "transferComplete", cause: "hangup", transferTo: "+15552223333" }), + }); + expect(res.statusCode).toBe(200); + expect(res.body).toContain("After dial"); + + const { url, params } = customerPost(1)!; + expect(url).toBe(AFTER); + expect(params).toMatchObject({ + CallSid: toCallSid("c-d2"), + CallStatus: "in-progress", + DialCallStatus: "completed", + DialBridged: "true", + DialCallSid: toCallSid("c-d2-bleg"), + DialCallDuration: "64", + }); + }); + + it("reports busy / no-answer / canceled / failed from the leg's cause with DialBridged=false and duration 0", async () => { + const cases: [string, string][] = [ + ["busy", "busy"], + ["rejected", "busy"], + ["timeout", "no-answer"], + ["cancel", "canceled"], + ["callback-error", "failed"], + ["something-new", "failed"], + ]; + for (const [cause, expected] of cases) { + const { app, customerPost } = makeApp(docs); + const id = `c-${cause}`; + await initiate(app, id); + await app.inject({ + method: "POST", + url: "/bw/transfer-leg", + headers: { authorization: webhookAuth }, + payload: bwEvent(`${id}-b`, { + eventType: "transferDisconnect", + parentCallId: id, + cause, + startTime: "2026-09-29T10:00:15Z", + endTime: "2026-09-29T10:00:45Z", + }), + }); + await app.inject({ + method: "POST", + url: continueUrl(AFTER), + headers: { authorization: webhookAuth }, + payload: bwEvent(id, { eventType: "transferComplete", cause }), + }); + expect(customerPost(1)!.params, cause).toMatchObject({ + DialCallStatus: expected, + DialBridged: "false", + DialCallDuration: "0", + DialCallSid: toCallSid(`${id}-b`), + }); + } + }); + + it("waits briefly for a leg event that arrives just after the completion", async () => { + const { app, customerPost } = makeApp(docs, config({ transferLegWaitMs: 500 })); + await initiate(app, "c-d3"); + const completion = app.inject({ + method: "POST", + url: continueUrl(AFTER), + headers: { authorization: webhookAuth }, + payload: bwEvent("c-d3", { eventType: "transferComplete", cause: "hangup" }), + }); + await new Promise((r) => setTimeout(r, 100)); + await app.inject({ + method: "POST", + url: "/bw/transfer-leg", + headers: { authorization: webhookAuth }, + payload: bwEvent("c-d3-b", { + eventType: "transferDisconnect", + parentCallId: "c-d3", + cause: "hangup", + answerTime: "2026-09-29T10:00:20Z", + endTime: "2026-09-29T10:00:50Z", + }), + }); + await completion; + expect(customerPost(1)!.params).toMatchObject({ DialCallSid: toCallSid("c-d3-b"), DialCallDuration: "30" }); + }); + + it("falls back to the parent's cause, without DialCallSid/DialCallDuration, when no leg event arrives", async () => { + const { app, customerPost } = makeApp(docs); + await initiate(app, "c-d4"); + const t0 = Date.now(); + await app.inject({ + method: "POST", + url: continueUrl(AFTER), + headers: { authorization: webhookAuth }, + payload: bwEvent("c-d4", { eventType: "transferComplete", cause: "busy" }), + }); + expect(Date.now() - t0).toBeGreaterThanOrEqual(140); // waited the configured window + const { params } = customerPost(1)!; + expect(params).toMatchObject({ DialCallStatus: "busy", DialBridged: "false" }); + expect(params.DialCallSid).toBeUndefined(); + expect(params.DialCallDuration).toBeUndefined(); + }); + + it("a parent cause of hangup with no leg event is reported as completed and bridged", async () => { + const { app, customerPost } = makeApp(docs); + await initiate(app, "c-d5"); + await app.inject({ + method: "POST", + url: continueUrl(AFTER), + headers: { authorization: webhookAuth }, + payload: bwEvent("c-d5", { eventType: "transferComplete", cause: "hangup" }), + }); + expect(customerPost(1)!.params).toMatchObject({ DialCallStatus: "completed", DialBridged: "true" }); + }); +}); + +describe("/bw/transfer-leg", () => { + it("requires webhook auth and safe ids", async () => { + const { app } = makeApp({}); + expect((await app.inject({ method: "POST", url: "/bw/transfer-leg", payload: { callId: "c-1", parentCallId: "c-0" } })).statusCode).toBe(401); + expect( + ( + await app.inject({ + method: "POST", + url: "/bw/transfer-leg", + headers: { authorization: webhookAuth }, + payload: { eventType: "transferDisconnect", callId: "c-1", parentCallId: "../etc" }, + }) + ).statusCode, + ).toBe(400); + expect( + ( + await app.inject({ + method: "POST", + url: "/bw/transfer-leg", + headers: { authorization: webhookAuth }, + payload: { eventType: "transferDisconnect", callId: "c-1", parentCallId: "c-0", cause: "hangup" }, + }) + ).statusCode, + ).toBe(204); + }); +}); + +// ─── Record action ─────────────────────────────────────────────────────────── + +describe("/bw/continue recordComplete -> Record action", () => { + const DONE = "https://customer.test/done"; + const docs = { + "https://customer.test/voice": ``, + [DONE]: `Thanks`, + }; + + it("sends RecordingUrl, RecordingSid, RecordingDuration and registers the recording", async () => { + const { app, customerPost } = makeApp(docs); + await initiate(app, "c-r1"); + const res = await app.inject({ + method: "POST", + url: continueUrl(DONE), + headers: { authorization: webhookAuth }, + payload: bwEvent("c-r1", { + eventType: "recordComplete", + recordingId: "r-abc", + mediaUrl: "https://voice.bandwidth.com/api/v2/accounts/x/calls/c-r1/recordings/r-abc/media", + duration: "PT12.4S", + channels: 1, + fileFormat: "wav", + }), + }); + expect(res.statusCode).toBe(200); + expect(res.body).toContain("Thanks"); + + const sid = toRecordingSid("r-abc"); + const { url, params } = customerPost(1)!; + expect(url).toBe(DONE); + expect(params).toMatchObject({ + CallSid: toCallSid("c-r1"), + CallStatus: "in-progress", + RecordingSid: sid, + RecordingUrl: `https://translator.test/2010-04-01/Accounts/AC123/Recordings/${sid}`, + RecordingDuration: "12", + }); + // Bandwidth's event has no digits field; Twilio's Digits cannot be reproduced. + expect(params.Digits).toBeUndefined(); + + // The recording is now resolvable through the Twilio-shaped facade. + const get = await app.inject({ + method: "GET", + url: `/2010-04-01/Accounts/AC123/Recordings/${sid}.json`, + headers: { authorization: restAuth }, + }); + expect(get.statusCode).toBe(200); + expect(get.json().sid).toBe(sid); + }); + + it("rejects an unsafe recordingId rather than registering it, and still continues the call", async () => { + const { app, customerPost } = makeApp(docs); + await initiate(app, "c-r2"); + const res = await app.inject({ + method: "POST", + url: continueUrl(DONE), + headers: { authorization: webhookAuth }, + payload: bwEvent("c-r2", { eventType: "recordComplete", recordingId: "../../x", duration: "PT5S" }), + }); + expect(res.statusCode).toBe(200); + expect(customerPost(1)!.params.RecordingSid).toBeUndefined(); + }); +}); + +// ─── Gather empty result ───────────────────────────────────────────────────── + +describe("/bw/continue gather with no input", () => { + const MENU = "https://customer.test/menu"; + const AGAIN = "https://customer.test/again"; + const docs = { + "https://customer.test/voice": ` + Press 1 for sales + We did not get that. + Try again + + `, + [MENU]: `You pressed one`, + [AGAIN]: `Second try handled`, + }; + + it("resumes the document after the Gather instead of requesting the action (Twilio semantics)", async () => { + const { app, posts } = makeApp(docs); + const first = await initiate(app, "c-g1"); + const gatherUrl = /gatherUrl="([^"]+)"/.exec(first.body)![1].replace(/&/g, "&"); + expect(gatherUrl).toContain("&gather=1"); + expect(gatherUrl).not.toContain("onEmpty"); + + const res = await app.inject({ + method: "POST", + url: new URL(gatherUrl).pathname + new URL(gatherUrl).search, + headers: { authorization: webhookAuth }, + payload: bwEvent("c-g1", { eventType: "gather", digits: "", terminatingDigit: "" }), + }); + expect(res.statusCode).toBe(200); + // The remainder: the fallback prompt, the second Gather, the voicemail Record. + expect(res.body).toContain("We did not get that."); + expect(res.body).toContain("Try again"); + expect(res.body).toContain(" { + const { app, customerPost } = makeApp(docs); + await initiate(app, "c-g2"); + const res = await app.inject({ + method: "POST", + url: continueUrl(AGAIN, "&gather=2&onEmpty=1"), + headers: { authorization: webhookAuth }, + payload: bwEvent("c-g2", { eventType: "gather", digits: "" }), + }); + expect(res.body).toContain("Second try handled"); + expect(customerPost(1)!.params.Digits).toBe(""); + }); + + it("treats Bandwidth's speech-timeout sentinel text as no input", async () => { + const { app, posts } = makeApp(docs); + await initiate(app, "c-g3"); + const res = await app.inject({ + method: "POST", + url: continueUrl(MENU, "&gather=1"), + headers: { authorization: webhookAuth }, + payload: bwEvent("c-g3", { eventType: "gather", digits: "", text: "Speech timeout elapsed before transcription" }), + }); + expect(res.body).toContain("We did not get that."); + expect(posts()).toBe(1); + }); + + it("still requests the action when digits or speech were received", async () => { + const { app, customerPost } = makeApp(docs); + await initiate(app, "c-g4"); + await app.inject({ + method: "POST", + url: continueUrl(MENU, "&gather=1"), + headers: { authorization: webhookAuth }, + payload: bwEvent("c-g4", { eventType: "gather", digits: "1", terminatingDigit: "" }), + }); + expect(customerPost(1)!.params).toMatchObject({ Digits: "1" }); + + await app.inject({ + method: "POST", + url: continueUrl(MENU, "&gather=1"), + headers: { authorization: webhookAuth }, + payload: bwEvent("c-g4", { eventType: "gather", digits: "", text: "sales please" }), + }); + expect(customerPost(2)!.params).toMatchObject({ SpeechResult: "sales please" }); + expect(customerPost(2)!.params.Digits).toBe(""); + }); + + it("falls back to requesting the action when there is no document to resume (fresh instance)", async () => { + const { app, customerPost } = makeApp(docs); + // No /bw/initiate on this instance, so no remembered TwiML for the call. + const res = await app.inject({ + method: "POST", + url: continueUrl(MENU, "&gather=1"), + headers: { authorization: webhookAuth }, + payload: bwEvent("c-g5", { eventType: "gather", digits: "" }), + }); + expect(res.body).toContain("You pressed one"); + expect(customerPost(0)!.params.Digits).toBe(""); + }); + + it("a document fetched from a later action is what gets resumed", async () => { + // After the caller presses 1, the customer returns a new document with its own Gather. + const SECOND = `Deep menuDeep fallback`; + const { app } = makeApp({ ...docs, [MENU]: SECOND }); + await initiate(app, "c-g6"); + const r1 = await app.inject({ + method: "POST", + url: continueUrl(MENU, "&gather=1"), + headers: { authorization: webhookAuth }, + payload: bwEvent("c-g6", { eventType: "gather", digits: "1" }), + }); + expect(r1.body).toContain("Deep menu"); + const r2 = await app.inject({ + method: "POST", + url: continueUrl("https://customer.test/deep", "&gather=1"), + headers: { authorization: webhookAuth }, + payload: bwEvent("c-g6", { eventType: "gather", digits: "" }), + }); + expect(r2.body).toContain("Deep fallback"); + expect(r2.body).not.toContain("Deep menu"); + }); +}); diff --git a/test/server-inbound.test.ts b/test/server-inbound.test.ts index b48b1bf..9621e19 100644 --- a/test/server-inbound.test.ts +++ b/test/server-inbound.test.ts @@ -59,7 +59,8 @@ describe("POST /bw/initiate", () => { payload: { eventType: "initiate", callId: "c-2", from: "+1", to: "+2", direction: "inbound" }, }); expect(res.body).toContain( - `gatherUrl="https://translator.test/bw/continue?next=${encodeURIComponent("https://customer.test/menu")}"`, + // The Gather's document position rides along so an empty result can resume after it (VAPI-4041). + `gatherUrl="https://translator.test/bw/continue?next=${encodeURIComponent("https://customer.test/menu")}&gather=1"`, ); }); diff --git a/test/translate-continue.test.ts b/test/translate-continue.test.ts new file mode 100644 index 0000000..48ead2f --- /dev/null +++ b/test/translate-continue.test.ts @@ -0,0 +1,128 @@ +/** + * VAPI-4041: what the translator gives the server so /bw/continue can answer a + * verb's action the way Twilio would. + * + * - Gather actions carry their document position and the actionOnEmptyResult + * flag through the rewriter, and a document can be resumed after the Nth Gather. + * - Dial targets get a transferDisconnectUrl (with callback creds) when the + * server provides one, so the dialed leg's outcome can be joined to the action. + */ +import { describe, it, expect } from "vitest"; +import { translateTwiml, type RewriteContext, type UrlKind } from "../src/translator/translate.js"; + +function recordingRewriter() { + const calls: { url: string; kind: UrlKind; ctx?: RewriteContext }[] = []; + const rewriteUrl = (url: string, kind: UrlKind, ctx?: RewriteContext) => { + calls.push({ url, kind, ctx }); + return `https://tr.test/bw/${kind}?u=${encodeURIComponent(url)}` + (ctx?.gatherIndex ? `&g=${ctx.gatherIndex}` : ""); + }; + return { calls, rewriteUrl }; +} + +const TWO_GATHERS = ` + Welcome + Press 1 + No input on menu + Try again + +`; + +describe("Gather action context for the rewriter", () => { + it("passes a 1-based document index and the actionOnEmptyResult flag for each Gather", () => { + const { calls, rewriteUrl } = recordingRewriter(); + translateTwiml(TWO_GATHERS, { rewriteUrl }); + const gathers = calls.filter((c) => c.kind === "action"); + expect(gathers.map((c) => c.ctx)).toEqual([ + { gatherIndex: 1, actionOnEmptyResult: false }, + { gatherIndex: 2, actionOnEmptyResult: true }, + ]); + // Other kinds get no Gather context. + expect(calls.find((c) => c.kind === "record")?.ctx).toBeUndefined(); + }); + + it("restarts the index for every document", () => { + const { calls, rewriteUrl } = recordingRewriter(); + translateTwiml(``, { rewriteUrl }); + translateTwiml(``, { rewriteUrl }); + expect(calls.map((c) => c.ctx?.gatherIndex)).toEqual([1, 1]); + }); +}); + +describe("resumeAfterGather", () => { + it("emits only the verbs after the Nth Gather", () => { + const { rewriteUrl } = recordingRewriter(); + const r = translateTwiml(TWO_GATHERS, { rewriteUrl, resumeAfterGather: 1 }); + expect(r.hasErrors).toBe(false); + expect(r.bxml).not.toContain("Welcome"); + expect(r.bxml).not.toContain("Press 1"); + expect(r.bxml).toMatch(/^<\?xml[^>]*>No input on menu<\/SpeakSentence> { + const { calls, rewriteUrl } = recordingRewriter(); + translateTwiml(TWO_GATHERS, { rewriteUrl, resumeAfterGather: 1 }); + const gathers = calls.filter((c) => c.kind === "action"); + expect(gathers).toHaveLength(1); + expect(gathers[0].ctx).toEqual({ gatherIndex: 2, actionOnEmptyResult: true }); + }); + + it("resuming after the last Gather yields an empty Response, which ends the call as Twilio would", () => { + const { rewriteUrl } = recordingRewriter(); + const r = translateTwiml(`Hi`, { + rewriteUrl, + resumeAfterGather: 1, + }); + expect(r.bxml).toMatch(/$/); + expect(r.findings).toEqual([]); + }); + + it("resuming past a Gather that does not exist also yields an empty Response", () => { + const r = translateTwiml(TWO_GATHERS, { resumeAfterGather: 5 }); + expect(r.bxml).toMatch(/$/); + }); + + it("is a no-op when unset or zero", () => { + const a = translateTwiml(TWO_GATHERS); + const b = translateTwiml(TWO_GATHERS, { resumeAfterGather: 0 }); + expect(b.bxml).toBe(a.bxml); + expect(a.bxml).toContain("Welcome"); + }); +}); + +describe("Dial transferDisconnectUrl", () => { + const dial = `+15552223333sip:agent@pbx.test`; + + it("stamps the server's transfer-leg URL and callback creds on every target when a Dial has an action", () => { + const r = translateTwiml(dial, { + rewriteUrl: (u) => `https://tr.test/bw/continue?next=${encodeURIComponent(u)}`, + callbackAuth: { username: "u", password: "p" }, + transferLegUrl: "https://tr.test/bw/transfer-leg", + }); + expect(r.bxml).toMatch( + /\+15552223333<\/PhoneNumber>/, + ); + expect(r.bxml).toMatch(//); + expect(r.bxml).toMatch(//); + }); + + it("does not stamp it when the Dial has no action (nothing to report to)", () => { + const r = translateTwiml(`+15552223333`, { + transferLegUrl: "https://tr.test/bw/transfer-leg", + }); + expect(r.bxml).not.toContain("transferDisconnectUrl"); + }); + + it("does not stamp it when no transferLegUrl is provided (standalone BXML generation)", () => { + const r = translateTwiml(dial); + expect(r.bxml).not.toContain("transferDisconnectUrl"); + expect(r.bxml).toContain(`+15552223333`); + }); + + it("describes the Dial action mapping and the caller-hangup case in a warning", () => { + const r = translateTwiml(dial); + const w = r.findings.find((f) => f.verb === "Dial" && /DialCallStatus/.test(f.message)); + expect(w?.severity).toBe("warning"); + expect(w?.message).toMatch(/caller hangup/); + }); +}); From 6a17661ab663b1b916806b99fa73bcbe5a9c8724 Mon Sep 17 00:00:00 2001 From: Madhu Ramasubramanian Date: Thu, 1 Oct 2026 13:59:48 -0400 Subject: [PATCH 2/2] VAPI-4041: key dialed legs to their Dial and prefer the answered leg Review fixes for the Dial action join: - A multi-number Dial reported whichever leg disconnected first, usually a cancelled loser. Now all of the Dial's legs are considered and the one that answered wins; if the parent bridged but the winner has not reported, no leg is reported rather than a loser. - Leftover or late leg events were applied to the next Dial on the call. Each Dial now gets a key (.) on both its transferCompleteUrl and its legs' transferDisconnectUrl, so legs only join their own Dial. The wait ends early once every expected leg has reported, and a call's unread legs are dropped on disconnect. --- AGENTS.md | 4 +- src/server/app.ts | 58 +++++++++++--- src/server/call-store.ts | 34 +++++--- src/translator/translate.ts | 31 +++++-- test/server-continue.test.ts | 138 +++++++++++++++++++++++++++++++- test/translate-continue.test.ts | 26 +++++- 6 files changed, 253 insertions(+), 38 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 01c98ee..823bd71 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -113,7 +113,9 @@ Translation is a fixed rulebook (`src/matrix/twilio-voice.json`), not a guess. `transferDisconnect` event, which the translator receives at `/bw/transfer-leg` and joins to the parent's `transferComplete` (waiting up to `transferLegWaitMs`, default 400 ms, since Bandwidth documents no - ordering between the two). As on Twilio, a caller hangup during the + ordering between the two). Each Dial's legs are keyed to that Dial, and for + a multi-number Dial the leg that answered is reported, not the cancelled + ones. As on Twilio, a caller hangup during the transfer ends the session without requesting the action. - `Gather` — a Gather that ends with no digits or speech does **not** request the action (Twilio semantics): the translator remembers each call's last diff --git a/src/server/app.ts b/src/server/app.ts index 4edae02..7a2e464 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -170,7 +170,9 @@ export function buildApp(config: ServerConfig, deps: ServerDeps): FastifyInstanc return u.toString().replace(/\/$/, ""); })(); - const rewriter = (base: string) => (url: string, kind: UrlKind, ctx?: RewriteContext) => { + /** `doc` identifies one translation, so a Dial's key is unique for the whole + * call, not just within its document (two documents can each have "Dial 1"). */ + const rewriter = (base: string, doc: string) => (url: string, kind: UrlKind, ctx?: RewriteContext) => { const absolute = new URL(url, base).toString(); // Recording-available events are async, fire-and-forget (no BXML continuation), // so they route to a dedicated egress endpoint rather than /bw/continue. @@ -184,6 +186,11 @@ export function buildApp(config: ServerConfig, deps: ServerDeps): FastifyInstanc const onEmpty = ctx.actionOnEmptyResult ? "&onEmpty=1" : ""; return `${config.publicBaseUrl}/bw/continue?next=${encodeURIComponent(absolute)}&gather=${ctx.gatherIndex}${onEmpty}`; } + // A Dial's action names the Dial (matching its legs' transferDisconnectUrl) + // and how many legs it rings, so /bw/continue joins only that Dial's legs. + if (kind === "transfer" && ctx?.dialIndex !== undefined) { + return `${config.publicBaseUrl}/bw/continue?next=${encodeURIComponent(absolute)}&dial=${doc}.${ctx.dialIndex}&legs=${ctx.dialTargets ?? 1}`; + } // A Twilio is the customer's bot. Bandwidth speaks its own // StartStream protocol, so the stream must come to us first; /bw/stream // bridges it to the bot in Twilio's Media Streams protocol. @@ -317,10 +324,11 @@ export function buildApp(config: ServerConfig, deps: ServerDeps): FastifyInstanc reply: FastifyReply, extra: Pick = {}, ) { + const doc = randomUUID().slice(0, 8); const result = translateTwiml(twiml, { - rewriteUrl: rewriter(documentUrl), + rewriteUrl: rewriter(documentUrl, doc), callbackAuth: { username: config.webhookUser, password: config.webhookPassword }, - transferLegUrl: `${config.publicBaseUrl}/bw/transfer-leg`, + transferLegUrl: (dialIndex) => `${config.publicBaseUrl}/bw/transfer-leg?dial=${doc}.${dialIndex}`, ...extra, }); if (result.hasErrors) { @@ -407,16 +415,32 @@ export function buildApp(config: ServerConfig, deps: ServerDeps): FastifyInstanc return fetchAndTranslate(voiceUrl, initiateParams(record, config.accountSid), reply, record); }); - /** Wait briefly for the dialed leg's transferDisconnect to land before the action fires. */ - async function awaitTransferLeg(parentCallId: string) { + /** + * Wait briefly for one Dial's legs to report, then pick the leg its action + * describes. A Dial with several targets rings them at once; when one answers + * the rest are cancelled and usually report first, so the answered leg wins + * regardless of arrival order. Waits until a leg that answered has arrived, + * every expected leg has, or transferLegWaitMs passes. + */ + async function awaitTransferLeg(parentCallId: string, dialKey: string, expectedLegs: number, parentCause?: string) { const deadline = Date.now() + (config.transferLegWaitMs ?? 400); - for (;;) { - const leg = store.takeTransferLeg(parentCallId); - if (leg || Date.now() >= deadline) return leg; + let legs = store.peekTransferLegs(parentCallId, dialKey); + while (!legs.some((l) => l.answerTime) && legs.length < expectedLegs && Date.now() < deadline) { await new Promise((r) => setTimeout(r, 25)); + legs = store.peekTransferLegs(parentCallId, dialKey); } + store.dropTransferLegs(parentCallId, dialKey); + const answered = legs.find((l) => l.answerTime); + if (answered) return answered; + // The parent says the transfer bridged but the answered leg has not reported: + // the legs in hand are the cancelled losers, so report none rather than one of them. + if (parentCause === "hangup") return undefined; + return legs.at(-1); } + /** Dial key as minted by the rewriter: `<8 hex>.`. */ + const isDialKey = (v: unknown): v is string => typeof v === "string" && /^[0-9a-f]{8}\.\d{1,4}$/.test(v); + // Synchronous BXML continuations: Bandwidth posts the verb's completion event // here (via a rewritten action URL) and executes whatever BXML we answer with. // Each event type is reshaped into the params Twilio would send to that verb's @@ -424,7 +448,7 @@ export function buildApp(config: ServerConfig, deps: ServerDeps): FastifyInstanc app.post("/bw/continue", async (req, reply) => { const event = req.body as BwEvent; if (!event || !isSafeBwId(event.callId)) return reply.code(400).send(serverErrors.invalidParam("callId")); - const query = req.query as { next?: string; gather?: string; onEmpty?: string }; + const query = req.query as { next?: string; gather?: string; onEmpty?: string; dial?: string; legs?: string }; if (!query.next) return reply.code(400).send(serverErrors.missingParam("next")); const record = store.get(event.callId) ?? @@ -461,7 +485,15 @@ export function buildApp(config: ServerConfig, deps: ServerDeps): FastifyInstanc break; } case "transferComplete": { - const leg = await awaitTransferLeg(event.callId); + // A URL without a valid dial key (minted before this change) shares the + // "" bucket with legs that likewise carry none. + const legsParam = Number(query.legs); + const leg = await awaitTransferLeg( + event.callId, + isDialKey(query.dial) ? query.dial : "", + Number.isInteger(legsParam) && legsParam > 0 ? legsParam : 1, + event.cause, + ); // The leg's own cause is authoritative for how the dialed call ended; // the parent's cause is the fallback when the leg event has not arrived. const cause = leg?.cause ?? event.cause; @@ -500,8 +532,10 @@ export function buildApp(config: ServerConfig, deps: ServerDeps): FastifyInstanc const event = req.body as BwEvent; if (!event || !isSafeBwId(event.callId)) return reply.code(400).send(serverErrors.invalidParam("callId")); if (!isSafeBwId(event.parentCallId)) return reply.code(400).send(serverErrors.invalidParam("parentCallId")); + const { dial } = req.query as { dial?: string }; + if (dial !== undefined && !isDialKey(dial)) return reply.code(400).send(serverErrors.invalidParam("dial")); if (event.eventType === "transferDisconnect") { - store.putTransferLeg(event.parentCallId, { + store.putTransferLeg(event.parentCallId, dial ?? "", { bwCallId: event.callId, cause: event.cause ?? "unknown", startTime: event.startTime, @@ -516,6 +550,8 @@ export function buildApp(config: ServerConfig, deps: ServerDeps): FastifyInstanc app.post("/bw/disconnect", async (req, reply) => { const event = req.body as BwEvent; if (!event || !isSafeBwId(event.callId)) return reply.code(400).send(serverErrors.invalidParam("callId")); + // Legs that reported after their Dial's wait will never be read. + store.clearTransferLegs(event.callId); const record = store.get(event.callId); if (record) { // Bandwidth bills (and Twilio reports) from answer to end; fall back to 0 diff --git a/src/server/call-store.ts b/src/server/call-store.ts index a498c1c..4be64d7 100644 --- a/src/server/call-store.ts +++ b/src/server/call-store.ts @@ -38,8 +38,10 @@ export class CallStore { private byBwId = new Map(); private bySid = new Map(); private recordingsBySid = new Map(); - /** Dialed-leg outcomes waiting for their parent's transferComplete, by parent call id. */ - private transferLegsByParent = new Map(); + /** Dialed-leg outcomes waiting for their Dial's transferComplete, by parent + * call id, then by the Dial they belong to. Keying by Dial keeps a late leg + * from one Dial out of the next Dial's action. */ + private transferLegsByParent = new Map>(); put(bwCallId: string, record: CallRecord): void { this.byBwId.set(bwCallId, record); this.bySid.set(record.sid, record); @@ -56,17 +58,23 @@ export class CallStore { getRecording(recordingSid: string): RecordingRef | undefined { return this.recordingsBySid.get(recordingSid); } - putTransferLeg(parentBwCallId: string, leg: TransferLegOutcome): void { - const legs = this.transferLegsByParent.get(parentBwCallId) ?? []; - legs.push(leg); - this.transferLegsByParent.set(parentBwCallId, legs); + putTransferLeg(parentBwCallId: string, dialKey: string, leg: TransferLegOutcome): void { + const byDial = this.transferLegsByParent.get(parentBwCallId) ?? new Map(); + byDial.set(dialKey, [...(byDial.get(dialKey) ?? []), leg]); + this.transferLegsByParent.set(parentBwCallId, byDial); } - /** Remove and return the oldest pending dialed-leg outcome for a parent call. */ - takeTransferLeg(parentBwCallId: string): TransferLegOutcome | undefined { - const legs = this.transferLegsByParent.get(parentBwCallId); - if (!legs || legs.length === 0) return undefined; - const leg = legs.shift(); - if (legs.length === 0) this.transferLegsByParent.delete(parentBwCallId); - return leg; + /** The dialed-leg outcomes received so far for one Dial, in arrival order. */ + peekTransferLegs(parentBwCallId: string, dialKey: string): readonly TransferLegOutcome[] { + return this.transferLegsByParent.get(parentBwCallId)?.get(dialKey) ?? []; + } + /** Forget one Dial's legs once its action has been answered. */ + dropTransferLegs(parentBwCallId: string, dialKey: string): void { + const byDial = this.transferLegsByParent.get(parentBwCallId); + byDial?.delete(dialKey); + if (byDial?.size === 0) this.transferLegsByParent.delete(parentBwCallId); + } + /** Forget every pending leg of a call, e.g. ones that arrived after their Dial's wait. */ + clearTransferLegs(parentBwCallId: string): void { + this.transferLegsByParent.delete(parentBwCallId); } } diff --git a/src/translator/translate.ts b/src/translator/translate.ts index 65b7ffd..aaedcf2 100644 --- a/src/translator/translate.ts +++ b/src/translator/translate.ts @@ -18,6 +18,10 @@ export interface RewriteContext { gatherIndex?: number; /** Twilio actionOnEmptyResult="true": request the action even with no input. */ actionOnEmptyResult?: boolean; + /** 1-based position of this among the Dials with an action in the document. */ + dialIndex?: number; + /** How many targets (Number/Sip) this rings, i.e. how many legs to expect. */ + dialTargets?: number; } export type RewriteUrl = (url: string, kind: UrlKind, ctx?: RewriteContext) => string; @@ -28,10 +32,12 @@ export interface TranslateOptions { * authenticates its continuation callbacks (e.g. /bw/continue). */ callbackAuth?: { username: string; password: string }; /** Absolute URL that receives Bandwidth's transferDisconnect event for every - * dialed leg (the translator's /bw/transfer-leg). Set by the server so a Dial - * action can report DialCallSid/DialCallDuration/DialBridged, which Bandwidth's - * transferComplete event does not carry. Standalone BXML generation leaves it unset. */ - transferLegUrl?: string; + * leg of the Nth Dial (the translator's /bw/transfer-leg). Set by the server so + * a Dial action can report DialCallSid/DialCallDuration/DialBridged, which + * Bandwidth's transferComplete event does not carry. The index matches the + * dialIndex the rewriter sees for that Dial's action, so the server can tie + * each leg to its own Dial. Standalone BXML generation leaves it unset. */ + transferLegUrl?: (dialIndex: number) => string; /** Emit only the verbs after the Nth (1-based, document order). Used * to resume a document when a Gather ends with no input: Twilio continues * with the following verbs, Bandwidth expects fresh BXML from the gatherUrl. */ @@ -229,6 +235,7 @@ export function translateTwiml(twiml: string, opts: TranslateOptions = {}): Tran bxmlByteBudget = MAX_TOTAL_BXML_BYTES; connectStreamSeq = 0; gatherSeq = 0; + dialSeq = 0; currentTransferLegUrl = opts.transferLegUrl; const root = parseTwiml(twiml); const findings: Finding[] = []; @@ -452,8 +459,10 @@ function translateGather( // Per-document counter of verbs in document order; reset per // translateTwiml call (same module-state caveat as bxmlByteBudget). let gatherSeq = 0; +// Per-document counter of verbs with an action; same caveat. +let dialSeq = 0; // The server's transferDisconnect endpoint for the current translation, if any. -let currentTransferLegUrl: string | undefined; +let currentTransferLegUrl: ((dialIndex: number) => string) | undefined; function translateRecord( node: TwimlNode, @@ -567,13 +576,19 @@ function translateDial( callTimeout: node.attrs.timeout, }; if (node.attrs.action) { - attrs.transferCompleteUrl = rewrite(node.attrs.action, "transfer"); + const dialIndex = ++dialSeq; + attrs.transferCompleteUrl = rewrite(node.attrs.action, "transfer", { + dialIndex, + dialTargets: targets.length, + }); // Bandwidth's transferComplete names only the original call. The dialed // leg's id, answer time, and end time arrive on its own transferDisconnect // event, which the server joins to the action callback for DialCallSid, // DialCallDuration, and DialBridged. - if (currentTransferLegUrl) - for (const t of targets) t.attrs = { ...t.attrs, transferDisconnectUrl: currentTransferLegUrl }; + if (currentTransferLegUrl) { + const legUrl = currentTransferLegUrl(dialIndex); + for (const t of targets) t.attrs = { ...t.attrs, transferDisconnectUrl: legUrl }; + } } // Handle Twilio Dial record attribute → prepend StartRecording before Transfer. diff --git a/test/server-continue.test.ts b/test/server-continue.test.ts index 695f777..cc310de 100644 --- a/test/server-continue.test.ts +++ b/test/server-continue.test.ts @@ -87,6 +87,18 @@ async function initiate(app: any, callId: string) { const continueUrl = (next: string, extra = "") => `/bw/continue?next=${encodeURIComponent(next)}${extra}`; +const unescape = (s: string) => s.replace(/&/g, "&"); +/** Path+query of an absolute translator URL, for app.inject. */ +const pathOf = (url: string) => new URL(url).pathname + new URL(url).search; + +/** Each Transfer in a BXML body: its transferCompleteUrl and its targets' transferDisconnectUrls. */ +function dialUrls(bxml: string) { + return [...bxml.matchAll(/]*>(.*?)<\/Transfer>/g)].map((m) => ({ + complete: unescape(m[1]), + legs: [...m[2].matchAll(/transferDisconnectUrl="([^"]+)"/g)].map((l) => unescape(l[1])), + })); +} + // ─── Dial action ───────────────────────────────────────────────────────────── describe("/bw/continue transferComplete -> Dial action", () => { @@ -100,8 +112,11 @@ describe("/bw/continue transferComplete -> Dial action", () => { const { app } = makeApp(docs); const res = await initiate(app, "c-d1"); expect(res.body).toMatch( - /\+15552223333<\/PhoneNumber>/, + /\+15552223333<\/PhoneNumber>/, ); + // The action names the same Dial and how many legs it rings. + const { complete, legs } = dialUrls(res.body)[0]; + expect(complete).toContain(`&dial=${new URL(legs[0]).searchParams.get("dial")}&legs=1`); }); it("joins the dialed leg's disconnect to the action: completed, bridged, DialCallSid, DialCallDuration", async () => { @@ -242,7 +257,128 @@ describe("/bw/continue transferComplete -> Dial action", () => { }); }); +describe("Dial action picks the right leg (multi-target, repeated Dials)", () => { + const AFTER = "https://customer.test/after"; + const NEXT = "https://customer.test/next"; + + const legEvent = (url: string, parent: string, leg: string, extra: Record) => ({ + method: "POST" as const, + url: pathOf(url), + headers: { authorization: webhookAuth }, + payload: bwEvent(leg, { eventType: "transferDisconnect", parentCallId: parent, ...extra }), + }); + const completion = (url: string, callId: string, cause: string) => ({ + method: "POST" as const, + url: pathOf(url), + headers: { authorization: webhookAuth }, + payload: bwEvent(callId, { eventType: "transferComplete", cause }), + }); + + it("reports the answered leg even when the cancelled leg reported first", async () => { + const { app, customerPost } = makeApp({ + "https://customer.test/voice": `+15550000001+15550000002`, + [AFTER]: `After`, + }); + const [dial] = dialUrls((await initiate(app, "c-m1")).body); + expect(dial.legs).toHaveLength(2); + expect(dial.complete).toContain("&legs=2"); + + // B answers, so A is cancelled at once and its event lands first. + await app.inject(legEvent(dial.legs[0], "c-m1", "c-m1-a", { cause: "cancel", startTime: "2026-09-29T10:00:00Z", endTime: "2026-09-29T10:00:05Z" })); + await app.inject( + legEvent(dial.legs[1], "c-m1", "c-m1-b", { cause: "hangup", answerTime: "2026-09-29T10:00:05Z", endTime: "2026-09-29T10:00:45Z" }), + ); + await app.inject(completion(dial.complete, "c-m1", "hangup")); + + expect(customerPost(1)!.params).toMatchObject({ + DialCallStatus: "completed", + DialBridged: "true", + DialCallSid: toCallSid("c-m1-b"), + DialCallDuration: "40", + }); + }); + + it("does not report a cancelled loser when the answered leg has not reported yet", async () => { + const { app, customerPost } = makeApp({ + "https://customer.test/voice": `+15550000001+15550000002`, + [AFTER]: `After`, + }); + const [dial] = dialUrls((await initiate(app, "c-m2")).body); + await app.inject(legEvent(dial.legs[0], "c-m2", "c-m2-a", { cause: "cancel" })); + await app.inject(completion(dial.complete, "c-m2", "hangup")); + const { params } = customerPost(1)!; + expect(params).toMatchObject({ DialCallStatus: "completed", DialBridged: "true" }); + expect(params.DialCallSid).toBeUndefined(); + }); + + it("answers as soon as every leg of an unanswered multi-target Dial has reported", async () => { + const { app, customerPost } = makeApp( + { + "https://customer.test/voice": `+15550000001+15550000002`, + [AFTER]: `After`, + }, + config({ transferLegWaitMs: 5000 }), + ); + const [dial] = dialUrls((await initiate(app, "c-m3")).body); + await app.inject(legEvent(dial.legs[0], "c-m3", "c-m3-a", { cause: "timeout" })); + await app.inject(legEvent(dial.legs[1], "c-m3", "c-m3-b", { cause: "timeout" })); + const t0 = Date.now(); + await app.inject(completion(dial.complete, "c-m3", "timeout")); + expect(Date.now() - t0).toBeLessThan(1000); + expect(customerPost(1)!.params).toMatchObject({ DialCallStatus: "no-answer", DialBridged: "false" }); + }); + + it("a leg that reports after its Dial's wait is not applied to the next Dial", async () => { + const { app, customerPost } = makeApp({ + "https://customer.test/voice": `+15550000001`, + [AFTER]: `+15550000002`, + [NEXT]: `Done`, + }); + const [first] = dialUrls((await initiate(app, "c-m4")).body); + + // Dial #1 completes with no leg event inside the wait window. + const r1 = await app.inject(completion(first.complete, "c-m4", "hangup")); + expect(customerPost(1)!.params.DialCallSid).toBeUndefined(); + // Its leg reports late. + await app.inject( + legEvent(first.legs[0], "c-m4", "c-m4-first", { cause: "hangup", answerTime: "2026-09-29T10:00:05Z", endTime: "2026-09-29T10:00:45Z" }), + ); + + // Dial #2 (from the action's document) times out with no leg event of its own. + const [second] = dialUrls(r1.body); + expect(new URL(second.legs[0]).searchParams.get("dial")).not.toBe(new URL(first.legs[0]).searchParams.get("dial")); + await app.inject(completion(second.complete, "c-m4", "timeout")); + const { params } = customerPost(2)!; + expect(params).toMatchObject({ DialCallStatus: "no-answer", DialBridged: "false" }); + expect(params.DialCallSid).toBeUndefined(); + }); + + it("drops unread legs when the call disconnects", async () => { + const { app, customerPost } = makeApp({ + "https://customer.test/voice": `+15550000001`, + [AFTER]: `After`, + }); + await initiate(app, "c-m5"); + // An old-style (unkeyed) leg, then the call ends before any Dial reads it. + await app.inject(legEvent("https://translator.test/bw/transfer-leg", "c-m5", "c-m5-x", { cause: "hangup", answerTime: "2026-09-29T10:00:05Z" })); + await app.inject({ method: "POST", url: "/bw/disconnect", headers: { authorization: webhookAuth }, payload: bwEvent("c-m5", { eventType: "disconnect" }) }); + await app.inject(completion(`https://translator.test${continueUrl(AFTER)}`, "c-m5", "busy")); + expect(customerPost(1)!.params.DialCallSid).toBeUndefined(); + }); +}); + describe("/bw/transfer-leg", () => { + it("rejects a malformed dial key", async () => { + const { app } = makeApp({}); + const res = await app.inject({ + method: "POST", + url: "/bw/transfer-leg?dial=../x", + headers: { authorization: webhookAuth }, + payload: { eventType: "transferDisconnect", callId: "c-1", parentCallId: "c-0", cause: "hangup" }, + }); + expect(res.statusCode).toBe(400); + }); + it("requires webhook auth and safe ids", async () => { const { app } = makeApp({}); expect((await app.inject({ method: "POST", url: "/bw/transfer-leg", payload: { callId: "c-1", parentCallId: "c-0" } })).statusCode).toBe(401); diff --git a/test/translate-continue.test.ts b/test/translate-continue.test.ts index 48ead2f..79bd59c 100644 --- a/test/translate-continue.test.ts +++ b/test/translate-continue.test.ts @@ -97,18 +97,36 @@ describe("Dial transferDisconnectUrl", () => { const r = translateTwiml(dial, { rewriteUrl: (u) => `https://tr.test/bw/continue?next=${encodeURIComponent(u)}`, callbackAuth: { username: "u", password: "p" }, - transferLegUrl: "https://tr.test/bw/transfer-leg", + transferLegUrl: (i) => `https://tr.test/bw/transfer-leg?dial=${i}`, }); expect(r.bxml).toMatch( - /\+15552223333<\/PhoneNumber>/, + /\+15552223333<\/PhoneNumber>/, ); - expect(r.bxml).toMatch(//); + expect(r.bxml).toMatch(//); expect(r.bxml).toMatch(//); }); + it("numbers each Dial with an action and tells the rewriter how many targets it rings", () => { + const { calls, rewriteUrl } = recordingRewriter(); + const r = translateTwiml( + ` + +15550000001+15550000002 + +15550000003 + +15550000004 + `, + { rewriteUrl, transferLegUrl: (i) => `https://tr.test/leg?dial=${i}` }, + ); + expect(calls.filter((c) => c.kind === "transfer").map((c) => c.ctx)).toEqual([ + { dialIndex: 1, dialTargets: 2 }, + { dialIndex: 2, dialTargets: 1 }, + ]); + expect(r.bxml.match(/leg\?dial=1/g)).toHaveLength(2); + expect(r.bxml.match(/leg\?dial=2/g)).toHaveLength(1); + }); + it("does not stamp it when the Dial has no action (nothing to report to)", () => { const r = translateTwiml(`+15552223333`, { - transferLegUrl: "https://tr.test/bw/transfer-leg", + transferLegUrl: () => "https://tr.test/bw/transfer-leg", }); expect(r.bxml).not.toContain("transferDisconnectUrl"); });