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()