mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-22 08:38:13 +02:00
feat: add ultravox realtime and fix signature issue in telephony
- Add UltraVox realtime - Fix signature issue on telephony
This commit is contained in:
parent
9135c2da13
commit
ea0cac63cd
24 changed files with 2082 additions and 133 deletions
185
api/tests/telephony/plivo/test_routes.py
Normal file
185
api/tests/telephony/plivo/test_routes.py
Normal 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()
|
||||
253
api/tests/telephony/twilio/test_routes.py
Normal file
253
api/tests/telephony/twilio/test_routes.py
Normal 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()
|
||||
178
api/tests/telephony/vobiz/test_routes.py
Normal file
178
api/tests/telephony/vobiz/test_routes.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue