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

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