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
|
|
@ -15,11 +15,6 @@ from api.db.base_client import BaseDBClient
|
|||
from api.db.models import CampaignModel, TelephonyConfigurationModel
|
||||
|
||||
|
||||
class TelephonyConfigurationDuplicateAccountError(Exception):
|
||||
"""Raised when saving a config whose account_id collides with an existing
|
||||
config of the same provider in the same organization."""
|
||||
|
||||
|
||||
class TelephonyConfigurationInUseError(Exception):
|
||||
"""Raised when deleting a config that is still referenced by a campaign."""
|
||||
|
||||
|
|
@ -67,29 +62,6 @@ class TelephonyConfigurationClient(BaseDBClient):
|
|||
)
|
||||
return result.scalars().first()
|
||||
|
||||
async def find_telephony_config_by_account(
|
||||
self, provider: str, account_id_field: str, account_id: str
|
||||
) -> Optional[TelephonyConfigurationModel]:
|
||||
"""Global lookup used by the workflow-agnostic inbound dispatcher.
|
||||
|
||||
Returns the single config whose stored credentials contain
|
||||
``credentials[account_id_field] == account_id``. Filters in Python
|
||||
over the per-provider candidate set since credentials is JSON.
|
||||
"""
|
||||
if not account_id_field or not account_id:
|
||||
return None
|
||||
async with self.async_session() as session:
|
||||
result = await session.execute(
|
||||
select(TelephonyConfigurationModel).where(
|
||||
TelephonyConfigurationModel.provider == provider,
|
||||
)
|
||||
)
|
||||
for cand in result.scalars().all():
|
||||
stored = (cand.credentials or {}).get(account_id_field)
|
||||
if stored and stored == account_id:
|
||||
return cand
|
||||
return None
|
||||
|
||||
async def list_telephony_configurations_by_provider(
|
||||
self, organization_id: int, provider: str
|
||||
) -> List[TelephonyConfigurationModel]:
|
||||
|
|
@ -126,19 +98,9 @@ class TelephonyConfigurationClient(BaseDBClient):
|
|||
provider: str,
|
||||
credentials: Dict[str, Any],
|
||||
is_default_outbound: bool = False,
|
||||
account_id_credential_field: Optional[str] = None,
|
||||
) -> TelephonyConfigurationModel:
|
||||
"""Create a new config. Raises ``TelephonyConfigurationDuplicateAccountError``
|
||||
if the same provider+account_id is already configured for the org."""
|
||||
if account_id_credential_field:
|
||||
await self._guard_duplicate_account(
|
||||
organization_id,
|
||||
provider,
|
||||
credentials.get(account_id_credential_field),
|
||||
account_id_credential_field,
|
||||
exclude_id=None,
|
||||
)
|
||||
|
||||
"""Create a new config row. Duplicate-account guarding is the caller's
|
||||
responsibility; this method does not enforce it."""
|
||||
async with self.async_session() as session:
|
||||
existing_count = await session.scalar(
|
||||
select(func.count(TelephonyConfigurationModel.id)).where(
|
||||
|
|
@ -172,22 +134,12 @@ class TelephonyConfigurationClient(BaseDBClient):
|
|||
organization_id: int,
|
||||
name: Optional[str] = None,
|
||||
credentials: Optional[Dict[str, Any]] = None,
|
||||
account_id_credential_field: Optional[str] = None,
|
||||
) -> Optional[TelephonyConfigurationModel]:
|
||||
async with self.async_session() as session:
|
||||
row = await session.get(TelephonyConfigurationModel, config_id)
|
||||
if not row or row.organization_id != organization_id:
|
||||
return None
|
||||
|
||||
if credentials is not None and account_id_credential_field:
|
||||
await self._guard_duplicate_account(
|
||||
organization_id,
|
||||
row.provider,
|
||||
credentials.get(account_id_credential_field),
|
||||
account_id_credential_field,
|
||||
exclude_id=config_id,
|
||||
)
|
||||
|
||||
if name is not None:
|
||||
row.name = name
|
||||
if credentials is not None:
|
||||
|
|
@ -238,33 +190,6 @@ class TelephonyConfigurationClient(BaseDBClient):
|
|||
await session.commit()
|
||||
return True
|
||||
|
||||
async def _guard_duplicate_account(
|
||||
self,
|
||||
organization_id: int,
|
||||
provider: str,
|
||||
account_id: Optional[str],
|
||||
credential_field: str,
|
||||
exclude_id: Optional[int],
|
||||
) -> None:
|
||||
if not account_id:
|
||||
return
|
||||
async with self.async_session() as session:
|
||||
result = await session.execute(
|
||||
select(TelephonyConfigurationModel).where(
|
||||
TelephonyConfigurationModel.organization_id == organization_id,
|
||||
TelephonyConfigurationModel.provider == provider,
|
||||
)
|
||||
)
|
||||
for row in result.scalars().all():
|
||||
if exclude_id is not None and row.id == exclude_id:
|
||||
continue
|
||||
stored = (row.credentials or {}).get(credential_field)
|
||||
if stored and stored == account_id:
|
||||
raise TelephonyConfigurationDuplicateAccountError(
|
||||
f"A {provider} configuration with this account is already "
|
||||
f"registered (config id {row.id})."
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def _clear_default_outbound(session, organization_id: int) -> None:
|
||||
await session.execute(
|
||||
|
|
|
|||
|
|
@ -123,6 +123,104 @@ class TelephonyPhoneNumberClient(BaseDBClient):
|
|||
)
|
||||
return result.scalars().first()
|
||||
|
||||
async def find_inbound_route_by_account(
|
||||
self,
|
||||
provider: str,
|
||||
account_id_field: str,
|
||||
account_id: str,
|
||||
to_number: str,
|
||||
country_hint: Optional[str] = None,
|
||||
organization_id: Optional[int] = None,
|
||||
) -> Optional[Tuple[TelephonyConfigurationModel, TelephonyPhoneNumberModel]]:
|
||||
"""Combined primary-path lookup for inbound dispatch.
|
||||
|
||||
One SQL roundtrip that joins ``telephony_configurations`` and
|
||||
``telephony_phone_numbers`` and matches all of:
|
||||
provider, ``credentials[account_id_field] == account_id``,
|
||||
``phone.address_normalized == canonical(to_number)``, and
|
||||
``phone.is_active``. Replaces the previous pattern of resolving the
|
||||
config and the phone number in two separate queries with a Python-side
|
||||
loop over candidate configs.
|
||||
|
||||
Returns ``(config, phone_number)`` or None when the primary path
|
||||
misses (e.g. legacy non-E.164 stored addresses); the caller should
|
||||
fall back to the fuzzy ``numbers_match`` path in that case.
|
||||
"""
|
||||
if not (provider and account_id_field and account_id and to_number):
|
||||
return None
|
||||
|
||||
normalized = normalize_telephony_address(to_number, country_hint=country_hint)
|
||||
|
||||
async with self.async_session() as session:
|
||||
stmt = (
|
||||
select(TelephonyConfigurationModel, TelephonyPhoneNumberModel)
|
||||
.join(
|
||||
TelephonyPhoneNumberModel,
|
||||
TelephonyPhoneNumberModel.telephony_configuration_id
|
||||
== TelephonyConfigurationModel.id,
|
||||
)
|
||||
.where(
|
||||
TelephonyConfigurationModel.provider == provider,
|
||||
TelephonyConfigurationModel.credentials.op("->>")(account_id_field)
|
||||
== account_id,
|
||||
TelephonyPhoneNumberModel.address_normalized
|
||||
== normalized.canonical,
|
||||
TelephonyPhoneNumberModel.is_active.is_(True),
|
||||
)
|
||||
)
|
||||
if organization_id is not None:
|
||||
stmt = stmt.where(
|
||||
TelephonyConfigurationModel.organization_id == organization_id
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
row = result.first()
|
||||
if not row:
|
||||
return None
|
||||
return row[0], row[1]
|
||||
|
||||
async def find_inbound_routing_conflict(
|
||||
self,
|
||||
provider: str,
|
||||
account_id_field: str,
|
||||
account_id: str,
|
||||
address: str,
|
||||
country_hint: Optional[str] = None,
|
||||
) -> Optional[Tuple[TelephonyConfigurationModel, TelephonyPhoneNumberModel]]:
|
||||
"""Inbound dispatch keys on (provider, credentials[account_id_field],
|
||||
address_normalized) — see ``find_inbound_route_by_account``. That tuple
|
||||
must be globally unique or two orgs would race for the same call.
|
||||
|
||||
Returns the conflicting (config, phone_number) — possibly in another
|
||||
org — when inserting a row with this combination would break that
|
||||
invariant, or None when the row is safe to insert. Returns None for
|
||||
providers that don't carry an account_id (e.g. ARI), which use a
|
||||
different inbound path.
|
||||
"""
|
||||
if not (provider and account_id_field and account_id):
|
||||
return None
|
||||
|
||||
normalized = normalize_telephony_address(address, country_hint=country_hint)
|
||||
|
||||
async with self.async_session() as session:
|
||||
stmt = (
|
||||
select(TelephonyConfigurationModel, TelephonyPhoneNumberModel)
|
||||
.join(
|
||||
TelephonyPhoneNumberModel,
|
||||
TelephonyPhoneNumberModel.telephony_configuration_id
|
||||
== TelephonyConfigurationModel.id,
|
||||
)
|
||||
.where(
|
||||
TelephonyConfigurationModel.provider == provider,
|
||||
TelephonyConfigurationModel.credentials.op("->>")(account_id_field)
|
||||
== account_id,
|
||||
TelephonyPhoneNumberModel.address_normalized
|
||||
== normalized.canonical,
|
||||
)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
row = result.first()
|
||||
return (row[0], row[1]) if row else None
|
||||
|
||||
async def create_phone_number(
|
||||
self,
|
||||
organization_id: int,
|
||||
|
|
|
|||
|
|
@ -446,6 +446,37 @@ class WorkflowClient(BaseDBClient):
|
|||
)
|
||||
return result.scalars().first()
|
||||
|
||||
async def get_workflow_by_uuid(
|
||||
self, workflow_uuid: str, organization_id: int
|
||||
) -> WorkflowModel | None:
|
||||
async with self.async_session() as session:
|
||||
result = await session.execute(
|
||||
select(WorkflowModel)
|
||||
.options(
|
||||
selectinload(WorkflowModel.current_definition),
|
||||
selectinload(WorkflowModel.released_definition),
|
||||
)
|
||||
.where(
|
||||
WorkflowModel.workflow_uuid == workflow_uuid,
|
||||
WorkflowModel.organization_id == organization_id,
|
||||
)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
async def get_workflow_by_uuid_unscoped(
|
||||
self, workflow_uuid: str
|
||||
) -> WorkflowModel | None:
|
||||
async with self.async_session() as session:
|
||||
result = await session.execute(
|
||||
select(WorkflowModel)
|
||||
.options(
|
||||
selectinload(WorkflowModel.current_definition),
|
||||
selectinload(WorkflowModel.released_definition),
|
||||
)
|
||||
.where(WorkflowModel.workflow_uuid == workflow_uuid)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
async def update_workflow(
|
||||
self,
|
||||
workflow_id: int,
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ class WorkflowRunClient(BaseDBClient):
|
|||
call_type: CallType = CallType.OUTBOUND,
|
||||
initial_context: dict = None,
|
||||
gathered_context: dict = None,
|
||||
logs: dict = None,
|
||||
campaign_id: int = None,
|
||||
queued_run_id: int = None,
|
||||
use_draft: bool = False,
|
||||
|
|
@ -91,6 +92,7 @@ class WorkflowRunClient(BaseDBClient):
|
|||
definition_id=target_def.id if target_def else None,
|
||||
initial_context=initial_context or default_context,
|
||||
gathered_context=gathered_context or {},
|
||||
logs=logs or {},
|
||||
campaign_id=campaign_id,
|
||||
queued_run_id=queued_run_id,
|
||||
storage_backend=current_backend.value,
|
||||
|
|
|
|||
138
api/routes/agent_stream.py
Normal file
138
api/routes/agent_stream.py
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
"""Agent-stream WebSocket endpoint.
|
||||
|
||||
A single ``/agent-stream/{workflow_uuid}`` socket where a caller can drive
|
||||
an agent run by passing everything inline in the query string — including
|
||||
provider credentials. The standard ``/telephony/ws/...`` path requires a
|
||||
``TelephonyConfigurationModel`` row stored in the org; this one does not.
|
||||
|
||||
Auth: the workflow UUID itself acts as the identifier — no API key.
|
||||
Routing: when ``?provider=<registered>`` matches a telephony provider, we
|
||||
dispatch to that provider's ``handle_external_websocket``. The raw-audio
|
||||
branch (no provider) is reserved for a future protocol decision and
|
||||
currently rejects with 1011.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, WebSocket
|
||||
from loguru import logger
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
from api.db import db_client
|
||||
from api.enums import CallType, WorkflowRunState
|
||||
from api.services.quota_service import check_dograh_quota_by_user_id
|
||||
from api.services.telephony import registry as telephony_registry
|
||||
from pipecat.utils.run_context import set_current_org_id, set_current_run_id
|
||||
|
||||
router = APIRouter(prefix="/agent-stream")
|
||||
|
||||
|
||||
@router.websocket("/{workflow_uuid}")
|
||||
async def agent_stream_websocket(
|
||||
websocket: WebSocket,
|
||||
workflow_uuid: str,
|
||||
):
|
||||
"""Generic agent-stream WebSocket.
|
||||
|
||||
Query params:
|
||||
provider: registered telephony provider name (e.g. ``cloudonix``)
|
||||
from / to / callId: call metadata persisted on the workflow run
|
||||
...: provider-specific credentials/identifiers (e.g. ``session``,
|
||||
``AccountSid``, ``CallSid`` for cloudonix)
|
||||
|
||||
Without ``provider`` the raw-audio branch is currently not implemented.
|
||||
"""
|
||||
await websocket.accept()
|
||||
params = dict(websocket.query_params)
|
||||
provider_name: Optional[str] = params.get("provider")
|
||||
|
||||
if not provider_name:
|
||||
logger.warning(
|
||||
f"agent-stream raw audio branch not yet supported "
|
||||
f"(workflow_uuid={workflow_uuid})"
|
||||
)
|
||||
await websocket.close(code=1011, reason="Raw audio stream not yet implemented")
|
||||
return
|
||||
|
||||
spec = telephony_registry.get_optional(provider_name)
|
||||
if spec is None:
|
||||
logger.warning(f"agent-stream unknown provider: {provider_name}")
|
||||
await websocket.close(code=1008, reason=f"Unknown provider: {provider_name}")
|
||||
return
|
||||
|
||||
workflow = await db_client.get_workflow_by_uuid_unscoped(workflow_uuid)
|
||||
if not workflow:
|
||||
logger.warning(f"agent-stream workflow {workflow_uuid} not found")
|
||||
await websocket.close(code=1008, reason="Workflow not found")
|
||||
return
|
||||
|
||||
quota_result = await check_dograh_quota_by_user_id(
|
||||
workflow.user_id, workflow_id=workflow.id
|
||||
)
|
||||
if not quota_result.has_quota:
|
||||
logger.warning(
|
||||
f"agent-stream quota exceeded for user {workflow.user_id}: "
|
||||
f"{quota_result.error_message}"
|
||||
)
|
||||
await websocket.close(
|
||||
code=1008, reason=quota_result.error_message or "Quota exceeded"
|
||||
)
|
||||
return
|
||||
|
||||
numeric_suffix = int(str(uuid.uuid4()).replace("-", "")[:8], 16) % 100000000
|
||||
workflow_run_name = f"WR-AGS-{numeric_suffix:08d}"
|
||||
call_id = params.get("callId") or params.get("CallSid")
|
||||
initial_context = {
|
||||
**(workflow.template_context_variables or {}),
|
||||
"provider": provider_name,
|
||||
"caller_number": params.get("from"),
|
||||
"called_number": params.get("to"),
|
||||
"direction": "inbound",
|
||||
}
|
||||
workflow_run = await db_client.create_workflow_run(
|
||||
workflow_run_name,
|
||||
workflow.id,
|
||||
provider_name,
|
||||
user_id=workflow.user_id,
|
||||
call_type=CallType.INBOUND,
|
||||
initial_context=initial_context,
|
||||
gathered_context={"call_id": call_id} if call_id else {},
|
||||
logs={
|
||||
"inbound_webhook": {
|
||||
"domain": params.get("Domain"),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
set_current_run_id(workflow_run.id)
|
||||
set_current_org_id(workflow.organization_id)
|
||||
|
||||
await db_client.update_workflow_run(
|
||||
run_id=workflow_run.id, state=WorkflowRunState.RUNNING.value
|
||||
)
|
||||
|
||||
provider_instance = spec.provider_cls({})
|
||||
try:
|
||||
await provider_instance.handle_external_websocket(
|
||||
websocket,
|
||||
organization_id=workflow.organization_id,
|
||||
workflow_id=workflow.id,
|
||||
user_id=workflow.user_id,
|
||||
workflow_run_id=workflow_run.id,
|
||||
params=params,
|
||||
)
|
||||
except NotImplementedError as e:
|
||||
logger.warning(f"agent-stream provider {provider_name} not supported: {e}")
|
||||
try:
|
||||
await websocket.close(code=1011, reason=str(e))
|
||||
except RuntimeError:
|
||||
pass
|
||||
except WebSocketDisconnect as e:
|
||||
logger.info(f"agent-stream disconnected: code={e.code} reason={e.reason}")
|
||||
except Exception as e:
|
||||
logger.error(f"agent-stream error for run {workflow_run.id}: {e}")
|
||||
try:
|
||||
await websocket.close(1011, "Internal server error")
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
|
@ -2,6 +2,7 @@ from fastapi import APIRouter
|
|||
from loguru import logger
|
||||
from pydantic import BaseModel
|
||||
|
||||
from api.routes.agent_stream import router as agent_stream_router
|
||||
from api.routes.auth import router as auth_router
|
||||
from api.routes.campaign import router as campaign_router
|
||||
from api.routes.credentials import router as credentials_router
|
||||
|
|
@ -56,6 +57,7 @@ router.include_router(knowledge_base_router)
|
|||
router.include_router(workflow_recording_router)
|
||||
router.include_router(auth_router)
|
||||
router.include_router(node_types_router)
|
||||
router.include_router(agent_stream_router)
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
|
|
|
|||
|
|
@ -8,10 +8,7 @@ from sqlalchemy.exc import IntegrityError
|
|||
from api.constants import DEFAULT_CAMPAIGN_RETRY_CONFIG, DEFAULT_ORG_CONCURRENCY_LIMIT
|
||||
from api.db import db_client
|
||||
from api.db.models import UserModel
|
||||
from api.db.telephony_configuration_client import (
|
||||
TelephonyConfigurationDuplicateAccountError,
|
||||
TelephonyConfigurationInUseError,
|
||||
)
|
||||
from api.db.telephony_configuration_client import TelephonyConfigurationInUseError
|
||||
from api.enums import OrganizationConfigurationKey, PostHogEvent
|
||||
from api.schemas.telephony_config import (
|
||||
TelephonyConfigRequest,
|
||||
|
|
@ -130,17 +127,6 @@ async def get_telephony_providers_metadata(user: UserModel = Depends(get_user)):
|
|||
return TelephonyProvidersMetadataResponse(providers=providers)
|
||||
|
||||
|
||||
def _account_id_field(provider: str) -> str:
|
||||
"""The credential field that uniquely identifies the provider account.
|
||||
|
||||
Empty string for providers without an account-id concept (e.g. ARI).
|
||||
Drives the duplicate-account guard at save time and account-id matching
|
||||
at inbound webhook time.
|
||||
"""
|
||||
spec = telephony_registry.get_optional(provider)
|
||||
return spec.account_id_credential_field if spec else ""
|
||||
|
||||
|
||||
def preserve_masked_fields(provider: str, request_dict: dict, existing: dict):
|
||||
"""If the client re-submitted a masked sensitive field, restore the original."""
|
||||
for field_name in _sensitive_fields(provider):
|
||||
|
|
@ -157,6 +143,14 @@ def _credentials_from_payload(config: TelephonyConfigRequest) -> dict:
|
|||
return payload
|
||||
|
||||
|
||||
async def _run_preprocess_hook(provider: str, credentials: dict) -> dict:
|
||||
"""Invoke the provider's optional credentials preprocessor before save."""
|
||||
spec = telephony_registry.get_optional(provider)
|
||||
if spec and spec.preprocess_credentials_on_save:
|
||||
return await spec.preprocess_credentials_on_save(credentials)
|
||||
return credentials
|
||||
|
||||
|
||||
def _phone_number_to_response(
|
||||
row, inbound_workflow_name: Optional[str] = None
|
||||
) -> PhoneNumberResponse:
|
||||
|
|
@ -166,7 +160,7 @@ def _phone_number_to_response(
|
|||
|
||||
|
||||
async def _sync_inbound_for_phone_number(
|
||||
config_id: int, address: str
|
||||
config_id: int, organization_id: int, address: str
|
||||
) -> ProviderSyncStatus:
|
||||
"""Push inbound webhook configuration to the provider.
|
||||
|
||||
|
|
@ -178,7 +172,7 @@ async def _sync_inbound_for_phone_number(
|
|||
bind/unbind the number, not rewrite per-workflow URLs.
|
||||
"""
|
||||
try:
|
||||
provider = await get_telephony_provider_by_id(config_id)
|
||||
provider = await get_telephony_provider_by_id(config_id, organization_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load telephony provider for config {config_id}: {e}")
|
||||
return ProviderSyncStatus(ok=False, message=f"Provider load failed: {e}")
|
||||
|
|
@ -237,6 +231,7 @@ async def create_telephony_configuration(
|
|||
raise HTTPException(status_code=400, detail="No organization selected")
|
||||
|
||||
credentials = _credentials_from_payload(request.config)
|
||||
credentials = await _run_preprocess_hook(request.config.provider, credentials)
|
||||
|
||||
try:
|
||||
row = await db_client.create_telephony_configuration(
|
||||
|
|
@ -245,12 +240,20 @@ async def create_telephony_configuration(
|
|||
provider=request.config.provider,
|
||||
credentials=credentials,
|
||||
is_default_outbound=request.is_default_outbound,
|
||||
account_id_credential_field=_account_id_field(request.config.provider),
|
||||
)
|
||||
except TelephonyConfigurationDuplicateAccountError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
except IntegrityError as e:
|
||||
raise HTTPException(status_code=409, detail=f"Duplicate name: {e}")
|
||||
if "uq_telephony_configurations_org_name" in str(e):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
f"A telephony configuration named '{request.name}' already "
|
||||
f"exists in this organization. Pick a different name."
|
||||
),
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Telephony configuration violates a uniqueness constraint.",
|
||||
)
|
||||
|
||||
capture_event(
|
||||
distinct_id=str(user.provider_id),
|
||||
|
|
@ -310,17 +313,14 @@ async def update_telephony_configuration(
|
|||
preserve_masked_fields(
|
||||
existing.provider, credentials, existing.credentials or {}
|
||||
)
|
||||
credentials = await _run_preprocess_hook(existing.provider, credentials)
|
||||
|
||||
try:
|
||||
row = await db_client.update_telephony_configuration(
|
||||
config_id=config_id,
|
||||
organization_id=user.selected_organization_id,
|
||||
name=request.name,
|
||||
credentials=credentials,
|
||||
account_id_credential_field=_account_id_field(existing.provider),
|
||||
)
|
||||
except TelephonyConfigurationDuplicateAccountError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
row = await db_client.update_telephony_configuration(
|
||||
config_id=config_id,
|
||||
organization_id=user.selected_organization_id,
|
||||
name=request.name,
|
||||
credentials=credentials,
|
||||
)
|
||||
|
||||
return _detail_response(row)
|
||||
|
||||
|
|
@ -422,13 +422,49 @@ async def create_phone_number(
|
|||
):
|
||||
if not user.selected_organization_id:
|
||||
raise HTTPException(status_code=400, detail="No organization selected")
|
||||
await _ensure_config_belongs_to_org(config_id, user.selected_organization_id)
|
||||
cfg = await _ensure_config_belongs_to_org(config_id, user.selected_organization_id)
|
||||
|
||||
if request.inbound_workflow_id is not None:
|
||||
await _ensure_workflow_belongs_to_org(
|
||||
request.inbound_workflow_id, user.selected_organization_id
|
||||
)
|
||||
|
||||
# Inbound dispatch (find_inbound_route_by_account) keys on (provider,
|
||||
# credentials[account_id_field], address_normalized) without the org, so
|
||||
# that tuple has to be globally unique. Reject up front if another config —
|
||||
# in this org or any other — already owns the same combination.
|
||||
spec = telephony_registry.get_optional(cfg.provider)
|
||||
account_field = spec.account_id_credential_field if spec else ""
|
||||
account_id = (cfg.credentials or {}).get(account_field) if account_field else None
|
||||
if account_id:
|
||||
try:
|
||||
conflict = await db_client.find_inbound_routing_conflict(
|
||||
provider=cfg.provider,
|
||||
account_id_field=account_field,
|
||||
account_id=account_id,
|
||||
address=request.address,
|
||||
country_hint=request.country_code,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
if conflict:
|
||||
existing_cfg, existing_phone = conflict
|
||||
same_org = existing_cfg.organization_id == user.selected_organization_id
|
||||
scope = (
|
||||
f"telephony configuration '{existing_cfg.name}'"
|
||||
if same_org
|
||||
else "another organization using the same provider account"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
f"Phone number {existing_phone.address} is already registered "
|
||||
f"under {scope}. Inbound calls cannot be uniquely routed when "
|
||||
f"the same number is configured against the same provider "
|
||||
f"account in more than one place."
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
row = await db_client.create_phone_number(
|
||||
organization_id=user.selected_organization_id,
|
||||
|
|
@ -452,7 +488,7 @@ async def create_phone_number(
|
|||
response = _phone_number_to_response(row)
|
||||
if request.inbound_workflow_id is not None:
|
||||
response.provider_sync = await _sync_inbound_for_phone_number(
|
||||
config_id, row.address
|
||||
config_id, user.selected_organization_id, row.address
|
||||
)
|
||||
return response
|
||||
|
||||
|
|
@ -517,7 +553,7 @@ async def update_phone_number(
|
|||
# Sync the provider application or address with the inbound
|
||||
# calling webhook address
|
||||
response.provider_sync = await _sync_inbound_for_phone_number(
|
||||
config_id, row.address
|
||||
config_id, user.selected_organization_id, row.address
|
||||
)
|
||||
return response
|
||||
|
||||
|
|
@ -608,7 +644,6 @@ async def save_telephony_configuration(
|
|||
payload = request.model_dump()
|
||||
new_addresses = payload.pop("from_numbers", []) or []
|
||||
payload.pop("provider", None)
|
||||
field = _account_id_field(request.provider)
|
||||
|
||||
default = await db_client.get_default_telephony_configuration(
|
||||
user.selected_organization_id
|
||||
|
|
@ -616,27 +651,19 @@ async def save_telephony_configuration(
|
|||
|
||||
if default and default.provider == request.provider:
|
||||
preserve_masked_fields(request.provider, payload, default.credentials or {})
|
||||
try:
|
||||
row = await db_client.update_telephony_configuration(
|
||||
config_id=default.id,
|
||||
organization_id=user.selected_organization_id,
|
||||
credentials=payload,
|
||||
account_id_credential_field=field,
|
||||
)
|
||||
except TelephonyConfigurationDuplicateAccountError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
row = await db_client.update_telephony_configuration(
|
||||
config_id=default.id,
|
||||
organization_id=user.selected_organization_id,
|
||||
credentials=payload,
|
||||
)
|
||||
else:
|
||||
try:
|
||||
row = await db_client.create_telephony_configuration(
|
||||
organization_id=user.selected_organization_id,
|
||||
name=f"{request.provider.title()} Default",
|
||||
provider=request.provider,
|
||||
credentials=payload,
|
||||
is_default_outbound=True,
|
||||
account_id_credential_field=field,
|
||||
)
|
||||
except TelephonyConfigurationDuplicateAccountError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
row = await db_client.create_telephony_configuration(
|
||||
organization_id=user.selected_organization_id,
|
||||
name=f"{request.provider.title()} Default",
|
||||
provider=request.provider,
|
||||
credentials=payload,
|
||||
is_default_outbound=True,
|
||||
)
|
||||
|
||||
# Replace the phone-number set with the inline payload.
|
||||
existing_numbers = await db_client.list_phone_numbers_for_config(row.id)
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ from api.services.telephony.factory import (
|
|||
get_default_telephony_provider,
|
||||
get_telephony_provider,
|
||||
get_telephony_provider_by_id,
|
||||
get_telephony_provider_for_run,
|
||||
)
|
||||
from api.services.telephony.transfer_event_protocol import (
|
||||
TransferEvent,
|
||||
|
|
@ -77,14 +78,14 @@ async def initiate_call(
|
|||
telephony_configuration_id = request.telephony_configuration_id
|
||||
|
||||
if telephony_configuration_id:
|
||||
cfg = await db_client.get_telephony_configuration_for_org(
|
||||
telephony_configuration_id, user.selected_organization_id
|
||||
)
|
||||
if not cfg:
|
||||
try:
|
||||
provider = await get_telephony_provider_by_id(
|
||||
telephony_configuration_id, user.selected_organization_id
|
||||
)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="telephony_configuration_not_found"
|
||||
)
|
||||
provider = await get_telephony_provider_by_id(telephony_configuration_id)
|
||||
else:
|
||||
try:
|
||||
provider = await get_default_telephony_provider(
|
||||
|
|
@ -281,6 +282,7 @@ async def _validate_inbound_request(
|
|||
Validate all aspects of inbound request.
|
||||
Returns: (is_valid, error_type, workflow_context, provider_instance)
|
||||
"""
|
||||
from api.services.telephony import registry as telephony_registry
|
||||
|
||||
workflow = await db_client.get_workflow(workflow_id)
|
||||
if not workflow:
|
||||
|
|
@ -290,34 +292,60 @@ async def _validate_inbound_request(
|
|||
user_id = workflow.user_id
|
||||
provider = normalized_data.provider
|
||||
|
||||
# Resolve which of the org's configs this webhook came from (account_id match).
|
||||
(
|
||||
validation_result,
|
||||
telephony_configuration_id,
|
||||
) = await _resolve_inbound_telephony_config(
|
||||
organization_id, provider_class, normalized_data.account_id
|
||||
)
|
||||
if validation_result != TelephonyError.VALID:
|
||||
return False, validation_result, {}, None
|
||||
# Primary path: one combined query that resolves config + phone number
|
||||
# together (joins configs and phone_numbers with provider, account_id,
|
||||
# and called-number filters). Falls back to the two-step config-then-
|
||||
# phone resolution to cover providers without account_id (ARI) and
|
||||
# legacy non-E.164 stored addresses.
|
||||
spec = telephony_registry.get_optional(provider_class.PROVIDER_NAME)
|
||||
account_field = spec.account_id_credential_field if spec else ""
|
||||
|
||||
# Verify the called number is registered to that config.
|
||||
phone_number_id = await _verify_organization_phone_number(
|
||||
normalized_data.to_number,
|
||||
organization_id,
|
||||
telephony_configuration_id,
|
||||
provider_class.PROVIDER_NAME,
|
||||
normalized_data.to_country,
|
||||
normalized_data.from_country,
|
||||
)
|
||||
if phone_number_id is None:
|
||||
return False, TelephonyError.PHONE_NUMBER_NOT_CONFIGURED, {}, None
|
||||
telephony_configuration_id: Optional[int] = None
|
||||
phone_number_id: Optional[int] = None
|
||||
|
||||
if account_field and normalized_data.account_id:
|
||||
match = await db_client.find_inbound_route_by_account(
|
||||
provider=provider_class.PROVIDER_NAME,
|
||||
account_id_field=account_field,
|
||||
account_id=normalized_data.account_id,
|
||||
to_number=normalized_data.to_number,
|
||||
country_hint=normalized_data.to_country,
|
||||
organization_id=organization_id,
|
||||
)
|
||||
if match:
|
||||
cfg_row, phone_row = match
|
||||
telephony_configuration_id = cfg_row.id
|
||||
phone_number_id = phone_row.id
|
||||
|
||||
if telephony_configuration_id is None:
|
||||
(
|
||||
validation_result,
|
||||
telephony_configuration_id,
|
||||
) = await _resolve_inbound_telephony_config(
|
||||
organization_id, provider_class, normalized_data.account_id
|
||||
)
|
||||
if validation_result != TelephonyError.VALID:
|
||||
return False, validation_result, {}, None
|
||||
|
||||
phone_number_id = await _verify_organization_phone_number(
|
||||
normalized_data.to_number,
|
||||
organization_id,
|
||||
telephony_configuration_id,
|
||||
provider_class.PROVIDER_NAME,
|
||||
normalized_data.to_country,
|
||||
normalized_data.from_country,
|
||||
)
|
||||
if phone_number_id is None:
|
||||
return False, TelephonyError.PHONE_NUMBER_NOT_CONFIGURED, {}, None
|
||||
|
||||
# Verify webhook signature using the matched config's credentials. The
|
||||
# provider extracts its own signature/timestamp/nonce headers from the
|
||||
# dict, so this dispatcher stays generic.
|
||||
backend_endpoint, _ = await get_backend_endpoints()
|
||||
webhook_url = f"{backend_endpoint}/api/v1/telephony/inbound/{workflow_id}"
|
||||
provider_instance = await get_telephony_provider_by_id(telephony_configuration_id)
|
||||
provider_instance = await get_telephony_provider_by_id(
|
||||
telephony_configuration_id, organization_id
|
||||
)
|
||||
signature_valid = await provider_instance.verify_inbound_signature(
|
||||
webhook_url, webhook_data, headers, raw_body
|
||||
)
|
||||
|
|
@ -365,17 +393,21 @@ async def _create_inbound_workflow_run(
|
|||
"caller_number": normalized_data.from_number,
|
||||
"called_number": normalized_data.to_number,
|
||||
"direction": "inbound",
|
||||
"account_id": normalized_data.account_id,
|
||||
"provider": provider,
|
||||
"from_country": normalized_data.from_country,
|
||||
"to_country": normalized_data.to_country,
|
||||
"raw_webhook_data": normalized_data.raw_data,
|
||||
"telephony_configuration_id": telephony_configuration_id,
|
||||
"from_phone_number_id": from_phone_number_id,
|
||||
},
|
||||
gathered_context={
|
||||
"call_id": call_id,
|
||||
},
|
||||
logs={
|
||||
"inbound_webhook": {
|
||||
"account_id": normalized_data.account_id,
|
||||
"from_country": normalized_data.from_country,
|
||||
"to_country": normalized_data.to_country,
|
||||
"from_phone_number_id": from_phone_number_id,
|
||||
"raw_webhook_data": normalized_data.raw_data,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
|
|
@ -515,8 +547,9 @@ async def _handle_telephony_websocket(
|
|||
f"WebSocket connected for {provider_type} provider, workflow_run {workflow_run_id}"
|
||||
)
|
||||
|
||||
# Get the telephony provider instance
|
||||
provider = await get_telephony_provider(workflow.organization_id)
|
||||
provider = await get_telephony_provider_for_run(
|
||||
workflow_run, workflow.organization_id
|
||||
)
|
||||
|
||||
# Verify the provider matches what was stored
|
||||
if provider.PROVIDER_NAME != provider_type:
|
||||
|
|
@ -590,62 +623,37 @@ async def handle_inbound_run(request: Request):
|
|||
)
|
||||
return generic_hangup_response()
|
||||
|
||||
# 1. Resolve config globally from (provider, account_id).
|
||||
# 1. Resolve (config, phone_number) in a single SQL roundtrip that
|
||||
# joins telephony_configurations and telephony_phone_numbers and
|
||||
# filters on (provider, credentials[account_id_field], called number
|
||||
# canonical address, is_active). The phone-number row's existence in
|
||||
# the matched config simultaneously identifies the org — we never
|
||||
# match a config from one org against a phone owned by another.
|
||||
spec = telephony_registry.get_optional(provider_class.PROVIDER_NAME)
|
||||
account_field = spec.account_id_credential_field if spec else ""
|
||||
config = await db_client.find_telephony_config_by_account(
|
||||
provider_class.PROVIDER_NAME,
|
||||
account_field,
|
||||
normalized_data.account_id or "",
|
||||
)
|
||||
if not config:
|
||||
logger.warning(
|
||||
f"/inbound/run: no config matched provider="
|
||||
f"{provider_class.PROVIDER_NAME} account_id={normalized_data.account_id}"
|
||||
)
|
||||
return provider_class.generate_validation_error_response(
|
||||
TelephonyError.ACCOUNT_VALIDATION_FAILED
|
||||
)
|
||||
|
||||
organization_id = config.organization_id
|
||||
telephony_configuration_id = config.id
|
||||
|
||||
# 2. Resolve workflow via the called number's inbound_workflow_id.
|
||||
phone_row = await db_client.find_active_phone_number_for_inbound(
|
||||
organization_id,
|
||||
normalized_data.to_number,
|
||||
provider_class.PROVIDER_NAME,
|
||||
match = await db_client.find_inbound_route_by_account(
|
||||
provider=provider_class.PROVIDER_NAME,
|
||||
account_id_field=account_field,
|
||||
account_id=normalized_data.account_id or "",
|
||||
to_number=normalized_data.to_number,
|
||||
country_hint=normalized_data.to_country,
|
||||
)
|
||||
# Legacy fallback for non-E.164 stored addresses.
|
||||
if (
|
||||
not phone_row
|
||||
or phone_row.telephony_configuration_id != telephony_configuration_id
|
||||
):
|
||||
phone_row = None
|
||||
for row in await db_client.list_phone_numbers_for_config(
|
||||
telephony_configuration_id
|
||||
):
|
||||
if not row.is_active:
|
||||
continue
|
||||
if numbers_match(
|
||||
normalized_data.to_number,
|
||||
row.address,
|
||||
normalized_data.to_country,
|
||||
normalized_data.from_country,
|
||||
):
|
||||
phone_row = row
|
||||
break
|
||||
|
||||
if not phone_row:
|
||||
if not match:
|
||||
logger.warning(
|
||||
f"/inbound/run: number {normalized_data.to_number} not registered "
|
||||
f"in config {telephony_configuration_id}"
|
||||
f"/inbound/run: no inbound route matched "
|
||||
f"provider={provider_class.PROVIDER_NAME} "
|
||||
f"account_id={normalized_data.account_id} "
|
||||
f"to={normalized_data.to_number}"
|
||||
)
|
||||
return provider_class.generate_validation_error_response(
|
||||
TelephonyError.PHONE_NUMBER_NOT_CONFIGURED
|
||||
)
|
||||
|
||||
config, phone_row = match
|
||||
telephony_configuration_id = config.id
|
||||
|
||||
if not phone_row.inbound_workflow_id:
|
||||
logger.warning(
|
||||
f"/inbound/run: number {normalized_data.to_number} has no "
|
||||
|
|
@ -656,8 +664,13 @@ async def handle_inbound_run(request: Request):
|
|||
)
|
||||
|
||||
workflow_id = phone_row.inbound_workflow_id
|
||||
workflow = await db_client.get_workflow(workflow_id)
|
||||
workflow = await db_client.get_workflow(
|
||||
workflow_id, organization_id=config.organization_id
|
||||
)
|
||||
if not workflow:
|
||||
logger.warning(
|
||||
f"/inbound/run: workflow not found {workflow_id} for org {config.organization_id}"
|
||||
)
|
||||
return provider_class.generate_validation_error_response(
|
||||
TelephonyError.WORKFLOW_NOT_FOUND
|
||||
)
|
||||
|
|
@ -667,7 +680,7 @@ async def handle_inbound_run(request: Request):
|
|||
backend_endpoint, wss_backend_endpoint = await get_backend_endpoints()
|
||||
webhook_url = f"{backend_endpoint}/api/v1/telephony/inbound/run"
|
||||
provider_instance = await get_telephony_provider_by_id(
|
||||
telephony_configuration_id
|
||||
telephony_configuration_id, config.organization_id
|
||||
)
|
||||
signature_valid = await provider_instance.verify_inbound_signature(
|
||||
webhook_url, webhook_data, headers, raw_body
|
||||
|
|
|
|||
|
|
@ -240,6 +240,7 @@ class WorkflowResponse(BaseModel):
|
|||
workflow_configurations: dict | None = None
|
||||
version_number: int | None = None
|
||||
version_status: str | None = None
|
||||
workflow_uuid: str | None = None
|
||||
|
||||
|
||||
class WorkflowListResponse(BaseModel):
|
||||
|
|
@ -695,6 +696,7 @@ async def get_workflow(
|
|||
"workflow_configurations": workflow_configs,
|
||||
"version_number": active_def.version_number if active_def else None,
|
||||
"version_status": active_def.status if active_def else None,
|
||||
"workflow_uuid": workflow.workflow_uuid,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,15 @@
|
|||
"""Request/response schemas for the phone-number CRUD endpoints."""
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
# Mirrors the regexes in api/utils/telephony_address.py — keep in sync.
|
||||
_ADDRESS_FORMAT_STRIP_RE = re.compile(r"[\s\-()]")
|
||||
_ADDRESS_E164_RE = re.compile(r"^\+\d{8,15}$")
|
||||
_ADDRESS_BARE_DIGITS_RE = re.compile(r"^\d{8,15}$")
|
||||
|
||||
|
||||
class PhoneNumberCreateRequest(BaseModel):
|
||||
|
|
@ -22,6 +28,38 @@ class PhoneNumberCreateRequest(BaseModel):
|
|||
is_default_caller_id: bool = False
|
||||
extra_metadata: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_address_shape(self) -> "PhoneNumberCreateRequest":
|
||||
"""Reject the one shape that produces a broken canonical form:
|
||||
8-15 bare digits without a leading "+" and without a country code.
|
||||
|
||||
Without a country hint, ``normalize_telephony_address`` would treat
|
||||
such input as PSTN and return a junk E.164 (e.g. "02271264296" →
|
||||
"+02271264296"). Either include the "+" and dial code, or pass
|
||||
``country_code`` so the helper can apply the right prefix.
|
||||
|
||||
Other shapes (SIP URIs, short extensions, alphanumerics) are
|
||||
intentionally permissive — the address parser handles them.
|
||||
"""
|
||||
raw = self.address.strip()
|
||||
# SIP URI: backend parser handles it.
|
||||
if raw.lower().startswith(("sip:", "sips:")):
|
||||
return self
|
||||
stripped = _ADDRESS_FORMAT_STRIP_RE.sub("", raw)
|
||||
# E.164 shape — fine without country hint.
|
||||
if _ADDRESS_E164_RE.fullmatch(stripped):
|
||||
return self
|
||||
# 8-15 bare digits — must have country_code, otherwise the
|
||||
# canonical form will be wrong.
|
||||
if _ADDRESS_BARE_DIGITS_RE.fullmatch(stripped) and not self.country_code:
|
||||
raise ValueError(
|
||||
"PSTN addresses without a leading '+' need a country_code "
|
||||
"(ISO-2, e.g. 'US' or 'IN') so we can produce the right "
|
||||
"E.164 form. Either include the country code in the address "
|
||||
"(e.g. '+14155551234') or set country_code."
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class PhoneNumberUpdateRequest(BaseModel):
|
||||
"""Partial update. ``address`` is intentionally immutable — to change a
|
||||
|
|
|
|||
|
|
@ -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] = {}
|
||||
|
|
|
|||
|
|
@ -119,57 +119,6 @@ def _test_number_formats_with_country_code(
|
|||
return False
|
||||
|
||||
|
||||
def normalize_phone_number(phone_number: str, country_code: str = None) -> str:
|
||||
"""
|
||||
Normalize a phone number to E.164 format using country context.
|
||||
|
||||
Args:
|
||||
phone_number: Phone number to normalize
|
||||
country_code: ISO country code (e.g., "US", "IN") for context
|
||||
|
||||
Returns:
|
||||
Phone number in E.164 format (e.g., "+14155552671", "+919876543210")
|
||||
"""
|
||||
if not phone_number:
|
||||
return ""
|
||||
|
||||
# Remove spaces, hyphens, and other formatting
|
||||
clean_number = (
|
||||
phone_number.replace(" ", "").replace("-", "").replace("(", "").replace(")", "")
|
||||
)
|
||||
|
||||
# Already in E.164 format
|
||||
if clean_number.startswith("+"):
|
||||
return clean_number
|
||||
|
||||
# Get dialing code for the country
|
||||
if country_code:
|
||||
dialing_code = get_country_code(country_code)
|
||||
if dialing_code:
|
||||
# Remove leading 0 if present (common in many countries)
|
||||
if clean_number.startswith("0"):
|
||||
clean_number = clean_number[1:]
|
||||
|
||||
# Add country code if not already present
|
||||
if not clean_number.startswith(dialing_code):
|
||||
return f"+{dialing_code}{clean_number}"
|
||||
else:
|
||||
return f"+{clean_number}"
|
||||
|
||||
# Fallback: try to guess common formats
|
||||
if clean_number.startswith("0") and len(clean_number) == 11:
|
||||
# Without country context, prefer India for now
|
||||
return f"+91{clean_number[1:]}"
|
||||
elif len(clean_number) == 10:
|
||||
# Without context, this is ambiguous - return as-is with + prefix
|
||||
return f"+{clean_number}"
|
||||
elif not clean_number.startswith("+"):
|
||||
# Add + prefix if missing
|
||||
return f"+{clean_number}"
|
||||
|
||||
return clean_number
|
||||
|
||||
|
||||
def normalize_webhook_data(provider_class, webhook_data):
|
||||
"""Normalize webhook data using the provider's parse method"""
|
||||
return provider_class.parse_inbound_webhook(webhook_data)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue