feat: add vonage telephony

This commit is contained in:
Sabiha Khan 2025-10-24 09:28:11 +05:30
parent a01f2df7ea
commit 26a9ae2381
35 changed files with 2031 additions and 539 deletions

View file

@ -170,10 +170,10 @@ async def start_campaign(
user: UserModel = Depends(get_user),
) -> CampaignResponse:
"""Start campaign execution"""
# Check if organization has TWILIO_CONFIGURATION configured
# Check if organization has TELEPHONY_CONFIGURATION configured
twilio_config = await db_client.get_configuration(
user.selected_organization_id,
OrganizationConfigurationKey.TWILIO_CONFIGURATION.value,
OrganizationConfigurationKey.TELEPHONY_CONFIGURATION.value,
)
if not twilio_config or not twilio_config.value:
@ -278,10 +278,10 @@ async def resume_campaign(
user: UserModel = Depends(get_user),
) -> CampaignResponse:
"""Resume a paused campaign"""
# Check if organization has TWILIO_CONFIGURATION configured
# Check if organization has TELEPHONY_CONFIGURATION configured
twilio_config = await db_client.get_configuration(
user.selected_organization_id,
OrganizationConfigurationKey.TWILIO_CONFIGURATION.value,
OrganizationConfigurationKey.TELEPHONY_CONFIGURATION.value,
)
if not twilio_config or not twilio_config.value:

View file

