feat: refactor telephony to support multiple telephony configurations (#251)

Co-authored-by: Sabiha Khan <sabihak89@gmail.com>
This commit is contained in:
Abhishek 2026-04-29 11:39:57 +05:30 committed by GitHub
parent 2f860e7f6d
commit e16f6438bd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
101 changed files with 10906 additions and 5420 deletions

View file

@ -1,7 +1,7 @@
import asyncio
import time
from datetime import UTC, datetime
from typing import Optional
from typing import TYPE_CHECKING, Optional
from loguru import logger
@ -15,10 +15,15 @@ from api.services.campaign.errors import (
PhoneNumberPoolExhaustedError,
)
from api.services.campaign.rate_limiter import rate_limiter
from api.services.telephony.base import TelephonyProvider
from api.services.telephony.factory import get_telephony_provider
from api.utils.common import get_backend_endpoints
if TYPE_CHECKING:
# Type-only — importing api.services.telephony eagerly triggers the
# provider package init, which can pull in this module via the routes
# chain and create a circular import. Runtime calls below go through
# ``factory.get_telephony_provider`` (lazy import inside the method).
from api.services.telephony.base import TelephonyProvider
class CampaignCallDispatcher:
"""Manages rate-limited and concurrent-limited call dispatching"""
@ -26,9 +31,30 @@ class CampaignCallDispatcher:
def __init__(self):
self.default_concurrent_limit = int(DEFAULT_ORG_CONCURRENCY_LIMIT)
async def get_telephony_provider(self, organization_id: int) -> TelephonyProvider:
"""Get telephony provider instance for specific organization"""
return await get_telephony_provider(organization_id)
async def get_telephony_provider(self, organization_id: int) -> "TelephonyProvider":
"""Get telephony provider instance for specific organization (default config)."""
from api.services.telephony.factory import get_default_telephony_provider
return await get_default_telephony_provider(organization_id)
async def get_provider_for_campaign(self, campaign) -> "TelephonyProvider":
"""Get the telephony provider pinned to this campaign's config. Falls back
to the org's default config for legacy campaigns whose
``telephony_configuration_id`` was never backfilled."""
from api.services.telephony.factory import (
get_default_telephony_provider,
get_telephony_provider_by_id,
)
if campaign.telephony_configuration_id:
return await get_telephony_provider_by_id(
campaign.telephony_configuration_id
)
logger.warning(
f"Campaign {campaign.id} has no telephony_configuration_id; "
f"falling back to org default for {campaign.organization_id}"
)
return await get_default_telephony_provider(campaign.organization_id)
async def get_org_concurrent_limit(self, organization_id: int) -> int:
"""Get the concurrent call limit for an organization."""
@ -75,9 +101,9 @@ class CampaignCallDispatcher:
logger.info(f"No more queued runs for campaign {campaign_id}")
return 0
# Initialize from_number pool for this org's provider
# Initialize from_number pool for this campaign's telephony config.
try:
provider = await self.get_telephony_provider(campaign.organization_id)
provider = await self.get_provider_for_campaign(campaign)
if provider.from_numbers:
await rate_limiter.initialize_from_number_pool(
campaign.organization_id, provider.from_numbers
@ -180,8 +206,8 @@ class CampaignCallDispatcher:
)
raise ValueError(f"No phone number in queued run {queued_run.id}")
# Get provider first to determine the mode
provider = await self.get_telephony_provider(campaign.organization_id)
# Get provider for this campaign's pinned telephony config.
provider = await self.get_provider_for_campaign(campaign)
workflow_run_mode = provider.PROVIDER_NAME
# Acquire a unique from_number from the pool
@ -206,6 +232,7 @@ class CampaignCallDispatcher:
"source_uuid": queued_run.source_uuid,
"caller_number": from_number,
"called_number": phone_number,
"telephony_configuration_id": campaign.telephony_configuration_id,
}
logger.info(f"Final initial_context: {initial_context}")

View file

