Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/matrix/twilio-voice.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"attributes": {
"voice": { "bxml": "voice", "status": "partial", "notes": "Common Twilio/Polly voice names are mapped to the nearest BW equivalent; see TWILIO_TO_BW_VOICE in translate.ts. BW supports 50+ named voices across multiple locales — the full allowlist is in BW_VOICES." },
"language": { "bxml": "locale", "status": "partial", "notes": "Locale formats differ." },
"loop": { "bxml": null, "status": "partial", "notes": "Emitted once; BXML has no loop attribute." }
"loop": { "bxml": null, "status": "partial", "notes": "BXML has no loop attribute, so a finite loop=\"N\" is expanded into the SpeakSentence repeated N times. loop=\"0\" (infinite) and invalid counts cannot be expressed inline, so they emit once and warn." }
}
},
"Play": {
Expand All @@ -19,7 +19,7 @@
"notes": "When a src URL is present it maps to PlayAudio. When the digits attribute is present it maps to SendDtmf (BW verb that sends DTMF tones mid-call). Both can appear in the same element: PlayAudio is emitted first, then SendDtmf. Twilio pause characters w (0.5 s), W (1 s), and comma (0.5 s) are passed through to BW SendDtmf unchanged — BW supports the same pause encoding.",
"docsUrl": "https://dev.bandwidth.com/docs/voice/bxml/playAudio",
"attributes": {
"loop": { "bxml": null, "status": "partial", "notes": "Emitted once; BXML has no loop attribute." },
"loop": { "bxml": null, "status": "partial", "notes": "BXML has no loop attribute, so a finite loop=\"N\" is expanded into the PlayAudio (and any SendDtmf) repeated N times. loop=\"0\" (infinite) and invalid counts cannot be expressed inline, so they emit once and warn." },
"digits": { "bxml": "SendDtmf", "status": "supported", "notes": "Maps to BW SendDtmf verb. Valid chars: 0-9, *, #, a-d, A-D, w (0.5s pause), W (1s pause), comma (0.5s pause)." }
}
},
Expand Down
56 changes: 49 additions & 7 deletions src/translator/translate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,29 @@ function warn(verb: string, message: string, findings: Finding[]): void {
findings.push({ severity: "warning", verb, message, docsUrl: matrix.verbs[verb]?.docsUrl });
}

/**
* Twilio's loop="N" repeats a <Say>/<Play>. BXML has no loop attribute, so a
* finite count is expanded into the verb repeated N times. loop="0" means
* "repeat until hangup" on Twilio and an invalid count has no meaning — neither
* can be expressed inline in BXML, so both fall back to a single emission plus a
* warning rather than silently dropping the intent.
*/
function applyLoop(els: XmlEl[], loop: string | undefined, verb: string, findings: Finding[]): XmlEl[] {
if (loop === undefined || loop === "1") return els;
if (loop === "0") {
warn(verb, 'loop="0" requests infinite repetition, which BXML cannot express inline; content will play once.', findings);
return els;
}
const n = Number(loop);
if (!Number.isInteger(n) || n < 1) {
warn(verb, `loop="${loop}" is not a valid repeat count; content will play once.`, findings);
return els;
}
const out: XmlEl[] = [];
for (let i = 0; i < n; i++) out.push(...els);
return out;
}

function translateVerb(
node: TwimlNode,
findings: Finding[],
Expand All @@ -215,21 +238,20 @@ function translateVerb(
);
}
}
if (node.attrs.loop && node.attrs.loop !== "1")
warn("Say", "loop attribute is not supported by BXML; content will play once.", findings);
// Use inner (SSML preserved as raw markup) rather than flattened text.
return [{ name: "SpeakSentence", attrs, children: [{ raw: node.inner }] }];
const say: XmlEl = { name: "SpeakSentence", attrs, children: [{ raw: node.inner }] };
return applyLoop([say], node.attrs.loop, "Say", findings);
}
case "Play": {
if (node.attrs.loop && node.attrs.loop !== "1")
warn("Play", "loop attribute is not supported by BXML; audio will play once.", findings);
const result: XmlEl[] = [];
// If there's a src URL, emit PlayAudio first
if (node.text) result.push({ name: "PlayAudio", children: [node.text] });
// If there are digits, emit SendDtmf (after any audio)
if (node.attrs.digits) result.push({ name: "SendDtmf", children: [node.attrs.digits] });
// Play with neither src nor digits is a no-op — emit nothing (malformed TwiML)
return result.length > 0 ? result : null;
if (result.length === 0) return null;
// loop="N" repeats the whole element (audio + any DTMF) N times.
return applyLoop(result, node.attrs.loop, "Play", findings);
}
case "Pause":
return [{ name: "Pause", attrs: { duration: node.attrs.length ?? "1" } }];
Expand Down Expand Up @@ -339,6 +361,20 @@ function translateRecord(
return [{ name: "Record", attrs }];
}

// Twilio <Dial> attributes with no BXML <Transfer> equivalent. Each present
// attribute produces an explicit warning so the migration report flags it
// instead of the behavior silently disappearing.
const UNSUPPORTED_DIAL_ATTRS: Record<string, string> = {
timeLimit:
"Dial timeLimit (maximum call duration) has no BXML Transfer equivalent; the transferred leg will not be automatically terminated.",
hangupOnStar:
"Dial hangupOnStar has no BXML equivalent; the caller pressing * will not end the transferred call.",
ringTone:
"Dial ringTone has no BXML equivalent; Bandwidth uses its own default ringback tone.",
answerOnBridge:
"Dial answerOnBridge is not replicated; Bandwidth answers the inbound leg before bridging, so early-media/ringback behavior may differ.",
};

