feat: add ultravox realtime and fix signature issue in telephony

- Add UltraVox realtime
- Fix signature issue on telephony
This commit is contained in:
Abhishek Kumar 2026-05-23 12:34:54 +05:30
parent 9135c2da13
commit ea0cac63cd
24 changed files with 2082 additions and 133 deletions

View file

@ -0,0 +1,185 @@
import base64
import hashlib
import hmac
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from urllib.parse import urlencode
import pytest
from starlette.requests import Request
from api.services.telephony.providers.plivo.provider import PlivoProvider
from api.services.telephony.providers.plivo.routes import (
handle_plivo_hangup_callback,
handle_plivo_xml_webhook,
)
def _provider() -> PlivoProvider:
return PlivoProvider(
{
"auth_id": "MA123",
"auth_token": "plivo-auth-token",
"from_numbers": ["+15551230002"],
}
)
def _request(
*,
path: str,
query: dict[str, str | int],
form_data: dict[str, str],
headers: dict[str, str] | None = None,
) -> Request:
body = urlencode(form_data).encode("utf-8")
query_string = urlencode(query).encode("utf-8")
request_headers = [
(b"content-type", b"application/x-www-form-urlencoded"),
*[
(name.lower().encode("ascii"), value.encode("ascii"))
for name, value in (headers or {}).items()
],
]
async def receive():
return {
"type": "http.request",
"body": body,
"more_body": False,
}
return Request(
{
"type": "http",
"method": "POST",
"scheme": "https",
"server": ("example.test", 443),
"path": path,
"query_string": query_string,
"headers": request_headers,
},
receive,
)
def _signature(
provider: PlivoProvider,
*,
path: str,
query: dict[str, str | int],
form_data: dict[str, str],
nonce: str,
) -> str:
url = f"https://example.test{path}"
if query:
url = f"{url}?{urlencode(query)}"
payload = f"{provider._construct_post_url(url, form_data)}.{nonce}"
return base64.b64encode(
hmac.new(
provider.auth_token.encode("utf-8"),
payload.encode("utf-8"),
hashlib.sha256,
).digest()
).decode("utf-8")
@pytest.mark.asyncio
async def test_plivo_xml_route_accepts_valid_signature_with_extra_query_param():
provider = _provider()
query = {
"workflow_id": 7,
"user_id": 8,
"workflow_run_id": 123,
"campaign_id": 42,
"organization_id": 11,
}
form_data = {
"CallUUID": "call-123",
"Direction": "outbound",
"From": "15551230001",
"To": "15551230002",
}
nonce = "nonce-123"
request = _request(
path="/api/v1/telephony/plivo-xml",
query=query,
form_data=form_data,
headers={
"x-plivo-signature-v3": _signature(
provider,
path="/api/v1/telephony/plivo-xml",
query=query,
form_data=form_data,
nonce=nonce,
),
"x-plivo-signature-v3-nonce": nonce,
},
)
with (
patch("api.services.telephony.providers.plivo.routes.db_client") as db_client,
patch(
"api.services.telephony.providers.plivo.routes.get_telephony_provider_for_run",
new_callable=AsyncMock,
return_value=provider,
),
patch.object(
provider,
"get_webhook_response",
new_callable=AsyncMock,
return_value="<Response/>",
) as get_webhook_response,
):
db_client.get_workflow_run_by_id = AsyncMock(
return_value=SimpleNamespace(gathered_context={}, workflow_id=7)
)
db_client.update_workflow_run = AsyncMock()
response = await handle_plivo_xml_webhook(
workflow_id=7,
user_id=8,
workflow_run_id=123,
organization_id=11,
request=request,
)
assert response.body == b"<Response/>"
get_webhook_response.assert_awaited_once_with(7, 8, 123)
db_client.update_workflow_run.assert_awaited_once()
@pytest.mark.asyncio
async def test_plivo_status_callback_rejects_missing_signature():
provider = _provider()
request = _request(
path="/api/v1/telephony/plivo/hangup-callback/123",
query={},
form_data={"CallUUID": "call-123", "Event": "hangup"},
)
with (
patch("api.services.telephony.providers.plivo.routes.db_client") as db_client,
patch(
"api.services.telephony.providers.plivo.routes.get_telephony_provider_for_run",
new_callable=AsyncMock,
return_value=provider,
),
patch(
"api.services.telephony.providers.plivo.routes._process_status_update",
new_callable=AsyncMock,
) as process_status,
):
db_client.get_workflow_run_by_id = AsyncMock(
return_value=SimpleNamespace(workflow_id=7)
)
db_client.get_workflow_by_id = AsyncMock(
return_value=SimpleNamespace(organization_id=11)
)
result = await handle_plivo_hangup_callback(
workflow_run_id=123, request=request
)
assert result == {"status": "error", "reason": "invalid_signature"}
process_status.assert_not_awaited()