@ -1,5 +1,4 @@
"""
Audio configuration for pipeline components.
"""Audio configuration for pipeline components.
This module provides centralized audio configuration to ensure consistent
sample rates across all pipeline components and proper coordination between
@ -11,8 +10,6 @@ from typing import Optional
from loguru import logger
from api.enums import WorkflowRunMode
@dataclass
class AudioConfig:
@ -84,61 +81,35 @@ class AudioConfig:
def create_audio_config(transport_type: str) -> AudioConfig:
"""Create audio configuration based on transport type.
"""Create audio configuration for a given transport.
Args:
transport_type: Type of transport ("webrtc", "twilio", "plivo", "vonage", "vobiz", "cloudonix")
Returns:
AudioConfig instance with appropriate settings
Telephony providers contribute their wire-format sample rate through the
provider registry (``ProviderSpec.transport_sample_rate``); WebRTC modes
use 16 kHz (transports handle resampling from/to 24 kHz). The remaining
AudioConfig fields are derived from the chosen rate.
"""
if transport_type in (
WorkflowRunMode.TWILIO.value,
WorkflowRunMode.PLIVO.value,
WorkflowRunMode.VOBIZ.value,
WorkflowRunMode.CLOUDONIX.value,
WorkflowRunMode.ARI.value,
WorkflowRunMode.TELNYX.value,
):
# Twilio, Plivo, Cloudonix, Vobiz, Telnyx, and ARI use MULAW at 8kHz
return AudioConfig(
transport_in_sample_rate=8000,
transport_out_sample_rate=8000,
vad_sample_rate=8000, # Use matching VAD rate
pipeline_sample_rate=8000, # Keep at 8kHz to avoid resampling
buffer_size_seconds=5.0,
)
elif transport_type == WorkflowRunMode.VONAGE.value:
# Vonage uses 16kHz Linear PCM
return AudioConfig(
transport_in_sample_rate=16000,
transport_out_sample_rate=16000,
vad_sample_rate=16000, # Use matching VAD rate
pipeline_sample_rate=16000, # Keep at 16kHz to avoid resampling
buffer_size_seconds=5.0,
)
elif transport_type in [
# Defer registry import to avoid an import cycle: the registry is imported
# by every telephony provider package at startup.
from api.enums import WorkflowRunMode
from api.services.telephony import registry
telephony_spec = registry.get_optional(transport_type)
if telephony_spec is not None:
rate = telephony_spec.transport_sample_rate
elif transport_type in (
WorkflowRunMode.WEBRTC.value,
WorkflowRunMode.SMALLWEBRTC.value,
]:
# WebRTC typically uses 24kHz or 48kHz, but we limit pipeline to 16kHz
# The transport will handle resampling between 24kHz and 16kHz
return AudioConfig(
transport_in_sample_rate=16000, # Transport will resample from 24kHz
transport_out_sample_rate=16000, # Transport will resample to 24kHz
vad_sample_rate=16000, # VAD native rate
pipeline_sample_rate=16000, # Keep pipeline at 16kHz
buffer_size_seconds=5.0,
)
):
rate = 16000
else:
# Default configuration
logger.warning(
f"Unknown transport type: {transport_type}, using default config"
)
return AudioConfig(
transport_in_sample_rate=16000,
transport_out_sample_rate=16000,
vad_sample_rate=16000,
pipeline_sample_rate=16000,
buffer_size_seconds=5.0,
)
rate = 16000
return AudioConfig(
transport_in_sample_rate=rate,
transport_out_sample_rate=rate,
vad_sample_rate=rate,
pipeline_sample_rate=rate,
)

View file

@ -0,0 +1,55 @@
"""Shared helper for building audio output mixers used by telephony transports."""
import os
from loguru import logger
from api.constants import APP_ROOT_DIR
from api.services.pipecat.audio_file_cache import get_cached_ambient_noise_path
from pipecat.audio.mixers.silence_mixer import SilenceAudioMixer
from pipecat.audio.mixers.soundfile_mixer import SoundfileMixer
librnnoise_path = os.path.normpath(
str(APP_ROOT_DIR / "native" / "rnnoise" / "librnnoise.so")
)
async def build_audio_out_mixer(
audio_out_sample_rate: int,
ambient_noise_config: dict | None,
):
"""Build the audio output mixer based on the ambient noise configuration.
Returns a ``SoundfileMixer`` when ambient noise is enabled, or a
``SilenceAudioMixer`` otherwise. Supports custom user-uploaded audio
files via the ``storage_key`` / ``storage_backend`` fields in the config.
"""
if not ambient_noise_config or not ambient_noise_config.get("enabled", False):
return SilenceAudioMixer()
volume = ambient_noise_config.get("volume", 0.3)
storage_key = ambient_noise_config.get("storage_key")
storage_backend = ambient_noise_config.get("storage_backend")
if storage_key and storage_backend:
cached_path = await get_cached_ambient_noise_path(
storage_key, storage_backend, audio_out_sample_rate
)
if cached_path:
return SoundfileMixer(
sound_files={"custom": cached_path},
default_sound="custom",
volume=volume,
)
logger.warning("Custom ambient noise file unavailable, falling back to default")
return SoundfileMixer(
sound_files={
"office": APP_ROOT_DIR
/ "assets"
/ f"office-ambience-{audio_out_sample_rate}-mono.wav"
},
default_sound="office",
volume=volume,
)

View file

@ -1,11 +1,10 @@
import asyncio
from typing import Optional
from fastapi import HTTPException, WebSocket
from fastapi import HTTPException
from loguru import logger
from api.db import db_client
from api.db.models import WorkflowModel
from api.enums import WorkflowRunMode
from api.services.configuration.registry import ServiceProviders
from api.services.pipecat.audio_config import AudioConfig, create_audio_config
@ -44,17 +43,9 @@ from api.services.pipecat.service_factory import (
from api.services.pipecat.tracing_config import (
ensure_tracing,
)
from api.services.pipecat.transport_setup import (
create_ari_transport,
create_cloudonix_transport,
create_plivo_transport,
create_telnyx_transport,
create_twilio_transport,
create_vobiz_transport,
create_vonage_transport,
create_webrtc_transport,
)
from api.services.pipecat.transport_setup import create_webrtc_transport
from api.services.pipecat.ws_sender_registry import get_ws_sender
from api.services.telephony import registry as telephony_registry
from api.services.workflow.dto import ReactFlowDTO
from api.services.workflow.pipecat_engine import PipecatEngine
from api.services.workflow.workflow import WorkflowGraph
@ -95,110 +86,75 @@ from pipecat.utils.run_context import set_current_org_id, set_current_run_id
ensure_tracing()
async def run_pipeline_twilio(
websocket_client: WebSocket,
stream_sid: str,
call_sid: str,
async def run_pipeline_telephony(
websocket,
*,
provider_name: str,
workflow_id: int,
workflow_run_id: int,
user_id: int,
call_id: str,
transport_kwargs: dict,
) -> None:
"""Run pipeline for Twilio connections"""
logger.debug(
f"Running pipeline for Twilio connection with workflow_id: {workflow_id} and workflow_run_id: {workflow_run_id}"
)
"""Run a pipeline for any telephony provider.
Replaces the previous per-provider run_pipeline_<x> functions. The
provider's transport factory and audio config are looked up from the
registry, so adding a new provider requires no changes here.
Args:
websocket: The accepted WebSocket from the provider.
provider_name: Stable identifier of the provider (registry key).
workflow_id: Workflow being executed.
workflow_run_id: Workflow run row.
user_id: Owner of the workflow.
call_id: Provider call identifier (stored in cost_info for billing).
transport_kwargs: Provider-specific kwargs forwarded to the transport
factory (e.g. stream_sid + call_sid for Twilio).
"""
logger.debug(f"Running {provider_name} pipeline for workflow_run {workflow_run_id}")
set_current_run_id(workflow_run_id)
# Store call ID in cost_info for later cost calculation (provider-agnostic)
cost_info = {"call_id": call_sid}
await db_client.update_workflow_run(workflow_run_id, cost_info=cost_info)
await db_client.update_workflow_run(workflow_run_id, cost_info={"call_id": call_id})
# Get workflow to extract all pipeline configurations
workflow = await db_client.get_workflow(workflow_id, user_id)
# Set org context early so tasks created by the transport inherit it
if workflow:
set_current_org_id(workflow.organization_id)
vad_config = None
ambient_noise_config = None
if workflow and workflow.workflow_configurations:
if "vad_configuration" in workflow.workflow_configurations:
vad_config = workflow.workflow_configurations["vad_configuration"]
if "ambient_noise_configuration" in workflow.workflow_configurations:
ambient_noise_config = workflow.workflow_configurations[
"ambient_noise_configuration"
]
vad_config = workflow.workflow_configurations.get("vad_configuration")
ambient_noise_config = workflow.workflow_configurations.get(
"ambient_noise_configuration"
)
# Create audio configuration for Twilio
audio_config = create_audio_config(WorkflowRunMode.TWILIO.value)
# The telephony config id is stamped on the workflow run when it's created
# (test call, campaign dispatch, inbound). Transports use it to load creds
# from the right config row. Falls back to None for legacy runs (transports
# then resolve the org's default config).
workflow_run = await db_client.get_workflow_run(workflow_run_id)
telephony_configuration_id = None
if workflow_run and workflow_run.initial_context:
telephony_configuration_id = workflow_run.initial_context.get(
"telephony_configuration_id"
)
transport = await create_twilio_transport(
websocket_client,
stream_sid,
call_sid,
spec = telephony_registry.get(provider_name)
audio_config = create_audio_config(provider_name)
transport = await spec.transport_factory(
websocket,
workflow_run_id,
audio_config,
workflow.organization_id,
vad_config,
ambient_noise_config,
vad_config=vad_config,
ambient_noise_config=ambient_noise_config,
telephony_configuration_id=telephony_configuration_id,
**transport_kwargs,
)
await _run_pipeline(
transport,
workflow_id,
workflow_run_id,
user_id,
audio_config=audio_config,
)
async def run_pipeline_plivo(
websocket_client: WebSocket,
stream_id: str,
call_id: str,
workflow_id: int,
workflow_run_id: int,
user_id: int,
) -> None:
"""Run pipeline for Plivo WebSocket connections."""
logger.info(
f"[run {workflow_run_id}] Starting Plivo pipeline - "
f"stream_id={stream_id}, call_id={call_id}, workflow_id={workflow_id}"
)
set_current_run_id(workflow_run_id)
cost_info = {"call_id": call_id}
await db_client.update_workflow_run(workflow_run_id, cost_info=cost_info)
workflow = await db_client.get_workflow(workflow_id, user_id)
if workflow:
set_current_org_id(workflow.organization_id)
vad_config = None
ambient_noise_config = None
if workflow and workflow.workflow_configurations:
if "vad_configuration" in workflow.workflow_configurations:
vad_config = workflow.workflow_configurations["vad_configuration"]
if "ambient_noise_configuration" in workflow.workflow_configurations:
ambient_noise_config = workflow.workflow_configurations[
"ambient_noise_configuration"
]
try:
audio_config = create_audio_config(WorkflowRunMode.PLIVO.value)
transport = await create_plivo_transport(
websocket_client,
stream_id,
call_id,
workflow_run_id,
audio_config,
workflow.organization_id,
vad_config,
ambient_noise_config,
)
await _run_pipeline(
transport,
workflow_id,
@ -206,341 +162,14 @@ async def run_pipeline_plivo(
user_id,
audio_config=audio_config,
)
logger.info(f"[run {workflow_run_id}] Plivo pipeline completed successfully")
except Exception as e:
logger.error(
f"[run {workflow_run_id}] Error in Plivo pipeline: {e}", exc_info=True
f"[run {workflow_run_id}] Error in {provider_name} pipeline: {e}",
exc_info=True,
)
raise
async def run_pipeline_vonage(
websocket_client,
call_uuid: str,
workflow: WorkflowModel,
organization_id: int,
workflow_id: int,
workflow_run_id: int,
user_id: int,
):
"""Run pipeline for Vonage WebSocket connections.
Vonage uses raw PCM audio over WebSocket instead of base64-encoded μ-law.
The audio is transmitted as binary frames at 16kHz by default.
"""
logger.info(f"Starting Vonage pipeline for workflow run {workflow_run_id}")
set_current_run_id(workflow_run_id)
set_current_org_id(organization_id)
# Store call ID in cost_info for later cost calculation (provider-agnostic)
cost_info = {"call_id": call_uuid}
await db_client.update_workflow_run(workflow_run_id, cost_info=cost_info)
# Extract VAD and ambient noise config from workflow
vad_config = None
ambient_noise_config = None
if workflow and workflow.workflow_configurations:
if "vad_configuration" in workflow.workflow_configurations:
vad_config = workflow.workflow_configurations["vad_configuration"]
if "ambient_noise_configuration" in workflow.workflow_configurations:
ambient_noise_config = workflow.workflow_configurations[
"ambient_noise_configuration"
]
try:
# Setup audio config for Vonage using the centralized config
audio_config = create_audio_config(WorkflowRunMode.VONAGE.value)
# Create Vonage transport
transport = await create_vonage_transport(
websocket_client,
call_uuid,
workflow_run_id,
audio_config,
organization_id,
vad_config,
ambient_noise_config,
)
# No special handshake needed for Vonage
# Audio streaming starts immediately
# Run the pipeline (same as Twilio/WebRTC)
await _run_pipeline(
transport,
workflow_id,
workflow_run_id,
user_id,
call_context_vars={},
audio_config=audio_config,
)
except Exception as e:
logger.error(f"Error in Vonage pipeline: {e}")
raise
async def run_pipeline_ari(
websocket_client: WebSocket,
channel_id: str,
workflow_id: int,
workflow_run_id: int,
user_id: int,
) -> None:
"""Run pipeline for Asterisk ARI WebSocket connections.
ARI uses raw 16-bit signed linear PCM (SLIN16) at 16kHz
transmitted as binary WebSocket frames via chan_websocket.
"""
logger.info(f"Starting ARI pipeline for workflow run {workflow_run_id}")
set_current_run_id(workflow_run_id)
# Store call ID (channel_id) in cost_info
cost_info = {"call_id": channel_id}
await db_client.update_workflow_run(workflow_run_id, cost_info=cost_info)
# Get workflow to extract configurations
workflow = await db_client.get_workflow(workflow_id, user_id)
# Set org context early so tasks created by the transport inherit it
if workflow:
set_current_org_id(workflow.organization_id)
vad_config = None
ambient_noise_config = None
if workflow and workflow.workflow_configurations:
if "vad_configuration" in workflow.workflow_configurations:
vad_config = workflow.workflow_configurations["vad_configuration"]
if "ambient_noise_configuration" in workflow.workflow_configurations:
ambient_noise_config = workflow.workflow_configurations[
"ambient_noise_configuration"
]
try:
audio_config = create_audio_config(WorkflowRunMode.ARI.value)
transport = await create_ari_transport(
websocket_client,
channel_id,
workflow_run_id,
audio_config,
workflow.organization_id,
vad_config,
ambient_noise_config,
)
await _run_pipeline(
transport,
workflow_id,
workflow_run_id,
user_id,
audio_config=audio_config,
)
except Exception as e:
logger.error(f"Error in ARI pipeline: {e}")
raise
async def run_pipeline_vobiz(
websocket_client: WebSocket,
stream_id: str,
call_id: str,
workflow_id: int,
workflow_run_id: int,
user_id: int,
) -> None:
"""Run pipeline for Vobiz using Plivo-compatible WebSocket protocol."""
logger.info(
f"[run {workflow_run_id}] Starting Vobiz pipeline - "
f"stream_id={stream_id}, call_id={call_id}, workflow_id={workflow_id}"
)
set_current_run_id(workflow_run_id)
cost_info = {"call_id": call_id}
await db_client.update_workflow_run(workflow_run_id, cost_info=cost_info)
workflow = await db_client.get_workflow(workflow_id, user_id)
# Set org context early so tasks created by the transport inherit it
if workflow:
set_current_org_id(workflow.organization_id)
vad_config = None
ambient_noise_config = None
if workflow and workflow.workflow_configurations:
if "vad_configuration" in workflow.workflow_configurations:
vad_config = workflow.workflow_configurations["vad_configuration"]
if "ambient_noise_configuration" in workflow.workflow_configurations:
ambient_noise_config = workflow.workflow_configurations[
"ambient_noise_configuration"
]
try:
audio_config = create_audio_config(WorkflowRunMode.VOBIZ.value)
logger.info(
f"[run {workflow_run_id}] Vobiz audio config: "
f"sample_rate={audio_config.transport_in_sample_rate}Hz, format=MULAW"
)
transport = await create_vobiz_transport(
websocket_client,
stream_id,
call_id,
workflow_run_id,
audio_config,
workflow.organization_id,
vad_config,
ambient_noise_config,
)
logger.info(f"[run {workflow_run_id}] Starting Vobiz pipeline execution")
await _run_pipeline(
transport,
workflow_id,
workflow_run_id,
user_id,
audio_config=audio_config,
)
logger.info(f"[run {workflow_run_id}] Vobiz pipeline completed successfully")
except Exception as e:
logger.error(
f"[run {workflow_run_id}] Error in Vobiz pipeline: {e}", exc_info=True
)
raise
async def run_pipeline_telnyx(
websocket_client: WebSocket,
stream_id: str,
call_control_id: str,
workflow_id: int,
workflow_run_id: int,
user_id: int,
) -> None:
"""Run pipeline for Telnyx Call Control WebSocket connections.
Telnyx uses PCMU at 8kHz over WebSocket with base64-encoded media events,
similar to Twilio's protocol.
"""
logger.info(
f"[run {workflow_run_id}] Starting Telnyx pipeline - "
f"stream_id={stream_id}, call_control_id={call_control_id}, "
f"workflow_id={workflow_id}"
)
set_current_run_id(workflow_run_id)
cost_info = {"call_id": call_control_id}
await db_client.update_workflow_run(workflow_run_id, cost_info=cost_info)
workflow = await db_client.get_workflow(workflow_id, user_id)
if workflow:
set_current_org_id(workflow.organization_id)
vad_config = None
ambient_noise_config = None
if workflow and workflow.workflow_configurations:
if "vad_configuration" in workflow.workflow_configurations:
vad_config = workflow.workflow_configurations["vad_configuration"]
if "ambient_noise_configuration" in workflow.workflow_configurations:
ambient_noise_config = workflow.workflow_configurations[
"ambient_noise_configuration"
]
try:
audio_config = create_audio_config(WorkflowRunMode.TELNYX.value)
transport = await create_telnyx_transport(
websocket_client,
stream_id,
call_control_id,
workflow_run_id,
audio_config,
workflow.organization_id,
vad_config,
ambient_noise_config,
)
await _run_pipeline(
transport,
workflow_id,
workflow_run_id,
user_id,
audio_config=audio_config,
)
logger.info(f"[run {workflow_run_id}] Telnyx pipeline completed successfully")
except Exception as e:
logger.error(
f"[run {workflow_run_id}] Error in Telnyx pipeline: {e}", exc_info=True
)
raise
async def run_pipeline_cloudonix(
websocket_client: WebSocket,
stream_sid: str,
workflow_id: int,
workflow_run_id: int,
user_id: int,
) -> None:
"""Run pipeline for Cloudonix connections"""
logger.debug(
f"Running pipeline for Cloudonix connection with workflow_id: {workflow_id} and workflow_run_id: {workflow_run_id}"
)
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
call_id = workflow_run.gathered_context.get("call_id")
if not call_id:
logger.warning("call_id not found in gathered_context")
raise Exception()
# Store call ID in cost_info for later cost calculation (provider-agnostic)
cost_info = {"call_id": call_id}
await db_client.update_workflow_run(workflow_run_id, cost_info=cost_info)
# Get workflow to extract all pipeline configurations
workflow = await db_client.get_workflow(workflow_id, user_id)
# Set org context early so tasks created by the transport inherit it
if workflow:
set_current_org_id(workflow.organization_id)
vad_config = None
ambient_noise_config = None
if workflow and workflow.workflow_configurations:
if "vad_configuration" in workflow.workflow_configurations:
vad_config = workflow.workflow_configurations["vad_configuration"]
if "ambient_noise_configuration" in workflow.workflow_configurations:
ambient_noise_config = workflow.workflow_configurations[
"ambient_noise_configuration"
]
# Create audio configuration for Cloudonix
audio_config = create_audio_config(WorkflowRunMode.CLOUDONIX.value)
transport = await create_cloudonix_transport(
websocket_client,
call_id,
stream_sid,
workflow_run_id,
audio_config,
workflow.organization_id,
vad_config,
ambient_noise_config,
)
await _run_pipeline(
transport,
workflow_id,
workflow_run_id,
user_id,
audio_config=audio_config,
)
async def run_pipeline_smallwebrtc(
webrtc_connection: SmallWebRTCConnection,
workflow_id: int,

View file

@ -1,514 +1,14 @@
import os
"""Transport factories for non-telephony pipelines.
from fastapi import WebSocket
from loguru import logger
Telephony transports live in their respective ``api.services.telephony.providers/<name>/transport.py``.
This module hosts only the shared, non-telephony transports (WebRTC, internal/LoopTalk).
"""
from api.constants import APP_ROOT_DIR
from api.db import db_client
from api.enums import OrganizationConfigurationKey
from api.services.pipecat.audio_config import AudioConfig
from api.services.pipecat.audio_file_cache import get_cached_ambient_noise_path
from api.services.telephony.providers.ari_call_strategies import (
ARIBridgeSwapStrategy,
ARIHangupStrategy,
)
from api.services.telephony.providers.cloudonix_call_strategies import (
CloudonixHangupStrategy,
)
from api.services.telephony.providers.twilio_call_strategies import (
TwilioConferenceStrategy,
TwilioHangupStrategy,
)
from pipecat.serializers.plivo import PlivoFrameSerializer
from pipecat.audio.mixers.silence_mixer import SilenceAudioMixer
from pipecat.audio.mixers.soundfile_mixer import SoundfileMixer
from pipecat.serializers.asterisk import AsteriskFrameSerializer
from pipecat.serializers.telnyx import TelnyxFrameSerializer
from pipecat.serializers.twilio import TwilioFrameSerializer
from pipecat.serializers.vobiz import VobizFrameSerializer
from pipecat.serializers.vonage import VonageFrameSerializer
from api.services.pipecat.audio_mixer import build_audio_out_mixer
from pipecat.transports.base_transport import TransportParams
from pipecat.transports.smallwebrtc.connection import SmallWebRTCConnection
from pipecat.transports.smallwebrtc.transport import SmallWebRTCTransport
from pipecat.transports.websocket.fastapi import (
FastAPIWebsocketParams,
FastAPIWebsocketTransport,
)
librnnoise_path = os.path.normpath(
str(APP_ROOT_DIR / "native" / "rnnoise" / "librnnoise.so")
)
async def _build_audio_out_mixer(
audio_out_sample_rate: int,
ambient_noise_config: dict | None,
):
"""Build the audio output mixer based on the ambient noise configuration.
Returns a ``SoundfileMixer`` when ambient noise is enabled, or a
``SilenceAudioMixer`` otherwise. Supports custom user-uploaded audio
files via the ``storage_key`` / ``storage_backend`` fields in the config.
"""
if not ambient_noise_config or not ambient_noise_config.get("enabled", False):
return SilenceAudioMixer()
volume = ambient_noise_config.get("volume", 0.3)
# Check for a custom uploaded ambient noise file
storage_key = ambient_noise_config.get("storage_key")
storage_backend = ambient_noise_config.get("storage_backend")
if storage_key and storage_backend:
cached_path = await get_cached_ambient_noise_path(
storage_key, storage_backend, audio_out_sample_rate
)
if cached_path:
return SoundfileMixer(
sound_files={"custom": cached_path},
default_sound="custom",
volume=volume,
)
logger.warning("Custom ambient noise file unavailable, falling back to default")
# Default built-in office ambience
return SoundfileMixer(
sound_files={
"office": APP_ROOT_DIR
/ "assets"
/ f"office-ambience-{audio_out_sample_rate}-mono.wav"
},
default_sound="office",
volume=volume,
)
async def create_twilio_transport(
websocket_client: WebSocket,
stream_sid: str,
call_sid: str,
workflow_run_id: int,
audio_config: AudioConfig,
organization_id: int,
vad_config: dict | None = None,
ambient_noise_config: dict | None = None,
):
"""Create a transport for Twilio connections"""
# Fetch Twilio credentials from organization config
config = await db_client.get_configuration(
organization_id, OrganizationConfigurationKey.TELEPHONY_CONFIGURATION.value
)
if not config or not config.value:
raise ValueError(
f"Twilio credentials not configured for organization {organization_id}"
)
account_sid = config.value.get("account_sid")
auth_token = config.value.get("auth_token")
if not account_sid or not auth_token:
raise ValueError(
f"Incomplete Twilio configuration for organization {organization_id}"
)
# Create strategy instances
transfer_strategy = TwilioConferenceStrategy()
hangup_strategy = TwilioHangupStrategy()
serializer = TwilioFrameSerializer(
stream_sid=stream_sid,
call_sid=call_sid,
account_sid=account_sid,
auth_token=auth_token,
transfer_strategy=transfer_strategy,
hangup_strategy=hangup_strategy,
)
mixer = await _build_audio_out_mixer(
audio_config.transport_out_sample_rate, ambient_noise_config
)
return FastAPIWebsocketTransport(
websocket=websocket_client,
params=FastAPIWebsocketParams(
audio_in_enabled=True,
audio_out_enabled=True,
audio_in_sample_rate=audio_config.transport_in_sample_rate,
audio_out_sample_rate=audio_config.transport_out_sample_rate,
audio_out_mixer=mixer,
serializer=serializer,
),
)
async def create_plivo_transport(
websocket_client: WebSocket,
stream_id: str,
call_id: str,
workflow_run_id: int,
audio_config: AudioConfig,
organization_id: int,
vad_config: dict | None = None,
ambient_noise_config: dict | None = None,
):
"""Create a transport for Plivo connections."""
from api.services.telephony.factory import load_telephony_config
config = await load_telephony_config(organization_id)
if config.get("provider") != "plivo":
raise ValueError(f"Expected Plivo provider, got {config.get('provider')}")
auth_id = config.get("auth_id")
auth_token = config.get("auth_token")
if not auth_id or not auth_token:
raise ValueError(
f"Incomplete Plivo configuration for organization {organization_id}"
)
serializer = PlivoFrameSerializer(
stream_id=stream_id,
call_id=call_id,
auth_id=auth_id,
auth_token=auth_token,
params=PlivoFrameSerializer.InputParams(
plivo_sample_rate=8000,
sample_rate=audio_config.pipeline_sample_rate,
),
)
mixer = await _build_audio_out_mixer(
audio_config.transport_out_sample_rate, ambient_noise_config
)
return FastAPIWebsocketTransport(
websocket=websocket_client,
params=FastAPIWebsocketParams(
audio_in_enabled=True,
audio_out_enabled=True,
audio_in_sample_rate=audio_config.transport_in_sample_rate,
audio_out_sample_rate=audio_config.transport_out_sample_rate,
audio_out_mixer=mixer,
serializer=serializer,
),
)
async def create_cloudonix_transport(
websocket_client: WebSocket,
call_id: str,
stream_sid: str,
workflow_run_id: int,
audio_config: AudioConfig,
organization_id: int,
vad_config: dict | None = None,
ambient_noise_config: dict | None = None,
):
"""Create a transport for Cloudonix connections"""
# Load Cloudonix configuration from database
from api.services.telephony.factory import load_telephony_config
config = await load_telephony_config(organization_id)
if config.get("provider") != "cloudonix":
raise ValueError(f"Expected Cloudonix provider, got {config.get('provider')}")
bearer_token = config.get("bearer_token")
domain_id = config.get("domain_id")
if not bearer_token or not domain_id:
raise ValueError(
f"Incomplete Cloudonix configuration for organization {organization_id}. "
f"Required: bearer_token, domain_id"
)
from pipecat.serializers.cloudonix import CloudonixFrameSerializer
hangup_strategy = CloudonixHangupStrategy()
serializer = CloudonixFrameSerializer(
call_id=call_id,
stream_sid=stream_sid,
domain_id=domain_id,
bearer_token=bearer_token,
hangup_strategy=hangup_strategy,
)
mixer = await _build_audio_out_mixer(
audio_config.transport_out_sample_rate, ambient_noise_config
)
return FastAPIWebsocketTransport(
websocket=websocket_client,
params=FastAPIWebsocketParams(
audio_in_enabled=True,
audio_out_enabled=True,
audio_in_sample_rate=audio_config.transport_in_sample_rate,
audio_out_sample_rate=audio_config.transport_out_sample_rate,
audio_out_mixer=mixer,
serializer=serializer,
audio_out_10ms_chunks=2,
),
)
async def create_telnyx_transport(
websocket_client: WebSocket,
stream_id: str,
call_control_id: str,
workflow_run_id: int,
audio_config: AudioConfig,
organization_id: int,
vad_config: dict | None = None,
ambient_noise_config: dict | None = None,
):
"""Create a transport for Telnyx connections."""
config = await db_client.get_configuration(
organization_id, OrganizationConfigurationKey.TELEPHONY_CONFIGURATION.value
)
if not config or not config.value:
raise ValueError(
f"Telnyx credentials not configured for organization {organization_id}"
)
if config.value.get("provider") != "telnyx":
raise ValueError(
f"Expected Telnyx provider, got {config.value.get('provider')}"
)
api_key = config.value.get("api_key")
if not api_key:
raise ValueError(
f"Incomplete Telnyx configuration for organization {organization_id}"
)
serializer = TelnyxFrameSerializer(
stream_id=stream_id,
call_control_id=call_control_id,
api_key=api_key,
outbound_encoding="PCMU",
inbound_encoding="PCMU",
)
mixer = await _build_audio_out_mixer(
audio_config.transport_out_sample_rate, ambient_noise_config
)
return FastAPIWebsocketTransport(
websocket=websocket_client,
params=FastAPIWebsocketParams(
audio_in_enabled=True,
audio_out_enabled=True,
audio_in_sample_rate=audio_config.transport_in_sample_rate,
audio_out_sample_rate=audio_config.transport_out_sample_rate,
audio_out_mixer=mixer,
serializer=serializer,
),
)
async def create_ari_transport(
websocket_client: WebSocket,
channel_id: str,
workflow_run_id: int,
audio_config: AudioConfig,
organization_id: int,
vad_config: dict | None = None,
ambient_noise_config: dict | None = None,
):
"""Create a transport for Asterisk ARI connections"""
from api.services.telephony.factory import load_telephony_config
config = await load_telephony_config(organization_id)
if config.get("provider") != "ari":
raise ValueError(f"Expected ARI provider, got {config.get('provider')}")
ari_endpoint = config.get("ari_endpoint")
app_name = config.get("app_name")
app_password = config.get("app_password")
if not ari_endpoint or not app_name or not app_password:
raise ValueError(
f"Incomplete ARI configuration for organization {organization_id}. "
f"Required: ari_endpoint, app_name, app_password"
)
# Create strategy instances
transfer_strategy = ARIBridgeSwapStrategy()
hangup_strategy = ARIHangupStrategy()
serializer = AsteriskFrameSerializer(
channel_id=channel_id,
ari_endpoint=ari_endpoint,
app_name=app_name,
app_password=app_password,
transfer_strategy=transfer_strategy,
hangup_strategy=hangup_strategy,
params=AsteriskFrameSerializer.InputParams(
asterisk_sample_rate=audio_config.transport_in_sample_rate,
sample_rate=audio_config.pipeline_sample_rate,
),
)
mixer = await _build_audio_out_mixer(
audio_config.transport_out_sample_rate, ambient_noise_config
)
return FastAPIWebsocketTransport(
websocket=websocket_client,
params=FastAPIWebsocketParams(
audio_in_enabled=True,
audio_out_enabled=True,
audio_in_sample_rate=audio_config.transport_in_sample_rate,
audio_out_sample_rate=audio_config.transport_out_sample_rate,
audio_out_mixer=mixer,
serializer=serializer,
),
)
async def create_vonage_transport(
websocket_client,
call_uuid: str,
workflow_run_id: int,
audio_config: AudioConfig,
organization_id: int,
vad_config: dict | None = None,
ambient_noise_config: dict | None = None,
):
"""Create a transport for Vonage connections"""
# Use the factory to load config from database
from api.services.telephony.factory import load_telephony_config
config = await load_telephony_config(organization_id)
if config.get("provider") != "vonage":
raise ValueError(f"Expected Vonage provider, got {config.get('provider')}")
application_id = config.get("application_id")
private_key = config.get("private_key")
if not application_id or not private_key:
raise ValueError(
f"Incomplete Vonage configuration for organization {organization_id}"
)
serializer = VonageFrameSerializer(
call_uuid=call_uuid,
application_id=application_id,
private_key=private_key,
params=VonageFrameSerializer.InputParams(
vonage_sample_rate=audio_config.transport_in_sample_rate,
sample_rate=audio_config.pipeline_sample_rate,
),
)
mixer = await _build_audio_out_mixer(
audio_config.transport_out_sample_rate, ambient_noise_config
)
# Important: Vonage uses binary WebSocket mode, not text
return FastAPIWebsocketTransport(
websocket=websocket_client,
params=FastAPIWebsocketParams(
audio_in_enabled=True,
audio_out_enabled=True,
audio_in_sample_rate=audio_config.transport_in_sample_rate,
audio_out_sample_rate=audio_config.transport_out_sample_rate,
audio_out_mixer=mixer,
serializer=serializer,
),
)
async def create_vobiz_transport(
websocket_client: WebSocket,
stream_id: str,
call_id: str,
workflow_run_id: int,
audio_config: AudioConfig,
organization_id: int,
vad_config: dict | None = None,
ambient_noise_config: dict | None = None,
):
"""Create a transport for Vobiz connections.
Vobiz uses Plivo-compatible WebSocket protocol:
- MULAW audio at 8kHz (same as Twilio)
- Base64-encoded audio in JSON messages
- PlivoFrameSerializer handles the protocol
"""
from loguru import logger
logger.info(
f"[run {workflow_run_id}] Creating Vobiz transport - "
f"stream_id={stream_id}, call_id={call_id}"
)
# Load Vobiz configuration from database
from api.services.telephony.factory import load_telephony_config
config = await load_telephony_config(organization_id)
if config.get("provider") != "vobiz":
raise ValueError(f"Expected Vobiz provider, got {config.get('provider')}")
auth_id = config.get("auth_id")
auth_token = config.get("auth_token")
if not auth_id or not auth_token:
raise ValueError(
f"Incomplete Vobiz configuration for organization {organization_id}"
)
logger.debug(
f"[run {workflow_run_id}] Vobiz config loaded - auth_id={auth_id}, "
f"from_numbers={len(config.get('from_numbers', []))} numbers"
)
# Use VobizFrameSerializer for Vobiz WebSocket protocol
serializer = VobizFrameSerializer(
stream_id=stream_id,
call_id=call_id,
auth_id=auth_id,
auth_token=auth_token,
params=VobizFrameSerializer.InputParams(
vobiz_sample_rate=8000, # Vobiz uses MULAW at 8kHz
sample_rate=audio_config.pipeline_sample_rate,
),
)
logger.debug(
f"[run {workflow_run_id}] VobizFrameSerializer created for Vobiz - "
f"transport_rate=8000Hz, pipeline_rate={audio_config.pipeline_sample_rate}Hz"
)
mixer = await _build_audio_out_mixer(
audio_config.transport_out_sample_rate, ambient_noise_config
)
# Create WebSocket transport (same structure as Twilio/Vonage)
transport = FastAPIWebsocketTransport(
websocket=websocket_client,
params=FastAPIWebsocketParams(
audio_in_enabled=True,
audio_out_enabled=True,
audio_in_sample_rate=audio_config.transport_in_sample_rate,
audio_out_sample_rate=audio_config.transport_out_sample_rate,
audio_out_mixer=mixer,
serializer=serializer,
),
)
logger.info(
f"[run {workflow_run_id}] Vobiz transport created successfully (VAD enabled)"
)
return transport
async def create_webrtc_transport(
@ -518,9 +18,8 @@ async def create_webrtc_transport(
vad_config: dict | None = None,
ambient_noise_config: dict | None = None,
):
"""Create a transport for WebRTC connections"""
mixer = await _build_audio_out_mixer(
"""Create a transport for WebRTC connections."""
mixer = await build_audio_out_mixer(
audio_config.transport_out_sample_rate, ambient_noise_config
)
@ -556,29 +55,3 @@ def create_internal_transport(
pass
# Commented out because looptalk coming in the regular import flow
# was causing issue. May be move this to looptalk/orchestrator.py
# Create and return the internal transport with latency
# return InternalTransport(
# params=TransportParams(
# audio_out_enabled=True,
# audio_out_sample_rate=audio_config.transport_out_sample_rate,
# audio_out_channels=1,
# audio_in_enabled=True,
# audio_in_sample_rate=audio_config.transport_in_sample_rate,
# audio_in_channels=1,
# audio_out_mixer=(
# SoundfileMixer(
# sound_files={
# "office": APP_ROOT_DIR
# / "assets"
# / f"office-ambience-{audio_config.transport_out_sample_rate}-mono.wav"
# },
# default_sound="office",
# volume=ambient_noise_config.get("volume", 0.3),
# )
# if ambient_noise_config and ambient_noise_config.get("enabled", False)
# else SilenceAudioMixer()
# ),
# ),
# latency_seconds=latency_seconds,
# )

View file

@ -11,6 +11,7 @@ from loguru import logger
from api.db import db_client
from api.db.models import UserModel
from api.services.configuration.registry import ServiceProviders
from api.services.configuration.resolve import resolve_effective_config
from api.services.mps_service_key_client import mps_service_key_client
@ -23,14 +24,23 @@ class QuotaCheckResult:
error_code: str = ""
async def check_dograh_quota(user: UserModel) -> QuotaCheckResult:
async def check_dograh_quota(
user: UserModel, workflow_id: int | None = None
) -> QuotaCheckResult:
"""Check if user has sufficient Dograh quota for making a call.
This function checks if the user is using any Dograh services (LLM, STT, TTS)
and validates that they have sufficient credits remaining.
When ``workflow_id`` is provided, the workflow's per-workflow
``model_overrides`` are merged onto the user's global config so the quota
check runs against the credentials that will actually be used for the call
(rather than always falling back to the user's defaults).
Args:
user: The user to check quota for
workflow_id: Optional workflow whose ``model_overrides`` should be
applied when resolving the effective service config.
Returns:
QuotaCheckResult with has_quota=True if user has sufficient quota or
@ -41,6 +51,15 @@ async def check_dograh_quota(user: UserModel) -> QuotaCheckResult:
# Get user configurations
user_config = await db_client.get_user_configurations(user.id)
if workflow_id is not None:
workflow = await db_client.get_workflow_by_id(workflow_id)
if workflow:
model_overrides = (workflow.workflow_configurations or {}).get(
"model_overrides"
)
if model_overrides:
user_config = resolve_effective_config(user_config, model_overrides)
# Check if user is using any Dograh service
using_dograh = False
dograh_api_keys = set()
@ -112,13 +131,20 @@ async def check_dograh_quota(user: UserModel) -> QuotaCheckResult:
return QuotaCheckResult(has_quota=True)
async def check_dograh_quota_by_user_id(user_id: int) -> QuotaCheckResult:
async def check_dograh_quota_by_user_id(
user_id: int, workflow_id: int | None = None
) -> QuotaCheckResult:
"""Check Dograh quota by user ID.
Convenience function that fetches the user and then checks quota.
Convenience function that fetches the user and then checks quota. When
``workflow_id`` is provided, the workflow's ``model_overrides`` are
applied so the quota check evaluates the credentials that will actually
be used for the call.
Args:
user_id: The ID of the user to check quota for
workflow_id: Optional workflow whose per-workflow overrides should
be applied to the user's config before checking quota.
Returns:
QuotaCheckResult with quota status
@ -129,4 +155,4 @@ async def check_dograh_quota_by_user_id(user_id: int) -> QuotaCheckResult:
has_quota=False,
error_message="User not found",
)
return await check_dograh_quota(user)
return await check_dograh_quota(user, workflow_id=workflow_id)

View file

@ -0,0 +1,13 @@
"""Telephony package.
Importing this package eagerly loads every provider in
``api/services/telephony/providers/`` so each one self-registers with the
registry before any consumer (factory, routes, schemas) runs. Python
guarantees this ``__init__.py`` runs before any submodule of the package,
so submodules like ``factory`` and ``registry`` can stay free of provider
imports no lazy flags, no cycle.
"""
from . import (
providers as _providers, # noqa: F401 -- import for side effects (registration)
)

View file

@ -24,7 +24,7 @@ from loguru import logger
from api.constants import REDIS_URL
from api.db import db_client
from api.enums import CallType, OrganizationConfigurationKey, WorkflowRunMode
from api.enums import CallType, WorkflowRunMode
from api.services.quota_service import check_dograh_quota_by_user_id
from api.services.telephony.call_transfer_manager import get_call_transfer_manager
from api.services.telephony.transfer_event_protocol import (
@ -44,18 +44,18 @@ class ARIConnection:
def __init__(
self,
organization_id: int,
telephony_configuration_id: int,
ari_endpoint: str,
app_name: str,
app_password: str,
ws_client_name: str = "",
inbound_workflow_id: int = None,
):
self.organization_id = organization_id
self.telephony_configuration_id = telephony_configuration_id
self.ari_endpoint = ari_endpoint.rstrip("/")
self.app_name = app_name
self.app_password = app_password
self.ws_client_name = ws_client_name
self.inbound_workflow_id = inbound_workflow_id
self._ws: Optional[websockets.ClientConnection] = None
self._task: Optional[asyncio.Task] = None
@ -135,8 +135,8 @@ class ARIConnection:
@property
def connection_key(self) -> str:
"""Unique key for this connection based on config."""
return f"{self.organization_id}:{self.ari_endpoint}:{self.app_name}"
"""Unique key for this connection — one per ARI config row."""
return f"config:{self.telephony_configuration_id}"
async def start(self):
"""Start the WebSocket connection in a background task."""
@ -468,22 +468,43 @@ class ARIConnection:
called_number = channel.get("dialplan", {}).get("exten", "unknown")
try:
# 1. Check inbound_workflow_id is configured
if not self.inbound_workflow_id:
# 1. Resolve the workflow from the called extension via the
# telephony_phone_numbers row scoped to this connection's config.
phone_row = await db_client.find_active_phone_number_for_inbound(
self.organization_id, called_number, "ari"
)
if (
not phone_row
or phone_row.telephony_configuration_id
!= self.telephony_configuration_id
):
logger.warning(
f"[ARI org={self.organization_id}] Inbound call on channel {channel_id} "
f"but no inbound_workflow_id configured — hanging up"
f"[ARI org={self.organization_id}] Inbound call to extension "
f"{called_number} on channel {channel_id} — no matching phone "
f"number registered for config {self.telephony_configuration_id}, "
f"hanging up"
)
await self._delete_channel(channel_id)
return
inbound_workflow_id = phone_row.inbound_workflow_id
if not inbound_workflow_id:
logger.warning(
f"[ARI org={self.organization_id}] Inbound call to extension "
f"{called_number} on channel {channel_id} — phone number "
f"{phone_row.address} has no inbound_workflow_id assigned, "
f"hanging up"
)
await self._delete_channel(channel_id)
return
# 2. Load workflow to get user_id and verify organization
workflow = await db_client.get_workflow(
self.inbound_workflow_id, organization_id=self.organization_id
inbound_workflow_id, organization_id=self.organization_id
)
if not workflow:
logger.warning(
f"[ARI org={self.organization_id}] Workflow {self.inbound_workflow_id} "
f"[ARI org={self.organization_id}] Workflow {inbound_workflow_id} "
f"not found or doesn't belong to this organization — hanging up"
)
await self._delete_channel(channel_id)
@ -491,8 +512,10 @@ class ARIConnection:
user_id = workflow.user_id
# 3. Check quota
quota_result = await check_dograh_quota_by_user_id(user_id)
# 3. Check quota (apply per-workflow model_overrides).
quota_result = await check_dograh_quota_by_user_id(
user_id, workflow_id=inbound_workflow_id
)
if not quota_result.has_quota:
logger.warning(
f"[ARI org={self.organization_id}] Quota exceeded for user {user_id} "
@ -505,7 +528,7 @@ class ARIConnection:
call_id = channel_id
workflow_run = await db_client.create_workflow_run(
name=f"ARI Inbound {caller_number}",
workflow_id=self.inbound_workflow_id,
workflow_id=inbound_workflow_id,
mode=WorkflowRunMode.ARI.value,
user_id=user_id,
call_type=CallType.INBOUND,
@ -534,7 +557,7 @@ class ARIConnection:
channel_id,
channel_state,
str(workflow_run.id),
str(self.inbound_workflow_id),
str(inbound_workflow_id),
str(user_id),
)
except Exception as e:
@ -902,19 +925,19 @@ class ARIManager:
for config in active_configs:
org_id = config["organization_id"]
telephony_configuration_id = config["telephony_configuration_id"]
ari_endpoint = config["ari_endpoint"]
app_name = config["app_name"]
app_password = config["app_password"]
ws_client_name = config["ws_client_name"]
inbound_workflow_id = config.get("inbound_workflow_id")
conn = ARIConnection(
org_id,
telephony_configuration_id,
ari_endpoint,
app_name,
app_password,
ws_client_name,
inbound_workflow_id=inbound_workflow_id,
)
key = conn.connection_key
@ -923,19 +946,26 @@ class ARIManager:
if key not in self._connections:
# New configuration - start connection
logger.info(
f"[ARI Manager] New ARI config for org {org_id}: {ari_endpoint}"
f"[ARI Manager] New ARI config {telephony_configuration_id} "
f"for org {org_id}: {ari_endpoint}"
)
self._connections[key] = conn
await conn.start()
else:
# Existing configuration - check if password or inbound_workflow_id changed
# Existing configuration — reconnect if connection-level fields
# (endpoint, app, password, ws client) changed. Workflow IDs are
# resolved per-call via telephony_phone_numbers, so changes to
# them don't require a reconnect.
existing = self._connections[key]
if (
existing.app_password != app_password
or existing.inbound_workflow_id != inbound_workflow_id
existing.ari_endpoint != conn.ari_endpoint
or existing.app_name != app_name
or existing.app_password != app_password
or existing.ws_client_name != ws_client_name
):
logger.info(
f"[ARI Manager] Config changed for org {org_id}, reconnecting..."
f"[ARI Manager] Config {telephony_configuration_id} "
f"changed for org {org_id}, reconnecting..."
)
await existing.stop()
self._connections[key] = conn
@ -953,47 +983,44 @@ class ARIManager:
if active_configs:
logger.info(
f"[ARI Manager] Active connections: {len(self._connections)} "
f"(orgs: {[c['organization_id'] for c in active_configs]})"
f"(configs: {[c['telephony_configuration_id'] for c in active_configs]})"
)
else:
logger.debug("[ARI Manager] No ARI configurations found")
async def _load_ari_configs(self) -> list:
"""Load all ARI telephony configurations from the database."""
rows = await db_client.get_configurations_by_provider(
OrganizationConfigurationKey.TELEPHONY_CONFIGURATION.value, "ari"
)
"""Load all ARI telephony configurations from the multi-config tables."""
rows = await db_client.list_all_telephony_configurations_by_provider("ari")
configs = []
for row in rows:
org_id = row["organization_id"]
value = row["value"]
ari_endpoint = value.get("ari_endpoint")
app_name = value.get("app_name")
app_password = value.get("app_password")
ws_client_name = value.get("ws_client_name", "")
credentials = row.credentials or {}
ari_endpoint = credentials.get("ari_endpoint")
app_name = credentials.get("app_name")
app_password = credentials.get("app_password")
ws_client_name = credentials.get("ws_client_name", "")
if not all([ari_endpoint, app_name, app_password]):
logger.warning(
f"[ARI Manager] Incomplete ARI config for org {org_id}, skipping"
f"[ARI Manager] Incomplete ARI config {row.id} "
f"for org {row.organization_id}, skipping"
)
continue
if not ws_client_name:
logger.warning(
f"[ARI Manager] Missing ws_client_name for org {org_id}, "
f"externalMedia WebSocket won't work"
f"[ARI Manager] Missing ws_client_name for config {row.id} "
f"(org {row.organization_id}), externalMedia WebSocket won't work"
)
configs.append(
{
"organization_id": org_id,
"organization_id": row.organization_id,
"telephony_configuration_id": row.id,
"ari_endpoint": ari_endpoint,
"app_name": app_name,
"app_password": app_password,
"ws_client_name": ws_client_name,
"inbound_workflow_id": value.get("inbound_workflow_id"),
}
)

View file

@ -27,6 +27,19 @@ class CallInitiationResult:
) # Full provider response for debugging
@dataclass
class ProviderSyncResult:
"""Result of pushing a configuration change to the upstream provider.
Used by ``configure_inbound`` (and similar provider-side syncs) so callers
can surface a non-fatal warning to the user when the DB write succeeded
but the provider API rejected the change.
"""
ok: bool
message: Optional[str] = None # human-readable detail when ok=False
@dataclass
class NormalizedInboundData:
"""Standardized inbound call data across all providers."""
@ -264,38 +277,76 @@ class TelephonyProvider(ABC):
@abstractmethod
async def verify_inbound_signature(
self, url: str, webhook_data: Dict[str, Any], signature: str
self,
url: str,
webhook_data: Dict[str, Any],
headers: Dict[str, str],
body: str = "",
) -> bool:
"""
Verify the signature of an inbound webhook for security.
Each provider extracts its own signature/timestamp/nonce headers.
Returning True when no signature is present means "no verification
attempted" — providers should return False if a signature *is*
present but invalid.
Args:
url: The full webhook URL
webhook_data: The webhook payload
signature: The signature header from the provider
url: The full webhook URL the provider POSTed to
webhook_data: Parsed webhook payload (form fields or JSON)
headers: HTTP headers from the request (case-insensitive lookup
is the provider's responsibility)
body: Raw request body only used by providers that sign over
the body bytes (e.g. Vobiz)
Returns:
True if signature is valid, False otherwise
True if signature is valid (or none required), False otherwise
"""
pass
@staticmethod
@abstractmethod
async def generate_inbound_response(
websocket_url: str, workflow_run_id: int = None
) -> tuple:
async def start_inbound_stream(
self,
*,
websocket_url: str,
workflow_run_id: int,
normalized_data: "NormalizedInboundData",
backend_endpoint: str,
) -> Any:
"""
Generate the appropriate response for an inbound webhook.
Bring up the inbound media stream for this provider and return the
HTTP response body the webhook caller expects.
Markup-response providers (Twilio, Plivo, Vobiz, ...) build and
return their TwiML/XML/NCCO directly. Call-control providers
(Telnyx) issue the REST calls needed to answer the call and start
streaming, then return a simple acknowledgement.
Args:
websocket_url: WebSocket URL for audio streaming
workflow_run_id: Optional workflow run ID for tracking
workflow_run_id: Workflow run ID for tracking
normalized_data: Parsed inbound webhook payload (provides
``call_id`` for providers that need it)
backend_endpoint: Public HTTPS base URL of this backend
(already resolved by the caller); providers that need to
build status / events URLs use this instead of re-fetching
Returns:
FastAPI Response object
FastAPI Response object (or dict/JSON-serializable value)
"""
pass
async def configure_inbound(
self, address: str, webhook_url: Optional[str]
) -> ProviderSyncResult:
"""Sync inbound routing for ``address`` to the provider.
``webhook_url`` set: point the provider's resource for this number at
the URL. ``None``: clear it. Default is a no-op for providers that
don't support programmatic webhook configuration (e.g. ARI).
"""
return ProviderSyncResult(ok=True)
@staticmethod
@abstractmethod
def generate_error_response(error_type: str, message: str) -> tuple:

View file

@ -1,173 +1,215 @@
"""
Factory for creating telephony providers.
Handles configuration loading from environment (OSS) or database (SaaS).
The providers themselves don't know or care where config comes from.
"""Factory for creating telephony providers.
Resolves a provider instance from a stored telephony configuration. Three
resolution paths exist:
* by config id the canonical path used by outbound (test calls, campaigns,
API triggers) and by the websocket transport once a workflow run has
``initial_context.telephony_configuration_id`` stamped on it.
* by org default used as a fallback when no specific config is requested
(e.g. the legacy ``/telephony-config`` endpoint, the back-compat
``get_telephony_provider(organization_id)`` shim).
* for inbound given a detected provider and an account-id from the webhook,
iterate the org's configs of that provider and return the one whose stored
account-id credential matches.
Provider classes don't need to know about the new storage shape. They still
receive a normalized config dict containing credentials plus a
``from_numbers`` list of address strings, which the factory assembles by
joining ``telephony_phone_numbers``.
"""
from typing import Any, Dict, List, Type
from typing import Any, Dict, List, Optional, Tuple, Type
from loguru import logger
from api.db import db_client
from api.enums import OrganizationConfigurationKey
from api.db.models import TelephonyConfigurationModel
from api.services.telephony import registry
from api.services.telephony.base import TelephonyProvider
from api.services.telephony.providers.ari_provider import ARIProvider
from api.services.telephony.providers.cloudonix_provider import CloudonixProvider
from api.services.telephony.providers.plivo_provider import PlivoProvider
from api.services.telephony.providers.telnyx_provider import TelnyxProvider
from api.services.telephony.providers.twilio_provider import TwilioProvider
from api.services.telephony.providers.vobiz_provider import VobizProvider
from api.services.telephony.providers.vonage_provider import VonageProvider
async def load_telephony_config_by_id(
telephony_configuration_id: int,
) -> Dict[str, Any]:
"""Load and normalize the config row by primary key.
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).
"""
if not telephony_configuration_id:
raise ValueError("telephony_configuration_id is required")
row = await db_client.get_telephony_configuration(telephony_configuration_id)
if not row:
raise ValueError(
f"Telephony configuration {telephony_configuration_id} not found"
)
return await _normalize_with_phone_numbers(row)
async def load_default_telephony_config(organization_id: int) -> Dict[str, Any]:
"""Load the org's default outbound config."""
if not organization_id:
raise ValueError("organization_id is required")
row = await db_client.get_default_telephony_configuration(organization_id)
if not row:
raise ValueError(
f"No default telephony configuration found for organization "
f"{organization_id}"
)
return await _normalize_with_phone_numbers(row)
async def find_telephony_config_for_inbound(
organization_id: int, provider_name: str, account_id: Optional[str]
) -> 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.
"""
spec = registry.get_optional(provider_name)
if not spec:
return None
candidates = await db_client.list_telephony_configurations_by_provider(
organization_id, provider_name
)
if not candidates:
return None
field = spec.account_id_credential_field
matched: Optional[TelephonyConfigurationModel] = None
if not field:
# Provider has no account-id concept (e.g. ARI); only one config of this
# provider is meaningful per org.
if len(candidates) == 1:
matched = candidates[0]
else:
logger.warning(
f"Provider {provider_name} has multiple configs in org "
f"{organization_id} but no account_id field to disambiguate; "
f"picking the default outbound (or first)."
)
matched = next(
(c for c in candidates if c.is_default_outbound), candidates[0]
)
else:
for cand in candidates:
stored = (cand.credentials or {}).get(field)
if stored and account_id and stored == account_id:
matched = cand
break
if not matched:
return None
normalized = await _normalize_with_phone_numbers(matched)
return matched.id, normalized
async def get_telephony_provider_by_id(
telephony_configuration_id: int,
) -> TelephonyProvider:
config = await load_telephony_config_by_id(telephony_configuration_id)
return _instantiate(config)
async def get_default_telephony_provider(organization_id: int) -> TelephonyProvider:
config = await load_default_telephony_config(organization_id)
return _instantiate(config)
async def get_telephony_provider_for_inbound(
organization_id: int, provider_name: str, account_id: Optional[str]
) -> Optional[Tuple[int, TelephonyProvider]]:
"""Returns ``(config_id, provider_instance)`` or None when no config matches."""
match = await find_telephony_config_for_inbound(
organization_id, provider_name, account_id
)
if not match:
return None
config_id, config = match
return config_id, _instantiate(config)
async def load_credentials_for_transport(
organization_id: int,
telephony_configuration_id: Optional[int],
expected_provider: str,
) -> Dict[str, Any]:
"""Helper for per-provider transport modules.
Resolves the right credentials for a websocket transport given what's
available on the workflow run. Uses ``telephony_configuration_id`` when
stamped (the new path), otherwise falls back to the org's default config
so legacy runs created before the multi-config migration still work.
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)
else:
config = await load_default_telephony_config(organization_id)
actual = config.get("provider")
if actual != expected_provider:
raise ValueError(
f"Expected {expected_provider} provider, got {actual} "
f"(config_id={telephony_configuration_id}, org={organization_id})"
)
return config
# ---------------------------------------------------------------------------
# Back-compat shims
# ---------------------------------------------------------------------------
async def load_telephony_config(organization_id: int) -> Dict[str, Any]:
"""
Load telephony configuration from database.
"""Deprecated: returns the org's default config.
Args:
organization_id: Organization ID for database config
Returns:
Configuration dictionary with provider type and credentials
Raises:
ValueError: If no configuration found for the organization
"""
if not organization_id:
raise ValueError("Organization ID is required to load telephony configuration")
logger.debug(f"Loading telephony config from database for org {organization_id}")
config = await db_client.get_configuration(
organization_id,
OrganizationConfigurationKey.TELEPHONY_CONFIGURATION.value,
)
if config and config.value:
# Simple single-provider format
provider = config.value.get("provider", "twilio")
if provider == "twilio":
return {
"provider": "twilio",
"account_sid": config.value.get("account_sid"),
"auth_token": config.value.get("auth_token"),
"from_numbers": config.value.get("from_numbers", []),
}
elif provider == "plivo":
return {
"provider": "plivo",
"auth_id": config.value.get("auth_id"),
"auth_token": config.value.get("auth_token"),
"from_numbers": config.value.get("from_numbers", []),
}
elif provider == "vonage":
return {
"provider": "vonage",
"application_id": config.value.get("application_id"),
"private_key": config.value.get("private_key"),
"api_key": config.value.get("api_key"),
"api_secret": config.value.get("api_secret"),
"from_numbers": config.value.get("from_numbers", []),
}
elif provider == "vobiz":
return {
"provider": "vobiz",
"auth_id": config.value.get("auth_id"),
"auth_token": config.value.get("auth_token"),
"from_numbers": config.value.get("from_numbers", []),
}
elif provider == "cloudonix":
return {
"provider": "cloudonix",
"bearer_token": config.value.get("bearer_token"),
"api_key": config.value.get("api_key"), # For x-cx-apikey validation
"domain_id": config.value.get("domain_id"),
"from_numbers": config.value.get("from_numbers", []),
}
elif provider == "telnyx":
return {
"provider": "telnyx",
"api_key": config.value.get("api_key"),
"connection_id": config.value.get("connection_id"),
"from_numbers": config.value.get("from_numbers", []),
}
elif provider == "ari":
return {
"provider": "ari",
"ari_endpoint": config.value.get("ari_endpoint"),
"app_name": config.value.get("app_name"),
"app_password": config.value.get("app_password"),
"inbound_workflow_id": config.value.get("inbound_workflow_id"),
"from_numbers": config.value.get("from_numbers", []),
}
else:
raise ValueError(f"Unknown provider in config: {provider}")
raise ValueError(
f"No telephony configuration found for organization {organization_id}"
)
Existing callers that don't carry a config id continue to work via this
shim. New code should pass an explicit telephony_configuration_id."""
return await load_default_telephony_config(organization_id)
async def get_telephony_provider(organization_id: int) -> TelephonyProvider:
"""Deprecated: returns a provider for the org's default config.
See ``load_telephony_config`` above. New code should call
``get_telephony_provider_by_id`` with the resolved config id.
"""
Factory function to create telephony providers.
Args:
organization_id: Organization ID (required)
Returns:
Configured telephony provider instance
Raises:
ValueError: If provider type is unknown or configuration is invalid
"""
# Load configuration
config = await load_telephony_config(organization_id)
provider_type = config.get("provider", "twilio")
logger.info(f"Creating {provider_type} telephony provider")
# Create provider instance with configuration
if provider_type == "twilio":
return TwilioProvider(config)
elif provider_type == "plivo":
return PlivoProvider(config)
elif provider_type == "vonage":
return VonageProvider(config)
elif provider_type == "vobiz":
return VobizProvider(config)
elif provider_type == "cloudonix":
return CloudonixProvider(config)
elif provider_type == "telnyx":
return TelnyxProvider(config)
elif provider_type == "ari":
return ARIProvider(config)
else:
raise ValueError(f"Unknown telephony provider: {provider_type}")
return await get_default_telephony_provider(organization_id)
async def get_all_telephony_providers() -> List[Type[TelephonyProvider]]:
"""
Get all available telephony provider classes for webhook detection.
"""All registered provider classes — used by inbound webhook detection."""
return [spec.provider_cls for spec in registry.all_specs()]
Returns:
List of provider classes that can be used for webhook detection
"""
return [
ARIProvider,
CloudonixProvider,
PlivoProvider,
TelnyxProvider,
TwilioProvider,
VobizProvider,
VonageProvider,
]
# ---------------------------------------------------------------------------
# Internals
# ---------------------------------------------------------------------------
async def _normalize_with_phone_numbers(
row: TelephonyConfigurationModel,
) -> Dict[str, Any]:
"""Run the provider's config_loader over the credentials, then attach the
active phone numbers as a ``from_numbers`` list (raw address strings)."""
spec = registry.get(row.provider)
raw = dict(row.credentials or {})
raw["provider"] = row.provider
base = spec.config_loader(raw)
addresses = await db_client.list_active_normalized_addresses_for_config(row.id)
base["from_numbers"] = addresses
return base
def _instantiate(config: Dict[str, Any]) -> TelephonyProvider:
spec = registry.get(config["provider"])
logger.info(f"Creating {spec.name} telephony provider")
return spec.provider_cls(config)

View file

@ -0,0 +1,123 @@
# Telephony Providers
Each subdirectory here is a self-registering telephony provider. Adding a new one should touch this folder plus **exactly two lines** outside it. If a change you're making requires editing `factory.py`, `audio_config.py`, `run_pipeline.py`, `routes/telephony.py`, or any frontend file, stop — that's a smell. Push the variation through the registry instead.
## Anatomy of a provider package
```
providers/<name>/
├── __init__.py # Required. Builds + register()s ProviderSpec
├── config.py # Required. Pydantic Request + Response, both with `provider: Literal["<name>"]`
├── provider.py # Required. TelephonyProvider subclass
├── transport.py # Required. async create_transport(...) -> FastAPIWebsocketTransport
├── serializers.py # Optional but conventional. Re-export from pipecat
├── routes.py # Optional. APIRouter mounted lazily under /api/v1/telephony
└── strategies.py # Optional. Transfer/Hangup strategies for the frame serializer
```
Every file is provider-local. Nothing here imports another provider package.
## The two edits outside this folder
After creating `providers/<name>/`:
1. `providers/__init__.py` — add `<name>` to the import-for-side-effects list. Registration runs at import time.
2. `api/schemas/telephony_config.py` — import `<Name>ConfigurationRequest`/`Response` and add the request to the `TelephonyConfigRequest` `Union[...]` and the response as an optional field on `TelephonyConfigurationResponse`.
If you find yourself editing anything else, re-read the registry plumbing first:
| Want to change... | Source of truth |
| --- | --- |
| Outbound provider lookup | `factory.get_telephony_provider*` reads `registry.get(name).provider_cls` |
| Stored credentials → constructor dict | `ProviderSpec.config_loader` |
| Audio sample rate / VAD rate | `ProviderSpec.transport_sample_rate` (full `AudioConfig` is built in `pipecat/audio_config.py::create_audio_config`) |
| Which transport runs in `run_pipeline_telephony` | `ProviderSpec.transport_factory` |
| Save-request validation + masked response shape | `ProviderSpec.config_request_cls` / `config_response_cls` |
| Form rendered by the telephony-config UI | `ProviderSpec.ui_metadata` (`ProviderUIField` list) |
| Which credential masks on read | `ui_metadata.fields[*].sensitive=True` (no separate list) |
| Inbound webhook → config row matching | `ProviderSpec.account_id_credential_field` |
| HTTP routes (answer URL, status callbacks) | `providers/<name>/routes.py` (auto-mounted via `importlib`) |
## ProviderSpec — minimum viable shape
```python
SPEC = ProviderSpec(
name="<name>", # registry key, WorkflowRunMode value, stored discriminator
provider_cls=YourProvider,
config_loader=_config_loader, # raw dict from DB → constructor dict
transport_factory=create_transport,
transport_sample_rate=8000, # wire-format rate; pipecat derives the full AudioConfig
config_request_cls=YourProviderConfigurationRequest,
config_response_cls=YourProviderConfigurationResponse,
ui_metadata=ProviderUIMetadata(...), # drives the form UI
account_id_credential_field="api_key", # "" if provider has no account-id concept
)
register(SPEC)
```
`ProviderSpec` is frozen — immutable post-registration. Re-registration with the same instance is a no-op; re-registration with a different instance raises.
## Registration is import-driven, not config-driven
`api/services/telephony/__init__.py` imports `providers/` for side effects. Don't add a registration call elsewhere — by the time `factory`, `audio_config`, or `run_pipeline_telephony` look the spec up, the package init has already executed.
The package init **does not import `routes.py`**`api/routes/telephony.py::_mount_provider_routers()` walks `registry.all_specs()` and uses `importlib.import_module(f"...providers.{spec.name}.routes")`, treating `ModuleNotFoundError` as "no routes for this provider." This is what keeps `from api.services.telephony.base import TelephonyProvider` from fanning out to every route handler in the app. Don't undo it by importing `.routes` from `__init__.py`.
## Conventions
### `provider: Literal["<name>"]` on both Request and Response
Pydantic's discriminated union dispatches on this field. Forgetting `Literal` makes the union accept any provider's payload as yours. Default it to the literal so save calls don't have to send it explicitly.
### Transports load credentials lazily
Always:
```python
from api.services.telephony.factory import load_credentials_for_transport
config = await load_credentials_for_transport(
organization_id, telephony_configuration_id, expected_provider="<name>",
)
```
Never read the org's default config from `transport.py`. The workflow run carries `telephony_configuration_id` in `initial_context` for multi-config orgs; `load_credentials_for_transport` resolves the right row and validates the provider matches.
### `_config_loader` is a pure dict reshape
It runs over `TelephonyConfigurationModel.credentials` (the JSONB column). Don't do I/O in it. Don't pull `from_numbers` from credentials — the factory attaches active phone numbers from `telephony_phone_numbers` after the loader runs, by joining and normalizing addresses.
### Sensitive fields
Mark every credential field `sensitive=True` in `ProviderUIMetadata`. The org routes derive masking from `ui_metadata`, not from a separate hardcoded list. If you re-submit a masked value, `preserve_masked_fields` restores the original — relying on this means you should never write `sensitive=False` on a real secret to "make the form simpler."
### Inbound webhook routing
When multiple configs of the same provider live in one org (e.g. two Twilio sub-accounts), the inbound dispatcher matches the webhook to a config by `credentials[<account_id_credential_field>]`. Set this to whatever your provider stamps on inbound payloads (`account_sid` for Twilio, `auth_id` for Plivo, etc.). Set `""` only when the provider truly has no account-id concept (e.g. ARI — there's at most one config per org).
### `configure_inbound` defaults to no-op
Override only when the provider supports programmatic webhook binding (Plivo `application_id`, Telnyx app config). Markup-response providers that learn the webhook URL from console-side configuration leave the default. Returning `ProviderSyncResult(ok=False, message="...")` surfaces a non-fatal warning to the user without aborting the DB write.
## Reference implementations
Pick the closest shape and copy from it.
| Provider | Pick when... |
| --- | --- |
| `twilio/` | Markup-response (TwiML), HMAC-signed webhooks, conference-style transfers, status callbacks. The most full-featured reference. |
| `plivo/` | Markup-response with multi-callback signature schemes, programmatic answer-URL sync via Application API. |
| `vonage/` | JWT auth, 16 kHz Linear PCM wire format, NCCO JSON responses. |
| `cloudonix/` | SIP-trunk-style with custom transfer/hangup strategies. |
| `telnyx/` | Call-control style — REST calls to answer/stream rather than markup response. |
| `vobiz/` | Body-signed webhooks (signature covers raw bytes). |
| `ari/` | Smallest viable: no `routes.py`, no `verify_inbound_signature`, WebSocket-only, no account-id. |
## What NOT to do
- **Don't import another provider's `provider.py` or `transport.py`.** Cross-provider behavior belongs in `services/telephony/` (e.g. `status_processor`, `ari_manager`, `call_transfer_manager`), not in another provider's package.
- **Don't add a hardcoded provider list anywhere.** If you need to iterate, use `registry.all_specs()` / `registry.names()`.
- **Don't add a route under `routes/telephony.py` for a single provider.** Provider-specific handlers go in `providers/<name>/routes.py`. Cross-provider handlers (`/inbound/run`, `/twiml`) stay in `routes/telephony.py`.
- **Don't import `.routes` from a provider's `__init__.py`.** That's the cycle we deliberately broke — see "Registration is import-driven."
- **Don't write a frontend form for a new provider.** The UI consumes `GET /api/v1/organizations/telephony-providers/metadata` and renders generically from `ProviderUIField`. If a `field.type` you need doesn't exist (`text`/`password`/`textarea`/`string-array`/`number`), extend the renderer in `ui/src/app/(authenticated)/telephony-configurations/` once — not per provider.
- **Don't run a database migration to add a provider.** The discriminator lives in JSONB credentials and a `VARCHAR(64)` `mode` column; nothing in the DB schema knows the set of provider names.

View file

@ -1 +1,17 @@
# Telephony provider implementations
"""Telephony provider implementations.
Importing this module triggers each provider package to register itself
with ``api.services.telephony.registry``. Adding a new provider requires
exactly one new line below no edits to factory, audio_config, schemas,
or run_pipeline.
"""
from api.services.telephony.providers import ( # noqa: F401 -- import for side effects (registration)
ari,
cloudonix,
plivo,
telnyx,
twilio,
vobiz,
vonage,
)

View file

@ -0,0 +1,86 @@
"""ARI (Asterisk REST Interface) telephony provider package."""
from typing import Any, Dict
from api.services.telephony.registry import (
ProviderSpec,
ProviderUIField,
ProviderUIMetadata,
register,
)
from .config import ARIConfigurationRequest, ARIConfigurationResponse
from .provider import ARIProvider
from .transport import create_transport
def _config_loader(value: Dict[str, Any]) -> Dict[str, Any]:
return {
"provider": "ari",
"ari_endpoint": value.get("ari_endpoint"),
"app_name": value.get("app_name"),
"app_password": value.get("app_password"),
"from_numbers": value.get("from_numbers", []),
}
_UI_METADATA = ProviderUIMetadata(
display_name="Asterisk ARI",
docs_url="https://docs.dograh.com/integrations/telephony/asterisk-ari",
fields=[
ProviderUIField(
name="ari_endpoint",
label="ARI Endpoint",
type="text",
description="ARI base URL (e.g., http://asterisk.example.com:8088)",
),
ProviderUIField(
name="app_name",
label="Stasis App Name",
type="text",
description="Stasis application name registered in Asterisk",
),
ProviderUIField(
name="app_password",
label="ARI Password",
type="password",
sensitive=True,
),
ProviderUIField(
name="ws_client_name",
label="websocket_client.conf Name",
type="text",
description="websocket_client.conf connection name for externalMedia",
),
ProviderUIField(
name="from_numbers",
label="From Extensions",
type="string-array",
description="SIP extensions/numbers for outbound calls",
),
],
)
SPEC = ProviderSpec(
name="ari",
provider_cls=ARIProvider,
config_loader=_config_loader,
transport_factory=create_transport,
transport_sample_rate=8000,
config_request_cls=ARIConfigurationRequest,
ui_metadata=_UI_METADATA,
config_response_cls=ARIConfigurationResponse,
)
register(SPEC)
__all__ = [
"SPEC",
"ARIConfigurationRequest",
"ARIConfigurationResponse",
"ARIProvider",
"create_transport",
]

View file

@ -0,0 +1,37 @@
"""ARI (Asterisk REST Interface) telephony configuration schemas."""
from typing import List, Literal
from pydantic import BaseModel, Field
class ARIConfigurationRequest(BaseModel):
"""Request schema for Asterisk ARI configuration."""
provider: Literal["ari"] = Field(default="ari")
ari_endpoint: str = Field(
..., description="ARI base URL (e.g., http://asterisk.example.com:8088)"
)
app_name: str = Field(
..., description="Stasis application name registered in Asterisk"
)
app_password: str = Field(..., description="ARI user password")
ws_client_name: str = Field(
default="",
description="websocket_client.conf connection name for externalMedia (e.g., dograh_staging)",
)
from_numbers: List[str] = Field(
default_factory=list,
description="List of SIP extensions/numbers for outbound calls (optional)",
)
class ARIConfigurationResponse(BaseModel):
"""Response schema for ARI configuration with masked sensitive fields."""
provider: Literal["ari"] = Field(default="ari")
ari_endpoint: str
app_name: str
app_password: str # Masked
ws_client_name: str = ""
from_numbers: List[str]

View file

@ -50,7 +50,6 @@ class ARIProvider(TelephonyProvider):
self.ari_endpoint = config.get("ari_endpoint", "").rstrip("/")
self.app_name = config.get("app_name", "")
self.app_password = config.get("app_password", "")
self.inbound_workflow_id = config.get("inbound_workflow_id")
self.from_numbers = config.get("from_numbers", [])
if isinstance(self.from_numbers, str):
@ -251,7 +250,7 @@ class ARIProvider(TelephonyProvider):
Unlike Twilio (which sends "connected" and "start" JSON messages),
Asterisk chan_websocket starts streaming audio immediately.
"""
from api.services.pipecat.run_pipeline import run_pipeline_ari
from api.services.pipecat.run_pipeline import run_pipeline_telephony
# Get channel_id from workflow run context
workflow_run = await db_client.get_workflow_run(workflow_run_id, user_id)
@ -263,8 +262,14 @@ class ARIProvider(TelephonyProvider):
f"[ARI] Starting pipeline for workflow_run {workflow_run_id}, channel={channel_id}"
)
await run_pipeline_ari(
websocket, channel_id, workflow_id, workflow_run_id, user_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=channel_id,
transport_kwargs={"channel_id": channel_id},
)
# ======== INBOUND CALL METHODS ========
@ -307,15 +312,23 @@ class ARIProvider(TelephonyProvider):
return phone_number or ""
async def verify_inbound_signature(
self, url: str, webhook_data: Dict[str, Any], signature: str
self,
url: str,
webhook_data: Dict[str, Any],
headers: Dict[str, str],
body: str = "",
) -> bool:
"""ARI authenticates via WebSocket connection credentials, not signatures."""
return True
@staticmethod
async def generate_inbound_response(
websocket_url: str, workflow_run_id: int = None
) -> tuple:
async def start_inbound_stream(
self,
*,
websocket_url: str,
workflow_run_id: int,
normalized_data,
backend_endpoint: str,
):
"""ARI does not generate HTTP responses for inbound calls."""
from fastapi import Response