function translateDial(
node: TwimlNode,
findings: Finding[],
Expand Down Expand Up @@ -385,9 +421,15 @@ function translateDial(

warn(
"Dial",
"Deep Dial semantics (answerOnBridge, child-call status propagation) are not replicated in P0; validate call-progress behavior.",
"Child-call status propagation is not fully replicated in P0; validate call-progress behavior.",
findings,
);
// Twilio Dial attributes the adapter cannot map to BXML Transfer. Surfacing
// each one explicitly (rather than dropping it silently) is the product's
// no-silent-degradation contract — the customer learns exactly what won't carry over.
for (const [attr, message] of Object.entries(UNSUPPORTED_DIAL_ATTRS)) {
if (node.attrs[attr] !== undefined) warn("Dial", message, findings);
}
const attrs: Record<string, string | undefined> = {
transferCallerId: node.attrs.callerId,
callTimeout: node.attrs.timeout,
Expand Down
20 changes: 20 additions & 0 deletions test/translate-dial.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,26 @@ describe("Dial", () => {
const r = translateTwiml(`<Response><Dial><Queue>q1</Queue></Dial></Response>`);
expect(r.hasErrors).toBe(true);
});

it("warns on silently-dropped Dial attributes (timeLimit, hangupOnStar, ringTone, answerOnBridge)", () => {
const r = translateTwiml(
`<Response><Dial timeLimit="3600" hangupOnStar="true" ringTone="us" answerOnBridge="true">+15552223333</Dial></Response>`,
);
for (const attr of ["timeLimit", "hangupOnStar", "ringTone", "answerOnBridge"]) {
expect(
r.findings.some(
(f) => f.verb === "Dial" && f.severity === "warning" && f.message.includes(attr),
),
).toBe(true);
}
});

it("does not warn about a Dial attribute that is not present", () => {
const r = translateTwiml(`<Response><Dial>+15552223333</Dial></Response>`);
expect(r.findings.some((f) => f.verb === "Dial" && f.message.includes("hangupOnStar"))).toBe(
false,
);
});
});

describe("Connect/Stream", () => {
Expand Down
50 changes: 50 additions & 0 deletions test/translate-loop.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { describe, it, expect } from "vitest";
import { translateTwiml } from "../src/translator/translate.js";

// Twilio's loop="N" repeats a <Say>/<Play>. BXML has no loop attribute, so the
// adapter expands a finite count into repeated verbs. loop="0" (infinite) and
// invalid counts can't be expressed inline, so they fall back to a single play
// plus a warning (no silent degradation).
describe("Say loop", () => {
it("loop=3 emits the SpeakSentence three times with no warning", () => {
const r = translateTwiml(`<Response><Say loop="3">Hi</Say></Response>`);
expect(r.bxml.match(/<SpeakSentence>Hi<\/SpeakSentence>/g)?.length).toBe(3);
expect(r.findings.some((f) => f.verb === "Say")).toBe(false);
});
it("loop=1 (and absent) emits once", () => {
const r = translateTwiml(`<Response><Say loop="1">Hi</Say></Response>`);
expect(r.bxml.match(/<SpeakSentence>Hi<\/SpeakSentence>/g)?.length).toBe(1);
const r2 = translateTwiml(`<Response><Say>Hi</Say></Response>`);
expect(r2.bxml.match(/<SpeakSentence>Hi<\/SpeakSentence>/g)?.length).toBe(1);
});
it("loop=0 (infinite) emits once and warns", () => {
const r = translateTwiml(`<Response><Say loop="0">Hi</Say></Response>`);
expect(r.bxml.match(/<SpeakSentence>Hi<\/SpeakSentence>/g)?.length).toBe(1);
expect(r.findings.some((f) => f.verb === "Say" && f.severity === "warning")).toBe(true);
});
it("invalid loop emits once and warns", () => {
const r = translateTwiml(`<Response><Say loop="abc">Hi</Say></Response>`);
expect(r.bxml.match(/<SpeakSentence>Hi<\/SpeakSentence>/g)?.length).toBe(1);
expect(r.findings.some((f) => f.verb === "Say" && f.severity === "warning")).toBe(true);
});
});

describe("Play loop", () => {
it("loop=2 emits the PlayAudio twice with no warning", () => {
const r = translateTwiml(`<Response><Play loop="2">https://x.test/a.mp3</Play></Response>`);
expect(r.bxml.match(/<PlayAudio>https:\/\/x\.test\/a\.mp3<\/PlayAudio>/g)?.length).toBe(2);
expect(r.findings.some((f) => f.verb === "Play")).toBe(false);
});
it("loop repeats both PlayAudio and SendDtmf as a unit", () => {
const r = translateTwiml(
`<Response><Play loop="2" digits="123">https://x.test/a.mp3</Play></Response>`,
);
expect(r.bxml.match(/<PlayAudio>/g)?.length).toBe(2);
expect(r.bxml.match(/<SendDtmf>/g)?.length).toBe(2);
});
it("loop=0 (infinite) emits once and warns", () => {
const r = translateTwiml(`<Response><Play loop="0">https://x.test/a.mp3</Play></Response>`);
expect(r.bxml.match(/<PlayAudio>/g)?.length).toBe(1);
expect(r.findings.some((f) => f.verb === "Play" && f.severity === "warning")).toBe(true);
});
});