feat: agent stream for cloudonix OPBX (#261)

* feat: agent stream for cloudonix OPBX

* feat: make cloudonix app name optional

* feat: create application while configuring telephony config

* fix: get telephony configuration from stamped workflow run

* fix: fix vobiz hangup URL
This commit is contained in:
Abhishek 2026-05-02 15:53:58 +05:30 committed by GitHub
parent 5cfdbeff02
commit 7fd3b96470
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
48 changed files with 1529 additions and 545 deletions

View file

@ -48,7 +48,7 @@ class CampaignCallDispatcher:
if campaign.telephony_configuration_id:
return await get_telephony_provider_by_id(
campaign.telephony_configuration_id
campaign.telephony_configuration_id, campaign.organization_id
)
logger.warning(
f"Campaign {campaign.id} has no telephony_configuration_id; "

View file

@ -537,6 +537,7 @@ class ARIConnection:
"called_number": called_number,
"direction": "inbound",
"provider": "ari",
"telephony_configuration_id": self.telephony_configuration_id,
},
gathered_context={
"call_id": call_id,

View file

@ -262,19 +262,6 @@ class TelephonyProvider(ABC):
"""
pass
@abstractmethod
def normalize_phone_number(self, phone_number: str) -> str:
"""
Normalize a phone number to E.164 format for this provider.
Args:
phone_number: Raw phone number from webhook
Returns:
Phone number in E.164 format (+country_code_number)
"""
pass
@abstractmethod
async def verify_inbound_signature(
self,
@ -336,6 +323,28 @@ class TelephonyProvider(ABC):
"""
pass
async def handle_external_websocket(
self,
websocket: "WebSocket",
*,
organization_id: int,
workflow_id: int,
user_id: int,
workflow_run_id: int,
params: Dict[str, str],
) -> None:
"""Handle the agent-stream WebSocket where credentials are passed inline.
Used by ``/api/v1/agent-stream/{workflow_uuid}`` when the caller carries
provider credentials in the query string (no stored
``TelephonyConfigurationModel`` row required). ``organization_id`` is
passed so providers can scope any config lookups to the workflow's
org. Default raises so providers that haven't opted in fail loudly.
"""
raise NotImplementedError(
f"Agent-stream not supported for provider {self.PROVIDER_NAME}"
)
async def configure_inbound(
self, address: str, webhook_url: Optional[str]
) -> ProviderSyncResult:

View file

@ -24,27 +24,35 @@ from typing import Any, Dict, List, Optional, Tuple, Type
from loguru import logger
from api.db import db_client
from api.db.models import TelephonyConfigurationModel
from api.db.models import TelephonyConfigurationModel, WorkflowRunModel
from api.services.telephony import registry
from api.services.telephony.base import TelephonyProvider
async def load_telephony_config_by_id(
telephony_configuration_id: int,
organization_id: int,
) -> Dict[str, Any]:
"""Load and normalize the config row by primary key.
"""Load and normalize the config row by primary key, scoped to the org.
Returns a dict in the shape each provider class expects in its constructor
(provider name + provider-specific credentials + ``from_numbers`` list of
raw address strings).
raw address strings). Raises ``ValueError`` if the config doesn't exist
or doesn't belong to ``organization_id`` — the org scope is what makes
this safe to expose to user-driven request flows.
"""
if not telephony_configuration_id:
raise ValueError("telephony_configuration_id is required")
if not organization_id:
raise ValueError("organization_id is required")
row = await db_client.get_telephony_configuration(telephony_configuration_id)
row = await db_client.get_telephony_configuration_for_org(
telephony_configuration_id, organization_id
)
if not row:
raise ValueError(
f"Telephony configuration {telephony_configuration_id} not found"
f"Telephony configuration {telephony_configuration_id} not found "
f"for organization {organization_id}"
)
return await _normalize_with_phone_numbers(row)
@ -68,6 +76,9 @@ async def find_telephony_config_for_inbound(
) -> Optional[Tuple[int, Dict[str, Any]]]:
"""Match an inbound webhook to one of the org's configs of the detected
provider. Returns ``(config_id, normalized_config)`` or None.
Always scoped to ``organization_id`` never matches across orgs even if
two orgs happen to have credentials with the same account_id.
"""
spec = registry.get_optional(provider_name)
if not spec:
@ -96,10 +107,10 @@ async def find_telephony_config_for_inbound(
matched = next(
(c for c in candidates if c.is_default_outbound), candidates[0]
)
else:
elif account_id:
for cand in candidates:
stored = (cand.credentials or {}).get(field)
if stored and account_id and stored == account_id:
if stored and stored == account_id:
matched = cand
break
@ -112,11 +123,32 @@ async def find_telephony_config_for_inbound(
async def get_telephony_provider_by_id(
telephony_configuration_id: int,
organization_id: int,
) -> TelephonyProvider:
config = await load_telephony_config_by_id(telephony_configuration_id)
config = await load_telephony_config_by_id(
telephony_configuration_id, organization_id
)
return _instantiate(config)
async def get_telephony_provider_for_run(
workflow_run: WorkflowRunModel,
organization_id: int,
) -> TelephonyProvider:
"""Resolve the provider for a given workflow run.
Prefers ``initial_context.telephony_configuration_id`` stamped at run
creation by ``/initiate-call``, ``_create_inbound_workflow_run``, the
campaign dispatcher, and ``public_agent``. Falls back to the org's
default config so legacy runs created before the multi-config migration
still resolve.
"""
cfg_id = (workflow_run.initial_context or {}).get("telephony_configuration_id")
if cfg_id:
return await get_telephony_provider_by_id(cfg_id, organization_id)
return await get_default_telephony_provider(organization_id)
async def get_default_telephony_provider(organization_id: int) -> TelephonyProvider:
config = await load_default_telephony_config(organization_id)
return _instantiate(config)
@ -149,7 +181,9 @@ async def load_credentials_for_transport(
Raises ValueError when the resolved config is for a different provider.
"""
if telephony_configuration_id:
config = await load_telephony_config_by_id(telephony_configuration_id)
config = await load_telephony_config_by_id(
telephony_configuration_id, organization_id
)
else:
config = await load_default_telephony_config(organization_id)

View file

@ -307,10 +307,6 @@ class ARIProvider(TelephonyProvider):
"""ARI doesn't use account IDs for validation."""
return True
def normalize_phone_number(self, phone_number: str) -> str:
"""Normalize phone number - ARI uses extensions as-is."""
return phone_number or ""
async def verify_inbound_signature(
self,
url: str,

View file

@ -1,16 +1,22 @@
"""Cloudonix telephony provider package."""
import uuid
from typing import Any, Dict
import aiohttp
from fastapi import HTTPException
from loguru import logger
from api.services.telephony.registry import (
ProviderSpec,
ProviderUIField,
ProviderUIMetadata,
register,
)
from api.utils.common import get_backend_endpoints
from .config import CloudonixConfigurationRequest, CloudonixConfigurationResponse
from .provider import CloudonixProvider
from .provider import CLOUDONIX_API_BASE_URL, CloudonixProvider
from .transport import create_transport
@ -25,6 +31,66 @@ def _config_loader(value: Dict[str, Any]) -> Dict[str, Any]:
}
async def _ensure_application_name(credentials: Dict[str, Any]) -> Dict[str, Any]:
"""Auto-create a Cloudonix Voice Application if one wasn't supplied.
The application is created with our inbound dispatcher URL pre-set the
same URL ``configure_inbound`` would PATCH later so inbound calls work
immediately for any DNID bound to this application.
"""
if credentials.get("application_name"):
return credentials
bearer_token = credentials.get("bearer_token")
domain_id = credentials.get("domain_id")
if not bearer_token or not domain_id:
return credentials
backend_endpoint, _ = await get_backend_endpoints()
inbound_url = f"{backend_endpoint}/api/v1/telephony/inbound/run"
name = f"dograh-{uuid.uuid4().hex[:12]}"
endpoint = (
f"{CLOUDONIX_API_BASE_URL}/customers/self/domains/{domain_id}/applications"
)
body = {"name": name, "type": "cxml", "url": inbound_url, "method": "POST"}
headers = {
"Authorization": f"Bearer {bearer_token}",
"Content-Type": "application/json",
}
try:
async with aiohttp.ClientSession() as session:
async with session.post(endpoint, json=body, headers=headers) as response:
response_text = await response.text()
if response.status not in (200, 201):
logger.error(
f"[Cloudonix] applicationCreate failed: "
f"HTTP {response.status} body={response_text}"
)
raise HTTPException(
status_code=response.status,
detail=(
f"Failed to auto-create Cloudonix Voice Application: "
f"HTTP {response.status} {response_text}"
),
)
data = await response.json()
except aiohttp.ClientError as e:
logger.error(f"[Cloudonix] applicationCreate transport error: {e}")
raise HTTPException(
status_code=502,
detail=f"Failed to reach Cloudonix to auto-create application: {e}",
)
created_name = data.get("name") or name
logger.info(
f"[Cloudonix] auto-created Voice Application '{created_name}' on domain "
f"{domain_id}"
)
return {**credentials, "application_name": created_name}
_UI_METADATA = ProviderUIMetadata(
display_name="Cloudonix",
docs_url="https://docs.dograh.com/integrations/telephony/cloudonix",
@ -41,9 +107,11 @@ _UI_METADATA = ProviderUIMetadata(
name="application_name",
label="Application Name",
type="text",
required=False,
description=(
"Cloudonix Voice Application name whose url is updated when "
"inbound workflows are attached to numbers on this domain"
"inbound workflows are attached to numbers on this domain. "
"Leave blank and we will auto-create one for you on save."
),
),
ProviderUIField(
@ -65,6 +133,7 @@ SPEC = ProviderSpec(
ui_metadata=_UI_METADATA,
config_response_cls=CloudonixConfigurationResponse,
account_id_credential_field="domain_id",
preprocess_credentials_on_save=_ensure_application_name,
)

View file

@ -1,8 +1,8 @@
"""Cloudonix telephony configuration schemas."""
from typing import List, Literal
from typing import List, Literal, Optional
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator
class CloudonixConfigurationRequest(BaseModel):
@ -11,12 +11,24 @@ class CloudonixConfigurationRequest(BaseModel):
provider: Literal["cloudonix"] = Field(default="cloudonix")
bearer_token: str = Field(..., description="Cloudonix API Bearer Token")
domain_id: str = Field(..., description="Cloudonix Domain ID")
application_name: str = Field(
...,
@field_validator("domain_id")
@classmethod
def _normalize_domain_id(cls, v: str) -> str:
v = (v or "").strip()
if not v:
return v
if v.endswith(".cloudonix.net"):
return v
return f"{v}.cloudonix.net"
application_name: Optional[str] = Field(
default=None,
description=(
"Cloudonix Voice Application name. The application's url is "
"updated when inbound workflows are attached to numbers on "
"this domain."
"this domain. If omitted, an application is auto-created on "
"save and its name is stored on the configuration."
),
)
from_numbers: List[str] = Field(
@ -30,5 +42,5 @@ class CloudonixConfigurationResponse(BaseModel):
provider: Literal["cloudonix"] = Field(default="cloudonix")
bearer_token: str # Masked
domain_id: str
application_name: str
application_name: Optional[str] = None
from_numbers: List[str]

View file

@ -10,6 +10,7 @@ import aiohttp
from fastapi import HTTPException
from loguru import logger
from api.db import db_client
from api.enums import WorkflowRunMode
from api.services.telephony.base import (
CallInitiationResult,
@ -22,6 +23,8 @@ from api.utils.common import get_backend_endpoints
if TYPE_CHECKING:
from fastapi import WebSocket
CLOUDONIX_API_BASE_URL = "https://api.cloudonix.io"
class CloudonixProvider(TelephonyProvider):
"""
@ -45,7 +48,7 @@ class CloudonixProvider(TelephonyProvider):
- from_numbers: List of phone numbers to use (optional, fetched from API if not provided)
"""
self.bearer_token = config.get("bearer_token")
self.domain_id = config.get("domain_id")
self.domain_id = self._normalize_domain(config.get("domain_id"))
self.application_name = config.get("application_name")
self.from_numbers = config.get("from_numbers", [])
@ -53,7 +56,25 @@ class CloudonixProvider(TelephonyProvider):
if isinstance(self.from_numbers, str):
self.from_numbers = [self.from_numbers]
self.base_url = "https://api.cloudonix.io"
self.base_url = CLOUDONIX_API_BASE_URL
@staticmethod
def _normalize_domain(domain: Optional[str]) -> Optional[str]:
"""Ensure a Cloudonix domain is fully qualified.
Cloudonix domains are always of the form ``<name>.cloudonix.net``.
Users sometimes configure or pass just ``<name>``; normalize so
equality checks against stored credentials and API URLs work
regardless of input form.
"""
if not domain:
return domain
domain = domain.strip()
if not domain:
return domain
if domain.endswith(".cloudonix.net"):
return domain
return f"{domain}.cloudonix.net"
def _get_auth_headers(self) -> Dict[str, str]:
"""Generate authorization headers for Cloudonix API."""
@ -388,7 +409,6 @@ class CloudonixProvider(TelephonyProvider):
2. "start" event with streamSid and callSid
3. Then audio messages
"""
from api.db import db_client
from api.services.pipecat.run_pipeline import run_pipeline_telephony
try:
@ -453,6 +473,163 @@ class CloudonixProvider(TelephonyProvider):
logger.error(f"Error in Cloudonix WebSocket handler: {e}")
raise
async def handle_external_websocket(
self,
websocket: "WebSocket",
*,
organization_id: int,
workflow_id: int,
user_id: int,
workflow_run_id: int,
params: Dict[str, str],
) -> None:
"""Agent-stream entry point.
``Domain`` (domain id) is read from the query string. The bearer
token comes from the stored Cloudonix telephony configuration
matched by ``domain_id`` within the workflow's organization — never
from the URL or stream payload. The websocket handshake (connected
/ start) is identical to the standard inbound flow.
Before starting the pipeline we (a) require an existing Cloudonix
telephony configuration for the supplied ``domain_id`` and (b)
validate the call session with Cloudonix using the bearer token
from that configuration. Either failure closes the socket with
4400.
"""
from api.services.pipecat.run_pipeline import run_pipeline_telephony
domain_id = self._normalize_domain(params.get("Domain"))
if not domain_id:
logger.error("Cloudonix agent-stream missing required param: Domain")
await websocket.close(code=4400, reason="Missing Domain query param")
return
config = await self._find_config_by_domain(organization_id, domain_id)
if not config:
logger.error(
f"Cloudonix agent-stream: no telephony configuration found "
f"for domain_id={domain_id}"
)
await websocket.close(
code=4400, reason=f"Unknown Cloudonix domain: {domain_id}"
)
return
bearer_token = (config.credentials or {}).get("bearer_token")
if not bearer_token:
logger.error(
f"Cloudonix agent-stream: telephony configuration {config.id} "
f"is missing bearer_token in credentials"
)
await websocket.close(
code=4400, reason="Cloudonix configuration missing bearer_token"
)
return
try:
first_msg = await websocket.receive_text()
msg = json.loads(first_msg)
if msg.get("event") != "connected":
logger.error(f"Expected 'connected' event, got: {msg.get('event')}")
await websocket.close(code=4400, reason="Expected connected event")
return
start_msg = json.loads(await websocket.receive_text())
if start_msg.get("event") != "start":
logger.error("Expected 'start' event second")
await websocket.close(code=4400, reason="Expected start event")
return
try:
stream_sid = start_msg["start"]["streamSid"]
call_sid = start_msg["start"]["callSid"]
except KeyError:
logger.error("Missing streamSid or callSid in start message")
await websocket.close(code=4400, reason="Missing stream identifiers")
return
if not await self._validate_session(domain_id, call_sid, bearer_token):
await websocket.close(
code=4400, reason="Cloudonix session validation failed"
)
return
logger.info(
f"Cloudonix agent-stream connected for workflow_run "
f"{workflow_run_id} stream_sid={stream_sid} call_sid={call_sid} "
f"telephony_configuration_id={config.id}"
)
await run_pipeline_telephony(
websocket,
provider_name=self.PROVIDER_NAME,
workflow_id=workflow_id,
workflow_run_id=workflow_run_id,
user_id=user_id,
call_id=call_sid,
transport_kwargs={
"call_id": call_sid,
"stream_sid": stream_sid,
"bearer_token": bearer_token,
"domain_id": domain_id,
},
)
except Exception as e:
logger.error(f"Error in Cloudonix agent-stream handler: {e}")
raise
async def _validate_session(
self, domain_id: str, call_id: str, bearer_token: str
) -> bool:
"""Confirm the session is live with Cloudonix.
Hits ``GET /customers/self/domains/{domain_id}/sessions/{call_id}``
with the supplied bearer token. A 200 response means both the
token is valid and the session exists.
"""
endpoint = (
f"{self.base_url}/customers/self/domains/{domain_id}/sessions/{call_id}"
)
headers = {
"Authorization": f"Bearer {bearer_token}",
"Content-Type": "application/json",
}
try:
async with aiohttp.ClientSession() as http:
async with http.get(endpoint, headers=headers) as response:
if response.status == 200:
return True
body = await response.text()
logger.error(
f"Cloudonix session validation failed: "
f"HTTP {response.status} domain_id={domain_id} "
f"call_id={call_id} body={body}"
)
return False
except Exception as e:
logger.error(
f"Cloudonix session validation error for domain_id={domain_id} "
f"call_id={call_id}: {e}"
)
return False
async def _find_config_by_domain(self, organization_id: int, domain_id: str):
"""Find a Cloudonix config by its normalized ``domain_id`` within
``organization_id`` scoped lookup so credentials from a different
org can never be used."""
normalized = self._normalize_domain(domain_id)
if not normalized:
return None
candidates = await db_client.list_telephony_configurations_by_provider(
organization_id, self.PROVIDER_NAME
)
for cand in candidates:
if (cand.credentials or {}).get("domain_id") == normalized:
return cand
return None
# ======== INBOUND CALL METHODS ========
@classmethod
@ -510,7 +687,9 @@ class CloudonixProvider(TelephonyProvider):
call_id = webhook_data.get("Session") or webhook_data.get("CallSid") or token
account_id = webhook_data.get("Domain") or webhook_data.get("AccountSid", "")
account_id = CloudonixProvider._normalize_domain(
webhook_data.get("Domain") or webhook_data.get("AccountSid", "")
)
# Extract underlying provider information from SessionData if available
session_data = webhook_data.get("SessionData", {})
@ -554,35 +733,9 @@ class CloudonixProvider(TelephonyProvider):
if not stored_domain:
return False
return webhook_account_id == stored_domain
def normalize_phone_number(self, phone_number: str) -> str:
"""
Normalize a phone number to E.164 format for Cloudonix.
Cloudonix typically provides numbers in E.164 format already,
but we'll ensure proper formatting.
"""
if not phone_number:
return ""
# Remove any spaces or formatting
clean_number = (
phone_number.replace(" ", "")
.replace("-", "")
.replace("(", "")
.replace(")", "")
)
# If already in E.164 format (+...), return as-is
if clean_number.startswith("+"):
return clean_number
# If starts with country code but no +, add it
if len(clean_number) >= 10:
return f"+{clean_number}"
return clean_number
return CloudonixProvider._normalize_domain(
webhook_account_id
) == CloudonixProvider._normalize_domain(stored_domain)
async def verify_inbound_signature(
self,

View file

@ -10,7 +10,7 @@ from fastapi import APIRouter, Request
from loguru import logger
from api.db import db_client
from api.services.telephony.factory import get_telephony_provider
from api.services.telephony.factory import get_telephony_provider_for_run
from api.services.telephony.status_processor import (
StatusCallbackRequest,
_process_status_update,
@ -56,7 +56,9 @@ async def handle_cloudonix_status_callback(
logger.warning(f"Workflow {workflow_run.workflow_id} not found")
return {"status": "ignored", "reason": "workflow_not_found"}
provider = await get_telephony_provider(workflow.organization_id)
provider = await get_telephony_provider_for_run(
workflow_run, workflow.organization_id
)
# Parse the callback data into generic format
parsed_data = provider.parse_status_callback(callback_data)

View file

@ -4,6 +4,7 @@ from typing import Any, Dict
from loguru import logger
from api.services.telephony.providers.cloudonix.provider import CLOUDONIX_API_BASE_URL
from pipecat.serializers.call_strategies import HangupStrategy
@ -41,7 +42,7 @@ class CloudonixHangupStrategy(HangupStrategy):
)
return False
endpoint = f"https://api.cloudonix.io/customers/self/domains/{domain_id}/sessions/{call_id}"
endpoint = f"{CLOUDONIX_API_BASE_URL}/customers/self/domains/{domain_id}/sessions/{call_id}"
headers = {
"Authorization": f"Bearer {bearer_token}",
"Content-Type": "application/json",

View file

@ -25,14 +25,22 @@ async def create_transport(
telephony_configuration_id: int | None = None,
call_id: str,
stream_sid: str,
bearer_token: str | None = None,
domain_id: str | None = None,
):
"""Create a transport for Cloudonix connections."""
config = await load_credentials_for_transport(
organization_id, telephony_configuration_id, expected_provider="cloudonix"
)
"""Create a transport for Cloudonix connections.
bearer_token = config.get("bearer_token")
domain_id = config.get("domain_id")
When ``bearer_token`` and ``domain_id`` are both supplied, they are used
directly and no DB lookup is performed this is the agent-stream path
where the caller brings credentials inline. Otherwise credentials are
resolved from the org's stored telephony configuration.
"""
if not (bearer_token and domain_id):
config = await load_credentials_for_transport(
organization_id, telephony_configuration_id, expected_provider="cloudonix"
)
bearer_token = config.get("bearer_token")
domain_id = config.get("domain_id")
if not bearer_token or not domain_id:
raise ValueError(

View file

@ -1,18 +1,26 @@
"""Plivo telephony provider package."""
import uuid
from typing import Any, Dict
import aiohttp
from fastapi import HTTPException
from loguru import logger
from api.services.telephony.registry import (
ProviderSpec,
ProviderUIField,
ProviderUIMetadata,
register,
)
from api.utils.common import get_backend_endpoints
from .config import PlivoConfigurationRequest, PlivoConfigurationResponse
from .provider import PlivoProvider
from .transport import create_transport
PLIVO_API_BASE_URL = "https://api.plivo.com/v1"
def _config_loader(value: Dict[str, Any]) -> Dict[str, Any]:
return {
@ -24,6 +32,73 @@ def _config_loader(value: Dict[str, Any]) -> Dict[str, Any]:
}
async def _ensure_application_id(credentials: Dict[str, Any]) -> Dict[str, Any]:
"""Auto-create a Plivo Application if one wasn't supplied.
The application is created with our inbound dispatcher URL pre-set the
same URL ``configure_inbound`` would POST later so inbound calls work
immediately for any number bound to this application.
"""
if credentials.get("application_id"):
return credentials
auth_id = credentials.get("auth_id")
auth_token = credentials.get("auth_token")
if not auth_id or not auth_token:
return credentials
backend_endpoint, _ = await get_backend_endpoints()
inbound_url = f"{backend_endpoint}/api/v1/telephony/inbound/run"
app_name = f"dograh-{uuid.uuid4().hex[:12]}"
endpoint = f"{PLIVO_API_BASE_URL}/Account/{auth_id}/Application/"
body = {
"app_name": app_name,
"answer_url": inbound_url,
"answer_method": "POST",
"hangup_url": "",
}
auth = aiohttp.BasicAuth(auth_id, auth_token)
try:
async with aiohttp.ClientSession() as session:
async with session.post(endpoint, json=body, auth=auth) as response:
response_text = await response.text()
if response.status not in (200, 201, 202):
logger.error(
f"[Plivo] applicationCreate failed: "
f"HTTP {response.status} body={response_text}"
)
raise HTTPException(
status_code=response.status,
detail=(
f"Failed to auto-create Plivo Application: "
f"HTTP {response.status} {response_text}"
),
)
data = await response.json()
except aiohttp.ClientError as e:
logger.error(f"[Plivo] applicationCreate transport error: {e}")
raise HTTPException(
status_code=502,
detail=f"Failed to reach Plivo to auto-create application: {e}",
)
created_id = data.get("app_id")
if not created_id:
logger.error(f"[Plivo] applicationCreate response missing app_id: {data}")
raise HTTPException(
status_code=502,
detail=f"Plivo applicationCreate response missing app_id: {data}",
)
logger.info(
f"[Plivo] auto-created Application '{app_name}' (id={created_id}) on "
f"account {auth_id}"
)
return {**credentials, "application_id": str(created_id)}
_UI_METADATA = ProviderUIMetadata(
display_name="Plivo",
docs_url="https://docs.dograh.com/integrations/telephony/plivo",
@ -36,9 +111,11 @@ _UI_METADATA = ProviderUIMetadata(
name="application_id",
label="Application ID",
type="text",
required=False,
description=(
"Plivo Application ID whose answer_url is updated when inbound "
"workflows are attached to numbers on this account"
"workflows are attached to numbers on this account. Leave blank "
"and we will auto-create one for you on save."
),
),
ProviderUIField(
@ -61,6 +138,7 @@ SPEC = ProviderSpec(
ui_metadata=_UI_METADATA,
config_response_cls=PlivoConfigurationResponse,
account_id_credential_field="auth_id",
preprocess_credentials_on_save=_ensure_application_id,
)

View file

@ -1,6 +1,6 @@
"""Plivo telephony configuration schemas."""
from typing import List, Literal
from typing import List, Literal, Optional
from pydantic import BaseModel, Field
@ -11,11 +11,13 @@ class PlivoConfigurationRequest(BaseModel):
provider: Literal["plivo"] = Field(default="plivo")
auth_id: str = Field(..., description="Plivo Auth ID")
auth_token: str = Field(..., description="Plivo Auth Token")
application_id: str = Field(
...,
application_id: Optional[str] = Field(
default=None,
description=(
"Plivo Application ID. The application's answer_url is updated "
"when inbound workflows are attached to numbers on this account."
"when inbound workflows are attached to numbers on this account. "
"If omitted, an application is auto-created on save and its id "
"is stored on the configuration."
),
)
from_numbers: List[str] = Field(
@ -29,5 +31,5 @@ class PlivoConfigurationResponse(BaseModel):
provider: Literal["plivo"] = Field(default="plivo")
auth_id: str # Masked
auth_token: str # Masked
application_id: str
application_id: Optional[str] = None
from_numbers: List[str]

View file

@ -23,6 +23,7 @@ from api.services.telephony.base import (
TelephonyProvider,
)
from api.utils.common import get_backend_endpoints
from api.utils.telephony_address import normalize_telephony_address
if TYPE_CHECKING:
from fastapi import WebSocket
@ -363,14 +364,16 @@ class PlivoProvider(TelephonyProvider):
@staticmethod
def parse_inbound_webhook(webhook_data: Dict[str, Any]) -> NormalizedInboundData:
from_raw = webhook_data.get("From", "")
to_raw = webhook_data.get("To", "")
return NormalizedInboundData(
provider=PlivoProvider.PROVIDER_NAME,
call_id=webhook_data.get("CallUUID", "")
or webhook_data.get("RequestUUID", ""),
from_number=PlivoProvider.normalize_phone_number(
webhook_data.get("From", "")
),
to_number=PlivoProvider.normalize_phone_number(webhook_data.get("To", "")),
from_number=normalize_telephony_address(from_raw).canonical
if from_raw
else "",
to_number=normalize_telephony_address(to_raw).canonical if to_raw else "",
direction=webhook_data.get("Direction", ""),
call_status=webhook_data.get("CallStatus", ""),
account_id=webhook_data.get("AuthID") or webhook_data.get("ParentAuthID"),
@ -389,21 +392,6 @@ class PlivoProvider(TelephonyProvider):
)
return bool(config_data.get("auth_id"))
@staticmethod
def normalize_phone_number(phone_number: str) -> str:
if not phone_number:
return ""
clean_number = phone_number.lstrip("+")
if clean_number.startswith("1") and len(clean_number) == 11:
return f"+{clean_number}"
if len(clean_number) == 10:
return f"+1{clean_number}"
if len(clean_number) > 10:
return f"+{clean_number}"
return phone_number
async def verify_inbound_signature(
self,
url: str,

View file

@ -12,7 +12,7 @@ from loguru import logger
from starlette.responses import HTMLResponse
from api.db import db_client
from api.services.telephony.factory import get_telephony_provider
from api.services.telephony.factory import get_telephony_provider_for_run
from api.services.telephony.status_processor import (
StatusCallbackRequest,
_process_status_update,
@ -48,7 +48,9 @@ async def _handle_plivo_status_callback(
logger.warning(f"Workflow {workflow_run.workflow_id} not found")
return {"status": "ignored", "reason": "workflow_not_found"}
provider = await get_telephony_provider(workflow.organization_id)
provider = await get_telephony_provider_for_run(
workflow_run, workflow.organization_id
)
signature = x_plivo_signature_v3 or x_plivo_signature_ma_v3
if signature:
@ -95,7 +97,8 @@ async def handle_plivo_xml_webhook(
Returns Plivo XML response with Stream element.
"""
set_current_run_id(workflow_run_id)
provider = await get_telephony_provider(organization_id)
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
provider = await get_telephony_provider_for_run(workflow_run, organization_id)
form_data = await request.form()
callback_data = dict(form_data)
@ -123,7 +126,6 @@ async def handle_plivo_xml_webhook(
call_id = callback_data.get("CallUUID") or callback_data.get("RequestUUID")
if call_id:
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
gathered_context = dict(workflow_run.gathered_context or {})
gathered_context["call_id"] = call_id
await db_client.update_workflow_run(

View file

@ -1,18 +1,26 @@
"""Telnyx telephony provider package."""
import uuid
from typing import Any, Dict
import aiohttp
from fastapi import HTTPException
from loguru import logger
from api.services.telephony.registry import (
ProviderSpec,
ProviderUIField,
ProviderUIMetadata,
register,
)
from api.utils.common import get_backend_endpoints
from .config import TelnyxConfigurationRequest, TelnyxConfigurationResponse
from .provider import TelnyxProvider
from .transport import create_transport
TELNYX_API_BASE_URL = "https://api.telnyx.com/v2"
def _config_loader(value: Dict[str, Any]) -> Dict[str, Any]:
return {
@ -23,6 +31,82 @@ def _config_loader(value: Dict[str, Any]) -> Dict[str, Any]:
}
async def _ensure_connection_id(credentials: Dict[str, Any]) -> Dict[str, Any]:
"""Auto-create a Telnyx Call Control Application if one wasn't supplied.
The application is created with our inbound dispatcher URL pre-set on
``webhook_event_url`` the same URL ``configure_inbound`` would PATCH
later so inbound calls work immediately for any number bound to this
application.
"""
if credentials.get("connection_id"):
return credentials
api_key = credentials.get("api_key")
if not api_key:
return credentials
backend_endpoint, _ = await get_backend_endpoints()
inbound_url = f"{backend_endpoint}/api/v1/telephony/inbound/run"
application_name = f"dograh-{uuid.uuid4().hex[:12]}"
endpoint = f"{TELNYX_API_BASE_URL}/call_control_applications"
body = {
"application_name": application_name,
"webhook_event_url": inbound_url,
}
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
try:
async with aiohttp.ClientSession() as session:
async with session.post(endpoint, json=body, headers=headers) as response:
response_text = await response.text()
if response.status not in (200, 201):
logger.error(
f"[Telnyx] callControlApplicationCreate failed: "
f"HTTP {response.status} body={response_text}"
)
raise HTTPException(
status_code=response.status,
detail=(
f"Failed to auto-create Telnyx Call Control "
f"Application: HTTP {response.status} "
f"{response_text}"
),
)
payload = await response.json()
except aiohttp.ClientError as e:
logger.error(f"[Telnyx] callControlApplicationCreate transport error: {e}")
raise HTTPException(
status_code=502,
detail=(
f"Failed to reach Telnyx to auto-create Call Control Application: {e}"
),
)
created_id = (payload.get("data") or {}).get("id")
if not created_id:
logger.error(
f"[Telnyx] callControlApplicationCreate response missing data.id: {payload}"
)
raise HTTPException(
status_code=502,
detail=(
f"Telnyx callControlApplicationCreate response missing "
f"data.id: {payload}"
),
)
logger.info(
f"[Telnyx] auto-created Call Control Application "
f"'{application_name}' (id={created_id})"
)
return {**credentials, "connection_id": str(created_id)}
_UI_METADATA = ProviderUIMetadata(
display_name="Telnyx",
docs_url="https://docs.dograh.com/integrations/telephony/telnyx",
@ -34,7 +118,11 @@ _UI_METADATA = ProviderUIMetadata(
name="connection_id",
label="Call Control App ID",
type="text",
description="Telnyx Call Control Application ID (connection_id)",
required=False,
description=(
"Telnyx Call Control Application ID (connection_id). Leave "
"blank and we will auto-create one for you on save."
),
),
ProviderUIField(
name="from_numbers",
@ -56,6 +144,7 @@ SPEC = ProviderSpec(
ui_metadata=_UI_METADATA,
config_response_cls=TelnyxConfigurationResponse,
account_id_credential_field="connection_id",
preprocess_credentials_on_save=_ensure_connection_id,
)

View file

@ -1,6 +1,6 @@
"""Telnyx telephony configuration schemas."""
from typing import List, Literal
from typing import List, Literal, Optional
from pydantic import BaseModel, Field
@ -10,8 +10,13 @@ class TelnyxConfigurationRequest(BaseModel):
provider: Literal["telnyx"] = Field(default="telnyx")
api_key: str = Field(..., description="Telnyx API Key")
connection_id: str = Field(
..., description="Telnyx Call Control Application ID (connection_id)"
connection_id: Optional[str] = Field(
default=None,
description=(
"Telnyx Call Control Application ID (connection_id). If omitted, "
"a Call Control Application is auto-created on save and its id is "
"stored on the configuration."
),
)
# Phone numbers are managed via the dedicated phone-numbers endpoints; the
# legacy /telephony-config POST shim still accepts them inline.
@ -25,5 +30,5 @@ class TelnyxConfigurationResponse(BaseModel):
provider: Literal["telnyx"] = Field(default="telnyx")
api_key: str # Masked
connection_id: str
connection_id: Optional[str] = None
from_numbers: List[str]

View file

@ -20,6 +20,7 @@ from api.services.telephony.base import (
TelephonyProvider,
)
from api.utils.common import get_backend_endpoints
from api.utils.telephony_address import normalize_telephony_address
if TYPE_CHECKING:
from fastapi import WebSocket
@ -403,11 +404,15 @@ class TelnyxProvider(TelephonyProvider):
if direction == "incoming":
direction = "inbound"
from_raw = payload.get("from", "")
to_raw = payload.get("to", "")
return NormalizedInboundData(
provider=TelnyxProvider.PROVIDER_NAME,
call_id=payload.get("call_control_id", ""),
from_number=TelnyxProvider.normalize_phone_number(payload.get("from", "")),
to_number=TelnyxProvider.normalize_phone_number(payload.get("to", "")),
from_number=normalize_telephony_address(from_raw).canonical
if from_raw
else "",
to_number=normalize_telephony_address(to_raw).canonical if to_raw else "",
direction=direction,
call_status=normalize_event_type(data.get("event_type", "")),
account_id=payload.get("connection_id"),
@ -421,13 +426,6 @@ class TelnyxProvider(TelephonyProvider):
return False
return config_data.get("connection_id") == webhook_account_id
@staticmethod
def normalize_phone_number(phone_number: str) -> str:
"""Normalize phone number to E.164 format.
Telnyx already provides numbers in E.164 format
"""
return phone_number or ""
async def verify_inbound_signature(
self,
url: str,

View file

@ -10,7 +10,7 @@ from fastapi import APIRouter, Request
from loguru import logger
from api.db import db_client
from api.services.telephony.factory import get_telephony_provider
from api.services.telephony.factory import get_telephony_provider_for_run
from api.services.telephony.providers.telnyx.provider import normalize_event_type
from api.services.telephony.status_processor import (
StatusCallbackRequest,
@ -60,7 +60,9 @@ async def handle_telnyx_events(
logger.warning(f"Workflow {workflow_run.workflow_id} not found")
return {"status": "ignored", "reason": "workflow_not_found"}
provider = await get_telephony_provider(workflow.organization_id)
provider = await get_telephony_provider_for_run(
workflow_run, workflow.organization_id
)
# Parse the callback data into generic format
parsed_data = provider.parse_status_callback(event_data)

View file

@ -351,13 +351,15 @@ class TwilioProvider(TelephonyProvider):
"""
Parse Twilio-specific inbound webhook data into normalized format.
"""
from_raw = webhook_data.get("From", "")
to_raw = webhook_data.get("To", "")
return NormalizedInboundData(
provider=TwilioProvider.PROVIDER_NAME,
call_id=webhook_data.get("CallSid", ""),
from_number=TwilioProvider.normalize_phone_number(
webhook_data.get("From", "")
),
to_number=TwilioProvider.normalize_phone_number(webhook_data.get("To", "")),
from_number=normalize_telephony_address(from_raw).canonical
if from_raw
else "",
to_number=normalize_telephony_address(to_raw).canonical if to_raw else "",
direction=webhook_data.get("Direction", ""),
call_status=webhook_data.get("CallStatus", ""),
account_id=webhook_data.get("AccountSid"),
@ -368,27 +370,6 @@ class TwilioProvider(TelephonyProvider):
raw_data=webhook_data,
)
@staticmethod
def normalize_phone_number(phone_number: str) -> str:
"""
Normalize a phone number to E.164 format for Twilio.
Twilio already provides numbers in E.164 format.
"""
if not phone_number:
return ""
# Twilio numbers are already in E.164 format (+1234567890)
if phone_number.startswith("+"):
return phone_number
# If for some reason it doesn't have +, assume US and add +1
if phone_number.startswith("1") and len(phone_number) == 11:
return f"+{phone_number}"
elif len(phone_number) == 10:
return f"+1{phone_number}"
return phone_number
@staticmethod
def validate_account_id(config_data: dict, webhook_account_id: str) -> bool:
"""Validate Twilio account_sid from webhook matches configuration"""

View file

@ -12,7 +12,7 @@ from loguru import logger
from starlette.responses import HTMLResponse
from api.db import db_client
from api.services.telephony.factory import get_telephony_provider
from api.services.telephony.factory import get_telephony_provider_for_run
from api.services.telephony.status_processor import (
StatusCallbackRequest,
_process_status_update,
@ -32,7 +32,8 @@ async def handle_twiml_webhook(
Returns provider-specific response (e.g., TwiML for Twilio).
"""
provider = await get_telephony_provider(organization_id)
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
provider = await get_telephony_provider_for_run(workflow_run, organization_id)
response_content = await provider.get_webhook_response(
workflow_id, user_id, workflow_run_id
@ -70,7 +71,9 @@ async def handle_twilio_status_callback(
logger.warning(f"Workflow {workflow_run.workflow_id} not found")
return {"status": "ignored", "reason": "workflow_not_found"}
provider = await get_telephony_provider(workflow.organization_id)
provider = await get_telephony_provider_for_run(
workflow_run, workflow.organization_id
)
if x_webhook_signature:
backend_endpoint, _ = await get_backend_endpoints()

View file

@ -1,18 +1,26 @@
"""Vobiz telephony provider package."""
import uuid
from typing import Any, Dict
import aiohttp
from fastapi import HTTPException
from loguru import logger
from api.services.telephony.registry import (
ProviderSpec,
ProviderUIField,
ProviderUIMetadata,
register,
)
from api.utils.common import get_backend_endpoints
from .config import VobizConfigurationRequest, VobizConfigurationResponse
from .provider import VobizProvider
from .transport import create_transport
VOBIZ_API_BASE_URL = "https://api.vobiz.ai/api"
def _config_loader(value: Dict[str, Any]) -> Dict[str, Any]:
return {
@ -24,6 +32,77 @@ def _config_loader(value: Dict[str, Any]) -> Dict[str, Any]:
}
async def _ensure_application_id(credentials: Dict[str, Any]) -> Dict[str, Any]:
"""Auto-create a Vobiz Application if one wasn't supplied.
The application is created with our inbound dispatcher URL pre-set the
same URL ``configure_inbound`` would POST later so inbound calls work
immediately for any number bound to this application.
"""
if credentials.get("application_id"):
return credentials
auth_id = credentials.get("auth_id")
auth_token = credentials.get("auth_token")
if not auth_id or not auth_token:
return credentials
backend_endpoint, _ = await get_backend_endpoints()
inbound_url = f"{backend_endpoint}/api/v1/telephony/inbound/run"
app_name = f"dograh-{uuid.uuid4().hex[:12]}"
endpoint = f"{VOBIZ_API_BASE_URL}/v1/Account/{auth_id}/Application/"
body = {
"app_name": app_name,
"answer_url": inbound_url,
"answer_method": "POST",
"hangup_url": "",
}
headers = {
"X-Auth-ID": auth_id,
"X-Auth-Token": auth_token,
"Content-Type": "application/json",
}
try:
async with aiohttp.ClientSession() as session:
async with session.post(endpoint, json=body, headers=headers) as response:
response_text = await response.text()
if response.status not in (200, 201):
logger.error(
f"[Vobiz] applicationCreate failed: "
f"HTTP {response.status} body={response_text}"
)
raise HTTPException(
status_code=response.status,
detail=(
f"Failed to auto-create Vobiz Application: "
f"HTTP {response.status} {response_text}"
),
)
data = await response.json()
except aiohttp.ClientError as e:
logger.error(f"[Vobiz] applicationCreate transport error: {e}")
raise HTTPException(
status_code=502,
detail=f"Failed to reach Vobiz to auto-create application: {e}",
)
created_id = data.get("app_id")
if not created_id:
logger.error(f"[Vobiz] applicationCreate response missing app_id: {data}")
raise HTTPException(
status_code=502,
detail=f"Vobiz applicationCreate response missing app_id: {data}",
)
logger.info(
f"[Vobiz] auto-created Application '{app_name}' (id={created_id}) on "
f"account {auth_id}"
)
return {**credentials, "application_id": str(created_id)}
_UI_METADATA = ProviderUIMetadata(
display_name="Vobiz",
docs_url="https://docs.dograh.com/integrations/telephony/vobiz",
@ -42,9 +121,11 @@ _UI_METADATA = ProviderUIMetadata(
name="application_id",
label="Application ID",
type="text",
required=False,
description=(
"Vobiz Application ID whose answer_url is updated when "
"inbound workflows are attached to numbers on this account"
"inbound workflows are attached to numbers on this account. "
"Leave blank and we will auto-create one for you on save."
),
),
ProviderUIField(
@ -67,6 +148,7 @@ SPEC = ProviderSpec(
ui_metadata=_UI_METADATA,
config_response_cls=VobizConfigurationResponse,
account_id_credential_field="auth_id",
preprocess_credentials_on_save=_ensure_application_id,
)

View file

@ -1,6 +1,6 @@
"""Vobiz telephony configuration schemas."""
from typing import List, Literal
from typing import List, Literal, Optional
from pydantic import BaseModel, Field
@ -11,11 +11,13 @@ class VobizConfigurationRequest(BaseModel):
provider: Literal["vobiz"] = Field(default="vobiz")
auth_id: str = Field(..., description="Vobiz Account ID (e.g., MA_SYQRLN1K)")
auth_token: str = Field(..., description="Vobiz Auth Token")
application_id: str = Field(
...,
application_id: Optional[str] = Field(
default=None,
description=(
"Vobiz Application ID. The application's answer_url is updated "
"when inbound workflows are attached to numbers on this account."
"when inbound workflows are attached to numbers on this account. "
"If omitted, an application is auto-created on save and its id "
"is stored on the configuration."
),
)
from_numbers: List[str] = Field(
@ -30,5 +32,5 @@ class VobizConfigurationResponse(BaseModel):
provider: Literal["vobiz"] = Field(default="vobiz")
auth_id: str # Masked
auth_token: str # Masked
application_id: str
application_id: Optional[str] = None
from_numbers: List[str]

View file

@ -18,6 +18,7 @@ from api.services.telephony.base import (
TelephonyProvider,
)
from api.utils.common import get_backend_endpoints
from api.utils.telephony_address import normalize_telephony_address
if TYPE_CHECKING:
from fastapi import WebSocket
@ -418,52 +419,41 @@ class VobizProvider(TelephonyProvider):
Determine if this provider can handle the incoming webhook.
Vobiz webhooks contain CallUUID field.
"""
return "CallUUID" in webhook_data
return "vobiz" in headers.get("user-agent", "").lower()
@staticmethod
def parse_inbound_webhook(webhook_data: Dict[str, Any]) -> NormalizedInboundData:
"""
Parse Vobiz-specific inbound webhook data into normalized format.
"""
# Vobiz webhooks don't carry country info, and our deployment is
# India-only today — hardcode "IN" so leading-0 trunk-prefix numbers
# (e.g. "02271264296") normalize to the right E.164 ("+912271264296").
# Revisit if/when we onboard a non-Indian Vobiz customer.
country = "IN"
from_raw = webhook_data.get("From", "")
to_raw = webhook_data.get("To", "")
return NormalizedInboundData(
provider=VobizProvider.PROVIDER_NAME,
call_id=webhook_data.get("CallUUID", ""),
from_number=VobizProvider.normalize_phone_number(
webhook_data.get("From", "")
),
to_number=VobizProvider.normalize_phone_number(webhook_data.get("To", "")),
from_number=normalize_telephony_address(
from_raw, country_hint=country
).canonical
if from_raw
else "",
to_number=normalize_telephony_address(
to_raw, country_hint=country
).canonical
if to_raw
else "",
direction=webhook_data.get("Direction", ""),
call_status=webhook_data.get("CallStatus", ""),
account_id=webhook_data.get("ParentAuthID"),
from_country=None, # Vobiz doesn't provide country information
to_country=None, # Vobiz doesn't provide country information
from_country=country,
to_country=country,
raw_data=webhook_data,
)
@staticmethod
def normalize_phone_number(phone_number: str) -> str:
"""
Normalize a phone number to E.164 format for Vobiz.
Vobiz sends numbers in various formats - normalize to E.164 with +.
"""
if not phone_number:
return ""
# Remove any existing + prefix
clean_number = phone_number.lstrip("+")
# If it starts with 1 and has 11 digits, it's a US number
if clean_number.startswith("1") and len(clean_number) == 11:
return f"+{clean_number}"
elif len(clean_number) == 10:
# Assume US number if 10 digits
return f"+1{clean_number}"
elif len(clean_number) > 10:
# International number without country code detection
return f"+{clean_number}"
return phone_number
@staticmethod
def validate_account_id(config_data: dict, webhook_account_id: str) -> bool:
"""Validate Vobiz auth_id from webhook matches configuration"""
@ -487,10 +477,13 @@ class VobizProvider(TelephonyProvider):
signature = headers.get("x-vobiz-signature", "")
timestamp = headers.get("x-vobiz-timestamp")
if not signature:
# FIXME: Vobiz is not sending the x-vobiz-signature. Temporarily
# returning True
# Vobiz always signs its webhooks; missing header means the
# request didn't come from Vobiz (or was tampered with).
logger.warning("Inbound Vobiz webhook missing X-Vobiz-Signature")
return False
return True
return await self.verify_webhook_signature(
url, webhook_data, signature, timestamp, body
)
@ -548,7 +541,7 @@ class VobizProvider(TelephonyProvider):
async with session.post(
app_endpoint, json=data, headers=headers
) as response:
if response.status != 200:
if response.status not in (200, 202):
body = await response.text()
logger.error(
f"Vobiz application update failed for "

View file

@ -13,7 +13,9 @@ from loguru import logger
from starlette.responses import HTMLResponse
from api.db import db_client
from api.services.telephony.factory import get_telephony_provider
from api.services.telephony.factory import (
get_telephony_provider_for_run,
)
from api.services.telephony.status_processor import (
StatusCallbackRequest,
_process_status_update,
@ -43,7 +45,8 @@ async def handle_vobiz_xml_webhook(
f"workflow_id={workflow_id}, user_id={user_id}, org_id={organization_id}"
)
provider = await get_telephony_provider(organization_id)
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
provider = await get_telephony_provider_for_run(workflow_run, organization_id)
logger.debug(f"[run {workflow_run_id}] Using provider: {provider.PROVIDER_NAME}")
@ -107,7 +110,9 @@ async def handle_vobiz_hangup_callback(
)
return {"status": "error", "reason": "workflow_not_found"}
provider = await get_telephony_provider(workflow.organization_id)
provider = await get_telephony_provider_for_run(
workflow_run, workflow.organization_id
)
# Get raw body for signature verification
raw_body = await request.body()
@ -147,7 +152,9 @@ async def handle_vobiz_hangup_callback(
logger.warning(f"[run {workflow_run_id}] Workflow not found")
return {"status": "ignored", "reason": "workflow_not_found"}
provider = await get_telephony_provider(workflow.organization_id)
provider = await get_telephony_provider_for_run(
workflow_run, workflow.organization_id
)
logger.debug(
f"[run {workflow_run_id}] Processing Vobiz hangup with provider: {provider.PROVIDER_NAME}"
@ -229,7 +236,9 @@ async def handle_vobiz_ring_callback(
)
return {"status": "error", "reason": "workflow_not_found"}
provider = await get_telephony_provider(workflow.organization_id)
provider = await get_telephony_provider_for_run(
workflow_run, workflow.organization_id
)
# Get raw body for signature verification
raw_body = await request.body()
@ -317,13 +326,34 @@ async def handle_vobiz_hangup_callback_by_workflow(
)
return {"status": "error", "message": "No call_uuid found"}
workflow_client = WorkflowClient()
workflow = await workflow_client.get_workflow_by_id(workflow_id)
workflow = await db_client.get_workflow_by_id(workflow_id)
if not workflow:
logger.warning(f"[workflow {workflow_id}] Workflow not found")
return {"status": "error", "message": "workflow_not_found"}
provider = await get_telephony_provider(workflow.organization_id)
try:
workflow_run = await db_client.get_workflow_run_by_call_id(call_uuid)
except Exception as e:
logger.error(
f"[workflow {workflow_id}] Error finding workflow run for call {call_uuid}: {e}"
)
return {"status": "error", "message": str(e)}
if not workflow_run or workflow_run.workflow_id != workflow_id:
logger.warning(
f"[workflow {workflow_id}] No workflow run found for call {call_uuid}"
)
return {"status": "ignored", "reason": "workflow_run_not_found"}
workflow_run_id = workflow_run.id
set_current_run_id(workflow_run_id)
logger.info(
f"[workflow {workflow_id}] Found workflow run {workflow_run_id} for call {call_uuid}"
)
provider = await get_telephony_provider_for_run(
workflow_run, workflow.organization_id
)
if x_vobiz_signature:
raw_body = await request.body()
@ -350,50 +380,6 @@ async def handle_vobiz_hangup_callback_by_workflow(
)
try:
db_client = WorkflowRunClient()
async with db_client.async_session() as session:
# Fetch workflow run with matching call_id in gathered_context
query = text("""
SELECT id FROM workflow_runs
WHERE workflow_id = :workflow_id
AND CAST(gathered_context AS jsonb) @> CAST(:call_id_json AS jsonb)
ORDER BY created_at DESC
LIMIT 1
""")
result = await session.execute(
query,
{
"workflow_id": workflow_id,
"call_id_json": json.dumps({"call_id": call_uuid}),
},
)
workflow_run_row = result.fetchone()
if not workflow_run_row:
logger.warning(
f"[workflow {workflow_id}] No workflow run found for call {call_uuid}"
)
return {"status": "ignored", "reason": "workflow_run_not_found"}
workflow_run_id = workflow_run_row[0]
set_current_run_id(workflow_run_id)
logger.info(
f"[workflow {workflow_id}] Found workflow run {workflow_run_id} for call {call_uuid}"
)
except Exception as e:
logger.error(
f"[workflow {workflow_id}] Error finding workflow run for call {call_uuid}: {e}"
)
return {"status": "error", "message": str(e)}
try:
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
if not workflow_run:
logger.warning(f"[run {workflow_run_id}] Workflow run not found")
return {"status": "ignored", "reason": "workflow_run_not_found"}
parsed_data = provider.parse_status_callback(callback_data)
status = StatusCallbackRequest(

View file

@ -419,19 +419,6 @@ class VonageProvider(TelephonyProvider):
raw_data=webhook_data,
)
@staticmethod
def normalize_phone_number(phone_number: str) -> str:
"""
Normalize a phone number to E.164 format for Vonage.
"""
if not phone_number:
return ""
if phone_number.startswith("+"):
return phone_number
return f"+{phone_number}"
@staticmethod
def validate_account_id(config_data: dict, webhook_account_id: str) -> bool:
"""Validate Vonage account_id from webhook matches configuration"""

View file

@ -11,7 +11,7 @@ from fastapi import APIRouter, Request
from loguru import logger
from api.db import db_client
from api.services.telephony.factory import get_telephony_provider
from api.services.telephony.factory import get_telephony_provider_for_run
from api.services.telephony.status_processor import (
StatusCallbackRequest,
_process_status_update,
@ -33,7 +33,10 @@ async def handle_ncco_webhook(
Returns JSON response instead of XML like TwiML.
"""
provider = await get_telephony_provider(organization_id or user_id)
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
provider = await get_telephony_provider_for_run(
workflow_run, organization_id or user_id
)
response_content = await provider.get_webhook_response(
workflow_id, user_id, workflow_run_id
@ -97,7 +100,9 @@ async def handle_vonage_events(
logger.error(f"[run {workflow_run_id}] Workflow not found")
return {"status": "error", "message": "Workflow not found"}
provider = await get_telephony_provider(workflow.organization_id)
provider = await get_telephony_provider_for_run(
workflow_run, workflow.organization_id
)
# Parse the event data into generic format
parsed_data = provider.parse_status_callback(event_data)

View file

@ -64,6 +64,13 @@ TransportFactory = Callable[..., Awaitable[Any]]
# config dict that the provider class accepts in its constructor.
ConfigLoader = Callable[[Dict[str, Any]], Dict[str, Any]]
# Optional async hook invoked at create/update time. Receives the credentials
# dict the route is about to persist and returns a (possibly modified) dict.
# Use for provider-side I/O that mutates credentials before save (e.g. an
# external resource that must exist by the time the row lands). I/O is
# allowed; ``config_loader`` is reserved for pure dict reshaping.
CredentialsPreprocessor = Callable[[Dict[str, Any]], Awaitable[Dict[str, Any]]]
@dataclass(frozen=True)
class ProviderSpec:
@ -109,6 +116,10 @@ class ProviderSpec:
# exist for the same provider, and (b) reject duplicate-account saves.
# Empty string means the provider has no account-id concept (e.g. ARI).
account_id_credential_field: str = ""
# Optional async hook to mutate credentials before they're persisted on
# create/update. Called with the post-mask, post-merge credentials dict
# and must return the dict to write. Raise HTTPException to abort save.
preprocess_credentials_on_save: Optional[CredentialsPreprocessor] = None
_REGISTRY: Dict[str, ProviderSpec] = {}