@ -1,12 +1,16 @@
from fastapi import APIRouter, Depends, HTTPException
from loguru import logger
from api.db import db_client
from api.db.models import UserModel
from api.enums import OrganizationConfigurationKey
from typing import Optional, Union
from api.schemas.telephony_config import (
TelephonyConfigurationResponse,
TwilioConfigurationRequest,
TwilioConfigurationResponse,
VonageConfigurationRequest,
VonageConfigurationResponse,
)
from api.services.auth.depends import get_user
from api.services.configuration.masking import is_mask_of, mask_key
@ -16,36 +20,83 @@ router = APIRouter(prefix="/organizations", tags=["organizations"])
# TODO: Make endpoints provider-agnostic
@router.get("/telephony-config", response_model=TelephonyConfigurationResponse)
async def get_telephony_configuration(user: UserModel = Depends(get_user)):
"""Get telephony configuration for the user's organization with masked sensitive fields."""
async def get_telephony_configuration(
user: UserModel = Depends(get_user),
provider: Optional[str] = None # Query param to filter by provider
):
"""Get telephony configuration for the user's organization with masked sensitive fields.
Args:
provider: Optional provider filter ('twilio' or 'vonage').
If specified, only returns config if it matches the stored provider.
"""
if not user.selected_organization_id:
raise HTTPException(status_code=400, detail="No organization selected")
# Try new key first, fallback to old for backward compatibility
config = await db_client.get_configuration(
user.selected_organization_id,
OrganizationConfigurationKey.TWILIO_CONFIGURATION.value, # TODO: Use TELEPHONY_CONFIGURATION
OrganizationConfigurationKey.TELEPHONY_CONFIGURATION.value,
)
# TODO: Remove after telephony provider db migration is complete
if not config:
config = await db_client.get_configuration(
user.selected_organization_id,
OrganizationConfigurationKey.TWILIO_CONFIGURATION.value,
)
if not config or not config.value:
return TelephonyConfigurationResponse(twilio=None)
return TelephonyConfigurationResponse(twilio=None, vonage=None)
# Mask sensitive fields (account_sid and auth_token) before returning
account_sid = config.value.get("account_sid", "")
auth_token = config.value.get("auth_token", "")
# Simple single-provider format
stored_provider = config.value.get("provider", "twilio")
# If a specific provider is requested, only return config if it matches
if provider and provider != stored_provider:
# User is requesting a different provider than what's stored
return TelephonyConfigurationResponse(twilio=None, vonage=None)
if stored_provider == "twilio":
# Mask sensitive fields (account_sid and auth_token) before returning
account_sid = config.value.get("account_sid", "")
auth_token = config.value.get("auth_token", "")
return TelephonyConfigurationResponse(
twilio=TwilioConfigurationResponse(
provider="twilio",
account_sid=mask_key(account_sid) if account_sid else "",
auth_token=mask_key(auth_token) if auth_token else "",
from_numbers=config.value.get("from_numbers", []),
return TelephonyConfigurationResponse(
twilio=TwilioConfigurationResponse(
provider="twilio",
account_sid=mask_key(account_sid) if account_sid else "",
auth_token=mask_key(auth_token) if auth_token else "",
from_numbers=config.value.get("from_numbers", []),
),
vonage=None
)
)
elif stored_provider == "vonage":
# Mask sensitive fields for 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", "")
return TelephonyConfigurationResponse(
twilio=None,
vonage=VonageConfigurationResponse(
provider="vonage",
application_id=application_id, # Not masked, not sensitive
private_key=mask_key(private_key) if private_key else "",
api_key=mask_key(api_key) if api_key else None,
api_secret=mask_key(api_secret) if api_secret else None,
from_numbers=config.value.get("from_numbers", []),
)
)
else:
return TelephonyConfigurationResponse(twilio=None, vonage=None)
@router.post("/telephony-config")
async def save_telephony_configuration(
request: TwilioConfigurationRequest, user: UserModel = Depends(get_user)
request: Union[TwilioConfigurationRequest, VonageConfigurationRequest],
user: UserModel = Depends(get_user)
):
"""Save telephony configuration for the user's organization."""
if not user.selected_organization_id:
@ -54,33 +105,73 @@ async def save_telephony_configuration(
# Fetch existing configuration to handle masked values
existing_config = await db_client.get_configuration(
user.selected_organization_id,
OrganizationConfigurationKey.TWILIO_CONFIGURATION.value, # TODO: Use TELEPHONY_CONFIGURATION
OrganizationConfigurationKey.TELEPHONY_CONFIGURATION.value,
)
if not existing_config:
# Check old key for backward compatibility
existing_config = await db_client.get_configuration(
user.selected_organization_id,
OrganizationConfigurationKey.TWILIO_CONFIGURATION.value,
)
# Build new configuration
config_value = {
"provider": request.provider,
"account_sid": request.account_sid,
"auth_token": request.auth_token,
"from_numbers": request.from_numbers,
}
# Build simple single-provider configuration
if request.provider == "twilio":
config_value = {
"provider": "twilio",
"account_sid": request.account_sid,
"auth_token": request.auth_token,
"from_numbers": request.from_numbers,
}
elif request.provider == "vonage":
config_value = {
"provider": "vonage",
"application_id": request.application_id,
"private_key": request.private_key,
"api_key": getattr(request, 'api_key', None),
"api_secret": getattr(request, 'api_secret', None),
"from_numbers": request.from_numbers,
}
else:
raise HTTPException(status_code=400, detail=f"Unsupported provider: {request.provider}")
# If incoming values are masked (same as stored masked value), keep the original
# Handle masked values - only if same provider
if existing_config and existing_config.value:
# Check if account_sid is unchanged (masked value matches)
stored_account_sid = existing_config.value.get("account_sid", "")
if stored_account_sid and is_mask_of(request.account_sid, stored_account_sid):
config_value["account_sid"] = stored_account_sid # Keep original
# Check if auth_token is unchanged (masked value matches)
stored_auth_token = existing_config.value.get("auth_token", "")
if stored_auth_token and is_mask_of(request.auth_token, stored_auth_token):
config_value["auth_token"] = stored_auth_token # Keep original
existing_provider = existing_config.value.get("provider")
# Only preserve masked values if it's the same provider
if existing_provider == request.provider:
if request.provider == "twilio":
# Check if account_sid is unchanged (masked value matches)
if hasattr(request, 'account_sid') and is_mask_of(request.account_sid, existing_config.value.get("account_sid", "")):
config_value["account_sid"] = existing_config.value["account_sid"] # Keep original
# Check if auth_token is unchanged (masked value matches)
if hasattr(request, 'auth_token') and is_mask_of(request.auth_token, existing_config.value.get("auth_token", "")):
config_value["auth_token"] = existing_config.value["auth_token"] # Keep original
elif request.provider == "vonage":
# Check if private_key is unchanged (masked value matches)
if hasattr(request, 'private_key') and is_mask_of(request.private_key, existing_config.value.get("private_key", "")):
config_value["private_key"] = existing_config.value["private_key"] # Keep original
# Check if api_key is unchanged (masked value matches)
if hasattr(request, 'api_key') and request.api_key and is_mask_of(request.api_key, existing_config.value.get("api_key", "")):
config_value["api_key"] = existing_config.value["api_key"] # Keep original
# Check if api_secret is unchanged (masked value matches)
if hasattr(request, 'api_secret') and request.api_secret and is_mask_of(request.api_secret, existing_config.value.get("api_secret", "")):
config_value["api_secret"] = existing_config.value["api_secret"] # Keep original
# Always save to new TELEPHONY_CONFIGURATION key
await db_client.upsert_configuration(
user.selected_organization_id,
OrganizationConfigurationKey.TWILIO_CONFIGURATION.value,
OrganizationConfigurationKey.TELEPHONY_CONFIGURATION.value,
config_value,
)
# If old TWILIO_CONFIGURATION exists, delete it to avoid confusion
if existing_config and existing_config.key == OrganizationConfigurationKey.TWILIO_CONFIGURATION.value:
# Note: We're migrating from old to new key
logger.info(f"Migrated telephony config from TWILIO_CONFIGURATION to TELEPHONY_CONFIGURATION for org {user.selected_organization_id}")
return {"message": "Telephony configuration saved successfully"}

View file

@ -17,7 +17,7 @@ from api.enums import WorkflowRunMode
from api.services.auth.depends import get_user
from api.services.campaign.call_dispatcher import campaign_call_dispatcher
from api.services.campaign.campaign_event_publisher import get_campaign_event_publisher
from api.services.pipecat.run_pipeline import run_pipeline_twilio
from api.services.pipecat.run_pipeline import run_pipeline_twilio, run_pipeline_vonage
from api.services.telephony.factory import get_telephony_provider
from api.utils.tunnel import TunnelURLProvider
from pipecat.utils.context import set_current_run_id
@ -28,6 +28,7 @@ router = APIRouter(prefix="/telephony")
class InitiateCallRequest(BaseModel):
workflow_id: int
workflow_run_id: int | None = None
phone_number: str | None = None # Optional phone number to call
class StatusCallbackRequest(BaseModel):
@ -55,6 +56,31 @@ class StatusCallbackRequest(BaseModel):
duration=data.get("CallDuration") or data.get("Duration"),
extra=data
)
@classmethod
def from_vonage(cls, data: dict):
"""Convert Vonage event to generic format"""
# Map Vonage status to common 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
)
@router.post("/initiate-call")
@ -73,8 +99,29 @@ async def initiate_call(
detail="telephony_not_configured",
)
# Determine the workflow run mode based on provider type
from api.services.telephony.providers.twilio_provider import TwilioProvider
from api.services.telephony.providers.vonage_provider import VonageProvider
if isinstance(provider, TwilioProvider):
workflow_run_mode = WorkflowRunMode.TWILIO.value
elif isinstance(provider, VonageProvider):
workflow_run_mode = WorkflowRunMode.VONAGE.value
else:
# Default to TWILIO for backward compatibility
workflow_run_mode = WorkflowRunMode.TWILIO.value
user_configuration = await db_client.get_user_configurations(user.id)
# Use phone number from request, or fall back to user configuration
phone_number = request.phone_number or user_configuration.test_phone_number
if not phone_number:
raise HTTPException(
status_code=400,
detail="Phone number must be provided in request or set in user configuration"
)
workflow_run_id = request.workflow_run_id
if not workflow_run_id:
@ -82,9 +129,9 @@ async def initiate_call(
workflow_run = await db_client.create_workflow_run(
workflow_run_name,
request.workflow_id,
WorkflowRunMode.TWILIO.value, # TODO: Make this provider-agnostic
workflow_run_mode, # Now provider-agnostic
initial_context={
"phone_number": user_configuration.test_phone_number,
"phone_number": phone_number,
},
user_id=user.id,
)
@ -95,13 +142,15 @@ async def initiate_call(
raise HTTPException(status_code=400, detail="Workflow run not found")
workflow_run_name = workflow_run.name
if not user_configuration.test_phone_number:
raise HTTPException(status_code=400, detail="Test phone number not set")
# Construct webhook URL
# Construct webhook URL based on provider type
backend_endpoint = await TunnelURLProvider.get_tunnel_url()
# Check provider type to determine webhook endpoint
provider_type = getattr(provider, '__class__', None).__name__ if provider else None
webhook_endpoint = "ncco" if provider_type == "VonageProvider" else "twiml"
webhook_url = (
f"https://{backend_endpoint}/api/v1/telephony/twiml"
f"https://{backend_endpoint}/api/v1/telephony/{webhook_endpoint}"
f"?workflow_id={request.workflow_id}"
f"&user_id={user.id}"
f"&workflow_run_id={workflow_run_id}"
@ -109,12 +158,19 @@ async def initiate_call(
)
# Initiate call via provider
await provider.initiate_call(
to_number=user_configuration.test_phone_number,
result = await provider.initiate_call(
to_number=phone_number,
webhook_url=webhook_url,
workflow_run_id=workflow_run_id,
)
# Store call UUID for Vonage in workflow run context
if provider_type == "VonageProvider" and result and "uuid" in result:
await db_client.update_workflow_run(
run_id=workflow_run_id,
gathered_context={"call_uuid": result["uuid"]}
)
return {
"message": f"Call initiated successfully with run name {workflow_run_name}"
}
@ -143,47 +199,130 @@ async def handle_twiml_webhook(
return HTMLResponse(content=response_content, media_type="application/xml")
@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.
"""
# Get provider for organization
provider = await get_telephony_provider(organization_id or user_id)
# Generate NCCO response (JSON for Vonage)
response_content = await provider.get_webhook_response(
workflow_id, user_id, workflow_run_id
)
# Return JSON response for Vonage
return json.loads(response_content)
@router.websocket("/ws/{workflow_id}/{user_id}/{workflow_run_id}")
async def websocket_endpoint(
websocket: WebSocket, workflow_id: int, user_id: int, workflow_run_id: int
):
"""WebSocket endpoint for real-time call handling - matches original Twilio implementation."""
"""WebSocket endpoint for real-time call handling - supports both Twilio and Vonage."""
await websocket.accept()
try:
# "connected" (ignore)
msg = json.loads(await websocket.receive_text())
if msg.get("event") != "connected":
raise RuntimeError("Expected connected message first")
# "start" this has everything we need
start_msg = await websocket.receive_text()
# set the run context
set_current_run_id(workflow_run_id)
# Peek at the first message to determine provider
# Twilio sends JSON with "connected" event
# Vonage sends binary audio directly or may send metadata
first_msg = await websocket.receive()
if "text" in first_msg:
# Text message - likely Twilio
msg = json.loads(first_msg["text"])
if msg.get("event") == "connected":
# Definitely Twilio - follow Twilio flow
# "start" this has everything we need
start_msg = await websocket.receive_text()
logger.debug(f"Received start message: {start_msg}")
logger.debug(f"Received start message: {start_msg}")
start_msg = json.loads(start_msg)
if start_msg.get("event") != "start":
raise RuntimeError("Expected start message second")
start_msg = json.loads(start_msg)
if start_msg.get("event") != "start":
raise RuntimeError("Expected start message second")
try:
stream_sid = start_msg["start"]["streamSid"]
call_sid = start_msg["start"]["callSid"]
except KeyError:
logger.error(
"Missing callSID and streamSID in start message. Closing connection."
)
await websocket.close(code=4400, reason="Missing or bad start message")
return
try:
stream_sid = start_msg["start"]["streamSid"]
call_sid = start_msg["start"]["callSid"]
except KeyError:
logger.error(
"Missing callSID and streamSID in start message. Closing connection."
# Run Twilio pipeline
await run_pipeline_twilio(
websocket, stream_sid, call_sid, workflow_id, workflow_run_id, user_id
)
elif msg.get("event") == "websocket:connected":
# This is Vonage's initial connection message
logger.info(f"Vonage WebSocket connected for workflow_run {workflow_run_id}")
# Get workflow run to extract call UUID
workflow_run = await db_client.get_workflow_run(workflow_run_id)
workflow = await db_client.get_workflow(workflow_id)
# Extract call UUID from workflow run context
call_uuid = workflow_run.gathered_context.get("call_uuid") if workflow_run.gathered_context else None
if not call_uuid:
logger.error("No call UUID found for Vonage connection")
await websocket.close(code=4400, reason="Missing call UUID")
return
# Run Vonage pipeline
await run_pipeline_vonage(
websocket,
call_uuid,
workflow,
workflow.organization_id,
workflow_id,
workflow_run_id,
user_id
)
else:
# Unknown provider or format
logger.warning(f"Unknown first message format: {msg}")
elif "bytes" in first_msg:
# Binary message - likely Vonage audio
# For Vonage, we need to get the call UUID from the workflow run
workflow_run = await db_client.get_workflow_run(workflow_run_id)
workflow = await db_client.get_workflow(workflow_id)
# Extract call UUID from workflow run context
call_uuid = workflow_run.gathered_context.get("call_uuid") if workflow_run.gathered_context else None
if not call_uuid:
logger.error("No call UUID found for Vonage connection")
await websocket.close(code=4400, reason="Missing call UUID")
return
# Run Vonage pipeline
await run_pipeline_vonage(
websocket,
call_uuid,
workflow,
workflow.organization_id, # Use the actual organization_id from workflow
workflow_id,
workflow_run_id,
user_id
)
await websocket.close(code=4400, reason="Missing or bad start message")
return
# Run your Pipecat bot
await run_pipeline_twilio(
websocket, stream_sid, call_sid, workflow_id, workflow_run_id, user_id
)
except Exception as e:
logger.error(f"Error in Twilio WebSocket connection: {e}")
logger.error(f"Error in WebSocket connection: {e}")
await websocket.close(1011, "Internal server error")
@ -225,7 +364,7 @@ async def handle_status_callback(
)
if not is_valid:
logger.warning(f"Invalid status callback signature for run {workflow_run_id}")
logger.warning(f"Invalid webhook signature for workflow run {workflow_run_id}")
return {"status": "error", "reason": "invalid_signature"}
# Convert provider-specific callback to generic format
@ -312,4 +451,57 @@ async def _process_status_update(
run_id=workflow_run_id,
is_completed=True,
gathered_context={"call_tags": call_tags}
)
)
@router.post("/events/{workflow_run_id}")
async def handle_vonage_events(
request: Request,
workflow_run_id: int,
):
"""Handle Vonage event webhooks.
Vonage sends all call events to a single endpoint.
Events include: started, ringing, answered, complete, failed, etc.
"""
# 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"}
# If this is a completed call and 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}")
# Convert to generic status format
status_update = StatusCallbackRequest.from_vonage(event_data)
# Process the status update
await _process_status_update(workflow_run_id, status_update, workflow_run)
# Return 204 No Content as expected by Vonage
return {"status": "ok"}

View file

@ -20,7 +20,8 @@ from api.services.campaign.campaign_event_publisher import (
get_campaign_event_publisher,
)
from api.services.pipecat.run_pipeline import run_pipeline_twilio
from api.services.telephony.twilio import TwilioService
from api.services.telephony.factory import get_telephony_provider
from api.utils.tunnel import TunnelURLProvider
from pipecat.utils.context import set_current_run_id
router = APIRouter(prefix="/twilio")
@ -48,10 +49,10 @@ class TwilioStatusCallbackRequest(BaseModel):
async def initiate_call(
request: InitiateCallRequest, user: UserModel = Depends(get_user)
):
# Check if organization has TWILIO_CONFIGURATION configured
# Check if organization has TELEPHONY_CONFIGURATION configured
twilio_config = await db_client.get_configuration(
user.selected_organization_id,
OrganizationConfigurationKey.TWILIO_CONFIGURATION.value,
OrganizationConfigurationKey.TELEPHONY_CONFIGURATION.value,
)
if not twilio_config or not twilio_config.value:
@ -83,15 +84,16 @@ async def initiate_call(
workflow_run_name = workflow_run.name
if user_configuration.test_phone_number:
twilio_service = TwilioService(user.selected_organization_id)
await twilio_service.initiate_call(
# Use new provider pattern instead of legacy TwilioService
provider = await get_telephony_provider(user.selected_organization_id)
# Generate webhook URL for Twilio
backend_endpoint = await TunnelURLProvider.get_tunnel_url()
webhook_url = f"https://{backend_endpoint}/api/v1/twilio/twiml?workflow_id={request.workflow_id}&user_id={user.id}&workflow_run_id={workflow_run_id}&organization_id={user.selected_organization_id}"
await provider.initiate_call(
to_number=user_configuration.test_phone_number,
url_args={
"workflow_id": request.workflow_id,
"user_id": user.id,
"workflow_run_id": workflow_run_id,
"organization_id": user.selected_organization_id,
},
webhook_url=webhook_url,
workflow_run_id=workflow_run_id,
)
return {
@ -105,7 +107,9 @@ async def initiate_call(
async def start_call(
workflow_id: int, user_id: int, workflow_run_id: int, organization_id: int
):
twiml_content = await TwilioService(organization_id).get_start_call_twiml(
# Use new provider pattern for TwiML generation
provider = await get_telephony_provider(organization_id)
twiml_content = await provider.get_webhook_response(
workflow_id, user_id, workflow_run_id
)
return HTMLResponse(content=twiml_content, media_type="application/xml")