diff --git a/src/matrix/twilio-voice.json b/src/matrix/twilio-voice.json index cf3aee4..113ff05 100644 --- a/src/matrix/twilio-voice.json +++ b/src/matrix/twilio-voice.json @@ -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": { @@ -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)." } } }, diff --git a/src/translator/translate.ts b/src/translator/translate.ts index 0b0b445..920ba14 100644 --- a/src/translator/translate.ts +++ b/src/translator/translate.ts @@ -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 /. 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[], @@ -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" } }]; @@ -339,6 +361,20 @@ function translateRecord( return [{ name: "Record", attrs }]; } +// Twilio attributes with no BXML 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 = { + 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[], @@ -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 = { transferCallerId: node.attrs.callerId, callTimeout: node.attrs.timeout, diff --git a/test/translate-dial.test.ts b/test/translate-dial.test.ts index 44efc62..622cecb 100644 --- a/test/translate-dial.test.ts +++ b/test/translate-dial.test.ts @@ -30,6 +30,26 @@ describe("Dial", () => { const r = translateTwiml(`q1`); expect(r.hasErrors).toBe(true); }); + + it("warns on silently-dropped Dial attributes (timeLimit, hangupOnStar, ringTone, answerOnBridge)", () => { + const r = translateTwiml( + `+15552223333`, + ); + 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(`+15552223333`); + expect(r.findings.some((f) => f.verb === "Dial" && f.message.includes("hangupOnStar"))).toBe( + false, + ); + }); }); describe("Connect/Stream", () => { diff --git a/test/translate-loop.test.ts b/test/translate-loop.test.ts new file mode 100644 index 0000000..eea23d1 --- /dev/null +++ b/test/translate-loop.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from "vitest"; +import { translateTwiml } from "../src/translator/translate.js"; + +// Twilio's loop="N" repeats a /. 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(`Hi`); + expect(r.bxml.match(/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(`Hi`); + expect(r.bxml.match(/Hi<\/SpeakSentence>/g)?.length).toBe(1); + const r2 = translateTwiml(`Hi`); + expect(r2.bxml.match(/Hi<\/SpeakSentence>/g)?.length).toBe(1); + }); + it("loop=0 (infinite) emits once and warns", () => { + const r = translateTwiml(`Hi`); + expect(r.bxml.match(/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(`Hi`); + expect(r.bxml.match(/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(`https://x.test/a.mp3`); + expect(r.bxml.match(/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( + `https://x.test/a.mp3`, + ); + expect(r.bxml.match(//g)?.length).toBe(2); + expect(r.bxml.match(//g)?.length).toBe(2); + }); + it("loop=0 (infinite) emits once and warns", () => { + const r = translateTwiml(`https://x.test/a.mp3`); + expect(r.bxml.match(//g)?.length).toBe(1); + expect(r.findings.some((f) => f.verb === "Play" && f.severity === "warning")).toBe(true); + }); +});