View file

@ -0,0 +1,5 @@
"""Asterisk frame serializer (re-exported from pipecat)."""
from pipecat.serializers.asterisk import AsteriskFrameSerializer
__all__ = ["AsteriskFrameSerializer"]

View file

@ -0,0 +1,70 @@
"""ARI (Asterisk) transport factory."""
from fastapi import WebSocket
from api.services.pipecat.audio_config import AudioConfig
from api.services.pipecat.audio_mixer import build_audio_out_mixer
from api.services.telephony.factory import load_credentials_for_transport
from pipecat.transports.websocket.fastapi import (
FastAPIWebsocketParams,
FastAPIWebsocketTransport,
)
from .serializers import AsteriskFrameSerializer
from .strategies import ARIBridgeSwapStrategy, ARIHangupStrategy
async def create_transport(
websocket: WebSocket,
workflow_run_id: int,
audio_config: AudioConfig,
organization_id: int,
*,
vad_config: dict | None = None,
ambient_noise_config: dict | None = None,
telephony_configuration_id: int | None = None,
channel_id: str,
):
"""Create a transport for Asterisk ARI connections."""
config = await load_credentials_for_transport(
organization_id, telephony_configuration_id, expected_provider="ari"
)
ari_endpoint = config.get("ari_endpoint")
app_name = config.get("app_name")
app_password = config.get("app_password")
if not ari_endpoint or not app_name or not app_password:
raise ValueError(
f"Incomplete ARI configuration for organization {organization_id}. "
f"Required: ari_endpoint, app_name, app_password"
)
serializer = AsteriskFrameSerializer(
channel_id=channel_id,
ari_endpoint=ari_endpoint,
app_name=app_name,
app_password=app_password,
transfer_strategy=ARIBridgeSwapStrategy(),
hangup_strategy=ARIHangupStrategy(),
params=AsteriskFrameSerializer.InputParams(
asterisk_sample_rate=audio_config.transport_in_sample_rate,
sample_rate=audio_config.pipeline_sample_rate,
),
)
mixer = await build_audio_out_mixer(
audio_config.transport_out_sample_rate, ambient_noise_config
)
return FastAPIWebsocketTransport(
websocket=websocket,
params=FastAPIWebsocketParams(
audio_in_enabled=True,
audio_out_enabled=True,
audio_in_sample_rate=audio_config.transport_in_sample_rate,
audio_out_sample_rate=audio_config.transport_out_sample_rate,
audio_out_mixer=mixer,
serializer=serializer,
),
)

View file

@ -0,0 +1,80 @@
"""Cloudonix telephony provider package."""
from typing import Any, Dict
from api.services.telephony.registry import (
ProviderSpec,
ProviderUIField,
ProviderUIMetadata,
register,
)
from .config import CloudonixConfigurationRequest, CloudonixConfigurationResponse
from .provider import CloudonixProvider
from .transport import create_transport
def _config_loader(value: Dict[str, Any]) -> Dict[str, Any]:
return {
"provider": "cloudonix",
"bearer_token": value.get("bearer_token"),
"api_key": value.get("api_key"), # For x-cx-apikey validation
"domain_id": value.get("domain_id"),
"application_name": value.get("application_name"),
"from_numbers": value.get("from_numbers", []),
}
_UI_METADATA = ProviderUIMetadata(
display_name="Cloudonix",
docs_url="https://docs.dograh.com/integrations/telephony/cloudonix",
fields=[
ProviderUIField(
name="bearer_token",
label="Bearer Token",
type="password",
sensitive=True,
description="Cloudonix API Bearer Token",
),
ProviderUIField(name="domain_id", label="Domain ID", type="text"),
ProviderUIField(
name="application_name",
label="Application Name",
type="text",
description=(
"Cloudonix Voice Application name whose url is updated when "
"inbound workflows are attached to numbers on this domain"
),
),
ProviderUIField(
name="from_numbers",
label="Phone Numbers",
type="string-array",
),
],
)
SPEC = ProviderSpec(
name="cloudonix",
provider_cls=CloudonixProvider,
config_loader=_config_loader,
transport_factory=create_transport,
transport_sample_rate=8000,
config_request_cls=CloudonixConfigurationRequest,
ui_metadata=_UI_METADATA,
config_response_cls=CloudonixConfigurationResponse,
account_id_credential_field="domain_id",
)
register(SPEC)
__all__ = [
"SPEC",
"CloudonixConfigurationRequest",
"CloudonixConfigurationResponse",
"CloudonixProvider",
"create_transport",
]

View file

@ -0,0 +1,34 @@
"""Cloudonix telephony configuration schemas."""
from typing import List, Literal
from pydantic import BaseModel, Field
class CloudonixConfigurationRequest(BaseModel):
"""Request schema for Cloudonix configuration."""
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(
...,
description=(
"Cloudonix Voice Application name. The application's url is "
"updated when inbound workflows are attached to numbers on "
"this domain."
),
)
from_numbers: List[str] = Field(
default_factory=list, description="List of Cloudonix phone numbers (optional)"
)
class CloudonixConfigurationResponse(BaseModel):
"""Response schema for Cloudonix configuration with masked sensitive fields."""
provider: Literal["cloudonix"] = Field(default="cloudonix")
bearer_token: str # Masked
domain_id: str
application_name: str
from_numbers: List[str]

View file

@ -14,6 +14,7 @@ from api.enums import WorkflowRunMode
from api.services.telephony.base import (
CallInitiationResult,
NormalizedInboundData,
ProviderSyncResult,
TelephonyProvider,
)
from api.utils.common import get_backend_endpoints
@ -39,10 +40,13 @@ class CloudonixProvider(TelephonyProvider):
config: Dictionary containing:
- bearer_token: Cloudonix API Bearer Token
- domain_id: Cloudonix Domain ID
- application_name: Cloudonix Voice Application name whose
url is updated by ``configure_inbound``
- 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.application_name = config.get("application_name")
self.from_numbers = config.get("from_numbers", [])
# Handle both single number (string) and multiple numbers (list)
@ -384,7 +388,8 @@ class CloudonixProvider(TelephonyProvider):
2. "start" event with streamSid and callSid
3. Then audio messages
"""
from api.services.pipecat.run_pipeline import run_pipeline_cloudonix
from api.db import db_client
from api.services.pipecat.run_pipeline import run_pipeline_telephony
try:
# Wait for "connected" event
@ -421,9 +426,27 @@ class CloudonixProvider(TelephonyProvider):
f"stream_sid: {stream_sid} call_sid: {call_sid}"
)
# Run the Cloudonix pipeline
await run_pipeline_cloudonix(
websocket, stream_sid, workflow_id, workflow_run_id, user_id
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
call_id = (
workflow_run.gathered_context.get("call_id")
if workflow_run and workflow_run.gathered_context
else None
)
if not call_id:
logger.error(
f"call_id not found in gathered_context for workflow run {workflow_run_id}"
)
await websocket.close(code=4400, reason="Missing call_id")
return
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_id,
transport_kwargs={"call_id": call_id, "stream_sid": stream_sid},
)
except Exception as e:
@ -562,14 +585,20 @@ class CloudonixProvider(TelephonyProvider):
return clean_number
async def verify_inbound_signature(
self, url: str, webhook_data: Dict[str, Any], api_key: str
self,
url: str,
webhook_data: Dict[str, Any],
headers: Dict[str, str],
body: str = "",
) -> bool:
"""
Verify the API key of an inbound Cloudonix webhook for security.
Cloudonix uses x-cx-apikey header validation instead of signature verification.
The API key from the webhook should match the bearer_token in our configuration.
Cloudonix uses ``x-cx-apikey`` header validation instead of signature
verification. The API key from the webhook should match the
bearer_token in our configuration.
"""
api_key = headers.get("x-cx-apikey", "")
if not api_key:
logger.warning("No x-cx-apikey provided in Cloudonix webhook")
return False
@ -591,10 +620,86 @@ class CloudonixProvider(TelephonyProvider):
return True # TODO: update this post clarification from cloudonix
@staticmethod
async def generate_inbound_response(
websocket_url: str, workflow_run_id: int = None
) -> tuple:
async def configure_inbound(
self, address: str, webhook_url: Optional[str]
) -> ProviderSyncResult:
"""Update the ``url`` on the Cloudonix Voice Application.
PATCH is partial, so we send only ``url`` and ``method=POST`` (our
``/inbound/run`` is POST-only); ``type``, ``active``, and ``profile``
are preserved as configured in the cockpit. The URL is shared across
every DNID on the application clearing is a no-op to avoid
silently breaking inbound for sibling numbers.
"""
if webhook_url is None:
logger.info(
f"Cloudonix configure_inbound clear for {address}: skipping "
f"application update (url is shared across all DNIDs on Voice "
f"Application {self.application_name})"
)
return ProviderSyncResult(ok=True)
if not self.validate_config():
return ProviderSyncResult(
ok=False, message="Cloudonix provider not properly configured"
)
if not self.application_name:
return ProviderSyncResult(
ok=False,
message=(
"Cloudonix application_name is not configured. Set it in "
"the telephony configuration so inbound webhooks can be "
"synced to the right Voice Application."
),
)
app_endpoint = (
f"{self.base_url}/customers/self/domains/{self.domain_id}/"
f"applications/{self.application_name}"
)
data = {
"url": webhook_url,
"method": "POST",
}
try:
async with aiohttp.ClientSession() as session:
async with session.patch(
app_endpoint, json=data, headers=self._get_auth_headers()
) as response:
if response.status != 200:
body = await response.text()
logger.error(
f"Cloudonix Voice Application update failed for "
f"{self.application_name} on domain "
f"{self.domain_id}: {response.status} {body}"
)
return ProviderSyncResult(
ok=False,
message=f"Cloudonix API {response.status}: {body}",
)
except Exception as e:
logger.error(
f"Exception updating Cloudonix Voice Application "
f"{self.application_name}: {e}"
)
return ProviderSyncResult(ok=False, message=f"Cloudonix update failed: {e}")
logger.info(
f"Cloudonix url set on Voice Application {self.application_name} "
f"(domain={self.domain_id}, triggered by address {address})"
)
return ProviderSyncResult(ok=True)
async def start_inbound_stream(
self,
*,
websocket_url: str,
workflow_run_id: int,
normalized_data,
backend_endpoint: str,
):
"""
Generate the appropriate CXML response for an inbound Cloudonix webhook.

View file

@ -0,0 +1,131 @@
"""Cloudonix telephony routes (webhooks, status callbacks, answer URLs).
Mounted under ``/api/v1/telephony`` by ``api.routes.telephony`` via the
provider registry see ProviderSpec.router.
"""
import json
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.status_processor import (
StatusCallbackRequest,
_process_status_update,
)
from pipecat.utils.run_context import set_current_run_id
router = APIRouter()
@router.post("/cloudonix/status-callback/{workflow_run_id}")
async def handle_cloudonix_status_callback(
workflow_run_id: int,
request: Request,
):
"""Handle Cloudonix-specific status callbacks.
Cloudonix sends call status updates to the callback URL specified during call initiation.
"""
set_current_run_id(workflow_run_id)
# Parse callback data - determine if JSON or form data
content_type = request.headers.get("content-type", "")
if "application/json" in content_type:
callback_data = await request.json()
else:
# Assume form data (like Twilio)
form_data = await request.form()
callback_data = dict(form_data)
logger.info(
f"[run {workflow_run_id}] Received Cloudonix status callback: {json.dumps(callback_data)}"
)
# Get workflow run to find organization
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
if not workflow_run:
logger.warning(f"Workflow run {workflow_run_id} not found for status callback")
return {"status": "ignored", "reason": "workflow_run_not_found"}
# Get workflow and provider
workflow = await db_client.get_workflow_by_id(workflow_run.workflow_id)
if not workflow:
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)
# Parse the callback data into generic format
parsed_data = provider.parse_status_callback(callback_data)
# Create StatusCallbackRequest from parsed data
status_update = StatusCallbackRequest(
call_id=parsed_data["call_id"],
status=parsed_data["status"],
from_number=parsed_data.get("from_number"),
to_number=parsed_data.get("to_number"),
direction=parsed_data.get("direction"),
duration=parsed_data.get("duration"),
extra=parsed_data.get("extra", {}),
)
# Process the status update
await _process_status_update(workflow_run_id, status_update)
return {"status": "success"}
@router.post("/cloudonix/cdr")
async def handle_cloudonix_cdr(request: Request):
"""Handle Cloudonix CDR (Call Detail Record) webhooks.
Cloudonix sends CDR records when calls complete. The CDR contains:
- domain: Used to identify the organization
- call_id: Used to find the workflow run
- disposition: Call termination status (ANSWER, BUSY, CANCEL, FAILED, CONGESTION, NOANSWER)
- duration/billsec: Call duration information
"""
try:
cdr_data = await request.json()
except Exception as e:
logger.error(f"Failed to parse Cloudonix CDR JSON: {e}")
return {"status": "error", "message": "Invalid JSON payload"}
# Extract domain to find organization
domain = cdr_data.get("domain")
if not domain:
logger.warning("Cloudonix CDR missing domain field")
return {"status": "error", "message": "Missing domain field"}
# Extract call_id to find workflow run
call_id = cdr_data.get("session").get("token")
logger.info(f"Cloudonix CDR data for call id {call_id} - {cdr_data}")
if not call_id:
logger.warning("Cloudonix CDR missing call_id field")
return {"status": "error", "message": "Missing call_id field"}
# Find workflow run by call_id in gathered_context
workflow_run = await db_client.get_workflow_run_by_call_id(call_id)
if not workflow_run:
logger.warning(f"No workflow run found for Cloudonix call_id: {call_id}")
return {"status": "ignored", "reason": "workflow_run_not_found"}
workflow_run_id = workflow_run.id
set_current_run_id(workflow_run_id)
logger.info(f"[run {workflow_run_id}] Processing Cloudonix CDR for call {call_id}")
# Convert CDR to status update using StatusCallbackRequest
status_update = StatusCallbackRequest.from_cloudonix_cdr(cdr_data)
# Process the status update
await _process_status_update(workflow_run_id, status_update)
logger.info(
f"[run {workflow_run_id}] Cloudonix CDR processed successfully - "
f"disposition: {cdr_data.get('disposition')}, status: {status_update.status}"
)
return {"status": "success"}

