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
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -17,4 +17,5 @@ venv/
|
|||
coturn/
|
||||
*.wav
|
||||
dograh_pcm_cache/
|
||||
node_modules/
|
||||
node_modules/
|
||||
.vscode
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
139
docs/integrations/telephony/agent-stream.mdx
Normal file
139
docs/integrations/telephony/agent-stream.mdx
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
---
|
||||
title: "Agent Stream"
|
||||
description: "Stream audio to a Dograh agent over a WebSocket using inline provider credentials"
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Agent Stream is a WebSocket endpoint that lets an external caller drive a Dograh agent run by passing everything inline in the query string — including the telephony provider's credentials. Unlike the standard inbound webhook flow, this path does **not** require a stored telephony configuration in your Dograh organization. You bring the credentials with you when you connect.
|
||||
|
||||
This is useful when:
|
||||
|
||||
- You manage your telephony tenant outside Dograh and only want Dograh as the agent runtime
|
||||
- You're integrating Dograh into a SIP gateway or in-house dialer
|
||||
- You need a single endpoint that doesn't need per-tenant DB rows for every caller
|
||||
|
||||
The endpoint authenticates with a Dograh **API key** (so the connection itself is authorized against your organization), and routes the audio through the named provider's serializer.
|
||||
|
||||
<Warning>
|
||||
Agent Stream currently supports the **Cloudonix** provider only. Other providers
|
||||
return `NotImplementedError` until a per-provider implementation lands. If you
|
||||
need Twilio, Plivo, Telnyx, Vonage, ARI, or another provider, please open a
|
||||
request on [GitHub Discussions](https://github.com/dograh-hq/dograh/discussions)
|
||||
with your use case.
|
||||
</Warning>
|
||||
|
||||
## Endpoint
|
||||
|
||||
```
|
||||
wss://app.dograh.com/api/v1/agent-stream/{workflow_uuid}
|
||||
```
|
||||
|
||||
`{workflow_uuid}` is the agent's stable UUID (see [Get the Agent UUID](#get-the-agent-uuid) below). On self-hosted deployments, replace `app.dograh.com` with your backend host.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A Dograh agent (workflow) — published or in draft is fine
|
||||
- A Dograh API key with access to the organization that owns the agent
|
||||
- Provider credentials (for Cloudonix: bearer token + domain ID)
|
||||
|
||||
## Get the Agent UUID
|
||||
|
||||
The Agent UUID is the workflow's stable identifier — it doesn't change when versions are published. You can copy it from two places in the dashboard:
|
||||
|
||||
**From the workflow editor**
|
||||
|
||||
1. Open your agent in the workflow editor
|
||||
2. Click the **⋮** (more options) menu in the top-right of the header
|
||||
3. Click **Copy Agent UUID** — the toast confirms the copy
|
||||
|
||||
**From the agent's Settings page**
|
||||
|
||||
1. Open the agent and go to **Settings**
|
||||
2. Scroll to the **Agent UUID** section (also linked in the right-side nav)
|
||||
3. Click the UUID code block, or use the **Copy UUID** button
|
||||
|
||||
## Create a Dograh API key
|
||||
|
||||
Agent Stream auth uses your Dograh API key — not your provider's bearer token. The provider credentials go in separate query params (see [URL parameters](#url-parameters) below).
|
||||
|
||||
1. From the dashboard, navigate to **API Keys**
|
||||
2. Click **Create API key**, give it a name, and copy the generated key (it starts with `dg_`)
|
||||
3. Store it securely — Dograh shows the full key only once at creation time
|
||||
|
||||
For programmatic management, see the [API Keys reference](/api-reference/api-keys).
|
||||
|
||||
<Note>
|
||||
API keys are scoped to an organization. The agent referenced by the URL must
|
||||
belong to the same organization as the key, otherwise the connection is
|
||||
rejected with WebSocket close code `1008`.
|
||||
</Note>
|
||||
|
||||
## Connect to the WebSocket
|
||||
|
||||
### URL parameters
|
||||
|
||||
| Param | Required | Description |
|
||||
| --- | --- | --- |
|
||||
| `api_key` | Yes | Your Dograh API key (`dg_...`). Authorizes the connection. |
|
||||
| `provider` | Yes | Provider name. Currently only `cloudonix` is supported. |
|
||||
| `session` | Yes (cloudonix) | Cloudonix domain bearer token used to drive the call (hangup, etc.). |
|
||||
| `AccountSid` | Yes (cloudonix) | Cloudonix domain ID. |
|
||||
| `CallSid` | Yes (cloudonix) | Cloudonix call SID for this session. |
|
||||
| `callId` | No | SIP-side call identifier; persisted on the workflow run for record-keeping. |
|
||||
| `from` | No | Caller phone number, persisted on the workflow run as `caller_number`. |
|
||||
| `to` | No | Called phone number, persisted on the workflow run as `called_number`. |
|
||||
|
||||
### Cloudonix example
|
||||
|
||||
```
|
||||
wss://app.dograh.com/api/v1/agent-stream/{workflow_uuid}
|
||||
?api_key=dg_xxxxxxxxxxxxxxxx
|
||||
&provider=cloudonix
|
||||
&session={CLOUDONIX_BEARER_TOKEN}
|
||||
&AccountSid={CLOUDONIX_DOMAIN_ID}
|
||||
&CallSid={CALL_SID}
|
||||
&callId={SIP_CALL_ID}
|
||||
&from=+15555550100
|
||||
&to=+15555550199
|
||||
```
|
||||
|
||||
Use this URL inside the CXML `<Stream>` your Cloudonix Voice Application returns when the call needs to be bridged to the Dograh agent:
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Response>
|
||||
<Connect>
|
||||
<Stream url="wss://app.dograh.com/api/v1/agent-stream/{workflow_uuid}?api_key=dg_...&provider=cloudonix&session=...&AccountSid=...&CallSid=...&callId=...&from=...&to=..."/>
|
||||
</Connect>
|
||||
<Pause length="40"/>
|
||||
</Response>
|
||||
```
|
||||
|
||||
The first two messages on the socket should be Cloudonix's standard `connected` and `start` events (Twilio-compatible framing). Dograh extracts `streamSid` and `callSid` from the `start` event and begins streaming audio.
|
||||
|
||||
## Workflow run lifecycle
|
||||
|
||||
When the WebSocket is accepted, Dograh:
|
||||
|
||||
1. Resolves your API key to a user and organization
|
||||
2. Looks up the workflow by `workflow_uuid`, scoped to that organization
|
||||
3. Runs a quota check
|
||||
4. Creates a new `WorkflowRun` (`call_type=inbound`, `mode=cloudonix`, name `WR-AGS-XXXXXXXX`) with the `from`/`to`/`callId`/`AccountSid` fields stamped on `initial_context`
|
||||
5. Transitions the run to `running` and starts the agent pipeline
|
||||
|
||||
The run is visible under the agent's **Runs** tab as soon as it's minted, just like an inbound or outbound call.
|
||||
|
||||
## Close codes
|
||||
|
||||
| Code | Reason |
|
||||
| --- | --- |
|
||||
| `1008` | Auth or routing failure — invalid API key, unknown provider, workflow not found in your organization, or quota exceeded |
|
||||
| `1011` | Server-side failure or unsupported provider for Agent Stream |
|
||||
| `4400` | Provider-level handshake error — for cloudonix, missing `session`/`AccountSid` or malformed `connected`/`start` events |
|
||||
|
||||
## Security notes
|
||||
|
||||
- Treat the URL as a secret — both the Dograh API key and the provider bearer token sit in the query string. Store and transmit it only over TLS, and avoid logging the raw URL in places where access is broader than your operations team.
|
||||
- Rotate the Dograh API key from the dashboard if you suspect it has leaked.
|
||||
- The Dograh API key authorizes the connection against your organization. The provider bearer token is only used by Dograh to drive provider-side actions (e.g. hangup) — it is not stored on the workflow run.
|
||||
|
|
@ -42,6 +42,7 @@ const edgeTypes = {
|
|||
interface RenderWorkflowProps {
|
||||
initialWorkflowName: string;
|
||||
workflowId: number;
|
||||
workflowUuid?: string;
|
||||
initialFlow?: {
|
||||
nodes: FlowNode[];
|
||||
edges: FlowEdge[];
|
||||
|
|
@ -58,7 +59,7 @@ interface RenderWorkflowProps {
|
|||
user: { id: string; email?: string };
|
||||
}
|
||||
|
||||
function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialTemplateContextVariables, initialWorkflowConfigurations, initialVersionNumber, initialVersionStatus, user }: RenderWorkflowProps) {
|
||||
function RenderWorkflow({ initialWorkflowName, workflowId, workflowUuid, initialFlow, initialTemplateContextVariables, initialWorkflowConfigurations, initialVersionNumber, initialVersionStatus, user }: RenderWorkflowProps) {
|
||||
const router = useRouter();
|
||||
const [isPhoneCallDialogOpen, setIsPhoneCallDialogOpen] = useState(false);
|
||||
const [isVersionPanelOpen, setIsVersionPanelOpen] = useState(false);
|
||||
|
|
@ -303,6 +304,7 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
|
|||
rfInstance={rfInstance}
|
||||
onRun={onRun}
|
||||
workflowId={workflowId}
|
||||
workflowUuid={workflowUuid}
|
||||
saveWorkflow={guardedSaveWorkflow}
|
||||
user={user}
|
||||
onPhoneCallClick={() => setIsPhoneCallDialogOpen(true)}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { ReactFlowInstance } from "@xyflow/react";
|
||||
import { AlertCircle, ArrowLeft, ChevronDown, Copy, Download, Eye, History, LoaderCircle, Menu, MoreVertical, Phone, Rocket } from "lucide-react";
|
||||
import { AlertCircle, ArrowLeft, ChevronDown, Clipboard, Copy, Download, Eye, History, LoaderCircle, Menu, MoreVertical, Phone, Rocket } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import posthog from "posthog-js";
|
||||
import { useState } from "react";
|
||||
|
|
@ -37,6 +37,7 @@ interface WorkflowEditorHeaderProps {
|
|||
rfInstance: React.RefObject<ReactFlowInstance<FlowNode, FlowEdge> | null>;
|
||||
onRun: (mode: string) => Promise<void>;
|
||||
workflowId: number;
|
||||
workflowUuid?: string;
|
||||
saveWorkflow: (updateWorkflowDefinition?: boolean) => Promise<void>;
|
||||
user: { id: string; email?: string };
|
||||
onPhoneCallClick: () => void;
|
||||
|
|
@ -63,6 +64,7 @@ export const WorkflowEditorHeader = ({
|
|||
hasDraft,
|
||||
onPublished,
|
||||
workflowId,
|
||||
workflowUuid,
|
||||
}: WorkflowEditorHeaderProps) => {
|
||||
const router = useRouter();
|
||||
const { toggleSidebar } = useSidebar();
|
||||
|
|
@ -123,6 +125,19 @@ export const WorkflowEditorHeader = ({
|
|||
}
|
||||
};
|
||||
|
||||
const handleCopyAgentUuid = async () => {
|
||||
if (!workflowUuid) {
|
||||
toast.error("Agent UUID not available");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(workflowUuid);
|
||||
toast.success("Agent UUID copied");
|
||||
} catch {
|
||||
toast.error("Failed to copy Agent UUID");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadWorkflow = () => {
|
||||
if (!rfInstance.current) return;
|
||||
|
||||
|
|
@ -380,6 +395,14 @@ export const WorkflowEditorHeader = ({
|
|||
<Download className="w-4 h-4 mr-2" />
|
||||
Download Workflow
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={handleCopyAgentUuid}
|
||||
disabled={!workflowUuid}
|
||||
className="text-white hover:bg-[#2a2a2a] cursor-pointer"
|
||||
>
|
||||
<Clipboard className="w-4 h-4 mr-2" />
|
||||
Copy Agent UUID
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ export default function WorkflowDetailPage() {
|
|||
<RenderWorkflow
|
||||
initialWorkflowName={workflow.name}
|
||||
workflowId={workflow.id}
|
||||
workflowUuid={workflow.workflow_uuid ?? undefined}
|
||||
initialFlow={{
|
||||
nodes: workflow.workflow_definition.nodes as FlowNode[],
|
||||
edges: workflow.workflow_definition.edges as FlowEdge[],
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { format } from "date-fns";
|
||||
import { ArrowLeft, BookA, Brain, CalendarIcon, Download, ExternalLink, FileDown, Loader2, Mic, Pause, PhoneOff, Play, Rocket, Settings, Trash2Icon, Upload, Variable, X } from "lucide-react";
|
||||
import { ArrowLeft, BookA, Brain, CalendarIcon, Clipboard, Download, ExternalLink, FileDown, Fingerprint, Loader2, Mic, Pause, PhoneOff, Play, Rocket, Settings, Trash2Icon, Upload, Variable, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
|
@ -81,6 +81,7 @@ const NAV_ITEMS = [
|
|||
{ id: "recordings", label: "Recordings", icon: Mic },
|
||||
{ id: "deployment", label: "Deployment", icon: Rocket },
|
||||
{ id: "report", label: "Report", icon: FileDown },
|
||||
{ id: "identity", label: "Agent UUID", icon: Fingerprint },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -992,6 +993,53 @@ function VoicemailSection({
|
|||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Section: Agent UUID
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function AgentUuidSection({ workflowUuid }: { workflowUuid: string }) {
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(workflowUuid);
|
||||
toast.success("Agent UUID copied");
|
||||
} catch {
|
||||
toast.error("Failed to copy Agent UUID");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card id="identity">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Fingerprint className="h-4 w-4" />
|
||||
Agent UUID
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Stable identifier for this agent. Used in agent-stream URLs and
|
||||
other integrations where a numeric workflow ID isn't portable.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
title="Click to copy"
|
||||
className="group flex w-full items-center gap-2 rounded-md border bg-muted/20 p-2 text-left font-mono text-xs transition-colors hover:bg-muted/40"
|
||||
>
|
||||
<code className="flex-1 truncate">{workflowUuid}</code>
|
||||
<Clipboard className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-colors group-hover:text-foreground" />
|
||||
</button>
|
||||
</CardContent>
|
||||
<CardFooter className="border-t pt-6">
|
||||
<Button variant="outline" size="sm" onClick={handleCopy}>
|
||||
<Clipboard className="h-3.5 w-3.5 mr-2" />
|
||||
Copy UUID
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -1263,6 +1311,11 @@ function WorkflowSettingsInner({
|
|||
|
||||
{/* Report */}
|
||||
<ReportSection workflowId={workflowId} />
|
||||
|
||||
{/* Agent UUID */}
|
||||
{workflow.workflow_uuid && (
|
||||
<AgentUuidSection workflowUuid={workflow.workflow_uuid} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -704,9 +704,9 @@ export type CloudonixConfigurationRequest = {
|
|||
/**
|
||||
* Application Name
|
||||
*
|
||||
* Cloudonix Voice Application name. The application's url is updated when inbound workflows are attached to numbers on this domain.
|
||||
* Cloudonix Voice Application name. The application's url is updated when inbound workflows are attached to numbers on this domain. If omitted, an application is auto-created on save and its name is stored on the configuration.
|
||||
*/
|
||||
application_name: string;
|
||||
application_name?: string | null;
|
||||
/**
|
||||
* From Numbers
|
||||
*
|
||||
|
|
@ -736,7 +736,7 @@ export type CloudonixConfigurationResponse = {
|
|||
/**
|
||||
* Application Name
|
||||
*/
|
||||
application_name: string;
|
||||
application_name?: string | null;
|
||||
/**
|
||||
* From Numbers
|
||||
*/
|
||||
|
|
@ -2392,9 +2392,9 @@ export type PlivoConfigurationRequest = {
|
|||
/**
|
||||
* Application Id
|
||||
*
|
||||
* Plivo Application ID. The application's answer_url is updated when inbound workflows are attached to numbers on this account.
|
||||
* Plivo Application ID. The application's answer_url is updated 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.
|
||||
*/
|
||||
application_id: string;
|
||||
application_id?: string | null;
|
||||
/**
|
||||
* From Numbers
|
||||
*
|
||||
|
|
@ -2424,7 +2424,7 @@ export type PlivoConfigurationResponse = {
|
|||
/**
|
||||
* Application Id
|
||||
*/
|
||||
application_id: string;
|
||||
application_id?: string | null;
|
||||
/**
|
||||
* From Numbers
|
||||
*/
|
||||
|
|
@ -3388,9 +3388,9 @@ export type TelnyxConfigurationRequest = {
|
|||
/**
|
||||
* Connection Id
|
||||
*
|
||||
* Telnyx Call Control Application ID (connection_id)
|
||||
* 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.
|
||||
*/
|
||||
connection_id: string;
|
||||
connection_id?: string | null;
|
||||
/**
|
||||
* From Numbers
|
||||
*
|
||||
|
|
@ -3416,7 +3416,7 @@ export type TelnyxConfigurationResponse = {
|
|||
/**
|
||||
* Connection Id
|
||||
*/
|
||||
connection_id: string;
|
||||
connection_id?: string | null;
|
||||
/**
|
||||
* From Numbers
|
||||
*/
|
||||
|
|
@ -4125,9 +4125,9 @@ export type VobizConfigurationRequest = {
|
|||
/**
|
||||
* Application Id
|
||||
*
|
||||
* Vobiz Application ID. The application's answer_url is updated when inbound workflows are attached to numbers on this account.
|
||||
* Vobiz Application ID. The application's answer_url is updated 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.
|
||||
*/
|
||||
application_id: string;
|
||||
application_id?: string | null;
|
||||
/**
|
||||
* From Numbers
|
||||
*
|
||||
|
|
@ -4157,7 +4157,7 @@ export type VobizConfigurationResponse = {
|
|||
/**
|
||||
* Application Id
|
||||
*/
|
||||
application_id: string;
|
||||
application_id?: string | null;
|
||||
/**
|
||||
* From Numbers
|
||||
*/
|
||||
|
|
@ -4429,6 +4429,10 @@ export type WorkflowResponse = {
|
|||
* Version Status
|
||||
*/
|
||||
version_status?: string | null;
|
||||
/**
|
||||
* Workflow Uuid
|
||||
*/
|
||||
workflow_uuid?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -40,6 +40,26 @@ interface PhoneNumberDialogProps {
|
|||
|
||||
const NO_WORKFLOW = "__none__";
|
||||
|
||||
// Mirrors api/schemas/telephony_phone_number.py::_validate_address_shape and
|
||||
// api/utils/telephony_address.py — keep in sync. Returns an error message
|
||||
// when the address would normalize to a broken canonical form, or null when
|
||||
// the input is acceptable.
|
||||
const ADDRESS_FORMAT_STRIP_RE = /[\s\-()]/g;
|
||||
const ADDRESS_E164_RE = /^\+\d{8,15}$/;
|
||||
const ADDRESS_BARE_DIGITS_RE = /^\d{8,15}$/;
|
||||
|
||||
function validateAddress(rawAddress: string, countryCode: string): string | null {
|
||||
const trimmed = rawAddress.trim();
|
||||
if (!trimmed) return "Address is required";
|
||||
if (/^sips?:/i.test(trimmed)) return null;
|
||||
const stripped = trimmed.replace(ADDRESS_FORMAT_STRIP_RE, "");
|
||||
if (ADDRESS_E164_RE.test(stripped)) return null;
|
||||
if (ADDRESS_BARE_DIGITS_RE.test(stripped) && !countryCode.trim()) {
|
||||
return "PSTN addresses without a leading '+' need a Country (ISO-2) hint, or include the country code in the address (e.g. +14155551234).";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function PhoneNumberDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
|
|
@ -58,6 +78,7 @@ export function PhoneNumberDialog({
|
|||
const [inboundWorkflowId, setInboundWorkflowId] = useState<string>(NO_WORKFLOW);
|
||||
const [workflows, setWorkflows] = useState<{ id: number; name: string }[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [addressTouched, setAddressTouched] = useState(false);
|
||||
|
||||
// Reset form when the dialog opens.
|
||||
useEffect(() => {
|
||||
|
|
@ -70,8 +91,12 @@ export function PhoneNumberDialog({
|
|||
setInboundWorkflowId(
|
||||
existing?.inbound_workflow_id ? String(existing.inbound_workflow_id) : NO_WORKFLOW,
|
||||
);
|
||||
setAddressTouched(false);
|
||||
}, [open, existing]);
|
||||
|
||||
// Only validate the address on create — edits keep the immutable address.
|
||||
const addressError = isEdit ? null : validateAddress(address, countryCode);
|
||||
|
||||
// Load workflows for the inbound dropdown.
|
||||
useEffect(() => {
|
||||
if (!open || !user) return;
|
||||
|
|
@ -92,9 +117,13 @@ export function PhoneNumberDialog({
|
|||
}, [open, user, getAccessToken]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!isEdit && !address.trim()) {
|
||||
toast.error("Address is required");
|
||||
return;
|
||||
if (!isEdit) {
|
||||
const err = validateAddress(address, countryCode);
|
||||
if (err) {
|
||||
setAddressTouched(true);
|
||||
toast.error(err);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
|
|
@ -174,8 +203,13 @@ export function PhoneNumberDialog({
|
|||
placeholder="+19781899185, sip:101@asterisk.local, or 101"
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
onBlur={() => setAddressTouched(true)}
|
||||
disabled={isEdit}
|
||||
aria-invalid={addressTouched && !!addressError}
|
||||
/>
|
||||
{!isEdit && addressTouched && addressError && (
|
||||
<p className="text-xs text-destructive">{addressError}</p>
|
||||
)}
|
||||
{isEdit && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Address cannot be changed. Delete this number and create a new one to
|
||||
|
|
@ -257,7 +291,10 @@ export function PhoneNumberDialog({
|
|||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={submitting}>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting || (!isEdit && !!addressError)}
|
||||
>
|
||||
{submitting ? "Saving..." : isEdit ? "Save changes" : "Add"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use client';
|
||||
|
||||
import { Archive, Eye, RotateCcw } from 'lucide-react';
|
||||
import { Archive, Pencil, RotateCcw } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState, useTransition } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
|
@ -33,7 +33,7 @@ export function WorkflowTable({ workflows, showArchived }: WorkflowTableProps) {
|
|||
const [isPending, startTransition] = useTransition();
|
||||
const [loadingWorkflowId, setLoadingWorkflowId] = useState<number | null>(null);
|
||||
|
||||
const handleView = (id: number) => {
|
||||
const handleEdit = (id: number) => {
|
||||
router.push(`/workflow/${id}`);
|
||||
};
|
||||
|
||||
|
|
@ -108,11 +108,11 @@ export function WorkflowTable({ workflows, showArchived }: WorkflowTableProps) {
|
|||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleView(workflow.id)}
|
||||
onClick={() => handleEdit(workflow.id)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Eye size={16} />
|
||||
View
|
||||
<Pencil size={16} />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant={showArchived ? "default" : "outline"}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue