diff --git a/bandwidth/models/bxml/nestable_verb.py b/bandwidth/models/bxml/nestable_verb.py index 6dd0aa03..12a18266 100644 --- a/bandwidth/models/bxml/nestable_verb.py +++ b/bandwidth/models/bxml/nestable_verb.py @@ -6,7 +6,6 @@ @copyright Bandwidth INC """ from __future__ import annotations -import re from typing import Union import xml.etree.ElementTree as ET @@ -16,7 +15,6 @@ class NestableVerb(Verb): """Base class for nestable BXML verbs """ - ssml_regex = r"<([a-zA-Z//].*?)>" def __init__(self, tag: str, content: str = None, nested_verbs: list[Verb] = None): """Initialize the verb model diff --git a/bandwidth/models/bxml/root.py b/bandwidth/models/bxml/root.py index a0a23cde..57087e81 100644 --- a/bandwidth/models/bxml/root.py +++ b/bandwidth/models/bxml/root.py @@ -5,17 +5,15 @@ @copyright Bandwidth INC """ -import re from typing import List import xml.etree.ElementTree as ET -from bandwidth.models.bxml.verb import Verb +from bandwidth.models.bxml.verb import Verb, _unescape_ssml class Root: """Base class for BXML roots """ - ssml_regex = r"<([a-zA-Z//].*?)>" def __init__(self, tag: str, nested_verbs: List[Verb] = None): """Initialize instance of class @@ -76,4 +74,4 @@ def to_bxml(self) -> str: str: Serialized BXML string """ xml_document = self._generate_xml() - return re.sub(self.ssml_regex, r"<\1>", ET.tostring(xml_document._root, encoding='UTF-8', method='xml', xml_declaration=True).decode("utf8")) + return _unescape_ssml(ET.tostring(xml_document._root, encoding='UTF-8', method='xml', xml_declaration=True).decode("utf8")) diff --git a/bandwidth/models/bxml/verb.py b/bandwidth/models/bxml/verb.py index e100bdfe..272e2de0 100644 --- a/bandwidth/models/bxml/verb.py +++ b/bandwidth/models/bxml/verb.py @@ -10,6 +10,19 @@ from typing import Union import xml.etree.ElementTree as ET +_SPEAK_SENTENCE_REGEX = re.compile(r"(]*(?)(.*?)()", re.DOTALL) +_SSML_REGEX = re.compile(r"<([a-zA-Z//].*?)>") + + +def _unescape_ssml(bxml: str) -> str: + """Restore SSML tags inside SpeakSentence text content only. + + Attribute values and all other elements keep their XML escaping. + """ + return _SPEAK_SENTENCE_REGEX.sub( + lambda m: m.group(1) + _SSML_REGEX.sub(r"<\1>", m.group(2)) + m.group(3), bxml + ) + class Verb: """Base class for BXML verbs @@ -93,8 +106,5 @@ def to_bxml(self) -> str: Returns: str: Serialized BXML string """ - ssml_regex = r"<([a-zA-Z//].*?)>" - xml_document = self._generate_xml() - # if the root name is speakSentence, replace content with the SSML regex - return re.sub(ssml_regex, r"<\1>", ET.tostring(xml_document._root).decode('utf8')) + return _unescape_ssml(ET.tostring(xml_document._root).decode('utf8')) diff --git a/bandwidth/models/bxml/verbs/speak_sentence.py b/bandwidth/models/bxml/verbs/speak_sentence.py index 2f02f975..66f54d8f 100644 --- a/bandwidth/models/bxml/verbs/speak_sentence.py +++ b/bandwidth/models/bxml/verbs/speak_sentence.py @@ -5,10 +5,6 @@ @copyright Bandwidth INC """ -import re - -import xml.etree.ElementTree as ET - from ..nestable_verb import NestableVerb @@ -44,7 +40,3 @@ def _attributes(self): "gender": self.gender, "locale": self.locale, } - - def to_bxml(self) -> str: - xml_document = self._generate_xml() - return re.sub(self.ssml_regex, r"<\1>", ET.tostring(xml_document._root).decode('utf8')) diff --git a/test/unit/models/bxml/test_gather.py b/test/unit/models/bxml/test_gather.py index 41c37cdc..cfbe11ae 100644 --- a/test/unit/models/bxml/test_gather.py +++ b/test/unit/models/bxml/test_gather.py @@ -69,3 +69,11 @@ def test_add_verb(self): expected = 'https://audio.url/audio1.wavHello. Your number is asdf, lets play a game. What is 10 + 3. Press the pound key when finished.' self.gather.add_verb(self.speak_sentence) assert expected == self.gather.to_bxml() + + def test_ssml_unescaped_in_speak_sentence_but_not_in_gather_attributes(self): + expected = 'say this' + gather = Gather( + gather_url="https://example.com/?a=", + audio_verbs=[SpeakSentence(text="say this")] + ) + assert expected == gather.to_bxml() diff --git a/test/unit/models/bxml/test_response.py b/test/unit/models/bxml/test_response.py index 5d8a1320..b8cf180e 100644 --- a/test/unit/models/bxml/test_response.py +++ b/test/unit/models/bxml/test_response.py @@ -7,7 +7,7 @@ """ import unittest -from bandwidth.models.bxml import Response +from bandwidth.models.bxml import Response, Redirect, SpeakSentence class TestResponse(unittest.TestCase): @@ -23,3 +23,25 @@ def test_response_init(self): """ expected_bxml = "\n" assert self.response.to_bxml() == expected_bxml + + def test_ssml_unescaped_but_other_attributes_untouched(self): + """SSML in text is un-escaped; escaped characters in other elements' attributes are not + """ + expected_bxml = "\nHello. Your number is asdf, lets play a game." + self.response.add_verb(Redirect(redirect_url="https://example.com/a?info=")) + self.response.add_verb(SpeakSentence( + text='Hello. Your number is asdf, lets play a game.', + voice="julie", + gender="female", + locale="en_US" + )) + assert expected_bxml == self.response.to_bxml() + + def test_empty_speak_sentence_does_not_swallow_following_verbs(self): + """A with no text must not capture the verbs that follow it + """ + expected_bxml = "\nhi " + self.response.add_verb(SpeakSentence(text="")) + self.response.add_verb(Redirect(redirect_url="https://example.com/a?info=")) + self.response.add_verb(SpeakSentence(text="hi ")) + assert expected_bxml == self.response.to_bxml()