View file

@ -0,0 +1,253 @@
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from urllib.parse import urlencode
import pytest
from fastapi import HTTPException
from starlette.requests import Request
from twilio.request_validator import RequestValidator
from api.services.telephony.providers.twilio.provider import TwilioProvider
from api.services.telephony.providers.twilio.routes import (
handle_twilio_status_callback,
handle_twiml_webhook,
)
def _provider() -> TwilioProvider:
return TwilioProvider(
{
"account_sid": "AC123",
"auth_token": "twilio-auth-token",
"from_numbers": ["+15551230002"],
}
)
def _request(
*,
path: str,
query: dict[str, str | int],
form_data: dict[str, str],
headers: dict[str, str] | None = None,
) -> Request:
body = urlencode(form_data).encode("utf-8")
query_string = urlencode(query).encode("utf-8")
request_headers = [
(b"content-type", b"application/x-www-form-urlencoded"),
*[
(name.lower().encode("ascii"), value.encode("ascii"))
for name, value in (headers or {}).items()
],
]
async def receive():
return {
"type": "http.request",
"body": body,
"more_body": False,
}
return Request(
{
"type": "http",
"method": "POST",
"scheme": "https",
"server": ("example.test", 443),
"path": path,
"query_string": query_string,
"headers": request_headers,
},
receive,
)
def _signature(
provider: TwilioProvider,
*,
path: str,
query: dict[str, str | int],
form_data: dict[str, str],
) -> str:
url = f"https://example.test{path}"
if query:
url = f"{url}?{urlencode(query)}"
validator = RequestValidator(provider.auth_token)
return validator.compute_signature(url, form_data)
@pytest.mark.asyncio
async def test_twiml_route_accepts_valid_signature_with_extra_query_param():
provider = _provider()
query = {
"workflow_id": 7,
"user_id": 8,
"workflow_run_id": 123,
"campaign_id": 42,
"organization_id": 11,
}
form_data = {"CallSid": "CA123", "CallStatus": "in-progress"}
request = _request(
path="/api/v1/telephony/twiml",
query=query,
form_data=form_data,
headers={
"x-twilio-signature": _signature(
provider,
path="/api/v1/telephony/twiml",
query=query,
form_data=form_data,
)
},
)
with (
patch("api.services.telephony.providers.twilio.routes.db_client") as db_client,
patch(
"api.services.telephony.providers.twilio.routes.get_telephony_provider_for_run",
new_callable=AsyncMock,
return_value=provider,
),
patch.object(
provider,
"get_webhook_response",
new_callable=AsyncMock,
return_value="<Response/>",
) as get_webhook_response,
):
db_client.get_workflow_run_by_id = AsyncMock(
return_value=SimpleNamespace(id=123)
)
response = await handle_twiml_webhook(
workflow_id=7,
user_id=8,
workflow_run_id=123,
organization_id=11,
request=request,
)
assert response.body == b"<Response/>"
get_webhook_response.assert_awaited_once_with(7, 8, 123)
@pytest.mark.asyncio
async def test_twiml_route_rejects_missing_signature():
provider = _provider()
request = _request(
path="/api/v1/telephony/twiml",
query={
"workflow_id": 7,
"user_id": 8,
"workflow_run_id": 123,
"organization_id": 11,
},
form_data={"CallSid": "CA123"},
)
with (
patch("api.services.telephony.providers.twilio.routes.db_client") as db_client,
patch(
"api.services.telephony.providers.twilio.routes.get_telephony_provider_for_run",
new_callable=AsyncMock,
return_value=provider,
),
):
db_client.get_workflow_run_by_id = AsyncMock(
return_value=SimpleNamespace(id=123)
)
with pytest.raises(HTTPException) as exc_info:
await handle_twiml_webhook(
workflow_id=7,
user_id=8,
workflow_run_id=123,
organization_id=11,
request=request,
)
assert exc_info.value.status_code == 401
assert exc_info.value.detail == "Invalid webhook signature"
@pytest.mark.asyncio
async def test_twilio_status_callback_rejects_legacy_header_name():
provider = _provider()
form_data = {"CallSid": "CA123", "CallStatus": "completed"}
request = _request(
path="/api/v1/telephony/twilio/status-callback/123",
query={},
form_data=form_data,
headers={"x-webhook-signature": "not-a-twilio-signature"},
)
with (
patch("api.services.telephony.providers.twilio.routes.db_client") as db_client,
patch(
"api.services.telephony.providers.twilio.routes.get_telephony_provider_for_run",
new_callable=AsyncMock,
return_value=provider,
),
patch(
"api.services.telephony.providers.twilio.routes._process_status_update",
new_callable=AsyncMock,
) as process_status,
):
db_client.get_workflow_run_by_id = AsyncMock(
return_value=SimpleNamespace(workflow_id=7)
)
db_client.get_workflow_by_id = AsyncMock(
return_value=SimpleNamespace(organization_id=11)
)
with pytest.raises(HTTPException) as exc_info:
await handle_twilio_status_callback(workflow_run_id=123, request=request)
assert exc_info.value.status_code == 401
assert exc_info.value.detail == "Invalid webhook signature"
process_status.assert_not_awaited()
@pytest.mark.asyncio
async def test_twilio_status_callback_accepts_valid_signature():
provider = _provider()
form_data = {"CallSid": "CA123", "CallStatus": "completed"}
request = _request(
path="/api/v1/telephony/twilio/status-callback/123",
query={},
form_data=form_data,
headers={
"x-twilio-signature": _signature(
provider,
path="/api/v1/telephony/twilio/status-callback/123",
query={},
form_data=form_data,
)
},
)
with (
patch("api.services.telephony.providers.twilio.routes.db_client") as db_client,
patch(
"api.services.telephony.providers.twilio.routes.get_telephony_provider_for_run",
new_callable=AsyncMock,
return_value=provider,
),
patch(
"api.services.telephony.providers.twilio.routes._process_status_update",
new_callable=AsyncMock,
) as process_status,
):
db_client.get_workflow_run_by_id = AsyncMock(
return_value=SimpleNamespace(workflow_id=7)
)
db_client.get_workflow_by_id = AsyncMock(
return_value=SimpleNamespace(organization_id=11)
)
result = await handle_twilio_status_callback(
workflow_run_id=123, request=request
)
assert result == {"status": "success"}
process_status.assert_awaited_once()