View file

@ -0,0 +1,5 @@
"""Cloudonix frame serializer (re-exported from pipecat)."""
from pipecat.serializers.cloudonix import CloudonixFrameSerializer
__all__ = ["CloudonixFrameSerializer"]

View file

@ -0,0 +1,66 @@
"""Cloudonix transport factory."""
from fastapi import WebSocket
from api.services.pipecat.audio_config import AudioConfig
from api.services.pipecat.audio_mixer import build_audio_out_mixer
from api.services.telephony.factory import load_credentials_for_transport
from pipecat.transports.websocket.fastapi import (
FastAPIWebsocketParams,
FastAPIWebsocketTransport,
)
from .serializers import CloudonixFrameSerializer
from .strategies import CloudonixHangupStrategy
async def create_transport(
websocket: WebSocket,
workflow_run_id: int,
audio_config: AudioConfig,
organization_id: int,
*,
vad_config: dict | None = None,
ambient_noise_config: dict | None = None,
telephony_configuration_id: int | None = None,
call_id: str,
stream_sid: str,
):
"""Create a transport for Cloudonix connections."""
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(
f"Incomplete Cloudonix configuration for organization {organization_id}. "
f"Required: bearer_token, domain_id"
)
serializer = CloudonixFrameSerializer(
call_id=call_id,
stream_sid=stream_sid,
domain_id=domain_id,
bearer_token=bearer_token,
hangup_strategy=CloudonixHangupStrategy(),
)
mixer = await build_audio_out_mixer(
audio_config.transport_out_sample_rate, ambient_noise_config
)
return FastAPIWebsocketTransport(
websocket=websocket,
params=FastAPIWebsocketParams(
audio_in_enabled=True,
audio_out_enabled=True,
audio_in_sample_rate=audio_config.transport_in_sample_rate,
audio_out_sample_rate=audio_config.transport_out_sample_rate,
audio_out_mixer=mixer,
serializer=serializer,
audio_out_10ms_chunks=2,
),
)

View file

@ -0,0 +1,76 @@
"""Plivo telephony provider package."""
from typing import Any, Dict
from api.services.telephony.registry import (
ProviderSpec,
ProviderUIField,
ProviderUIMetadata,
register,
)
from .config import PlivoConfigurationRequest, PlivoConfigurationResponse
from .provider import PlivoProvider
from .transport import create_transport
def _config_loader(value: Dict[str, Any]) -> Dict[str, Any]:
return {
"provider": "plivo",
"auth_id": value.get("auth_id"),
"auth_token": value.get("auth_token"),
"application_id": value.get("application_id"),
"from_numbers": value.get("from_numbers", []),
}
_UI_METADATA = ProviderUIMetadata(
display_name="Plivo",
docs_url="https://docs.dograh.com/integrations/telephony/plivo",
fields=[
ProviderUIField(name="auth_id", label="Auth ID", type="text", sensitive=True),
ProviderUIField(
name="auth_token", label="Auth Token", type="password", sensitive=True
),
ProviderUIField(
name="application_id",
label="Application ID",
type="text",
description=(
"Plivo Application ID whose answer_url is updated when inbound "
"workflows are attached to numbers on this account"
),
),
ProviderUIField(
name="from_numbers",
label="Phone Numbers",
type="string-array",
description="E.164-formatted Plivo phone numbers used for outbound calls",
),
],
)
SPEC = ProviderSpec(
name="plivo",
provider_cls=PlivoProvider,
config_loader=_config_loader,
transport_factory=create_transport,
transport_sample_rate=8000,
config_request_cls=PlivoConfigurationRequest,
ui_metadata=_UI_METADATA,
config_response_cls=PlivoConfigurationResponse,
account_id_credential_field="auth_id",
)
register(SPEC)
__all__ = [
"SPEC",
"PlivoConfigurationRequest",
"PlivoConfigurationResponse",
"PlivoProvider",
"create_transport",
]

View file

@ -0,0 +1,33 @@
"""Plivo telephony configuration schemas."""
from typing import List, Literal
from pydantic import BaseModel, Field
class PlivoConfigurationRequest(BaseModel):
"""Request schema for Plivo configuration."""
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(
...,
description=(
"Plivo Application ID. The application's answer_url is updated "
"when inbound workflows are attached to numbers on this account."
),
)
from_numbers: List[str] = Field(
default_factory=list, description="List of Plivo phone numbers"
)
class PlivoConfigurationResponse(BaseModel):
"""Response schema for Plivo configuration with masked sensitive fields."""
provider: Literal["plivo"] = Field(default="plivo")
auth_id: str # Masked
auth_token: str # Masked
application_id: str
from_numbers: List[str]

View file

@ -6,7 +6,6 @@ import base64
import hashlib
import hmac
import json
import os
import random
from typing import TYPE_CHECKING, Any, Dict, List, Optional
from urllib.parse import parse_qs, urlparse, urlunparse
@ -20,6 +19,7 @@ from api.enums import WorkflowRunMode
from api.services.telephony.base import (
CallInitiationResult,
NormalizedInboundData,
ProviderSyncResult,
TelephonyProvider,
)
from api.utils.common import get_backend_endpoints
@ -39,6 +39,7 @@ class PlivoProvider(TelephonyProvider):
def __init__(self, config: Dict[str, Any]):
self.auth_id = config.get("auth_id")
self.auth_token = config.get("auth_token")
self.application_id = config.get("application_id")
self.from_numbers = config.get("from_numbers", [])
if isinstance(self.from_numbers, str):
@ -147,7 +148,9 @@ class PlivoProvider(TelephonyProvider):
@staticmethod
def _query_map(query: str) -> Dict[str, Any]:
return {
PlivoProvider._stringify_signature_value(key): PlivoProvider._stringify_signature_value(value)
PlivoProvider._stringify_signature_value(
key
): PlivoProvider._stringify_signature_value(value)
for key, value in parse_qs(query, keep_blank_values=True).items()
}
@ -157,7 +160,9 @@ class PlivoProvider(TelephonyProvider):
for key in sorted(params.keys()):
value = params[key]
if isinstance(value, list):
normalized_values = sorted(PlivoProvider._stringify_signature_value(value))
normalized_values = sorted(
PlivoProvider._stringify_signature_value(value)
)
parts.append("&".join(f"{key}={item}" for item in normalized_values))
else:
parts.append(f"{key}={PlivoProvider._stringify_signature_value(value)}")
@ -169,7 +174,9 @@ class PlivoProvider(TelephonyProvider):
for key in sorted(params.keys()):
value = params[key]
if isinstance(value, list):
normalized_values = sorted(PlivoProvider._stringify_signature_value(value))
normalized_values = sorted(
PlivoProvider._stringify_signature_value(value)
)
parts.append("".join(f"{key}{item}" for item in normalized_values))
elif isinstance(value, dict):
parts.append(f"{key}{PlivoProvider._sorted_params_string(value)}")
@ -178,9 +185,13 @@ class PlivoProvider(TelephonyProvider):
return "".join(parts)
@staticmethod
def _construct_get_url(uri: str, params: Dict[str, Any], empty_post_params: bool = True) -> str:
def _construct_get_url(
uri: str, params: Dict[str, Any], empty_post_params: bool = True
) -> str:
parsed_uri = urlparse(uri)
base_url = urlunparse((parsed_uri.scheme, parsed_uri.netloc, parsed_uri.path, "", "", ""))
base_url = urlunparse(
(parsed_uri.scheme, parsed_uri.netloc, parsed_uri.path, "", "", "")
)
combined_params = dict(params)
combined_params.update(PlivoProvider._query_map(parsed_uri.query))
@ -220,7 +231,9 @@ class PlivoProvider(TelephonyProvider):
).digest()
).decode("utf-8")
candidates = [candidate.strip() for candidate in signature.split(",") if candidate]
candidates = [
candidate.strip() for candidate in signature.split(",") if candidate
]
return any(hmac.compare_digest(computed, candidate) for candidate in candidates)
async def get_webhook_response(
@ -298,7 +311,7 @@ class PlivoProvider(TelephonyProvider):
user_id: int,
workflow_run_id: int,
) -> None:
from api.services.pipecat.run_pipeline import run_pipeline_plivo
from api.services.pipecat.run_pipeline import run_pipeline_telephony
first_msg = await websocket.receive_text()
start_msg = json.loads(first_msg)
@ -329,8 +342,14 @@ class PlivoProvider(TelephonyProvider):
await websocket.close(code=4400, reason="Missing call ID")
return
await run_pipeline_plivo(
websocket, stream_id, call_id, workflow_id, workflow_run_id, user_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_id,
transport_kwargs={"stream_id": stream_id, "call_id": call_id},
)
@classmethod
@ -338,8 +357,7 @@ class PlivoProvider(TelephonyProvider):
cls, webhook_data: Dict[str, Any], headers: Dict[str, str]
) -> bool:
has_plivo_signature = (
"x-plivo-signature-v3" in headers
or "x-plivo-signature-ma-v3" in headers
"x-plivo-signature-v3" in headers or "x-plivo-signature-ma-v3" in headers
)
return has_plivo_signature and "CallUUID" in webhook_data
@ -347,8 +365,11 @@ class PlivoProvider(TelephonyProvider):
def parse_inbound_webhook(webhook_data: Dict[str, Any]) -> NormalizedInboundData:
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", "")),
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", "")),
direction=webhook_data.get("Direction", ""),
call_status=webhook_data.get("CallStatus", ""),
@ -387,27 +408,111 @@ class PlivoProvider(TelephonyProvider):
self,
url: str,
webhook_data: Dict[str, Any],
signature: str,
nonce: str = "",
headers: Dict[str, str],
body: str = "",
) -> bool:
if os.getenv("ENVIRONMENT") == "local":
logger.warning(
"Skipping Plivo inbound signature verification in local environment"
)
return True
signature = headers.get("x-plivo-signature-v3") or headers.get(
"x-plivo-signature-ma-v3", ""
)
nonce = headers.get("x-plivo-signature-v3-nonce", "")
if not signature:
# Plivo always signs its webhooks; missing header means the
# request didn't come from Plivo (or was tampered with).
logger.warning("Inbound Plivo webhook missing X-Plivo-Signature-V3")
return False
return await self.verify_webhook_signature(url, webhook_data, signature, nonce)
@staticmethod
async def generate_inbound_response(
websocket_url: str, workflow_run_id: int = None
) -> tuple:
async def configure_inbound(
self, address: str, webhook_url: Optional[str]
) -> ProviderSyncResult:
"""Update the answer_url on the configured Plivo Application.
Plivo numbers don't carry an answer_url directly — the URL lives on a
Plivo Application, and a number is linked to one app via ``app_id``.
Every call to this method updates the answer_url on
``self.application_id``, regardless of which ``address`` triggered the
sync. ``address`` is informational. Linking the number to
``self.application_id`` (in the Plivo console, or via the Account
Phone Number API) is the operator's responsibility — we only update
the application's webhook here.
Clearing (``webhook_url=None``) is a no-op on Plivo's side: the URL
is shared across every number linked to this application, so
unsetting it for one number would silently break inbound for the
rest. The DB-level disconnect is sufficient inbound calls without
a matching workflow are rejected by the backend.
"""
if webhook_url is None:
logger.info(
f"Plivo configure_inbound clear for {address}: skipping "
f"application update (answer_url is shared across all numbers "
f"on application {self.application_id})"
)
return ProviderSyncResult(ok=True)
if not self.validate_config():
return ProviderSyncResult(
ok=False, message="Plivo provider not properly configured"
)
if not self.application_id:
return ProviderSyncResult(
ok=False,
message=(
"Plivo application_id is not configured. Set it in the "
"telephony configuration so inbound webhooks can be "
"synced to the right Application."
),
)
app_endpoint = f"{self.base_url}/Application/{self.application_id}/"
data = {
"answer_url": webhook_url,
"answer_method": "POST",
}
auth = aiohttp.BasicAuth(self.auth_id, self.auth_token)
try:
async with aiohttp.ClientSession() as session:
async with session.post(app_endpoint, json=data, auth=auth) as response:
if response.status not in (200, 202):
body = await response.text()
logger.error(
f"Plivo application update failed for "
f"{self.application_id}: {response.status} {body}"
)
return ProviderSyncResult(
ok=False,
message=f"Plivo API {response.status}: {body}",
)
except Exception as e:
logger.error(
f"Exception updating Plivo application {self.application_id}: {e}"
)
return ProviderSyncResult(ok=False, message=f"Plivo update failed: {e}")
logger.info(
f"Plivo answer_url set on application {self.application_id} "
f"(triggered by address {address})"
)
return ProviderSyncResult(ok=True)
async def start_inbound_stream(
self,
*,
websocket_url: str,
workflow_run_id: int,
normalized_data,
backend_endpoint: str,
):
from fastapi import Response
hangup_callback_attr = ""
if workflow_run_id:
backend_endpoint, _ = await get_backend_endpoints()
hangup_url = f"{backend_endpoint}/api/v1/telephony/plivo/hangup-callback/{workflow_run_id}"
hangup_callback_attr = f' statusCallbackUrl="{hangup_url}" statusCallbackMethod="POST"'
hangup_callback_attr = (
f' statusCallbackUrl="{hangup_url}" statusCallbackMethod="POST"'
)
plivo_xml = f"""<?xml version="1.0" encoding="UTF-8"?>
<Response>

View file

@ -0,0 +1,172 @@
"""Plivo telephony routes (webhooks, status callbacks, answer URLs).
Mounted under ``/api/v1/telephony`` by ``api.routes.telephony`` via the
provider registry see ProviderSpec.router.
"""
import json
from typing import Optional
from fastapi import APIRouter, Header, Request
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.status_processor import (
StatusCallbackRequest,
_process_status_update,
)
from api.utils.common import get_backend_endpoints
from pipecat.utils.run_context import set_current_run_id
router = APIRouter()
async def _handle_plivo_status_callback(
workflow_run_id: int,
request: Request,
x_plivo_signature_v3: Optional[str],
x_plivo_signature_ma_v3: Optional[str],
x_plivo_signature_v3_nonce: Optional[str],
):
set_current_run_id(workflow_run_id)
form_data = await request.form()
callback_data = dict(form_data)
logger.info(
f"[run {workflow_run_id}] Received Plivo callback: {json.dumps(callback_data)}"
)
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
if not workflow_run:
logger.warning(f"Workflow run {workflow_run_id} not found for Plivo callback")
return {"status": "ignored", "reason": "workflow_run_not_found"}
workflow = await db_client.get_workflow_by_id(workflow_run.workflow_id)
if not workflow:
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)
signature = x_plivo_signature_v3 or x_plivo_signature_ma_v3
if signature:
backend_endpoint, _ = await get_backend_endpoints()
callback_kind = request.url.path.split("/")[-2]
full_url = f"{backend_endpoint}/api/v1/telephony/plivo/{callback_kind}/{workflow_run_id}"
is_valid = await provider.verify_inbound_signature(
full_url,
callback_data,
dict(request.headers),
)
if not is_valid:
logger.warning(f"[run {workflow_run_id}] Invalid Plivo webhook signature")
return {"status": "error", "reason": "invalid_signature"}
parsed_data = provider.parse_status_callback(callback_data)
status_update = StatusCallbackRequest(
call_id=parsed_data["call_id"],
status=parsed_data["status"],
from_number=parsed_data.get("from_number"),
to_number=parsed_data.get("to_number"),
direction=parsed_data.get("direction"),
duration=parsed_data.get("duration"),
extra=parsed_data.get("extra", {}),
)
await _process_status_update(workflow_run_id, status_update)
return {"status": "success"}
@router.post("/plivo-xml", include_in_schema=False)
async def handle_plivo_xml_webhook(
workflow_id: int,
user_id: int,
workflow_run_id: int,
organization_id: int,
request: Request,
x_plivo_signature_v3: Optional[str] = Header(None),
x_plivo_signature_ma_v3: Optional[str] = Header(None),
x_plivo_signature_v3_nonce: Optional[str] = Header(None),
):
"""
Handle initial webhook from Plivo when an outbound call is answered.
Returns Plivo XML response with Stream element.
"""
set_current_run_id(workflow_run_id)
provider = await get_telephony_provider(organization_id)
form_data = await request.form()
callback_data = dict(form_data)
signature = x_plivo_signature_v3 or x_plivo_signature_ma_v3
if signature:
backend_endpoint, _ = await get_backend_endpoints()
full_url = (
f"{backend_endpoint}/api/v1/telephony/plivo-xml"
f"?workflow_id={workflow_id}"
f"&user_id={user_id}"
f"&workflow_run_id={workflow_run_id}"
f"&organization_id={organization_id}"
)
is_valid = await provider.verify_inbound_signature(
full_url, callback_data, dict(request.headers)
)
if not is_valid:
logger.warning(
f"[run {workflow_run_id}] Invalid Plivo signature on answer webhook"
)
return provider.generate_error_response(
"invalid_signature", "Invalid webhook signature."
)
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(
run_id=workflow_run_id, gathered_context=gathered_context
)
response_content = await provider.get_webhook_response(
workflow_id, user_id, workflow_run_id
)
return HTMLResponse(content=response_content, media_type="application/xml")
@router.post("/plivo/hangup-callback/{workflow_run_id}")
async def handle_plivo_hangup_callback(
workflow_run_id: int,
request: Request,
x_plivo_signature_v3: Optional[str] = Header(None),
x_plivo_signature_ma_v3: Optional[str] = Header(None),
x_plivo_signature_v3_nonce: Optional[str] = Header(None),
):
"""Handle Plivo hangup callbacks."""
return await _handle_plivo_status_callback(
workflow_run_id,
request,
x_plivo_signature_v3,
x_plivo_signature_ma_v3,
x_plivo_signature_v3_nonce,
)
@router.post("/plivo/ring-callback/{workflow_run_id}")
async def handle_plivo_ring_callback(
workflow_run_id: int,
request: Request,
x_plivo_signature_v3: Optional[str] = Header(None),
x_plivo_signature_ma_v3: Optional[str] = Header(None),
x_plivo_signature_v3_nonce: Optional[str] = Header(None),
):
"""Handle Plivo ring callbacks."""
return await _handle_plivo_status_callback(
workflow_run_id,
request,
x_plivo_signature_v3,
x_plivo_signature_ma_v3,
x_plivo_signature_v3_nonce,
)

View file

@ -0,0 +1,5 @@
"""Plivo frame serializer (re-exported from pipecat)."""
from pipecat.serializers.plivo import PlivoFrameSerializer
__all__ = ["PlivoFrameSerializer"]

View file

@ -0,0 +1,66 @@
"""Plivo transport factory."""
from fastapi import WebSocket
from api.services.pipecat.audio_config import AudioConfig
from api.services.pipecat.audio_mixer import build_audio_out_mixer
from api.services.telephony.factory import load_credentials_for_transport
from pipecat.transports.websocket.fastapi import (
FastAPIWebsocketParams,
FastAPIWebsocketTransport,
)
from .serializers import PlivoFrameSerializer
async def create_transport(
websocket: WebSocket,
workflow_run_id: int,
audio_config: AudioConfig,
organization_id: int,
*,
vad_config: dict | None = None,
ambient_noise_config: dict | None = None,
telephony_configuration_id: int | None = None,
stream_id: str,
call_id: str,
):
"""Create a transport for Plivo connections."""
config = await load_credentials_for_transport(
organization_id, telephony_configuration_id, expected_provider="plivo"
)
auth_id = config.get("auth_id")
auth_token = config.get("auth_token")
if not auth_id or not auth_token:
raise ValueError(
f"Incomplete Plivo configuration for organization {organization_id}"
)
serializer = PlivoFrameSerializer(
stream_id=stream_id,
call_id=call_id,
auth_id=auth_id,
auth_token=auth_token,
params=PlivoFrameSerializer.InputParams(
plivo_sample_rate=8000,
sample_rate=audio_config.pipeline_sample_rate,
),
)
mixer = await build_audio_out_mixer(
audio_config.transport_out_sample_rate, ambient_noise_config
)
return FastAPIWebsocketTransport(
websocket=websocket,
params=FastAPIWebsocketParams(
audio_in_enabled=True,
audio_out_enabled=True,
audio_in_sample_rate=audio_config.transport_in_sample_rate,
audio_out_sample_rate=audio_config.transport_out_sample_rate,
audio_out_mixer=mixer,
serializer=serializer,
),
)

View file

@ -0,0 +1,71 @@
"""Telnyx telephony provider package."""
from typing import Any, Dict
from api.services.telephony.registry import (
ProviderSpec,
ProviderUIField,
ProviderUIMetadata,
register,
)
from .config import TelnyxConfigurationRequest, TelnyxConfigurationResponse
from .provider import TelnyxProvider
from .transport import create_transport
def _config_loader(value: Dict[str, Any]) -> Dict[str, Any]:
return {
"provider": "telnyx",
"api_key": value.get("api_key"),
"connection_id": value.get("connection_id"),
"from_numbers": value.get("from_numbers", []),
}
_UI_METADATA = ProviderUIMetadata(
display_name="Telnyx",
docs_url="https://docs.dograh.com/integrations/telephony/telnyx",
fields=[
ProviderUIField(
name="api_key", label="API Key", type="password", sensitive=True
),
ProviderUIField(
name="connection_id",
label="Call Control App ID",
type="text",
description="Telnyx Call Control Application ID (connection_id)",
),
ProviderUIField(
name="from_numbers",
label="Phone Numbers",
type="string-array",
description="E.164-formatted Telnyx phone numbers",
),
],
)
SPEC = ProviderSpec(
name="telnyx",
provider_cls=TelnyxProvider,
config_loader=_config_loader,
transport_factory=create_transport,
transport_sample_rate=8000,
config_request_cls=TelnyxConfigurationRequest,
ui_metadata=_UI_METADATA,
config_response_cls=TelnyxConfigurationResponse,
account_id_credential_field="connection_id",
)
register(SPEC)
__all__ = [
"SPEC",
"TelnyxConfigurationRequest",
"TelnyxConfigurationResponse",
"TelnyxProvider",
"create_transport",
]

View file

@ -0,0 +1,29 @@
"""Telnyx telephony configuration schemas."""
from typing import List, Literal
from pydantic import BaseModel, Field
class TelnyxConfigurationRequest(BaseModel):
"""Request schema for Telnyx configuration."""
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)"
)
# Phone numbers are managed via the dedicated phone-numbers endpoints; the
# legacy /telephony-config POST shim still accepts them inline.
from_numbers: List[str] = Field(
default_factory=list, description="List of Telnyx phone numbers"
)
class TelnyxConfigurationResponse(BaseModel):
"""Response schema for Telnyx configuration with masked sensitive fields."""
provider: Literal["telnyx"] = Field(default="telnyx")
api_key: str # Masked
connection_id: str
from_numbers: List[str]

View file

@ -16,6 +16,7 @@ from api.enums import WorkflowRunMode
from api.services.telephony.base import (
CallInitiationResult,
NormalizedInboundData,
ProviderSyncResult,
TelephonyProvider,
)
from api.utils.common import get_backend_endpoints
@ -241,7 +242,7 @@ class TelnyxProvider(TelephonyProvider):
2. "start" event with stream_id, call_control_id, media_format
3. "media" events with base64-encoded audio
"""
from api.services.pipecat.run_pipeline import run_pipeline_telnyx
from api.services.pipecat.run_pipeline import run_pipeline_telephony
try:
# Wait for "connected" event
@ -290,14 +291,17 @@ class TelnyxProvider(TelephonyProvider):
f"call_control_id={call_control_id}"
)
# Run the Telnyx pipeline
await run_pipeline_telnyx(
await run_pipeline_telephony(
websocket,
stream_id,
call_control_id,
workflow_id,
workflow_run_id,
user_id,
provider_name=self.PROVIDER_NAME,
workflow_id=workflow_id,
workflow_run_id=workflow_run_id,
user_id=user_id,
call_id=call_control_id,
transport_kwargs={
"stream_id": stream_id,
"call_control_id": call_control_id,
},
)
except Exception as e:
@ -409,23 +413,155 @@ class TelnyxProvider(TelephonyProvider):
return phone_number or ""
async def verify_inbound_signature(
self, url: str, webhook_data: Dict[str, Any], signature: str
self,
url: str,
webhook_data: Dict[str, Any],
headers: Dict[str, str],
body: str = "",
) -> bool:
"""Required by the abstract interface. Telnyx signature verification
(Ed25519) is not yet implemented accepts all inbound webhooks for now.
(Ed25519 via ``telnyx-signature-ed25519``) is not yet implemented
accepts all inbound webhooks for now.
"""
return True
@staticmethod
async def generate_inbound_response(
websocket_url: str, workflow_run_id: int = None
) -> tuple:
"""Telnyx inbound calls don't use a webhook response for streaming.
The streaming is set up via Call Control commands.
"""
from fastapi import Response
async def configure_inbound(
self, address: str, webhook_url: Optional[str]
) -> ProviderSyncResult:
"""Update webhook_event_url on the Telnyx Call Control Application.
return Response(content="{}", media_type="application/json")
PATCH requires application_name even on partial updates, so we GET
first to preserve whatever name the user set in the cockpit. The URL
is shared across every number on the application clearing is a
no-op to avoid silently breaking inbound for sibling numbers.
"""
if webhook_url is None:
logger.info(
f"Telnyx configure_inbound clear for {address}: skipping "
f"application update (webhook_event_url is shared across all "
f"numbers on Call Control Application {self.connection_id})"
)
return ProviderSyncResult(ok=True)
if not self.validate_config():
return ProviderSyncResult(
ok=False, message="Telnyx provider not properly configured"
)
if not self.connection_id:
return ProviderSyncResult(
ok=False,
message=(
"Telnyx connection_id (Call Control Application ID) is "
"not configured. Set it in the telephony configuration "
"so inbound webhooks can be synced to the right "
"application."
),
)
app_endpoint = (
f"{self.TELNYX_API_BASE}/call_control_applications/{self.connection_id}"
)
try:
async with aiohttp.ClientSession() as session:
async with session.get(
app_endpoint, headers=self._headers()
) as response:
if response.status != 200:
body = await response.text()
logger.error(
f"Failed to fetch Telnyx Call Control Application "
f"{self.connection_id}: {response.status} {body}"
)
return ProviderSyncResult(
ok=False,
message=f"Telnyx API {response.status}: {body}",
)
app_data = await response.json()
except Exception as e:
logger.error(
f"Exception fetching Telnyx Call Control Application "
f"{self.connection_id}: {e}"
)
return ProviderSyncResult(ok=False, message=f"Telnyx lookup failed: {e}")
application_name = (app_data.get("data") or {}).get("application_name")
if not application_name:
return ProviderSyncResult(
ok=False,
message=(
f"Telnyx Call Control Application {self.connection_id} "
f"did not return an application_name; cannot PATCH "
f"without it."
),
)
update_body = {
"application_name": application_name,
"webhook_event_url": webhook_url,
}
try:
async with aiohttp.ClientSession() as session:
async with session.patch(
app_endpoint, json=update_body, headers=self._headers()
) as response:
if response.status != 200:
body = await response.text()
logger.error(
f"Telnyx Call Control Application update failed "
f"for {self.connection_id}: {response.status} "
f"{body}"
)
return ProviderSyncResult(
ok=False,
message=f"Telnyx API {response.status}: {body}",
)
except Exception as e:
logger.error(
f"Exception updating Telnyx Call Control Application "
f"{self.connection_id}: {e}"
)
return ProviderSyncResult(ok=False, message=f"Telnyx update failed: {e}")
logger.info(
f"Telnyx webhook_event_url set on Call Control Application "
f"{self.connection_id} (triggered by address {address})"
)
return ProviderSyncResult(ok=True)
async def start_inbound_stream(
self,
*,
websocket_url: str,
workflow_run_id: int,
normalized_data,
backend_endpoint: str,
):
"""Answer the inbound Telnyx call via Call Control and start streaming.
Unlike markup-response providers, Telnyx ignores webhook response
bodies for call control the call must be answered with a REST
call back to Telnyx before media can flow. We do that here and
return a simple acknowledgement; on failure, return the
ANSWER_FAILED error response so the route stays provider-agnostic.
"""
events_url = (
f"{backend_endpoint}/api/v1/telephony/telnyx/events/{workflow_run_id}"
)
try:
await self.answer_and_stream(
call_control_id=normalized_data.call_id,
stream_url=websocket_url,
webhook_url=events_url,
)
except Exception as e:
logger.error(f"Failed to answer Telnyx inbound call: {e}")
return self.generate_error_response(
"ANSWER_FAILED", "Failed to answer call"
)
return {"status": "ok"}
@staticmethod
def generate_error_response(error_type: str, message: str) -> tuple:

View file

@ -0,0 +1,77 @@
"""Telnyx telephony routes (webhooks, status callbacks, answer URLs).
Mounted under ``/api/v1/telephony`` by ``api.routes.telephony`` via the
provider registry see ProviderSpec.router.
"""
import json
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.status_processor import (
StatusCallbackRequest,
_process_status_update,
)
from pipecat.utils.run_context import set_current_run_id
router = APIRouter()
@router.post("/telnyx/events/{workflow_run_id}")
async def handle_telnyx_events(
request: Request,
workflow_run_id: int,
):
"""Handle Telnyx Call Control webhook events.
Telnyx sends all call lifecycle events (call.initiated, call.answered,
call.hangup, streaming.started, streaming.stopped) as JSON POST requests.
"""
set_current_run_id(workflow_run_id)
event_data = await request.json()
logger.info(
f"[run {workflow_run_id}] Received Telnyx event: {json.dumps(event_data)}"
)
# Extract event type from Telnyx envelope
data = event_data.get("data", {})
event_type = data.get("event_type", "")
# Skip streaming events — they're informational only
if event_type in ("streaming.started", "streaming.stopped"):
logger.debug(f"[run {workflow_run_id}] Telnyx streaming event: {event_type}")
return {"status": "success"}
# Get workflow run and provider
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
if not workflow_run:
logger.warning(f"Workflow run {workflow_run_id} not found for Telnyx event")
return {"status": "ignored", "reason": "workflow_run_not_found"}
workflow = await db_client.get_workflow_by_id(workflow_run.workflow_id)
if not workflow:
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)
# Parse the callback data into generic format
parsed_data = provider.parse_status_callback(event_data)
status_update = StatusCallbackRequest(
call_id=parsed_data["call_id"],
status=parsed_data["status"],
from_number=parsed_data.get("from_number"),
to_number=parsed_data.get("to_number"),
direction=parsed_data.get("direction"),
duration=parsed_data.get("duration"),
extra=parsed_data.get("extra", {}),
)
await _process_status_update(workflow_run_id, status_update)
return {"status": "success"}

