From 0543c1ee0fe071954df1929faebf6c9a46759281 Mon Sep 17 00:00:00 2001 From: Nicolas Asanov Date: Thu, 5 Nov 2026 11:59:12 -0500 Subject: [PATCH 1/2] fix(VAPI-4457): scope SSML un-escape to SpeakSentence text --- models/bxml/Root.ts | 10 ++++++++- models/bxml/verbs/Gather.ts | 4 ++-- models/bxml/verbs/SpeakSentence.ts | 4 ++-- tests/unit/models/bxml/Response.test.ts | 29 +++++++++++++++++++++++++ 4 files changed, 42 insertions(+), 5 deletions(-) diff --git a/models/bxml/Root.ts b/models/bxml/Root.ts index 1f35ce0..5611758 100644 --- a/models/bxml/Root.ts +++ b/models/bxml/Root.ts @@ -3,6 +3,14 @@ import { XMLBuilder, XMLWriterOptions } from 'xmlbuilder2/lib/interfaces'; import { Verb } from './Verb'; export const SSML_REGEX = /<([a-zA-Z\/\/].*?)>/g; +const SPEAK_SENTENCE_REGEX = /(]*(?)([\s\S]*?)(<\/SpeakSentence>)/g; + +/** + * Restore SSML tags inside SpeakSentence text content only. + * Attribute values and all other elements keep their XML escaping. + */ +export const unescapeSsml = (bxml: string): string => + bxml.replace(SPEAK_SENTENCE_REGEX, (_m, open, text, close) => open + text.replace(SSML_REGEX, '<$1>') + close); /** * @export @@ -54,6 +62,6 @@ export class Root { * @param options XML Serialization options */ toBxml(options?: XMLWriterOptions): string { - return this.generateXml().end(options).replace(SSML_REGEX, '<$1>'); + return unescapeSsml(this.generateXml().end(options)); } } diff --git a/models/bxml/verbs/Gather.ts b/models/bxml/verbs/Gather.ts index b3e202e..7d07c52 100644 --- a/models/bxml/verbs/Gather.ts +++ b/models/bxml/verbs/Gather.ts @@ -1,7 +1,7 @@ import { NestableVerb } from '../NestableVerb'; import { PlayAudio } from './PlayAudio'; import { SpeakSentence } from './SpeakSentence'; -import { SSML_REGEX } from '../Root'; +import { unescapeSsml } from '../Root'; import { XMLWriterOptions } from 'xmlbuilder2/lib/interfaces'; type AudioVerbs = Array; @@ -54,7 +54,7 @@ export class Gather extends NestableVerb { * @param options XML Serialization options */ toBxml(options?: XMLWriterOptions): string { - return this.generateXml().toString(options).replace(SSML_REGEX, '<$1>'); + return unescapeSsml(this.generateXml().toString(options)); } /** diff --git a/models/bxml/verbs/SpeakSentence.ts b/models/bxml/verbs/SpeakSentence.ts index a72442f..990ee1b 100644 --- a/models/bxml/verbs/SpeakSentence.ts +++ b/models/bxml/verbs/SpeakSentence.ts @@ -1,6 +1,6 @@ import { Verb } from '../Verb'; import { XMLWriterOptions } from 'xmlbuilder2/lib/interfaces'; -import { SSML_REGEX } from '../Root'; +import { unescapeSsml } from '../Root'; export interface SpeakSentenceAttributes { voice?: string; @@ -32,6 +32,6 @@ export class SpeakSentence extends Verb { * @param options XML Serialization options */ toBxml(options?: XMLWriterOptions): string { - return this.generateXml().toString(options).replace(SSML_REGEX, '<$1>'); + return unescapeSsml(this.generateXml().toString(options)); } } diff --git a/tests/unit/models/bxml/Response.test.ts b/tests/unit/models/bxml/Response.test.ts index e70de26..65a7b90 100644 --- a/tests/unit/models/bxml/Response.test.ts +++ b/tests/unit/models/bxml/Response.test.ts @@ -1,6 +1,9 @@ import { Response } from '../../../../models/bxml/Response'; import { PauseRecording } from '../../../../models/bxml/verbs/PauseRecording'; import { Root } from '../../../../models/bxml/Root'; +import { Redirect } from '../../../../models/bxml/verbs/Redirect'; +import { SpeakSentence } from '../../../../models/bxml/verbs/SpeakSentence'; +import { Gather } from '../../../../models/bxml/verbs/Gather'; describe('Response', () => { const pauseRecording = new PauseRecording(); @@ -43,4 +46,30 @@ describe('Response', () => { expect(response.toBxml()).toBe(expected); }); + describe('SSML un-escaping', () => { + test('should keep attribute values escaped in a Response', () => { + const redirect = new Redirect({ redirectUrl: 'https://example.com/?info=' }); + const bxml = new Response(redirect).toBxml(); + + expect(bxml).toContain('info=<https://cert.example.com/cert.pem>'); + expect(bxml).not.toContain('info= { + const redirect = new Redirect({ redirectUrl: 'https://example.com/?info=' }); + const speak = new SpeakSentence('hi there'); + const bxml = new Response([redirect, speak]).toBxml(); + + expect(bxml).toContain('info=<https://cert.example.com/cert.pem>'); + expect(bxml).toContain('hi there'); + }); + + test('should un-escape SSML in a Gather SpeakSentence but not in Gather attributes', () => { + const gather = new Gather({ gatherUrl: 'https://example.com/?a=' }, [new SpeakSentence('say this')]); + const bxml = gather.toBxml(); + + expect(bxml).toContain('gatherUrl="https://example.com/?a=<b>"'); + expect(bxml).toContain('say this'); + }); + }); }); From 76ca749215b9d39082bde4ce8c3792ce4690bb9f Mon Sep 17 00:00:00 2001 From: Nicolas Asanov Date: Thu, 5 Nov 2026 15:35:12 -0500 Subject: [PATCH 2/2] test(VAPI-4457): move Gather SSML test to Gather.test.ts --- tests/unit/models/bxml/Response.test.ts | 9 --------- tests/unit/models/bxml/verbs/Gather.test.ts | 7 +++++++ 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/tests/unit/models/bxml/Response.test.ts b/tests/unit/models/bxml/Response.test.ts index 65a7b90..968216e 100644 --- a/tests/unit/models/bxml/Response.test.ts +++ b/tests/unit/models/bxml/Response.test.ts @@ -3,7 +3,6 @@ import { PauseRecording } from '../../../../models/bxml/verbs/PauseRecording'; import { Root } from '../../../../models/bxml/Root'; import { Redirect } from '../../../../models/bxml/verbs/Redirect'; import { SpeakSentence } from '../../../../models/bxml/verbs/SpeakSentence'; -import { Gather } from '../../../../models/bxml/verbs/Gather'; describe('Response', () => { const pauseRecording = new PauseRecording(); @@ -63,13 +62,5 @@ describe('Response', () => { expect(bxml).toContain('info=<https://cert.example.com/cert.pem>'); expect(bxml).toContain('hi there'); }); - - test('should un-escape SSML in a Gather SpeakSentence but not in Gather attributes', () => { - const gather = new Gather({ gatherUrl: 'https://example.com/?a=' }, [new SpeakSentence('say this')]); - const bxml = gather.toBxml(); - - expect(bxml).toContain('gatherUrl="https://example.com/?a=<b>"'); - expect(bxml).toContain('say this'); - }); }); }); diff --git a/tests/unit/models/bxml/verbs/Gather.test.ts b/tests/unit/models/bxml/verbs/Gather.test.ts index 32b8dd2..039f754 100644 --- a/tests/unit/models/bxml/verbs/Gather.test.ts +++ b/tests/unit/models/bxml/verbs/Gather.test.ts @@ -65,4 +65,11 @@ describe('Gather', () => { gather.addAudioVerbs(playAudio); expect(gather.toBxml()).toBe(expected); }); + + test('should un-escape SSML in a Gather SpeakSentence but not in Gather attributes', () => { + const gather = new Gather({ gatherUrl: 'https://example.com/?a=' }, [new SpeakSentence('say this')]); + const expected = 'say this'; + + expect(gather.toBxml()).toBe(expected); + }); });