feat: agent stream for cloudonix OPBX (#261)

* feat: agent stream for cloudonix OPBX

* feat: make cloudonix app name optional

* feat: create application while configuring telephony config

* fix: get telephony configuration from stamped workflow run

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

3
.gitignore vendored
View file

@ -17,4 +17,5 @@ venv/
coturn/
*.wav
dograh_pcm_cache/
node_modules/
node_modules/
.vscode

View file

@ -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(

View file

@ -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,

View file

@ -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,

View file

@ -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
View 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

View file

@ -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):

View file

@ -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)

View file

@ -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

View file

@ -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,
}

View file

@ -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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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)

View 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.

View file

@ -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)}

View file

@ -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>

View file

@ -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[],

View file

@ -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&apos;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>

View file

@ -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;
};
/**

View file

@ -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>

View file

@ -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"}