View file

@ -0,0 +1,178 @@
import hashlib
import hmac
from datetime import UTC, datetime
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from urllib.parse import urlencode
import pytest
from starlette.requests import Request
from api.services.telephony.providers.vobiz.provider import VobizProvider
from api.services.telephony.providers.vobiz.routes import (
handle_vobiz_hangup_callback,
handle_vobiz_ring_callback,
)
def _provider() -> VobizProvider:
return VobizProvider(
{
"auth_id": "MA123",
"auth_token": "vobiz-auth-token",
"from_numbers": ["+15551230002"],
}
)
def _request(
*,
path: str,
form_data: dict[str, str],
headers: dict[str, str] | None = None,
) -> Request:
body = urlencode(form_data).encode("utf-8")
request_headers = [
(b"content-type", b"application/x-www-form-urlencoded"),
*[
(name.lower().encode("ascii"), value.encode("ascii"))
for name, value in (headers or {}).items()
],
]
async def receive():
return {
"type": "http.request",
"body": body,
"more_body": False,
}
return Request(
{
"type": "http",
"method": "POST",
"scheme": "https",
"server": ("example.test", 443),
"path": path,
"query_string": b"",
"headers": request_headers,
},
receive,
)
def _signed_headers(
provider: VobizProvider, *, form_data: dict[str, str]
) -> dict[str, str]:
timestamp = str(int(datetime.now(UTC).timestamp()))
body = urlencode(form_data)
signature = hmac.new(
provider.auth_token.encode("utf-8"),
f"{timestamp}.{body}".encode("utf-8"),
hashlib.sha256,
).hexdigest()
return {
"x-vobiz-signature": signature,
"x-vobiz-timestamp": timestamp,
}
@pytest.mark.asyncio
async def test_vobiz_hangup_callback_accepts_signed_form_body():
provider = _provider()
form_data = {
"CallUUID": "call-123",
"CallStatus": "completed",
"From": "15551230001",
"To": "15551230002",
"Direction": "outbound",
"Duration": "12",
}
headers = _signed_headers(provider, form_data=form_data)
request = _request(
path="/api/v1/telephony/vobiz/hangup-callback/123",
form_data=form_data,
headers=headers,
)
with (
patch("api.services.telephony.providers.vobiz.routes.db_client") as db_client,
patch(
"api.services.telephony.providers.vobiz.routes.get_telephony_provider_for_run",
new_callable=AsyncMock,
return_value=provider,
),
patch(
"api.services.telephony.providers.vobiz.routes.get_backend_endpoints",
new_callable=AsyncMock,
return_value=("https://example.test", "wss://example.test"),
),
patch(
"api.services.telephony.providers.vobiz.routes._process_status_update",
new_callable=AsyncMock,
) as process_status,
):
db_client.get_workflow_run_by_id = AsyncMock(
return_value=SimpleNamespace(workflow_id=7)
)
db_client.get_workflow_by_id = AsyncMock(
return_value=SimpleNamespace(organization_id=11)
)
result = await handle_vobiz_hangup_callback(
workflow_run_id=123,
request=request,
x_vobiz_signature=headers["x-vobiz-signature"],
x_vobiz_timestamp=headers["x-vobiz-timestamp"],
)
assert result == {"status": "success"}
process_status.assert_awaited_once()
@pytest.mark.asyncio
async def test_vobiz_ring_callback_accepts_signed_form_body():
provider = _provider()
form_data = {
"CallUUID": "call-123",
"CallStatus": "ringing",
"From": "15551230001",
"To": "15551230002",
}
headers = _signed_headers(provider, form_data=form_data)
request = _request(
path="/api/v1/telephony/vobiz/ring-callback/123",
form_data=form_data,
headers=headers,
)
workflow_run = SimpleNamespace(workflow_id=7, logs={})
with (
patch("api.services.telephony.providers.vobiz.routes.db_client") as db_client,
patch(
"api.services.telephony.providers.vobiz.routes.get_telephony_provider_for_run",
new_callable=AsyncMock,
return_value=provider,
),
patch(
"api.services.telephony.providers.vobiz.routes.get_backend_endpoints",
new_callable=AsyncMock,
return_value=("https://example.test", "wss://example.test"),
),
):
db_client.get_workflow_run_by_id = AsyncMock(return_value=workflow_run)
db_client.get_workflow_by_id = AsyncMock(
return_value=SimpleNamespace(organization_id=11)
)
db_client.update_workflow_run = AsyncMock()
result = await handle_vobiz_ring_callback(
workflow_run_id=123,
request=request,
x_vobiz_signature=headers["x-vobiz-signature"],
x_vobiz_timestamp=headers["x-vobiz-timestamp"],
)
assert result == {"status": "success"}
db_client.update_workflow_run.assert_awaited_once()