mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-07 07:55:16 +02:00
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:
parent
5cfdbeff02
commit
7fd3b96470
48 changed files with 1529 additions and 545 deletions
|
|
@ -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; "
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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 "
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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] = {}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue