voice-runtime 0.2.0__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {voice_runtime-0.2.0/voice_runtime.egg-info → voice_runtime-0.3.0}/PKG-INFO +1 -1
- {voice_runtime-0.2.0 → voice_runtime-0.3.0}/pyproject.toml +1 -1
- {voice_runtime-0.2.0 → voice_runtime-0.3.0}/tests/test_twilio_call.py +90 -1
- {voice_runtime-0.2.0 → voice_runtime-0.3.0}/voice_runtime/transports/twilio_call.py +17 -5
- {voice_runtime-0.2.0 → voice_runtime-0.3.0/voice_runtime.egg-info}/PKG-INFO +1 -1
- {voice_runtime-0.2.0 → voice_runtime-0.3.0}/LICENSE +0 -0
- {voice_runtime-0.2.0 → voice_runtime-0.3.0}/README.md +0 -0
- {voice_runtime-0.2.0 → voice_runtime-0.3.0}/setup.cfg +0 -0
- {voice_runtime-0.2.0 → voice_runtime-0.3.0}/tests/test_audio.py +0 -0
- {voice_runtime-0.2.0 → voice_runtime-0.3.0}/tests/test_azure_stt.py +0 -0
- {voice_runtime-0.2.0 → voice_runtime-0.3.0}/tests/test_azure_tts.py +0 -0
- {voice_runtime-0.2.0 → voice_runtime-0.3.0}/tests/test_elevenlabs_tts.py +0 -0
- {voice_runtime-0.2.0 → voice_runtime-0.3.0}/tests/test_factories.py +0 -0
- {voice_runtime-0.2.0 → voice_runtime-0.3.0}/tests/test_nc165_stt_protocol.py +0 -0
- {voice_runtime-0.2.0 → voice_runtime-0.3.0}/tests/test_nc166_on_committed.py +0 -0
- {voice_runtime-0.2.0 → voice_runtime-0.3.0}/tests/test_nc170_resilience.py +0 -0
- {voice_runtime-0.2.0 → voice_runtime-0.3.0}/tests/test_nc193_twilio_sms.py +0 -0
- {voice_runtime-0.2.0 → voice_runtime-0.3.0}/tests/test_nc199_on_recognizing.py +0 -0
- {voice_runtime-0.2.0 → voice_runtime-0.3.0}/tests/test_nc236_unique_mark_names.py +0 -0
- {voice_runtime-0.2.0 → voice_runtime-0.3.0}/tests/test_nc260_gap_a_tts_on_error.py +0 -0
- {voice_runtime-0.2.0 → voice_runtime-0.3.0}/tests/test_nc260_gap_b_ws_disconnect.py +0 -0
- {voice_runtime-0.2.0 → voice_runtime-0.3.0}/tests/test_nc260_gap_c_stt_fatal_errors.py +0 -0
- {voice_runtime-0.2.0 → voice_runtime-0.3.0}/tests/test_nc260_gap_e_stt_ready.py +0 -0
- {voice_runtime-0.2.0 → voice_runtime-0.3.0}/tests/test_nc260_gap_f_loop_warning.py +0 -0
- {voice_runtime-0.2.0 → voice_runtime-0.3.0}/tests/test_nc267_mock_providers.py +0 -0
- {voice_runtime-0.2.0 → voice_runtime-0.3.0}/tests/test_nc271_mock_bridge.py +0 -0
- {voice_runtime-0.2.0 → voice_runtime-0.3.0}/tests/test_nc340_stt_feed_survives_reconnect.py +0 -0
- {voice_runtime-0.2.0 → voice_runtime-0.3.0}/tests/test_session.py +0 -0
- {voice_runtime-0.2.0 → voice_runtime-0.3.0}/tests/test_stt_lifecycle.py +0 -0
- {voice_runtime-0.2.0 → voice_runtime-0.3.0}/tests/test_stt_tee.py +0 -0
- {voice_runtime-0.2.0 → voice_runtime-0.3.0}/tests/test_twilio_ws.py +0 -0
- {voice_runtime-0.2.0 → voice_runtime-0.3.0}/tests/test_vr002_twilio_http_timeout.py +0 -0
- {voice_runtime-0.2.0 → voice_runtime-0.3.0}/tests/test_vr003_rest_first_call_end_31921.py +0 -0
- {voice_runtime-0.2.0 → voice_runtime-0.3.0}/tests/test_vr004_migration_example.py +0 -0
- {voice_runtime-0.2.0 → voice_runtime-0.3.0}/tests/test_vr004_region_hangup.py +0 -0
- {voice_runtime-0.2.0 → voice_runtime-0.3.0}/tests/test_vr005_stt_session_task_ownership.py +0 -0
- {voice_runtime-0.2.0 → voice_runtime-0.3.0}/tests/test_vr006_migration_example.py +0 -0
- {voice_runtime-0.2.0 → voice_runtime-0.3.0}/tests/test_vr006_region_cdr.py +0 -0
- {voice_runtime-0.2.0 → voice_runtime-0.3.0}/tests/test_vr007_media_teardown_regions.py +0 -0
- {voice_runtime-0.2.0 → voice_runtime-0.3.0}/tests/test_vr009_azure_stt_language_id.py +0 -0
- {voice_runtime-0.2.0 → voice_runtime-0.3.0}/voice_runtime/__init__.py +0 -0
- {voice_runtime-0.2.0 → voice_runtime-0.3.0}/voice_runtime/audio.py +0 -0
- {voice_runtime-0.2.0 → voice_runtime-0.3.0}/voice_runtime/mock/__init__.py +0 -0
- {voice_runtime-0.2.0 → voice_runtime-0.3.0}/voice_runtime/mock/stt.py +0 -0
- {voice_runtime-0.2.0 → voice_runtime-0.3.0}/voice_runtime/mock/tts.py +0 -0
- {voice_runtime-0.2.0 → voice_runtime-0.3.0}/voice_runtime/providers/__init__.py +0 -0
- {voice_runtime-0.2.0 → voice_runtime-0.3.0}/voice_runtime/providers/azure_stt.py +0 -0
- {voice_runtime-0.2.0 → voice_runtime-0.3.0}/voice_runtime/providers/azure_tts.py +0 -0
- {voice_runtime-0.2.0 → voice_runtime-0.3.0}/voice_runtime/providers/elevenlabs_stt.py +0 -0
- {voice_runtime-0.2.0 → voice_runtime-0.3.0}/voice_runtime/providers/elevenlabs_tts.py +0 -0
- {voice_runtime-0.2.0 → voice_runtime-0.3.0}/voice_runtime/session.py +0 -0
- {voice_runtime-0.2.0 → voice_runtime-0.3.0}/voice_runtime/stt.py +0 -0
- {voice_runtime-0.2.0 → voice_runtime-0.3.0}/voice_runtime/stt_tee.py +0 -0
- {voice_runtime-0.2.0 → voice_runtime-0.3.0}/voice_runtime/transport.py +0 -0
- {voice_runtime-0.2.0 → voice_runtime-0.3.0}/voice_runtime/transports/__init__.py +0 -0
- {voice_runtime-0.2.0 → voice_runtime-0.3.0}/voice_runtime/transports/_twilio_client.py +0 -0
- {voice_runtime-0.2.0 → voice_runtime-0.3.0}/voice_runtime/transports/_twilio_models.py +0 -0
- {voice_runtime-0.2.0 → voice_runtime-0.3.0}/voice_runtime/transports/mock_bridge.py +0 -0
- {voice_runtime-0.2.0 → voice_runtime-0.3.0}/voice_runtime/transports/twilio_sms.py +0 -0
- {voice_runtime-0.2.0 → voice_runtime-0.3.0}/voice_runtime/transports/twilio_ws.py +0 -0
- {voice_runtime-0.2.0 → voice_runtime-0.3.0}/voice_runtime/tts.py +0 -0
- {voice_runtime-0.2.0 → voice_runtime-0.3.0}/voice_runtime.egg-info/SOURCES.txt +0 -0
- {voice_runtime-0.2.0 → voice_runtime-0.3.0}/voice_runtime.egg-info/dependency_links.txt +0 -0
- {voice_runtime-0.2.0 → voice_runtime-0.3.0}/voice_runtime.egg-info/requires.txt +0 -0
- {voice_runtime-0.2.0 → voice_runtime-0.3.0}/voice_runtime.egg-info/top_level.txt +0 -0
|
@@ -158,6 +158,91 @@ class TestInitiateOutboundCall:
|
|
|
158
158
|
mock_client.calls.create.assert_called_once()
|
|
159
159
|
|
|
160
160
|
|
|
161
|
+
_REGION_ENV = {
|
|
162
|
+
"TWILIO_ACCOUNT_SID": "",
|
|
163
|
+
"TWILIO_AUTH_TOKEN": "",
|
|
164
|
+
"TWILIO_PHONE_NUMBER": "+15551234567",
|
|
165
|
+
"VOICE_STREAM_URL": "https://example.ngrok.io",
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _ie1_region():
|
|
170
|
+
from voice_runtime.transports.twilio_call import TwilioRegion
|
|
171
|
+
|
|
172
|
+
return TwilioRegion(
|
|
173
|
+
api_host="api.dublin.ie1.twilio.com",
|
|
174
|
+
account_sid="AC1",
|
|
175
|
+
auth=("SK1", "secret"),
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class TestInitiateOutboundCallRegion:
|
|
180
|
+
"""An explicit region places the call with its own identity, no Auth Token."""
|
|
181
|
+
|
|
182
|
+
def test_region_uses_explicit_client_without_auth_token(self):
|
|
183
|
+
from voice_runtime.transports.twilio_call import initiate_outbound_call
|
|
184
|
+
|
|
185
|
+
mock_client = MagicMock()
|
|
186
|
+
mock_client.calls.create.return_value = MagicMock(sid="CA9")
|
|
187
|
+
region = _ie1_region()
|
|
188
|
+
|
|
189
|
+
with (
|
|
190
|
+
patch.dict("os.environ", _REGION_ENV, clear=False),
|
|
191
|
+
patch(
|
|
192
|
+
"voice_runtime.transports.twilio_call.build_explicit_twilio_client",
|
|
193
|
+
return_value=mock_client,
|
|
194
|
+
) as build_explicit,
|
|
195
|
+
patch(
|
|
196
|
+
"voice_runtime.transports.twilio_call.build_twilio_client"
|
|
197
|
+
) as build_ambient,
|
|
198
|
+
):
|
|
199
|
+
sid = initiate_outbound_call("+358401234567", region=region)
|
|
200
|
+
|
|
201
|
+
assert sid == "CA9"
|
|
202
|
+
build_explicit.assert_called_once_with(region)
|
|
203
|
+
build_ambient.assert_not_called()
|
|
204
|
+
_, kwargs = mock_client.calls.create.call_args
|
|
205
|
+
assert kwargs["to"] == "+358401234567"
|
|
206
|
+
assert kwargs["from_"] == "+15551234567"
|
|
207
|
+
assert 'url="wss://example.ngrok.io/voice"' in kwargs["twiml"]
|
|
208
|
+
|
|
209
|
+
def test_region_still_requires_stream_url(self):
|
|
210
|
+
from voice_runtime.transports.twilio_call import initiate_outbound_call
|
|
211
|
+
|
|
212
|
+
env = {**_REGION_ENV, "VOICE_STREAM_URL": ""}
|
|
213
|
+
with (
|
|
214
|
+
patch.dict("os.environ", env, clear=False),
|
|
215
|
+
pytest.raises(RuntimeError, match="VOICE_STREAM_URL"),
|
|
216
|
+
):
|
|
217
|
+
initiate_outbound_call("+358401234567", region=_ie1_region())
|
|
218
|
+
|
|
219
|
+
def test_region_still_requires_phone_number(self):
|
|
220
|
+
from voice_runtime.transports.twilio_call import initiate_outbound_call
|
|
221
|
+
|
|
222
|
+
env = {**_REGION_ENV, "TWILIO_PHONE_NUMBER": ""}
|
|
223
|
+
with (
|
|
224
|
+
patch.dict("os.environ", env, clear=False),
|
|
225
|
+
pytest.raises(RuntimeError, match="TWILIO_PHONE_NUMBER"),
|
|
226
|
+
):
|
|
227
|
+
initiate_outbound_call("+358401234567", region=_ie1_region())
|
|
228
|
+
|
|
229
|
+
def test_region_is_revalidated(self):
|
|
230
|
+
from voice_runtime.transports.twilio_call import initiate_outbound_call
|
|
231
|
+
|
|
232
|
+
with (
|
|
233
|
+
patch.dict("os.environ", _REGION_ENV, clear=False),
|
|
234
|
+
pytest.raises(ValueError, match="Invalid Twilio API host"),
|
|
235
|
+
):
|
|
236
|
+
initiate_outbound_call(
|
|
237
|
+
"+358401234567",
|
|
238
|
+
region={
|
|
239
|
+
"api_host": "evil.example.com",
|
|
240
|
+
"account_sid": "AC1",
|
|
241
|
+
"auth": ("a", "b"),
|
|
242
|
+
},
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
|
|
161
246
|
class TestHangupCall:
|
|
162
247
|
"""ninchat_voice NC-362: supervisor-side reap teardown rides this seam."""
|
|
163
248
|
|
|
@@ -184,7 +269,11 @@ class TestHangupCall:
|
|
|
184
269
|
from voice_runtime.transports.twilio_call import hangup_call
|
|
185
270
|
|
|
186
271
|
with (
|
|
187
|
-
patch.dict(
|
|
272
|
+
patch.dict(
|
|
273
|
+
"os.environ",
|
|
274
|
+
{"TWILIO_ACCOUNT_SID": "", "TWILIO_AUTH_TOKEN": ""},
|
|
275
|
+
clear=False,
|
|
276
|
+
),
|
|
188
277
|
pytest.raises(RuntimeError, match="TWILIO_ACCOUNT_SID"),
|
|
189
278
|
):
|
|
190
279
|
hangup_call("CA-x")
|
|
@@ -84,17 +84,23 @@ def build_route_stream_xml(stream_url: str, route_token: str) -> str:
|
|
|
84
84
|
</Response>""".strip()
|
|
85
85
|
|
|
86
86
|
|
|
87
|
-
def initiate_outbound_call(phone: str) -> str:
|
|
87
|
+
def initiate_outbound_call(phone: str, *, region: TwilioRegion | None = None) -> str:
|
|
88
88
|
"""Initiate an outbound Twilio call with Media Streams.
|
|
89
89
|
|
|
90
90
|
Args:
|
|
91
91
|
phone: Phone number to call (E.164 format).
|
|
92
|
+
region: Explicit routing and identity (e.g. an IE1 API key pair).
|
|
93
|
+
When given, the call is placed through that region and
|
|
94
|
+
TWILIO_ACCOUNT_SID/TWILIO_AUTH_TOKEN are not read. Without it,
|
|
95
|
+
the ambient Account Auth Token is used.
|
|
92
96
|
|
|
93
97
|
Returns:
|
|
94
98
|
call_sid from Twilio.
|
|
95
99
|
|
|
96
100
|
Raises:
|
|
97
|
-
RuntimeError: If VOICE_STREAM_URL or
|
|
101
|
+
RuntimeError: If VOICE_STREAM_URL, TWILIO_PHONE_NUMBER or, without
|
|
102
|
+
a region, the Twilio credentials are missing.
|
|
103
|
+
ValueError: If region is not a valid TwilioRegion.
|
|
98
104
|
"""
|
|
99
105
|
account_sid, auth_token, phone_number, stream_url = _get_twilio_env()
|
|
100
106
|
|
|
@@ -102,15 +108,21 @@ def initiate_outbound_call(phone: str) -> str:
|
|
|
102
108
|
raise RuntimeError(
|
|
103
109
|
"Set VOICE_STREAM_URL to a public WebSocket URL (use ngrok for local dev)"
|
|
104
110
|
)
|
|
105
|
-
if
|
|
106
|
-
|
|
111
|
+
if region is None:
|
|
112
|
+
if not account_sid or not auth_token:
|
|
113
|
+
raise RuntimeError("TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN required")
|
|
114
|
+
else:
|
|
115
|
+
region = TwilioRegion.model_validate(region)
|
|
107
116
|
if not phone_number:
|
|
108
117
|
raise RuntimeError("TWILIO_PHONE_NUMBER required")
|
|
109
118
|
|
|
110
119
|
twiml = build_stream_twiml(stream_url)
|
|
111
120
|
logger.info("Initiating outbound call to %s", phone)
|
|
112
121
|
|
|
113
|
-
|
|
122
|
+
if region is None:
|
|
123
|
+
client = build_twilio_client(account_sid, auth_token)
|
|
124
|
+
else:
|
|
125
|
+
client = build_explicit_twilio_client(region)
|
|
114
126
|
call = client.calls.create(
|
|
115
127
|
to=phone,
|
|
116
128
|
from_=phone_number,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|