View file

@ -0,0 +1,5 @@
"""Telnyx frame serializer (re-exported from pipecat)."""
from pipecat.serializers.telnyx import TelnyxFrameSerializer
__all__ = ["TelnyxFrameSerializer"]

View file

@ -0,0 +1,61 @@
"""Telnyx transport factory."""
from fastapi import WebSocket
from api.services.pipecat.audio_config import AudioConfig
from api.services.pipecat.audio_mixer import build_audio_out_mixer
from api.services.telephony.factory import load_credentials_for_transport
from pipecat.transports.websocket.fastapi import (
FastAPIWebsocketParams,
FastAPIWebsocketTransport,
)
from .serializers import TelnyxFrameSerializer
async def create_transport(
websocket: WebSocket,
workflow_run_id: int,
audio_config: AudioConfig,
organization_id: int,
*,
vad_config: dict | None = None,
ambient_noise_config: dict | None = None,
telephony_configuration_id: int | None = None,
stream_id: str,
call_control_id: str,
):
"""Create a transport for Telnyx connections."""
config = await load_credentials_for_transport(
organization_id, telephony_configuration_id, expected_provider="telnyx"
)
api_key = config.get("api_key")
if not api_key:
raise ValueError(
f"Incomplete Telnyx configuration for organization {organization_id}"
)
serializer = TelnyxFrameSerializer(
stream_id=stream_id,
call_control_id=call_control_id,
api_key=api_key,
outbound_encoding="PCMU",
inbound_encoding="PCMU",
)
mixer = await build_audio_out_mixer(
audio_config.transport_out_sample_rate, ambient_noise_config
)
return FastAPIWebsocketTransport(
websocket=websocket,
params=FastAPIWebsocketParams(
audio_in_enabled=True,
audio_out_enabled=True,
audio_in_sample_rate=audio_config.transport_in_sample_rate,
audio_out_sample_rate=audio_config.transport_out_sample_rate,
audio_out_mixer=mixer,
serializer=serializer,
),
)

View file

@ -0,0 +1,76 @@
"""Twilio telephony provider package."""
from typing import Any, Dict
from api.services.telephony.registry import (
ProviderSpec,
ProviderUIField,
ProviderUIMetadata,
register,
)
from .config import TwilioConfigurationRequest, TwilioConfigurationResponse
from .provider import TwilioProvider
from .transport import create_transport
def _config_loader(value: Dict[str, Any]) -> Dict[str, Any]:
return {
"provider": "twilio",
"account_sid": value.get("account_sid"),
"auth_token": value.get("auth_token"),
"from_numbers": value.get("from_numbers", []),
}
_UI_METADATA = ProviderUIMetadata(
display_name="Twilio",
docs_url="https://docs.dograh.com/integrations/telephony/twilio",
fields=[
ProviderUIField(
name="account_sid",
label="Account SID",
type="text",
sensitive=True,
description="Twilio Account SID (starts with AC)",
),
ProviderUIField(
name="auth_token",
label="Auth Token",
type="password",
sensitive=True,
description="Twilio Auth Token",
),
ProviderUIField(
name="from_numbers",
label="Phone Numbers",
type="string-array",
description="E.164-formatted Twilio phone numbers used for outbound calls",
),
],
)
SPEC = ProviderSpec(
name="twilio",
provider_cls=TwilioProvider,
config_loader=_config_loader,
transport_factory=create_transport,
transport_sample_rate=8000,
config_request_cls=TwilioConfigurationRequest,
ui_metadata=_UI_METADATA,
config_response_cls=TwilioConfigurationResponse,
account_id_credential_field="account_sid",
)
register(SPEC)
__all__ = [
"SPEC",
"TwilioConfigurationRequest",
"TwilioConfigurationResponse",
"TwilioProvider",
"create_transport",
]

View file

@ -0,0 +1,27 @@
"""Twilio telephony configuration schemas."""
from typing import List, Literal
from pydantic import BaseModel, Field
class TwilioConfigurationRequest(BaseModel):
"""Request schema for Twilio configuration."""
provider: Literal["twilio"] = Field(default="twilio")
account_sid: str = Field(..., description="Twilio Account SID")
auth_token: str = Field(..., description="Twilio Auth Token")
# Phone numbers are managed via the dedicated phone-numbers endpoints; the
# legacy /telephony-config POST shim still accepts them inline.
from_numbers: List[str] = Field(
default_factory=list, description="List of Twilio phone numbers"
)
class TwilioConfigurationResponse(BaseModel):
"""Response schema for Twilio configuration with masked sensitive fields."""
provider: Literal["twilio"] = Field(default="twilio")
account_sid: str # Masked (e.g., "****************def0")
auth_token: str # Masked (e.g., "****************abc1")
from_numbers: List[str]

View file

@ -15,9 +15,11 @@ from api.enums import WorkflowRunMode
from api.services.telephony.base import (
CallInitiationResult,
NormalizedInboundData,
ProviderSyncResult,
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
@ -253,7 +255,7 @@ class TwilioProvider(TelephonyProvider):
2. "start" event with streamSid and callSid
3. Then audio messages
"""
from api.services.pipecat.run_pipeline import run_pipeline_twilio
from api.services.pipecat.run_pipeline import run_pipeline_telephony
try:
# Wait for "connected" event
@ -288,9 +290,14 @@ class TwilioProvider(TelephonyProvider):
await websocket.close(code=4400, reason="Missing stream identifiers")
return
# Run the Twilio pipeline
await run_pipeline_twilio(
websocket, stream_sid, call_sid, workflow_id, workflow_run_id, user_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={"stream_sid": stream_sid, "call_sid": call_sid},
)
except Exception as e:
@ -392,17 +399,117 @@ class TwilioProvider(TelephonyProvider):
return stored_account_sid == webhook_account_id
async def verify_inbound_signature(
self, url: str, webhook_data: Dict[str, Any], signature: str
self,
url: str,
webhook_data: Dict[str, Any],
headers: Dict[str, str],
body: str = "",
) -> bool:
"""
Verify the signature of an inbound Twilio webhook for security.
Twilio signs requests with the ``X-Twilio-Signature`` header.
"""
signature = headers.get("x-twilio-signature", "")
if not signature:
# Twilio always signs its webhooks; missing header means the
# request didn't come from Twilio (or was tampered with).
logger.warning("Inbound Twilio webhook missing X-Twilio-Signature")
return False
return await self.verify_webhook_signature(url, webhook_data, signature)
@staticmethod
async def generate_inbound_response(
websocket_url: str, workflow_run_id: int = None
) -> tuple:
async def configure_inbound(
self, address: str, webhook_url: Optional[str]
) -> ProviderSyncResult:
"""Set (or clear) the VoiceUrl on Twilio's IncomingPhoneNumber for ``address``.
Looks up the number's SID by E.164 then POSTs the update. Non-PSTN
addresses (SIP URIs, extensions) are skipped Twilio's
IncomingPhoneNumbers resource only covers PSTN numbers.
"""
if not self.validate_config():
return ProviderSyncResult(
ok=False, message="Twilio provider not properly configured"
)
normalized = normalize_telephony_address(address)
if normalized.address_type != "pstn":
# Nothing to do on Twilio's side for SIP URIs/extensions.
return ProviderSyncResult(ok=True)
e164 = normalized.canonical
try:
sid = await self._lookup_incoming_number_sid(e164)
except Exception as e:
logger.error(f"Failed to look up Twilio number {e164}: {e}")
return ProviderSyncResult(ok=False, message=f"Twilio lookup failed: {e}")
if not sid:
return ProviderSyncResult(
ok=False,
message=(
f"Phone number {e164} is not owned by this Twilio account "
f"({self.account_sid}). Add it in the Twilio console first."
),
)
endpoint = f"{self.base_url}/IncomingPhoneNumbers/{sid}.json"
if webhook_url:
data = {
"VoiceUrl": webhook_url,
"VoiceMethod": "POST",
}
else:
# Clearing — Twilio treats empty string as "unset".
data = {
"VoiceUrl": "",
}
try:
async with aiohttp.ClientSession() as session:
auth = aiohttp.BasicAuth(self.account_sid, self.auth_token)
async with session.post(endpoint, data=data, auth=auth) as response:
if response.status not in (200, 201):
body = await response.text()
logger.error(
f"Twilio VoiceUrl update failed for {e164} "
f"(sid={sid}): {response.status} {body}"
)
return ProviderSyncResult(
ok=False,
message=f"Twilio API {response.status}: {body}",
)
except Exception as e:
logger.error(f"Exception updating Twilio VoiceUrl for {e164}: {e}")
return ProviderSyncResult(ok=False, message=f"Twilio update failed: {e}")
action = "set" if webhook_url else "cleared"
logger.info(f"Twilio VoiceUrl {action} for {e164} (sid={sid})")
return ProviderSyncResult(ok=True)
async def _lookup_incoming_number_sid(self, e164: str) -> Optional[str]:
"""Return the Twilio SID of the IncomingPhoneNumber matching ``e164``."""
endpoint = f"{self.base_url}/IncomingPhoneNumbers.json"
params = {"PhoneNumber": e164}
async with aiohttp.ClientSession() as session:
auth = aiohttp.BasicAuth(self.account_sid, self.auth_token)
async with session.get(endpoint, params=params, auth=auth) as response:
if response.status != 200:
body = await response.text()
raise Exception(f"Twilio API {response.status}: {body}")
data = await response.json()
numbers = data.get("incoming_phone_numbers") or []
if not numbers:
return None
return numbers[0].get("sid")
async def start_inbound_stream(
self,
*,
websocket_url: str,
workflow_run_id: int,
normalized_data,
backend_endpoint: str,
):
"""
Generate TwiML response for an inbound Twilio webhook.
@ -413,7 +520,6 @@ class TwilioProvider(TelephonyProvider):
# Generate StatusCallback URL using same pattern as outbound calls
status_callback_attr = ""
if workflow_run_id:
backend_endpoint, _ = await get_backend_endpoints()
status_callback_url = f"{backend_endpoint}/api/v1/telephony/twilio/status-callback/{workflow_run_id}"
status_callback_attr = f' statusCallback="{status_callback_url}"'

View file

@ -0,0 +1,106 @@
"""Twilio telephony routes (webhooks, status callbacks, answer URLs).
Mounted under ``/api/v1/telephony`` by ``api.routes.telephony`` via the
provider registry see ProviderSpec.router.
"""
import json
from typing import Optional
from fastapi import APIRouter, Header, Request
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.status_processor import (
StatusCallbackRequest,
_process_status_update,
)
from api.utils.common import get_backend_endpoints
from pipecat.utils.run_context import set_current_run_id
router = APIRouter()
@router.post("/twiml", include_in_schema=False)
async def handle_twiml_webhook(
workflow_id: int, user_id: int, workflow_run_id: int, organization_id: int
):
"""
Handle initial webhook from telephony provider.
Returns provider-specific response (e.g., TwiML for Twilio).
"""
provider = await get_telephony_provider(organization_id)
response_content = await provider.get_webhook_response(
workflow_id, user_id, workflow_run_id
)
return HTMLResponse(content=response_content, media_type="application/xml")
@router.post("/twilio/status-callback/{workflow_run_id}")
async def handle_twilio_status_callback(
workflow_run_id: int,
request: Request,
x_webhook_signature: Optional[str] = Header(None),
):
"""Handle Twilio-specific status callbacks."""
set_current_run_id(workflow_run_id)
# Parse form data
form_data = await request.form()
callback_data = dict(form_data)
logger.info(
f"[run {workflow_run_id}] Received status callback: {json.dumps(callback_data)}"
)
# Get workflow run to find organization
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
if not workflow_run:
logger.warning(f"Workflow run {workflow_run_id} not found for status callback")
return {"status": "ignored", "reason": "workflow_run_not_found"}
# Get workflow and provider
workflow = await db_client.get_workflow_by_id(workflow_run.workflow_id)
if not workflow:
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)
if x_webhook_signature:
backend_endpoint, _ = await get_backend_endpoints()
full_url = f"{backend_endpoint}/api/v1/telephony/twilio/status-callback/{workflow_run_id}"
is_valid = await provider.verify_webhook_signature(
full_url, callback_data, x_webhook_signature
)
if not is_valid:
logger.warning(
f"Invalid webhook signature for workflow run {workflow_run_id}"
)
return {"status": "error", "reason": "invalid_signature"}
# Parse the callback data into generic format
parsed_data = provider.parse_status_callback(callback_data)
# Create StatusCallbackRequest from parsed data
status_update = StatusCallbackRequest(
call_id=parsed_data["call_id"],
status=parsed_data["status"],
from_number=parsed_data.get("from_number"),
to_number=parsed_data.get("to_number"),
direction=parsed_data.get("direction"),
duration=parsed_data.get("duration"),
extra=parsed_data.get("extra", {}),
)
# Process the status update
await _process_status_update(workflow_run_id, status_update)
return {"status": "success"}

View file

@ -0,0 +1,10 @@
"""Twilio frame serializer.
Re-exported from pipecat. Kept local so transport.py imports from
``.serializers`` and we have an obvious place to drop a custom subclass if
pipecat upstream lags.
"""
from pipecat.serializers.twilio import TwilioFrameSerializer
__all__ = ["TwilioFrameSerializer"]

View file

@ -0,0 +1,65 @@
"""Twilio transport factory."""
from fastapi import WebSocket
from api.services.pipecat.audio_config import AudioConfig
from api.services.pipecat.audio_mixer import build_audio_out_mixer
from api.services.telephony.factory import load_credentials_for_transport
from pipecat.transports.websocket.fastapi import (
FastAPIWebsocketParams,
FastAPIWebsocketTransport,
)
from .serializers import TwilioFrameSerializer
from .strategies import TwilioConferenceStrategy, TwilioHangupStrategy
async def create_transport(
websocket: WebSocket,
workflow_run_id: int,
audio_config: AudioConfig,
organization_id: int,
*,
vad_config: dict | None = None,
ambient_noise_config: dict | None = None,
telephony_configuration_id: int | None = None,
stream_sid: str,
call_sid: str,
):
"""Create a transport for Twilio connections."""
config = await load_credentials_for_transport(
organization_id, telephony_configuration_id, expected_provider="twilio"
)
account_sid = config.get("account_sid")
auth_token = config.get("auth_token")
if not account_sid or not auth_token:
raise ValueError(
f"Incomplete Twilio configuration for organization {organization_id}"
)
serializer = TwilioFrameSerializer(
stream_sid=stream_sid,
call_sid=call_sid,
account_sid=account_sid,
auth_token=auth_token,
transfer_strategy=TwilioConferenceStrategy(),
hangup_strategy=TwilioHangupStrategy(),
)
mixer = await build_audio_out_mixer(
audio_config.transport_out_sample_rate, ambient_noise_config
)
return FastAPIWebsocketTransport(
websocket=websocket,
params=FastAPIWebsocketParams(
audio_in_enabled=True,
audio_out_enabled=True,
audio_in_sample_rate=audio_config.transport_in_sample_rate,
audio_out_sample_rate=audio_config.transport_out_sample_rate,
audio_out_mixer=mixer,
serializer=serializer,
),
)

View file

@ -0,0 +1,82 @@
"""Vobiz telephony provider package."""
from typing import Any, Dict
from api.services.telephony.registry import (
ProviderSpec,
ProviderUIField,
ProviderUIMetadata,
register,
)
from .config import VobizConfigurationRequest, VobizConfigurationResponse
from .provider import VobizProvider
from .transport import create_transport
def _config_loader(value: Dict[str, Any]) -> Dict[str, Any]:
return {
"provider": "vobiz",
"auth_id": value.get("auth_id"),
"auth_token": value.get("auth_token"),
"application_id": value.get("application_id"),
"from_numbers": value.get("from_numbers", []),
}
_UI_METADATA = ProviderUIMetadata(
display_name="Vobiz",
docs_url="https://docs.dograh.com/integrations/telephony/vobiz",
fields=[
ProviderUIField(
name="auth_id",
label="Account ID",
type="text",
sensitive=True,
description="Vobiz Account ID (e.g., MA_SYQRLN1K)",
),
ProviderUIField(
name="auth_token", label="Auth Token", type="password", sensitive=True
),
ProviderUIField(
name="application_id",
label="Application ID",
type="text",
description=(
"Vobiz Application ID whose answer_url is updated when "
"inbound workflows are attached to numbers on this account"
),
),
ProviderUIField(
name="from_numbers",
label="Phone Numbers",
type="string-array",
description="E.164-formatted phone numbers without + prefix",
),
],
)
SPEC = ProviderSpec(
name="vobiz",
provider_cls=VobizProvider,
config_loader=_config_loader,
transport_factory=create_transport,
transport_sample_rate=8000,
config_request_cls=VobizConfigurationRequest,
ui_metadata=_UI_METADATA,
config_response_cls=VobizConfigurationResponse,
account_id_credential_field="auth_id",
)
register(SPEC)
__all__ = [
"SPEC",
"VobizConfigurationRequest",
"VobizConfigurationResponse",
"VobizProvider",
"create_transport",
]

View file

@ -0,0 +1,34 @@
"""Vobiz telephony configuration schemas."""
from typing import List, Literal
from pydantic import BaseModel, Field
class VobizConfigurationRequest(BaseModel):
"""Request schema for Vobiz configuration."""
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(
...,
description=(
"Vobiz Application ID. The application's answer_url is updated "
"when inbound workflows are attached to numbers on this account."
),
)
from_numbers: List[str] = Field(
default_factory=list,
description="List of Vobiz phone numbers (E.164 without + prefix)",
)
class VobizConfigurationResponse(BaseModel):
"""Response schema for Vobiz configuration with masked sensitive fields."""
provider: Literal["vobiz"] = Field(default="vobiz")
auth_id: str # Masked
auth_token: str # Masked
application_id: str
from_numbers: List[str]

View file

@ -14,6 +14,7 @@ from api.enums import WorkflowRunMode
from api.services.telephony.base import (
CallInitiationResult,
NormalizedInboundData,
ProviderSyncResult,
TelephonyProvider,
)
from api.utils.common import get_backend_endpoints
@ -39,10 +40,13 @@ class VobizProvider(TelephonyProvider):
config: Dictionary containing:
- auth_id: Vobiz Account ID (e.g., MA_SYQRLN1K)
- auth_token: Vobiz Auth Token
- application_id: Vobiz Application ID whose answer_url is
updated by ``configure_inbound``
- from_numbers: List of phone numbers to use (E.164 format without +)
"""
self.auth_id = config.get("auth_id")
self.auth_token = config.get("auth_token")
self.application_id = config.get("application_id")
self.from_numbers = config.get("from_numbers", [])
# Handle both single number (string) and multiple numbers (list)
@ -356,7 +360,7 @@ class VobizProvider(TelephonyProvider):
Extracts stream_id and call_id from the start event and delegates
message handling to VobizFrameSerializer.
"""
from api.services.pipecat.run_pipeline import run_pipeline_vobiz
from api.services.pipecat.run_pipeline import run_pipeline_telephony
first_msg = await websocket.receive_text()
start_msg = json.loads(first_msg)
@ -386,8 +390,14 @@ class VobizProvider(TelephonyProvider):
f"stream_id: {stream_id}, call_id: {call_id}"
)
await run_pipeline_vobiz(
websocket, stream_id, call_id, workflow_id, workflow_run_id, user_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_id,
transport_kwargs={"stream_id": stream_id, "call_id": call_id},
)
logger.info(f"[run {workflow_run_id}] Vobiz pipeline completed")
@ -467,22 +477,107 @@ class VobizProvider(TelephonyProvider):
self,
url: str,
webhook_data: Dict[str, Any],
signature: str,
timestamp: str = None,
headers: Dict[str, str],
body: str = "",
) -> bool:
"""
Verify the signature of an inbound Vobiz webhook for security.
Uses the same HMAC-SHA256 verification as other Vobiz webhooks.
Uses HMAC-SHA256 over ``timestamp + '.' + body`` with the auth_token.
"""
signature = headers.get("x-vobiz-signature", "")
timestamp = headers.get("x-vobiz-timestamp")
if not signature:
# 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 await self.verify_webhook_signature(
url, webhook_data, signature, timestamp, body
)
@staticmethod
async def generate_inbound_response(
websocket_url: str, workflow_run_id: int = None
) -> tuple:
async def configure_inbound(
self, address: str, webhook_url: Optional[str]
) -> ProviderSyncResult:
"""Update answer_url on the Vobiz Application (Plivo-compatible model).
Vobiz's update is partial so we POST only ``answer_url`` and
``answer_method`` ``app_name``, ``hangup_url``, etc. stay as the
user set them. The URL is shared across every number on the
application clearing is a no-op to avoid silently breaking
inbound for sibling numbers.
"""
if webhook_url is None:
logger.info(
f"Vobiz configure_inbound clear for {address}: skipping "
f"application update (answer_url is shared across all numbers "
f"on application {self.application_id})"
)
return ProviderSyncResult(ok=True)
if not self.validate_config():
return ProviderSyncResult(
ok=False, message="Vobiz provider not properly configured"
)
if not self.application_id:
return ProviderSyncResult(
ok=False,
message=(
"Vobiz application_id is not configured. Set it in the "
"telephony configuration so inbound webhooks can be "
"synced to the right Application."
),
)
app_endpoint = (
f"{self.base_url}/v1/Account/{self.auth_id}/Application/"
f"{self.application_id}/"
)
data = {
"answer_url": webhook_url,
"answer_method": "POST",
}
headers = {
"X-Auth-ID": self.auth_id,
"X-Auth-Token": self.auth_token,
"Content-Type": "application/json",
}
try:
async with aiohttp.ClientSession() as session:
async with session.post(
app_endpoint, json=data, headers=headers
) as response:
if response.status != 200:
body = await response.text()
logger.error(
f"Vobiz application update failed for "
f"{self.application_id}: {response.status} {body}"
)
return ProviderSyncResult(
ok=False,
message=f"Vobiz API {response.status}: {body}",
)
except Exception as e:
logger.error(
f"Exception updating Vobiz application {self.application_id}: {e}"
)
return ProviderSyncResult(ok=False, message=f"Vobiz update failed: {e}")
logger.info(
f"Vobiz answer_url set on application {self.application_id} "
f"(triggered by address {address})"
)
return ProviderSyncResult(ok=True)
async def start_inbound_stream(
self,
*,
websocket_url: str,
workflow_run_id: int,
normalized_data,
backend_endpoint: str,
):
"""
Generate Vobiz XML response for an inbound webhook.

View file

@ -0,0 +1,420 @@
"""Vobiz telephony routes (webhooks, status callbacks, answer URLs).
Mounted under ``/api/v1/telephony`` by ``api.routes.telephony`` via the
provider registry see ProviderSpec.router.
"""
import json
from datetime import UTC, datetime
from typing import Optional
from fastapi import APIRouter, Header, Request
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.status_processor import (
StatusCallbackRequest,
_process_status_update,
)
from api.utils.common import get_backend_endpoints
from api.utils.telephony_helper import (
parse_webhook_request,
)
from pipecat.utils.run_context import set_current_run_id
router = APIRouter()
@router.post("/vobiz-xml", include_in_schema=False)
async def handle_vobiz_xml_webhook(
workflow_id: int, user_id: int, workflow_run_id: int, organization_id: int
):
"""
Handle initial webhook from Vobiz when call is answered.
Returns Vobiz XML response with Stream element.
Vobiz uses Plivo-compatible XML format similar to Twilio's TwiML.
"""
set_current_run_id(workflow_run_id)
logger.info(
f"[run {workflow_run_id}] Vobiz XML webhook called - "
f"workflow_id={workflow_id}, user_id={user_id}, org_id={organization_id}"
)
provider = await get_telephony_provider(organization_id)
logger.debug(f"[run {workflow_run_id}] Using provider: {provider.PROVIDER_NAME}")
response_content = await provider.get_webhook_response(
workflow_id, user_id, workflow_run_id
)
logger.debug(
f"[run {workflow_run_id}] Vobiz XML response generated:\n{response_content}"
)
return HTMLResponse(content=response_content, media_type="application/xml")
@router.post("/vobiz/hangup-callback/{workflow_run_id}")
async def handle_vobiz_hangup_callback(
workflow_run_id: int,
request: Request,
x_vobiz_signature: Optional[str] = Header(None),
x_vobiz_timestamp: Optional[str] = Header(None),
):
"""Handle Vobiz hangup callback (sent when call ends).
Vobiz sends callbacks to hangup_url when the call terminates.
This includes call duration, status, and billing information.
"""
set_current_run_id(workflow_run_id)
# Logging all headers and body to understand what Vobiz actually sends
all_headers = dict(request.headers)
logger.info(
f"[run {workflow_run_id}] Vobiz hangup callback - Headers: {json.dumps(all_headers)}"
)
# Parse the callback data (Vobiz sends form data or JSON)
form_data = await request.form()
callback_data = dict(form_data)
# TODO: Remove this debug logging after Vobiz team clarifies webhook authentication
logger.info(
f"[run {workflow_run_id}] Vobiz hangup callback - Body: {json.dumps(callback_data)}"
)
logger.info(
f"[run {workflow_run_id}] Received Vobiz hangup callback {json.dumps(callback_data)}"
)
# Verify signature if provided
if x_vobiz_signature:
# We need the workflow run to get organization for provider credentials
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 for signature verification"
)
return {"status": "error", "reason": "workflow_run_not_found"}
workflow = await db_client.get_workflow_by_id(workflow_run.workflow_id)
if not workflow:
logger.warning(
f"[run {workflow_run_id}] Workflow not found for signature verification"
)
return {"status": "error", "reason": "workflow_not_found"}
provider = await get_telephony_provider(workflow.organization_id)
# Get raw body for signature verification
raw_body = await request.body()
webhook_body = raw_body.decode("utf-8")
# Verify signature
backend_endpoint, _ = await get_backend_endpoints()
webhook_url = f"{backend_endpoint}/api/v1/telephony/vobiz/hangup-callback/{workflow_run_id}"
is_valid = await provider.verify_webhook_signature(
webhook_url,
callback_data,
x_vobiz_signature,
x_vobiz_timestamp,
webhook_body,
)
if not is_valid:
logger.warning(
f"[run {workflow_run_id}] Invalid Vobiz hangup callback signature"
)
return {"status": "error", "reason": "invalid_signature"}
logger.info(f"[run {workflow_run_id}] Vobiz hangup callback signature verified")
else:
# Get workflow run for processing (signature verification already got it if needed)
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 for Vobiz hangup callback"
)
return {"status": "ignored", "reason": "workflow_run_not_found"}
# Get workflow and provider
workflow = await db_client.get_workflow_by_id(workflow_run.workflow_id)
if not workflow:
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)
logger.debug(
f"[run {workflow_run_id}] Processing Vobiz hangup with provider: {provider.PROVIDER_NAME}"
)
# Parse the callback data into generic format
parsed_data = provider.parse_status_callback(callback_data)
logger.debug(
f"[run {workflow_run_id}] Parsed Vobiz callback data: {json.dumps(parsed_data)}"
)
# Create StatusCallbackRequest from parsed data
status_update = StatusCallbackRequest(
call_id=parsed_data["call_id"],
status=parsed_data["status"],
from_number=parsed_data.get("from_number"),
to_number=parsed_data.get("to_number"),
direction=parsed_data.get("direction"),
duration=parsed_data.get("duration"),
extra=parsed_data.get("extra", {}),
)
# Process the status update
await _process_status_update(workflow_run_id, status_update)
logger.info(f"[run {workflow_run_id}] Vobiz hangup callback processed successfully")
return {"status": "success"}
@router.post("/vobiz/ring-callback/{workflow_run_id}")
async def handle_vobiz_ring_callback(
workflow_run_id: int,
request: Request,
x_vobiz_signature: Optional[str] = Header(None),
x_vobiz_timestamp: Optional[str] = Header(None),
):
"""Handle Vobiz ring callback (sent when call starts ringing).
Vobiz can send callbacks to ring_url when the call starts ringing.
This is optional and used for tracking ringing status.
"""
set_current_run_id(workflow_run_id)
# Logging all headers and body to understand what Vobiz actually sends
all_headers = dict(request.headers)
logger.info(
f"[run {workflow_run_id}] Vobiz ring callback - Headers: {json.dumps(all_headers)}"
)
# Parse the callback data
form_data = await request.form()
callback_data = dict(form_data)
# TODO: Remove this debug logging after Vobiz team clarifies webhook authentication
logger.info(
f"[run {workflow_run_id}] Vobiz ring callback - Body: {json.dumps(callback_data)}"
)
logger.info(
f"[run {workflow_run_id}] Received Vobiz ring callback {json.dumps(callback_data)}"
)
# Verify signature if provided
if x_vobiz_signature:
# We need the workflow run to get organization for provider credentials
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 for signature verification"
)
return {"status": "error", "reason": "workflow_run_not_found"}
workflow = await db_client.get_workflow_by_id(workflow_run.workflow_id)
if not workflow:
logger.warning(
f"[run {workflow_run_id}] Workflow not found for signature verification"
)
return {"status": "error", "reason": "workflow_not_found"}
provider = await get_telephony_provider(workflow.organization_id)
# Get raw body for signature verification
raw_body = await request.body()
webhook_body = raw_body.decode("utf-8")
# Verify signature
backend_endpoint, _ = await get_backend_endpoints()
webhook_url = (
f"{backend_endpoint}/api/v1/telephony/vobiz/ring-callback/{workflow_run_id}"
)
is_valid = await provider.verify_webhook_signature(
webhook_url,
callback_data,
x_vobiz_signature,
x_vobiz_timestamp,
webhook_body,
)
if not is_valid:
logger.warning(
f"[run {workflow_run_id}] Invalid Vobiz ring callback signature"
)
return {"status": "error", "reason": "invalid_signature"}
logger.info(f"[run {workflow_run_id}] Vobiz ring callback signature verified")
else:
# Get workflow run for processing (signature verification already got it if needed)
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 for Vobiz ring callback"
)
return {"status": "ignored", "reason": "workflow_run_not_found"}
# Log the ringing event
telephony_callback_logs = workflow_run.logs.get("telephony_status_callbacks", [])
ring_log = {
"status": "ringing",
"timestamp": datetime.now(UTC).isoformat(),
"call_id": callback_data.get("call_uuid", callback_data.get("CallUUID", "")),
"event_type": "ring",
"raw_data": callback_data,
}
telephony_callback_logs.append(ring_log)
# Update workflow run logs
await db_client.update_workflow_run(
run_id=workflow_run_id,
logs={"telephony_status_callbacks": telephony_callback_logs},
)
logger.info(f"[run {workflow_run_id}] Vobiz ring callback logged")
return {"status": "success"}
@router.post("/vobiz/hangup-callback/workflow/{workflow_id}")
async def handle_vobiz_hangup_callback_by_workflow(
workflow_id: int,
request: Request,
x_vobiz_signature: Optional[str] = Header(None),
x_vobiz_timestamp: Optional[str] = Header(None),
):
"""Handle Vobiz hangup callback with workflow_id - finds workflow run by call_id."""
all_headers = dict(request.headers)
logger.info(
f"[workflow {workflow_id}] Vobiz hangup callback - Headers: {json.dumps(all_headers)}"
)
try:
callback_data, _ = await parse_webhook_request(request)
except ValueError:
callback_data = {}
call_uuid = callback_data.get("CallUUID") or callback_data.get("call_uuid")
logger.info(
f"[workflow {workflow_id}] Received Vobiz hangup callback for call {call_uuid}: {json.dumps(callback_data)}"
)
if not call_uuid:
logger.warning(
f"[workflow {workflow_id}] No call_uuid found in Vobiz hangup callback"
)
return {"status": "error", "message": "No call_uuid found"}
workflow_client = WorkflowClient()
workflow = await workflow_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)
if x_vobiz_signature:
raw_body = await request.body()
webhook_body = raw_body.decode("utf-8")
backend_endpoint, _ = await get_backend_endpoints()
webhook_url = f"{backend_endpoint}/api/v1/telephony/vobiz/hangup-callback/workflow/{workflow_id}"
is_valid = await provider.verify_webhook_signature(
webhook_url,
callback_data,
x_vobiz_signature,
x_vobiz_timestamp,
webhook_body,
)
if not is_valid:
logger.warning(
f"[workflow {workflow_id}] Invalid Vobiz hangup callback signature"
)
return {"status": "error", "message": "invalid_signature"}
logger.info(
f"[workflow {workflow_id}] Vobiz hangup callback signature verified"
)
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(
call_id=parsed_data["call_id"],
status=parsed_data["status"],
from_number=parsed_data.get("from_number"),
to_number=parsed_data.get("to_number"),
direction=parsed_data.get("direction"),
duration=parsed_data.get("duration"),
extra=parsed_data.get("extra", {}),
)
await _process_status_update(workflow_run_id, status)
logger.info(
f"[run {workflow_run_id}] Vobiz hangup callback processed successfully"
)
return {"status": "success"}
except Exception as e:
logger.error(
f"[run {workflow_run_id}] Error processing Vobiz hangup callback: {e}"
)
return {"status": "error", "message": str(e)}

View file

@ -0,0 +1,5 @@
"""Vobiz frame serializer (re-exported from pipecat)."""
from pipecat.serializers.vobiz import VobizFrameSerializer
__all__ = ["VobizFrameSerializer"]

View file

@ -0,0 +1,80 @@
"""Vobiz transport factory.
Vobiz uses Plivo-compatible WebSocket protocol:
- MULAW audio at 8kHz (same as Twilio)
- Base64-encoded audio in JSON messages
"""
from fastapi import WebSocket
from loguru import logger
from api.services.pipecat.audio_config import AudioConfig
from api.services.pipecat.audio_mixer import build_audio_out_mixer
from api.services.telephony.factory import load_credentials_for_transport
from pipecat.transports.websocket.fastapi import (
FastAPIWebsocketParams,
FastAPIWebsocketTransport,
)
from .serializers import VobizFrameSerializer
async def create_transport(
websocket: WebSocket,
workflow_run_id: int,
audio_config: AudioConfig,
organization_id: int,
*,
vad_config: dict | None = None,
ambient_noise_config: dict | None = None,
telephony_configuration_id: int | None = None,
stream_id: str,
call_id: str,
):
"""Create a transport for Vobiz connections."""
logger.info(
f"[run {workflow_run_id}] Creating Vobiz transport - "
f"stream_id={stream_id}, call_id={call_id}"
)
config = await load_credentials_for_transport(
organization_id, telephony_configuration_id, expected_provider="vobiz"
)
auth_id = config.get("auth_id")
auth_token = config.get("auth_token")
if not auth_id or not auth_token:
raise ValueError(
f"Incomplete Vobiz configuration for organization {organization_id}"
)
serializer = VobizFrameSerializer(
stream_id=stream_id,
call_id=call_id,
auth_id=auth_id,
auth_token=auth_token,
params=VobizFrameSerializer.InputParams(
vobiz_sample_rate=8000,
sample_rate=audio_config.pipeline_sample_rate,
),
)
mixer = await build_audio_out_mixer(
audio_config.transport_out_sample_rate, ambient_noise_config
)
transport = FastAPIWebsocketTransport(
websocket=websocket,
params=FastAPIWebsocketParams(
audio_in_enabled=True,
audio_out_enabled=True,
audio_in_sample_rate=audio_config.transport_in_sample_rate,
audio_out_sample_rate=audio_config.transport_out_sample_rate,
audio_out_mixer=mixer,
serializer=serializer,
),
)
logger.info(f"[run {workflow_run_id}] Vobiz transport created successfully")
return transport

View file

@ -0,0 +1,84 @@
"""Vonage telephony provider package."""
from typing import Any, Dict
from api.services.telephony.registry import (
ProviderSpec,
ProviderUIField,
ProviderUIMetadata,
register,
)
from .config import VonageConfigurationRequest, VonageConfigurationResponse
from .provider import VonageProvider
from .transport import create_transport
def _config_loader(value: Dict[str, Any]) -> Dict[str, Any]:
return {
"provider": "vonage",
"application_id": value.get("application_id"),
"private_key": value.get("private_key"),
"api_key": value.get("api_key"),
"api_secret": value.get("api_secret"),
"from_numbers": value.get("from_numbers", []),
}
_UI_METADATA = ProviderUIMetadata(
display_name="Vonage",
docs_url="https://docs.dograh.com/integrations/telephony/vonage",
fields=[
ProviderUIField(name="application_id", label="Application ID", type="text"),
ProviderUIField(
name="private_key",
label="Private Key",
type="textarea",
sensitive=True,
description="Vonage RSA private key for JWT generation",
),
ProviderUIField(
name="api_key",
label="API Key",
type="text",
sensitive=True,
),
ProviderUIField(
name="api_secret",
label="API Secret",
type="password",
sensitive=True,
),
ProviderUIField(
name="from_numbers",
label="Phone Numbers",
type="string-array",
description="Vonage phone numbers without + prefix",
),
],
)
SPEC = ProviderSpec(
name="vonage",
provider_cls=VonageProvider,
config_loader=_config_loader,
transport_factory=create_transport,
transport_sample_rate=16000,
config_request_cls=VonageConfigurationRequest,
ui_metadata=_UI_METADATA,
config_response_cls=VonageConfigurationResponse,
account_id_credential_field="api_key",
)
register(SPEC)
__all__ = [
"SPEC",
"VonageConfigurationRequest",
"VonageConfigurationResponse",
"VonageProvider",
"create_transport",
]

View file

@ -0,0 +1,30 @@
"""Vonage telephony configuration schemas."""
from typing import List, Literal
from pydantic import BaseModel, Field
class VonageConfigurationRequest(BaseModel):
"""Request schema for Vonage configuration."""
provider: Literal["vonage"] = Field(default="vonage")
api_key: str = Field(..., description="Vonage API Key")
api_secret: str = Field(..., description="Vonage API Secret")
application_id: str = Field(..., description="Vonage Application ID")
private_key: str = Field(..., description="Private key for JWT generation")
from_numbers: List[str] = Field(
default_factory=list,
description="List of Vonage phone numbers (without + prefix)",
)
class VonageConfigurationResponse(BaseModel):
"""Response schema for Vonage configuration with masked sensitive fields."""
provider: Literal["vonage"] = Field(default="vonage")
application_id: str # Not sensitive, can show full
api_key: str # Masked
api_secret: str # Masked
private_key: str # Masked
from_numbers: List[str]

View file

@ -16,6 +16,7 @@ from api.enums import WorkflowRunMode
from api.services.telephony.base import (
CallInitiationResult,
NormalizedInboundData,
ProviderSyncResult,
TelephonyProvider,
)
from api.utils.common import get_backend_endpoints
@ -324,7 +325,7 @@ class VonageProvider(TelephonyProvider):
2. Or directly start with binary audio
"""
from api.db import db_client
from api.services.pipecat.run_pipeline import run_pipeline_vonage
from api.services.pipecat.run_pipeline import run_pipeline_telephony
try:
# Get workflow run to extract call UUID
@ -375,15 +376,14 @@ class VonageProvider(TelephonyProvider):
logger.debug(f"Vonage started with binary audio for {workflow_run_id}")
# The pipeline will handle this first audio chunk
# Run the Vonage pipeline
await run_pipeline_vonage(
await run_pipeline_telephony(
websocket,
call_uuid,
workflow,
workflow.organization_id,
workflow_id,
workflow_run_id,
user_id,
provider_name=self.PROVIDER_NAME,
workflow_id=workflow_id,
workflow_run_id=workflow_run_id,
user_id=user_id,
call_id=call_uuid,
transport_kwargs={"call_uuid": call_uuid},
)
except Exception as e:
@ -442,17 +442,134 @@ class VonageProvider(TelephonyProvider):
return stored_api_key == webhook_account_id
async def verify_inbound_signature(
self, url: str, webhook_data: Dict[str, Any], signature: str
self,
url: str,
webhook_data: Dict[str, Any],
headers: Dict[str, str],
body: str = "",
) -> bool:
"""
Vonage inbound signature verification - minimalist implementation.
"""
return True
@staticmethod
async def generate_inbound_response(
websocket_url: str, workflow_run_id: int = None
) -> tuple:
async def configure_inbound(
self, address: str, webhook_url: Optional[str]
) -> ProviderSyncResult:
"""Update the answer_url on Vonage's Application for ``address``.
Vonage routes inbound calls per-application: a single ``answer_url`` on
``self.application_id`` applies to every number attached to it. The
``address`` argument is informational every call to this method
rewrites (or leaves alone) the application's webhook, regardless of
which number triggered the sync.
Vonage's PUT /v2/applications/{id} is full-replacement, so we GET the
current application, mutate ``capabilities.voice.webhooks.answer_url``,
and PUT the result back. ``api_key`` and ``api_secret`` are used for
Basic auth on the application API (the JWT auth used elsewhere is for
the Voice API, not the Application API).
Clearing (``webhook_url=None``) is a no-op on the Vonage side: the URL
is shared across all numbers on this application, so unsetting it for
one number would silently break inbound for every other number still
attached. The DB-level disconnect is sufficient inbound calls
without a matching workflow are rejected by the backend.
"""
if webhook_url is None:
logger.info(
f"Vonage configure_inbound clear for {address}: skipping "
f"application update (answer_url is shared across all numbers "
f"on application {self.application_id})"
)
return ProviderSyncResult(ok=True)
if not self.validate_config():
return ProviderSyncResult(
ok=False, message="Vonage provider not properly configured"
)
if not (self.api_key and self.api_secret):
return ProviderSyncResult(
ok=False,
message=(
"Vonage api_key and api_secret are required to update the "
"application's answer_url"
),
)
app_endpoint = f"{self.base_url}/v2/applications/{self.application_id}"
auth = aiohttp.BasicAuth(self.api_key, self.api_secret)
try:
async with aiohttp.ClientSession() as session:
async with session.get(app_endpoint, auth=auth) as response:
if response.status != 200:
body = await response.text()
logger.error(
f"Failed to fetch Vonage application "
f"{self.application_id}: {response.status} {body}"
)
return ProviderSyncResult(
ok=False,
message=f"Vonage API {response.status}: {body}",
)
app_data = await response.json()
except Exception as e:
logger.error(f"Exception fetching Vonage application: {e}")
return ProviderSyncResult(ok=False, message=f"Vonage lookup failed: {e}")
capabilities = app_data.get("capabilities") or {}
voice = capabilities.get("voice") or {}
webhooks = voice.get("webhooks") or {}
webhooks["answer_url"] = {
"address": webhook_url,
"http_method": "POST",
}
voice["webhooks"] = webhooks
capabilities["voice"] = voice
update_body = {
"name": app_data.get("name"),
"capabilities": capabilities,
}
if "privacy" in app_data:
update_body["privacy"] = app_data["privacy"]
try:
async with aiohttp.ClientSession() as session:
async with session.put(
app_endpoint, json=update_body, auth=auth
) as response:
if response.status not in (200, 201):
body = await response.text()
logger.error(
f"Vonage application update failed for "
f"{self.application_id}: {response.status} {body}"
)
return ProviderSyncResult(
ok=False,
message=f"Vonage API {response.status}: {body}",
)
except Exception as e:
logger.error(f"Exception updating Vonage application: {e}")
return ProviderSyncResult(ok=False, message=f"Vonage update failed: {e}")
logger.info(
f"Vonage answer_url set on application {self.application_id} "
f"(triggered by address {address})"
)
return ProviderSyncResult(ok=True)
async def start_inbound_stream(
self,
*,
websocket_url: str,
workflow_run_id: int,
normalized_data,
backend_endpoint: str,
):
"""
Generate NCCO response for inbound Vonage webhook.
"""

View file

@ -0,0 +1,120 @@
"""Vonage telephony routes (webhooks, status callbacks, answer URLs).
Mounted under ``/api/v1/telephony`` by ``api.routes.telephony`` via the
provider registry see ProviderSpec.router.
"""
import json
from typing import Optional
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.status_processor import (
StatusCallbackRequest,
_process_status_update,
)
from pipecat.utils.run_context import set_current_run_id
router = APIRouter()
@router.get("/ncco", include_in_schema=False)
async def handle_ncco_webhook(
workflow_id: int,
user_id: int,
workflow_run_id: int,
organization_id: Optional[int] = None,
):
"""Handle NCCO (Nexmo Call Control Objects) webhook for Vonage.
Returns JSON response instead of XML like TwiML.
"""
provider = await get_telephony_provider(organization_id or user_id)
response_content = await provider.get_webhook_response(
workflow_id, user_id, workflow_run_id
)
return json.loads(response_content)
@router.post("/vonage/events/{workflow_run_id}")
async def handle_vonage_events(
request: Request,
workflow_run_id: int,
):
"""Handle Vonage-specific event webhooks.
Vonage sends all call events to a single endpoint.
Events include: started, ringing, answered, complete, failed, etc.
"""
set_current_run_id(workflow_run_id)
# Parse the event data
event_data = await request.json()
logger.info(f"[run {workflow_run_id}] Received Vonage event: {event_data}")
# Get workflow run for processing
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
if not workflow_run:
logger.error(f"[run {workflow_run_id}] Workflow run not found")
return {"status": "error", "message": "Workflow run not found"}
# For a completed call that includes cost info, capture it immediately
if event_data.get("status") == "completed":
# Vonage sometimes includes price info in the webhook
if "price" in event_data or "rate" in event_data:
try:
if workflow_run.cost_info:
# Store immediate cost info if available
cost_info = workflow_run.cost_info.copy()
if "price" in event_data:
cost_info["vonage_webhook_price"] = float(event_data["price"])
if "rate" in event_data:
cost_info["vonage_webhook_rate"] = float(event_data["rate"])
if "duration" in event_data:
cost_info["vonage_webhook_duration"] = int(
event_data["duration"]
)
await db_client.update_workflow_run(
run_id=workflow_run_id, cost_info=cost_info
)
logger.info(
f"[run {workflow_run_id}] Captured Vonage cost info from webhook"
)
except Exception as e:
logger.error(
f"[run {workflow_run_id}] Failed to capture Vonage cost from webhook: {e}"
)
# Get workflow and provider
workflow = await db_client.get_workflow_by_id(workflow_run.workflow_id)
if not workflow:
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)
# Parse the event data into generic format
parsed_data = provider.parse_status_callback(event_data)
# Create StatusCallbackRequest from parsed data
status_update = StatusCallbackRequest(
call_id=parsed_data["call_id"],
status=parsed_data["status"],
from_number=parsed_data.get("from_number"),
to_number=parsed_data.get("to_number"),
direction=parsed_data.get("direction"),
duration=parsed_data.get("duration"),
extra=parsed_data.get("extra", {}),
)
# Process the status update
await _process_status_update(workflow_run_id, status_update)
# Return 204 No Content as expected by Vonage
return {"status": "ok"}

View file

@ -0,0 +1,5 @@
"""Vonage frame serializer (re-exported from pipecat)."""
from pipecat.serializers.vonage import VonageFrameSerializer
__all__ = ["VonageFrameSerializer"]

View file

@ -0,0 +1,63 @@
"""Vonage transport factory."""
from api.services.pipecat.audio_config import AudioConfig
from api.services.pipecat.audio_mixer import build_audio_out_mixer
from api.services.telephony.factory import load_credentials_for_transport
from pipecat.transports.websocket.fastapi import (
FastAPIWebsocketParams,
FastAPIWebsocketTransport,
)
from .serializers import VonageFrameSerializer
async def create_transport(
websocket,
workflow_run_id: int,
audio_config: AudioConfig,
organization_id: int,
*,
vad_config: dict | None = None,
ambient_noise_config: dict | None = None,
telephony_configuration_id: int | None = None,
call_uuid: str,
):
"""Create a transport for Vonage connections."""
config = await load_credentials_for_transport(
organization_id, telephony_configuration_id, expected_provider="vonage"
)
application_id = config.get("application_id")
private_key = config.get("private_key")
if not application_id or not private_key:
raise ValueError(
f"Incomplete Vonage configuration for organization {organization_id}"
)
serializer = VonageFrameSerializer(
call_uuid=call_uuid,
application_id=application_id,
private_key=private_key,
params=VonageFrameSerializer.InputParams(
vonage_sample_rate=audio_config.transport_in_sample_rate,
sample_rate=audio_config.pipeline_sample_rate,
),
)
mixer = await build_audio_out_mixer(
audio_config.transport_out_sample_rate, ambient_noise_config
)
# Vonage uses binary WebSocket mode, not text
return FastAPIWebsocketTransport(
websocket=websocket,
params=FastAPIWebsocketParams(
audio_in_enabled=True,
audio_out_enabled=True,
audio_in_sample_rate=audio_config.transport_in_sample_rate,
audio_out_sample_rate=audio_config.transport_out_sample_rate,
audio_out_mixer=mixer,
serializer=serializer,
),
)

View file

@ -0,0 +1,148 @@
"""Provider registry for telephony.
Each provider package registers itself by importing this module and calling
``register(ProviderSpec(...))`` from its ``__init__.py``. Consumers (factory,
audio config, run_pipeline, schemas) look up providers through ``get(name)``
or iterate via ``all_specs()`` instead of branching on provider name.
Adding a new provider should not require any edit outside its own folder
plus a single import line in ``providers/__init__.py``.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import (
TYPE_CHECKING,
Any,
Awaitable,
Callable,
Dict,
Iterable,
List,
Optional,
Type,
)
from pydantic import BaseModel
if TYPE_CHECKING:
from api.services.telephony.base import TelephonyProvider
@dataclass(frozen=True)
class ProviderUIField:
"""One form field for the telephony configuration UI.
Used to generate provider-specific config forms without per-provider
UI code. Field semantics mirror the Pydantic config_request_cls.
"""
name: str # Must match the Pydantic field name on config_request_cls
label: str
type: str # "text" | "password" | "textarea" | "string-array" | "number"
required: bool = True
sensitive: bool = False # If true, mask when displaying stored value
description: Optional[str] = None
placeholder: Optional[str] = None
@dataclass(frozen=True)
class ProviderUIMetadata:
"""Display metadata for a provider's configuration form."""
display_name: str
fields: List[ProviderUIField]
docs_url: Optional[str] = None
# Signature every provider's transport factory must satisfy.
# Provider-specific args (stream_sid, call_sid, channel_id, ...) are passed via **kwargs.
TransportFactory = Callable[..., Awaitable[Any]]
# Loader takes the raw config.value dict from the DB and returns a normalized
# config dict that the provider class accepts in its constructor.
ConfigLoader = Callable[[Dict[str, Any]], Dict[str, Any]]
@dataclass(frozen=True)
class ProviderSpec:
"""Everything needed to plug a telephony provider into the platform.
Attributes:
name: Stable identifier (e.g., "twilio"). Used as the discriminator in
stored config JSON and as the WorkflowRunMode value.
provider_cls: The TelephonyProvider subclass.
config_loader: Normalizes raw stored config into the dict shape the
provider constructor expects. Replaces the if/elif chain in the
old factory.load_telephony_config().
transport_factory: Async callable that creates the pipecat transport
for an accepted WebSocket. Provider-specific kwargs (stream_sid,
call_sid, etc.) are forwarded as ``**kwargs``.
transport_sample_rate: Wire-format audio sample rate this provider
uses (e.g. 8000 for Twilio/Plivo, 16000 for Vonage). The pipecat
layer derives the full ``AudioConfig`` from this.
config_request_cls: Pydantic model for incoming save requests.
config_response_cls: Pydantic model for outgoing (masked) responses.
ui_metadata: Optional form metadata used by the telephony-config
UI to render a provider-specific form. Surfaced via
``GET /api/v1/telephony/providers/metadata``.
Note: provider routes (webhooks, status callbacks, answer URLs) are
NOT carried on the spec. They live in
``providers/<name>/routes.py`` and are loaded on-demand by
``api.routes.telephony`` via ``importlib`` so route handlers (which
can have deep dependency chains into campaign/db code) don't get
pulled in just because someone imported a TelephonyProvider type.
"""
name: str
provider_cls: Type["TelephonyProvider"]
config_loader: ConfigLoader
transport_factory: TransportFactory
transport_sample_rate: int
config_request_cls: Type[BaseModel]
config_response_cls: Type[BaseModel]
ui_metadata: Optional[ProviderUIMetadata] = None
# Credential field that uniquely identifies the provider account. Used to
# (a) match an inbound webhook to the right org config when multiple configs
# 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 = ""
_REGISTRY: Dict[str, ProviderSpec] = {}
def register(spec: ProviderSpec) -> None:
"""Register a provider. Called once per provider at import time."""
if spec.name in _REGISTRY:
# Re-registration is benign as long as the spec is the same instance.
# Otherwise it indicates a duplicate provider name, which is a bug.
if _REGISTRY[spec.name] is not spec:
raise ValueError(f"Provider '{spec.name}' is already registered")
return
_REGISTRY[spec.name] = spec
def get(name: str) -> ProviderSpec:
"""Look up a registered provider by name."""
try:
return _REGISTRY[name]
except KeyError:
raise ValueError(f"Unknown telephony provider: {name}") from None
def get_optional(name: str) -> Optional[ProviderSpec]:
"""Look up a registered provider by name, returning None if not registered."""
return _REGISTRY.get(name)
def all_specs() -> List[ProviderSpec]:
"""Return all registered providers in name-sorted order (stable iteration)."""
return [_REGISTRY[k] for k in sorted(_REGISTRY)]
def names() -> Iterable[str]:
"""Return all registered provider names."""
return sorted(_REGISTRY)

View file

@ -0,0 +1,215 @@
"""Provider-agnostic call status processing.
Extracted from ``api/routes/telephony.py`` so that per-provider route
modules can import the processor and normalized request type without
introducing a circular import on the routes module.
"""
from datetime import UTC, datetime
from typing import Optional
from loguru import logger
from pydantic import BaseModel
from api.db import db_client
from api.enums import WorkflowRunState
from api.services.campaign.campaign_call_dispatcher import campaign_call_dispatcher
from api.services.campaign.campaign_event_publisher import (
get_campaign_event_publisher,
)
from api.services.campaign.circuit_breaker import circuit_breaker
class StatusCallbackRequest(BaseModel):
"""Normalized status callback shape used across all telephony providers.
Per-provider converters live as classmethods (``from_twilio``, ``from_plivo``,
``from_vonage``, ``from_cloudonix_cdr``) so the route handler for each
provider can map raw webhook payloads into this shape and hand off to
:func:`_process_status_update`.
"""
call_id: str
status: str
from_number: Optional[str] = None
to_number: Optional[str] = None
direction: Optional[str] = None
duration: Optional[str] = None
extra: dict = {}
@classmethod
def from_twilio(cls, data: dict):
"""Convert Twilio callback to generic format."""
return cls(
call_id=data.get("CallSid", ""),
status=data.get("CallStatus", ""),
from_number=data.get("From"),
to_number=data.get("To"),
direction=data.get("Direction"),
duration=data.get("CallDuration") or data.get("Duration"),
extra=data,
)
@classmethod
def from_plivo(cls, data: dict):
"""Convert Plivo callback to generic format."""
status_map = {
"in-progress": "answered",
"ringing": "ringing",
"ring": "ringing",
"completed": "completed",
"hangup": "completed",
"stopstream": "completed",
"busy": "busy",
"no-answer": "no-answer",
"cancel": "canceled",
"cancelled": "canceled",
"timeout": "no-answer",
}
call_status = (data.get("CallStatus") or data.get("Event") or "").lower()
return cls(
call_id=data.get("CallUUID", "") or data.get("RequestUUID", ""),
status=status_map.get(call_status, call_status),
from_number=data.get("From"),
to_number=data.get("To"),
direction=data.get("Direction"),
duration=data.get("Duration"),
extra=data,
)
@classmethod
def from_vonage(cls, data: dict):
"""Convert Vonage event to generic format."""
status_map = {
"started": "initiated",
"ringing": "ringing",
"answered": "answered",
"complete": "completed",
"failed": "failed",
"busy": "busy",
"timeout": "no-answer",
"rejected": "busy",
}
return cls(
call_id=data.get("uuid", ""),
status=status_map.get(data.get("status", ""), data.get("status", "")),
from_number=data.get("from"),
to_number=data.get("to"),
direction=data.get("direction"),
duration=data.get("duration"),
extra=data,
)
@classmethod
def from_cloudonix_cdr(cls, data: dict):
"""Convert Cloudonix CDR to generic format."""
disposition_map = {
"ANSWER": "completed",
"BUSY": "busy",
"CANCEL": "canceled",
"FAILED": "failed",
"CONGESTION": "failed",
"NOANSWER": "no-answer",
}
disposition = data.get("disposition", "")
status = disposition_map.get(disposition.upper(), disposition.lower())
return cls(
call_id=data.get("session").get("token"),
status=status,
from_number=data.get("from"),
to_number=data.get("to"),
duration=str(data.get("billsec") or data.get("duration") or 0),
extra=data,
)
async def _process_status_update(workflow_run_id: int, status: StatusCallbackRequest):
"""Process status updates from telephony providers.
Idempotent: handles repeated callbacks (e.g. from both webhook and CDR).
"""
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 in status update"
)
return
telephony_callback_logs = workflow_run.logs.get("telephony_status_callbacks", [])
telephony_callback_log = {
"status": status.status,
"timestamp": datetime.now(UTC).isoformat(),
"call_id": status.call_id,
"duration": status.duration,
**status.extra,
}
telephony_callback_logs.append(telephony_callback_log)
await db_client.update_workflow_run(
run_id=workflow_run_id,
logs={"telephony_status_callbacks": telephony_callback_logs},
)
if status.status == "completed":
logger.info(
f"[run {workflow_run_id}] Call completed with duration: {status.duration}s"
)
if workflow_run.campaign_id:
await campaign_call_dispatcher.release_call_slot(workflow_run_id)
await circuit_breaker.record_and_evaluate(
workflow_run.campaign_id, is_failure=False
)
if workflow_run.state != WorkflowRunState.COMPLETED.value:
await db_client.update_workflow_run(
run_id=workflow_run_id,
is_completed=True,
state=WorkflowRunState.COMPLETED.value,
)
elif status.status in ["failed", "busy", "no-answer", "canceled", "error"]:
logger.warning(
f"[run {workflow_run_id}] Call failed with status: {status.status}"
)
if workflow_run.campaign_id:
await campaign_call_dispatcher.release_call_slot(workflow_run_id)
await circuit_breaker.record_and_evaluate(
workflow_run.campaign_id,
is_failure=status.status in ("error", "failed"),
)
if status.status in ["busy", "no-answer"] and workflow_run.campaign_id:
publisher = await get_campaign_event_publisher()
await publisher.publish_retry_needed(
workflow_run_id=workflow_run_id,
reason=status.status.replace("-", "_"),
campaign_id=workflow_run.campaign_id,
queued_run_id=workflow_run.queued_run_id,
)
call_tags = (
workflow_run.gathered_context.get("call_tags", [])
if workflow_run.gathered_context
else []
)
call_tags.extend(["not_connected", f"telephony_{status.status.lower()}"])
await db_client.update_workflow_run(
run_id=workflow_run_id,
is_completed=True,
state=WorkflowRunState.COMPLETED.value,
gathered_context={"call_tags": call_tags},
)
elif status.status in ["in-progress", "initiated", "ringing"]:
# No-op while the call is in flight.
pass
else:
logger.warning(
f"[run {workflow_run_id}] Unexpected status update: {status.status}"
)