fix: telephony bugs and improve code structure

This commit is contained in:
Sabiha Khan 2025-11-04 13:03:55 +05:30
parent 491e6edd36
commit f080c563b1
24 changed files with 633 additions and 793 deletions

View file

@ -34,7 +34,7 @@ STACK_SECRET_SERVER_KEY="ssk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
STACK_PUBLISHABLE_CLIENT_KEY="pck_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
# Telephony Configuration
# Telephony providers are configured via UI/database only. Navigate to: Settings → Integrations → Telephony
# Telephony providers are configured via UI/database only. Navigate to: Workflow → Phone Call -> Configure Telephony
# Tracing and Analytics
ENABLE_TRACING=true

View file

@ -1,122 +0,0 @@
"""add_provider_info_to_cost_info
Revision ID: a57d25b75117
Revises: 982ec8e434be
Create Date: 2025-10-21 12:28:06.053318
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from alembic_postgresql_enum import TableReference
# revision identifiers, used by Alembic.
revision: str = 'a57d25b75117'
down_revision: Union[str, None] = '982ec8e434be'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""
Add provider info to existing cost_info JSON for backward compatibility.
This migration:
1. Adds 'vonage' to workflow_run_mode enum
2. Adds 'provider' field to cost_info for existing records
3. Migrates TWILIO_CONFIGURATION key to TELEPHONY_CONFIGURATION
"""
# Add 'vonage' to the workflow_run_mode enum using sync_enum_values like other migrations
op.sync_enum_values(
enum_schema="public",
enum_name="workflow_run_mode",
new_values=["twilio", "stasis", "webrtc", "smallwebrtc", "VOICE", "CHAT", "vonage"],
affected_columns=[
TableReference(
table_schema="public", table_name="workflow_runs", column_name="mode"
)
],
enum_values_to_rename=[],
)
# Update workflow_runs to add provider info based on mode
# Use jsonb_set() to add provider field while preserving existing data
op.execute("""
UPDATE workflow_runs
SET cost_info = jsonb_set(
CASE
WHEN cost_info IS NULL OR cost_info::text = '{}'
THEN '{}'::jsonb
ELSE cost_info::jsonb
END,
'{provider}',
'"twilio"'::jsonb,
true
)::json
WHERE mode = 'twilio'
AND (cost_info IS NULL OR cost_info::text NOT LIKE '%provider%')
""")
op.execute("""
UPDATE workflow_runs
SET cost_info = jsonb_set(
CASE
WHEN cost_info IS NULL OR cost_info::text = '{}'
THEN '{}'::jsonb
ELSE cost_info::jsonb
END,
'{provider}',
'"vonage"'::jsonb,
true
)::json
WHERE mode = 'vonage'
AND (cost_info IS NULL OR cost_info::text NOT LIKE '%provider%')
""")
# Simply rename the key from TWILIO_CONFIGURATION to TELEPHONY_CONFIGURATION
# Keep the same single-provider format
op.execute("""
UPDATE organization_configurations
SET key = 'TELEPHONY_CONFIGURATION'
WHERE key = 'TWILIO_CONFIGURATION';
""")
print("Migration complete: Added vonage to enum, provider info to cost_info, and renamed configuration key")
def downgrade() -> None:
"""
Remove provider info and revert key name.
Revert enum to previous state (removing 'vonage').
"""
# Remove provider field from cost_info while preserving other data
op.execute("""
UPDATE workflow_runs
SET cost_info = (cost_info::jsonb - 'provider')::json
WHERE cost_info::text LIKE '%provider%'
""")
# Revert key name
op.execute("""
UPDATE organization_configurations
SET key = 'TWILIO_CONFIGURATION'
WHERE key = 'TELEPHONY_CONFIGURATION';
""")
# Revert enum to previous state
op.sync_enum_values(
enum_schema="public",
enum_name="workflow_run_mode",
new_values=["twilio", "stasis", "webrtc", "smallwebrtc", "VOICE", "CHAT"],
affected_columns=[
TableReference(
table_schema="public", table_name="workflow_runs", column_name="mode"
)
],
enum_values_to_rename=[],
)
print("Downgrade complete: Removed provider info and reverted key name")

View file

@ -0,0 +1,100 @@
"""add_vonage_and_rename_config
Revision ID: a57d25b75117
Revises: 982ec8e434be
Create Date: 2025-10-21 12:28:06.053318
"""
from typing import Sequence, Union
from alembic import op
from alembic_postgresql_enum import TableReference
# revision identifiers, used by Alembic.
revision: str = 'a57d25b75117'
down_revision: Union[str, None] = '982ec8e434be'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""
Add vonage support and rename configuration keys.
This migration:
1. Adds 'vonage' to workflow_run_mode enum
2. Migrates TWILIO_CONFIGURATION key to TELEPHONY_CONFIGURATION
3. Renames twilio_status_callbacks to telephony_status_callbacks in workflow_run logs
"""
# Add 'vonage' to the workflow_run_mode enum
op.sync_enum_values(
enum_schema="public",
enum_name="workflow_run_mode",
new_values=["twilio", "stasis", "webrtc", "smallwebrtc", "VOICE", "CHAT", "vonage"],
affected_columns=[
TableReference(
table_schema="public", table_name="workflow_runs", column_name="mode"
)
],
enum_values_to_rename=[],
)
# Rename the key from TWILIO_CONFIGURATION to TELEPHONY_CONFIGURATION
op.execute("""
UPDATE organization_configurations
SET key = 'TELEPHONY_CONFIGURATION'
WHERE key = 'TWILIO_CONFIGURATION';
""")
# Rename twilio_status_callbacks to telephony_status_callbacks in workflow_run logs
op.execute("""
UPDATE workflow_runs
SET logs = jsonb_set(
logs::jsonb - 'twilio_status_callbacks',
'{telephony_status_callbacks}',
COALESCE(logs::jsonb->'twilio_status_callbacks', '[]'::jsonb)
)
WHERE logs::jsonb ? 'twilio_status_callbacks';
""")
print("Migration complete: Added vonage to enum, renamed configuration key, and updated status callback keys")
def downgrade() -> None:
"""
Revert configuration key names and enum.
"""
# Revert telephony_status_callbacks to twilio_status_callbacks in workflow_run logs
op.execute("""
UPDATE workflow_runs
SET logs = jsonb_set(
logs::jsonb - 'telephony_status_callbacks',
'{twilio_status_callbacks}',
COALESCE(logs::jsonb->'telephony_status_callbacks', '[]'::jsonb)
)
WHERE logs::jsonb ? 'telephony_status_callbacks';
""")
# Revert key name
op.execute("""
UPDATE organization_configurations
SET key = 'TWILIO_CONFIGURATION'
WHERE key = 'TELEPHONY_CONFIGURATION';
""")
# Revert enum to previous state
op.sync_enum_values(
enum_schema="public",
enum_name="workflow_run_mode",
new_values=["twilio", "stasis", "webrtc", "smallwebrtc", "VOICE", "CHAT"],
affected_columns=[
TableReference(
table_schema="public", table_name="workflow_runs", column_name="mode"
)
],
enum_values_to_rename=[],
)
print("Downgrade complete: Reverted configuration key names and enum")

View file

@ -12,7 +12,6 @@ from api.routes.s3_signed_url import router as s3_router
from api.routes.service_keys import router as service_keys_router
from api.routes.superuser import router as superuser_router
from api.routes.telephony import router as telephony_router
from api.routes.twilio import router as twilio_router # TODO: Remove after migrating workflow_run_cost.py
from api.routes.user import router as user_router
from api.routes.webrtc_signaling import router as webrtc_signaling_router
from api.routes.workflow import router as workflow_router
@ -22,8 +21,7 @@ router = APIRouter(
responses={404: {"description": "Not found"}},
)
router.include_router(telephony_router) # New generic telephony routes
router.include_router(twilio_router) # TODO: Remove after migrating workflow_run_cost.py
router.include_router(telephony_router)
router.include_router(rtc_offer_router)
router.include_router(superuser_router)
router.include_router(workflow_router)

View file

@ -1,10 +1,9 @@
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 typing import Union
from api.schemas.telephony_config import (
TelephonyConfigurationResponse,
TwilioConfigurationRequest,
@ -17,80 +16,65 @@ from api.services.configuration.masking import is_mask_of, mask_key
router = APIRouter(prefix="/organizations", tags=["organizations"])
# Provider configuration constants
PROVIDER_MASKED_FIELDS = {
"twilio": ["account_sid", "auth_token"],
"vonage": ["private_key", "api_key", "api_secret"]
}
# TODO: Make endpoints provider-agnostic
@router.get("/telephony-config", response_model=TelephonyConfigurationResponse)
async def get_telephony_configuration(
user: UserModel = Depends(get_user),
provider: Optional[str] = None # Query param to filter by provider
user: UserModel = Depends(get_user)
):
"""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.
"""
"""Get telephony configuration for the user's organization with masked sensitive fields."""
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.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, vonage=None)
return TelephonyConfigurationResponse()
# 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", "")
from_numbers = config.value.get("from_numbers", []) if account_sid and auth_token else []
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", []),
from_numbers=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", "")
from_numbers = config.value.get("from_numbers", []) if application_id and private_key else []
return TelephonyConfigurationResponse(
twilio=None,
vonage=VonageConfigurationResponse(
provider="vonage",
application_id=application_id, # Not masked, not sensitive
application_id=application_id,
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", []),
from_numbers=from_numbers,
)
)
else:
return TelephonyConfigurationResponse(twilio=None, vonage=None)
return TelephonyConfigurationResponse()
@router.post("/telephony-config")
@ -107,14 +91,8 @@ async def save_telephony_configuration(
user.selected_organization_id,
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 simple single-provider configuration
# Build single-provider configuration
if request.provider == "twilio":
config_value = {
"provider": "twilio",
@ -134,44 +112,28 @@ async def save_telephony_configuration(
else:
raise HTTPException(status_code=400, detail=f"Unsupported provider: {request.provider}")
# Handle masked values - only if same provider
if existing_config and existing_config.value:
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
if existing_provider == request.provider:
preserve_masked_fields(request, existing_config, config_value)
await db_client.upsert_configuration(
user.selected_organization_id,
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"}
def preserve_masked_fields(request, existing_config, config_value):
provider = request.provider
masked_fields = PROVIDER_MASKED_FIELDS.get(provider, [])
for field_name in masked_fields:
if hasattr(request, field_name):
field_value = getattr(request, field_name)
# Check if field has a value and is a masked version of the existing value
if field_value and is_mask_of(field_value, existing_config.value.get(field_name, "")):
config_value[field_name] = existing_config.value[field_name]

View file

@ -17,7 +17,6 @@ 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, 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,7 +27,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
phone_number: str | None = None
class StatusCallbackRequest(BaseModel):
@ -100,20 +99,10 @@ async def initiate_call(
)
# 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
workflow_run_mode = provider.PROVIDER_NAME
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:
@ -129,7 +118,7 @@ async def initiate_call(
workflow_run = await db_client.create_workflow_run(
workflow_run_name,
request.workflow_id,
workflow_run_mode, # Now provider-agnostic
workflow_run_mode,
initial_context={
"phone_number": phone_number,
},
@ -145,9 +134,7 @@ async def initiate_call(
# 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_endpoint = provider.WEBHOOK_ENDPOINT
webhook_url = (
f"https://{backend_endpoint}/api/v1/telephony/{webhook_endpoint}"
@ -164,12 +151,15 @@ async def initiate_call(
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"]}
)
# Store provider type and any provider-specific metadata in workflow run context
gathered_context = {
"provider": provider.PROVIDER_NAME,
**(result.provider_metadata or {})
}
await db_client.update_workflow_run(
run_id=workflow_run_id,
gathered_context=gathered_context
)
return {
"message": f"Call initiated successfully with run name {workflow_run_name}"
@ -187,15 +177,13 @@ async def handle_twiml_webhook(
Handle initial webhook from telephony provider.
Returns provider-specific response (e.g., TwiML for Twilio).
"""
# Get provider for organization - exactly like original gets TwilioService
provider = await get_telephony_provider(organization_id)
# Generate provider-specific response (TwiML for Twilio)
response_content = await provider.get_webhook_response(
workflow_id, user_id, workflow_run_id
)
# Return exactly like original - HTMLResponse with application/xml
return HTMLResponse(content=response_content, media_type="application/xml")
@ -210,15 +198,13 @@ async def handle_ncco_webhook(
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)
@ -226,113 +212,65 @@ async def handle_ncco_webhook(
async def websocket_endpoint(
websocket: WebSocket, workflow_id: int, user_id: int, workflow_run_id: int
):
"""WebSocket endpoint for real-time call handling - supports both Twilio and Vonage."""
"""WebSocket endpoint for real-time call handling - routes to provider-specific handlers."""
await websocket.accept()
try:
# set the run context
# 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()
# Get workflow run to determine provider type
workflow_run = await db_client.get_workflow_run(workflow_run_id)
if not workflow_run:
logger.error(f"Workflow run {workflow_run_id} not found")
await websocket.close(code=4404, reason="Workflow run not found")
return
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}")
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
# 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
# Get workflow for organization info
workflow = await db_client.get_workflow(workflow_id)
if not workflow:
logger.error(f"Workflow {workflow_id} not found")
await websocket.close(code=4404, reason="Workflow not found")
return
# Extract provider type from workflow run context
provider_type = None
if workflow_run.gathered_context:
provider_type = workflow_run.gathered_context.get("provider")
if not provider_type:
logger.error(f"No provider type found in workflow run {workflow_run_id}")
await websocket.close(code=4400, reason="Provider type not found")
return
logger.info(f"WebSocket connected for {provider_type} provider, workflow_run {workflow_run_id}")
# Get the telephony provider instance
provider = await get_telephony_provider(workflow.organization_id)
# Verify the provider matches what was stored
if provider.PROVIDER_NAME != provider_type:
logger.error(
f"Provider mismatch: expected {provider_type}, got {provider.PROVIDER_NAME}"
)
await websocket.close(code=4400, reason="Provider mismatch")
return
# Delegate to provider-specific handler
await provider.handle_websocket(websocket, workflow_id, user_id, workflow_run_id)
except Exception as e:
logger.error(f"Error in WebSocket connection: {e}")
await websocket.close(1011, "Internal server error")
@router.post("/status-callback/{workflow_run_id}")
async def handle_status_callback(
@router.post("/twilio/status-callback/{workflow_run_id}")
async def handle_twilio_status_callback(
workflow_run_id: int,
request: Request,
x_twilio_signature: Optional[str] = Header(None),
x_webhook_signature: Optional[str] = Header(None),
):
"""Handle status callbacks from telephony providers."""
"""Handle Twilio-specific status callbacks."""
# Parse form data
form_data = await request.form()
@ -348,28 +286,39 @@ async def handle_status_callback(
logger.warning(f"Workflow run {workflow_run_id} not found for status callback")
return {"status": "ignored", "reason": "workflow_run_not_found"}
# Get provider for verification (if signature provided)
if x_twilio_signature:
# Get organization from workflow run
workflow = await db_client.get_workflow_by_id(workflow_run.workflow_id)
if workflow:
provider = await get_telephony_provider(workflow.organization_id)
# Verify signature
backend_endpoint = await TunnelURLProvider.get_tunnel_url()
full_url = f"https://{backend_endpoint}/api/v1/telephony/status-callback/{workflow_run_id}"
is_valid = await provider.verify_webhook_signature(
full_url, callback_data, x_twilio_signature
)
if not is_valid:
logger.warning(f"Invalid webhook signature for workflow run {workflow_run_id}")
return {"status": "error", "reason": "invalid_signature"}
# 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"}
# Convert provider-specific callback to generic format
# (Currently assumes Twilio format, will be extended for other providers)
status_update = StatusCallbackRequest.from_twilio(callback_data)
provider = await get_telephony_provider(workflow.organization_id)
if x_webhook_signature:
backend_endpoint = await TunnelURLProvider.get_tunnel_url()
full_url = f"https://{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, workflow_run)
@ -385,20 +334,20 @@ async def _process_status_update(
"""Process status updates from telephony providers."""
# Log the status callback
twilio_callback_logs = workflow_run.logs.get("twilio_status_callbacks", [])
twilio_callback_log = {
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 # Include provider-specific data
}
twilio_callback_logs.append(twilio_callback_log)
telephony_callback_logs.append(telephony_callback_log)
# Update workflow run logs
await db_client.update_workflow_run(
run_id=workflow_run_id,
logs={"twilio_status_callbacks": twilio_callback_logs},
logs={"telephony_status_callbacks": telephony_callback_logs},
)
# Handle call completion
@ -454,12 +403,12 @@ async def _process_status_update(
)
@router.post("/events/{workflow_run_id}")
@router.post("/vonage/events/{workflow_run_id}")
async def handle_vonage_events(
request: Request,
workflow_run_id: int,
):
"""Handle Vonage event webhooks.
"""Handle Vonage-specific event webhooks.
Vonage sends all call events to a single endpoint.
Events include: started, ringing, answered, complete, failed, etc.
@ -474,7 +423,7 @@ async def handle_vonage_events(
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
# 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:
@ -497,8 +446,27 @@ async def handle_vonage_events(
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)
# 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, workflow_run)

View file

@ -1,264 +0,0 @@
# TODO: Remove this entire file after migrating workflow_run_cost.py to use telephony abstraction
# All endpoints here are deprecated - use /api/v1/telephony/* instead
import json
import random
from datetime import UTC, datetime
from typing import Annotated, Optional
from fastapi import APIRouter, Depends, Form, Header, HTTPException, Request, WebSocket
from loguru import logger
from pydantic import BaseModel
from starlette.responses import HTMLResponse
from api.db import db_client
from api.db.models import UserModel
from api.enums import OrganizationConfigurationKey, 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.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")
class InitiateCallRequest(BaseModel):
workflow_id: int
workflow_run_id: int | None = None
class TwilioStatusCallbackRequest(BaseModel):
CallSid: str
CallStatus: str
From: Optional[str] = None
To: Optional[str] = None
Direction: Optional[str] = None
Duration: Optional[str] = None
CallDuration: Optional[str] = None
RecordingUrl: Optional[str] = None
RecordingSid: Optional[str] = None
Timestamp: Optional[str] = None
@router.post("/initiate-call")
async def initiate_call(
request: InitiateCallRequest, user: UserModel = Depends(get_user)
):
# Check if organization has TELEPHONY_CONFIGURATION configured
twilio_config = await db_client.get_configuration(
user.selected_organization_id,
OrganizationConfigurationKey.TELEPHONY_CONFIGURATION.value,
)
if not twilio_config or not twilio_config.value:
raise HTTPException(
status_code=400,
detail="telephony_not_configured", # Special error code
)
user_configuration = await db_client.get_user_configurations(user.id)
workflow_run_id = request.workflow_run_id
if not workflow_run_id:
workflow_run_name = f"WR-TEL-{random.randint(1000, 9999)}"
workflow_run = await db_client.create_workflow_run(
workflow_run_name,
request.workflow_id,
WorkflowRunMode.TWILIO.value,
initial_context={
"phone_number": user_configuration.test_phone_number,
},
user_id=user.id,
)
workflow_run_id = workflow_run.id
else:
workflow_run = await db_client.get_workflow_run(workflow_run_id, user.id)
if not workflow_run:
raise HTTPException(status_code=400, detail="Workflow run not found")
workflow_run_name = workflow_run.name
if user_configuration.test_phone_number:
# 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,
webhook_url=webhook_url,
workflow_run_id=workflow_run_id,
)
return {
"message": f"Call initiated successfully with run name {workflow_run_name}"
}
else:
raise HTTPException(status_code=400, detail="Test phone number not set")
@router.post("/twiml", include_in_schema=False)
async def start_call(
workflow_id: int, user_id: int, workflow_run_id: int, organization_id: int
):
# 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")
@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
):
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)
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")
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
# 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}")
await websocket.close(1011, "Internal server error")
@router.post("/status-callback/{workflow_run_id}", include_in_schema=False)
async def status_callback(
request: Request,
workflow_run_id: int,
x_twilio_signature: Annotated[
Optional[str], Header(alias="X-Twilio-Signature")
] = None,
CallSid: str = Form(...),
CallStatus: str = Form(...),
From: Optional[str] = Form(None),
To: Optional[str] = Form(None),
Direction: Optional[str] = Form(None),
Duration: Optional[str] = Form(None),
CallDuration: Optional[str] = Form(None),
RecordingUrl: Optional[str] = Form(None),
RecordingSid: Optional[str] = Form(None),
Timestamp: Optional[str] = Form(None),
):
"""Handle Twilio status callbacks for call lifecycle events."""
try:
# TODO: Implement Twilio signature verification
# Create callback data object
callback_data = {
"CallSid": CallSid,
"CallStatus": CallStatus,
"From": From,
"To": To,
"Direction": Direction,
"Duration": Duration,
"CallDuration": CallDuration,
"RecordingUrl": RecordingUrl,
"RecordingSid": RecordingSid,
"Timestamp": Timestamp,
}
# Remove None values for cleaner logging
callback_data = {k: v for k, v in callback_data.items() if v is not None}
logger.info(
f"Received Twilio status callback for workflow_run_id {workflow_run_id}: {CallStatus}"
)
# Get the current workflow run
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
if not workflow_run:
logger.error(f"Workflow run {workflow_run_id} not found for callback")
return {"status": "error", "message": "Workflow run not found"}
callback_logs = workflow_run.logs.get("twilio_status_callbacks", [])
# Add new callback log entry to logs
callback_log = {
"status": CallStatus,
"timestamp": datetime.now(UTC).isoformat(),
"data": callback_data,
}
callback_logs.append(callback_log)
# Update the workflow run with the new logs
await db_client.update_workflow_run(
run_id=workflow_run_id, logs={"twilio_status_callbacks": callback_logs}
)
# Release concurrent slot when call ends (for any terminal status)
terminal_statuses = ["completed", "busy", "no-answer", "failed", "canceled"]
if CallStatus.lower() in terminal_statuses and workflow_run.campaign_id:
# Release the concurrent slot for this call
await campaign_call_dispatcher.release_call_slot(workflow_run_id)
# Check if retry is needed for campaign calls
if (
CallStatus.lower() in ["busy", "no-answer", "failed"]
and workflow_run.campaign_id
):
# Lets retry for busy and no-answer
if CallStatus.lower() in ["busy", "no-answer"]:
publisher = await get_campaign_event_publisher()
await publisher.publish_retry_needed(
workflow_run_id=workflow_run_id,
reason=CallStatus.lower().replace(
"-", "_"
), # Convert no-answer to no_answer
campaign_id=workflow_run.campaign_id,
queued_run_id=workflow_run.queued_run_id,
)
# Update workflow run with appropriate tags
call_tags = workflow_run.gathered_context.get("call_tags", [])
call_tags.extend(["not_connected", f"twilio_{CallStatus.lower()}"])
await db_client.update_workflow_run(
run_id=workflow_run_id,
is_completed=True,
gathered_context={
"call_tags": call_tags,
},
)
return {"status": "success", "message": "Callback processed"}
except Exception as e:
logger.error(f"Error processing Twilio status callback: {e}")
return {"status": "error", "message": str(e)}

View file

@ -124,7 +124,7 @@ class SignalingManager:
)
else:
# Create new connection using correct SmallWebRTC API
pc = SmallWebRTCConnection(ice_servers=ice_servers, connection_timeout_secs=20)
pc = SmallWebRTCConnection(ice_servers=ice_servers, connection_timeout_secs=60)
# Set the pc_id before initialization so it's available in get_answer()
pc._pc_id = pc_id

View file

@ -2,7 +2,6 @@ from typing import List, Optional
from pydantic import BaseModel, Field
# TODO: Make schemas provider-agnostic
class TwilioConfigurationRequest(BaseModel):
"""Request schema for Twilio configuration."""

View file

@ -189,11 +189,15 @@ class CampaignCallDispatcher:
# Create workflow run with queued_run_id tracking
workflow_run_name = f"WR-CAMPAIGN-{campaign.id}-{queued_run.id}"
# Get provider first to determine the mode
provider = await self.get_telephony_provider(campaign.organization_id)
workflow_run_mode = provider.PROVIDER_NAME
try:
workflow_run = await db_client.create_workflow_run(
name=workflow_run_name,
workflow_id=campaign.workflow_id,
mode=WorkflowRunMode.TWILIO.value,
mode=workflow_run_mode,
user_id=campaign.created_by,
initial_context=initial_context,
campaign_id=campaign.id,
@ -223,12 +227,11 @@ class CampaignCallDispatcher:
# Initiate call via telephony provider
try:
provider = await self.get_telephony_provider(campaign.organization_id)
# Construct webhook URL with parameters
backend_endpoint = await TunnelURLProvider.get_tunnel_url()
webhook_endpoint = provider.WEBHOOK_ENDPOINT
webhook_url = (
f"https://{backend_endpoint}/api/v1/telephony/twiml"
f"https://{backend_endpoint}/api/v1/telephony/{webhook_endpoint}"
f"?workflow_id={campaign.workflow_id}"
f"&user_id={campaign.created_by}"
f"&workflow_run_id={workflow_run.id}"
@ -243,7 +246,7 @@ class CampaignCallDispatcher:
)
logger.info(
f"Call initiated for workflow run {workflow_run.id}, SID: {call_result.get('sid')}"
f"Call initiated for workflow run {workflow_run.id}, Call ID: {call_result.call_id}"
)
except Exception as e:
@ -252,13 +255,13 @@ class CampaignCallDispatcher:
)
# Update workflow run as failed
twilio_callback_logs = workflow_run.logs.get("twilio_status_callbacks", [])
twilio_callback_log = {
telephony_callback_logs = workflow_run.logs.get("telephony_status_callbacks", [])
telephony_callback_log = {
"status": "failed",
"timestamp": datetime.now(UTC).isoformat(),
"data": {"error": str(e)},
}
twilio_callback_logs.append(twilio_callback_log)
telephony_callback_logs.append(telephony_callback_log)
await db_client.update_workflow_run(
run_id=workflow_run.id,
is_completed=True,
@ -266,7 +269,7 @@ class CampaignCallDispatcher:
"error": str(e),
},
logs={
"twilio_status_callbacks": twilio_callback_logs,
"telephony_status_callbacks": telephony_callback_logs,
},
)

View file

@ -102,13 +102,13 @@ class CampaignRunnerService:
}
async def _count_failed_campaign_calls(self, campaign_id: int) -> int:
"""Count failed calls by examining workflow_run Twilio callbacks"""
"""Count failed calls by examining workflow_run telephony callbacks"""
# Get all workflow runs for this campaign
workflow_runs = await db_client.get_workflow_runs_by_campaign(campaign_id)
failed_count = 0
for run in workflow_runs:
callbacks = run.logs.get("twilio_status_callbacks", [])
callbacks = run.logs.get("telephony_status_callbacks", [])
if callbacks:
# Check final status
final_status = callbacks[-1].get("status", "").lower()

View file

@ -71,8 +71,8 @@ async def run_pipeline_twilio(
)
set_current_run_id(workflow_run_id)
# Store Twilio call SID in cost_info for later cost calculation
cost_info = {"twilio_call_sid": call_sid, "provider": "twilio"}
# 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)
# Get workflow to extract all pipeline configurations
@ -126,8 +126,8 @@ async def run_pipeline_vonage(
logger.info(f"Starting Vonage pipeline for workflow run {workflow_run_id}")
set_current_run_id(workflow_run_id)
# Store Vonage call UUID in cost_info for later cost calculation
cost_info = {"vonage_call_uuid": call_uuid, "provider": "vonage"}
# 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

View file

@ -4,7 +4,20 @@ This allows easy switching between different providers (Twilio, Vonage, etc.)
while keeping business logic decoupled from specific implementations.
"""
from abc import ABC, abstractmethod
from typing import Any, Dict, List, Optional
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Dict, List, Optional
if TYPE_CHECKING:
from fastapi import WebSocket
@dataclass
class CallInitiationResult:
"""Standardized response from initiate_call across all providers."""
call_id: str # Provider's call identifier (SID for Twilio, UUID for Vonage)
status: str # Initial status (e.g., "queued", "initiated", "started")
provider_metadata: Dict[str, Any] = field(default_factory=dict) # Data that needs to be persisted
raw_response: Dict[str, Any] = field(default_factory=dict) # Full provider response for debugging
class TelephonyProvider(ABC):
@ -12,6 +25,8 @@ class TelephonyProvider(ABC):
Abstract base class for telephony providers.
All telephony providers must implement these core methods.
"""
PROVIDER_NAME = None
WEBHOOK_ENDPOINT = None
@abstractmethod
async def initiate_call(
@ -20,7 +35,7 @@ class TelephonyProvider(ABC):
webhook_url: str,
workflow_run_id: Optional[int] = None,
**kwargs: Any,
) -> Dict[str, Any]:
) -> CallInitiationResult:
"""
Initiate an outbound call.
@ -31,7 +46,7 @@ class TelephonyProvider(ABC):
**kwargs: Provider-specific additional parameters
Returns:
Dict containing call details (provider-specific format)
CallInitiationResult with standardized call details
"""
pass
@ -117,4 +132,45 @@ class TelephonyProvider(ABC):
- status: Call completion status
- raw_response: Full provider response for debugging
"""
pass
@abstractmethod
def parse_status_callback(self, data: Dict[str, Any]) -> Dict[str, Any]:
"""
Parse provider-specific status callback data into generic format.
Args:
data: Raw callback data from the provider
Returns:
Dict with standardized fields:
- call_id: Provider's call identifier
- status: Standardized status (completed, failed, busy, etc.)
- from_number: Optional caller number
- to_number: Optional recipient number
- duration: Optional call duration
- extra: Provider-specific additional data
"""
pass
@abstractmethod
async def handle_websocket(
self,
websocket: "WebSocket",
workflow_id: int,
user_id: int,
workflow_run_id: int,
) -> None:
"""
Handle provider-specific WebSocket connection for real-time call audio.
This method encapsulates all provider-specific WebSocket handshake and
message routing logic, keeping the main websocket endpoint clean.
Args:
websocket: The WebSocket connection
workflow_id: The workflow ID
user_id: The user ID
workflow_run_id: The workflow run ID
"""
pass

View file

@ -33,19 +33,11 @@ async def load_telephony_config(organization_id: int) -> Dict[str, Any]:
logger.debug(f"Loading telephony config from database for org {organization_id}")
# Try new key first
config = await db_client.get_configuration(
organization_id,
OrganizationConfigurationKey.TELEPHONY_CONFIGURATION.value,
)
# Fallback to old key for backward compatibility
if not config:
config = await db_client.get_configuration(
organization_id,
OrganizationConfigurationKey.TWILIO_CONFIGURATION.value,
)
if config and config.value:
# Simple single-provider format
provider = config.value.get("provider", "twilio")
@ -87,23 +79,18 @@ async def get_telephony_provider(
Raises:
ValueError: If provider type is unknown or configuration is invalid
"""
# Load configuration from appropriate source
# 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
# Provider doesn't know or care if config came from env or database
if provider_type == "twilio":
return TwilioProvider(config)
elif provider_type == "vonage":
return VonageProvider(config)
# Future providers can be added here
# elif provider_type == "plivo":
# return PlivoProvider(config)
else:
raise ValueError(f"Unknown telephony provider: {provider_type}")
raise ValueError(f"Unknown telephony provider: {provider_type}")

View file

@ -1,15 +1,20 @@
"""
Twilio implementation of the TelephonyProvider interface.
"""
import json
import random
from typing import Any, Dict, List, Optional
from typing import TYPE_CHECKING, Any, Dict, List, Optional
import aiohttp
from loguru import logger
from twilio.request_validator import RequestValidator
from api.services.telephony.base import TelephonyProvider
from api.services.telephony.base import CallInitiationResult, TelephonyProvider
from api.utils.tunnel import TunnelURLProvider
from api.enums import WorkflowRunMode
if TYPE_CHECKING:
from fastapi import WebSocket
class TwilioProvider(TelephonyProvider):
@ -17,6 +22,9 @@ class TwilioProvider(TelephonyProvider):
Twilio implementation of TelephonyProvider.
Accepts configuration and works the same regardless of OSS/SaaS mode.
"""
PROVIDER_NAME = WorkflowRunMode.TWILIO.value
WEBHOOK_ENDPOINT = "twiml"
def __init__(self, config: Dict[str, Any]):
"""
@ -44,7 +52,7 @@ class TwilioProvider(TelephonyProvider):
webhook_url: str,
workflow_run_id: Optional[int] = None,
**kwargs: Any,
) -> Dict[str, Any]:
) -> CallInitiationResult:
"""
Initiate an outbound call via Twilio.
"""
@ -67,14 +75,13 @@ class TwilioProvider(TelephonyProvider):
# Add status callback if workflow_run_id provided
if workflow_run_id:
backend_endpoint = await TunnelURLProvider.get_tunnel_url()
callback_url = f"https://{backend_endpoint}/api/v1/telephony/status-callback/{workflow_run_id}"
callback_url = f"https://{backend_endpoint}/api/v1/telephony/twilio/status-callback/{workflow_run_id}"
data.update({
"StatusCallback": callback_url,
"StatusCallbackEvent": ["initiated", "ringing", "answered", "completed"],
"StatusCallbackMethod": "POST"
})
# Add any additional kwargs
data.update(kwargs)
# Make the API request
@ -85,7 +92,14 @@ class TwilioProvider(TelephonyProvider):
error_data = await response.json()
raise Exception(f"Failed to initiate call: {error_data}")
return await response.json()
response_data = await response.json()
return CallInitiationResult(
call_id=response_data["sid"],
status=response_data.get("status", "queued"),
provider_metadata={}, # Twilio doesn't need to persist extra data
raw_response=response_data
)
async def get_call_status(self, call_id: str) -> Dict[str, Any]:
"""
@ -201,4 +215,75 @@ class TwilioProvider(TelephonyProvider):
"duration": 0,
"status": "error",
"error": str(e)
}
}
def parse_status_callback(self, data: Dict[str, Any]) -> Dict[str, Any]:
"""
Parse Twilio status callback data into generic format.
"""
return {
"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 # Include all original data
}
async def handle_websocket(
self,
websocket: "WebSocket",
workflow_id: int,
user_id: int,
workflow_run_id: int,
) -> None:
"""
Handle Twilio-specific WebSocket connection.
Twilio sends:
1. "connected" event first
2. "start" event with streamSid and callSid
3. Then audio messages
"""
from api.services.pipecat.run_pipeline import run_pipeline_twilio
try:
# Wait for "connected" event
first_msg = await websocket.receive_text()
msg = json.loads(first_msg)
if msg.get("event") != "connected":
logger.error(f"Expected 'connected' event, got: {msg.get('event')}")
await websocket.close(code=4400, reason="Expected connected event")
return
logger.debug(f"Twilio WebSocket connected for workflow_run {workflow_run_id}")
# Wait for "start" event with stream details
start_msg = await websocket.receive_text()
logger.debug(f"Received start message: {start_msg}")
start_msg = json.loads(start_msg)
if start_msg.get("event") != "start":
logger.error("Expected 'start' event second")
await websocket.close(code=4400, reason="Expected start event")
return
# Extract Twilio-specific identifiers
try:
stream_sid = start_msg["start"]["streamSid"]
call_sid = start_msg["start"]["callSid"]
except KeyError:
logger.error("Missing streamSid or callSid in start message")
await websocket.close(code=4400, reason="Missing stream identifiers")
return
# Run the Twilio pipeline
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 handler: {e}")
raise

View file

@ -4,14 +4,18 @@ Vonage (Nexmo) implementation of the TelephonyProvider interface.
import json
import random
import time
from typing import Any, Dict, List, Optional
from typing import TYPE_CHECKING, Any, Dict, List, Optional
import aiohttp
import jwt
from loguru import logger
from api.services.telephony.base import TelephonyProvider
from api.services.telephony.base import CallInitiationResult, TelephonyProvider
from api.utils.tunnel import TunnelURLProvider
from api.enums import WorkflowRunMode
if TYPE_CHECKING:
from fastapi import WebSocket
class VonageProvider(TelephonyProvider):
@ -19,7 +23,10 @@ class VonageProvider(TelephonyProvider):
Vonage implementation of TelephonyProvider.
Uses JWT authentication and NCCO for call control.
"""
PROVIDER_NAME = WorkflowRunMode.VONAGE.value
WEBHOOK_ENDPOINT = "ncco"
def __init__(self, config: Dict[str, Any]):
"""
Initialize VonageProvider with configuration.
@ -52,8 +59,8 @@ class VonageProvider(TelephonyProvider):
claims = {
"application_id": self.application_id,
"iat": int(time.time()),
"exp": int(time.time()) + 3600, # 1 hour expiry
"jti": str(time.time()) # Unique token ID
"exp": int(time.time()) + 3600,
"jti": str(time.time())
}
return jwt.encode(claims, self.private_key, algorithm="RS256")
@ -64,7 +71,7 @@ class VonageProvider(TelephonyProvider):
webhook_url: str,
workflow_run_id: Optional[int] = None,
**kwargs: Any,
) -> Dict[str, Any]:
) -> CallInitiationResult:
"""
Initiate an outbound call via Vonage Voice API.
"""
@ -75,7 +82,7 @@ class VonageProvider(TelephonyProvider):
# Select a random phone number
from_number = random.choice(self.from_numbers)
# Remove + prefix for Vonage
# Remove '+' prefix for Vonage
from_number = from_number.replace("+", "")
to_number = to_number.replace("+", "")
@ -98,13 +105,12 @@ class VonageProvider(TelephonyProvider):
# Add event webhook if workflow_run_id provided
if workflow_run_id:
backend_endpoint = await TunnelURLProvider.get_tunnel_url()
event_url = f"https://{backend_endpoint}/api/v1/telephony/events/{workflow_run_id}"
event_url = f"https://{backend_endpoint}/api/v1/telephony/vonage/events/{workflow_run_id}"
data.update({
"event_url": [event_url],
"event_method": "POST"
})
# Add any additional kwargs
data.update(kwargs)
# Generate JWT token
@ -118,7 +124,7 @@ class VonageProvider(TelephonyProvider):
async with aiohttp.ClientSession() as session:
async with session.post(
endpoint,
json=data, # Use json parameter for proper encoding
json=data,
headers=headers
) as response:
response_data = await response.json()
@ -126,7 +132,14 @@ class VonageProvider(TelephonyProvider):
if response.status != 201:
raise Exception(f"Failed to initiate call: {response_data}")
return response_data
return CallInitiationResult(
call_id=response_data["uuid"],
status=response_data.get("status", "started"),
provider_metadata={
"call_uuid": response_data["uuid"] # Vonage needs UUID persisted for WebSocket
},
raw_response=response_data
)
async def get_call_status(self, call_id: str) -> Dict[str, Any]:
"""
@ -179,8 +192,7 @@ class VonageProvider(TelephonyProvider):
return False
try:
# Vonage sends JWT in Authorization header
# Verify the JWT signature
# Vonage sends JWT in Authorization header. Verify the JWT signature
decoded = jwt.decode(
signature,
self.api_secret,
@ -213,9 +225,16 @@ class VonageProvider(TelephonyProvider):
}
]
# Return JSON instead of XML
return json.dumps(ncco)
def _get_auth_headers(self) -> Dict[str, str]:
"""Generate authorization headers for Vonage API."""
token = self._generate_jwt()
return {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
}
async def get_call_cost(self, call_id: str) -> Dict[str, Any]:
"""
Get cost information for a completed Vonage call.
@ -271,4 +290,101 @@ class VonageProvider(TelephonyProvider):
"duration": 0,
"status": "error",
"error": str(e)
}
}
def parse_status_callback(self, data: Dict[str, Any]) -> Dict[str, Any]:
"""
Parse Vonage event callback data into 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 {
"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 # Include all original data
}
async def handle_websocket(
self,
websocket: "WebSocket",
workflow_id: int,
user_id: int,
workflow_run_id: int,
) -> None:
"""
Handle Vonage-specific WebSocket connection.
Vonage can send:
1. JSON metadata first (websocket:connected event)
2. Or directly start with binary audio
"""
from api.db import db_client
from api.services.pipecat.run_pipeline import run_pipeline_vonage
try:
# Get workflow run to extract call UUID
workflow_run = await db_client.get_workflow_run(workflow_run_id)
if not workflow_run:
logger.error(f"Workflow run {workflow_run_id} not found")
await websocket.close(code=4404, reason="Workflow run not found")
return
# Get workflow for organization info
workflow = await db_client.get_workflow(workflow_id, user_id)
if not workflow:
logger.error(f"Workflow {workflow_id} not found")
await websocket.close(code=4404, reason="Workflow not found")
return
# 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(f"No call UUID found for Vonage connection in workflow run {workflow_run_id}")
await websocket.close(code=4400, reason="Missing call UUID")
return
logger.info(f"Vonage WebSocket connected for workflow_run {workflow_run_id}, call_uuid: {call_uuid}")
# Peek at first message to see if it's metadata or audio
first_msg = await websocket.receive()
if "text" in first_msg:
# JSON metadata - check if it's the connection event
msg = json.loads(first_msg["text"])
if msg.get("event") == "websocket:connected":
logger.debug(f"Received Vonage connection confirmation for {workflow_run_id}")
# Continue to pipeline regardless of message type
elif "bytes" in first_msg:
# Binary audio - Vonage started with audio immediately
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(
websocket,
call_uuid,
workflow,
workflow.organization_id,
workflow_id,
workflow_run_id,
user_id
)
except Exception as e:
logger.error(f"Error in Vonage WebSocket handler: {e}")
raise

View file

@ -177,7 +177,7 @@ async def monitor_campaign_progress(ctx: Dict, campaign_id: int) -> None:
failed_calls = 0
for run in workflow_runs:
callbacks = run.logs.get("twilio_status_callbacks", [])
callbacks = run.logs.get("telephony_status_callbacks", [])
if callbacks:
final_status = callbacks[-1].get("status", "").lower()
if final_status == "completed":

View file

@ -29,16 +29,18 @@ async def calculate_workflow_run_cost(ctx, workflow_run_id: int):
# Fetch telephony call cost for both Twilio and Vonage
telephony_cost_usd = 0.0
if workflow_run.mode in [WorkflowRunMode.TWILIO.value, WorkflowRunMode.VONAGE.value] and workflow_run.cost_info:
# Get the call ID based on provider
call_id = None
provider_name = workflow_run.cost_info.get("provider", "")
# Get the call ID (provider-agnostic approach with backward compatibility)
call_id = workflow_run.cost_info.get("call_id")
if workflow_run.mode == WorkflowRunMode.TWILIO.value:
call_id = workflow_run.cost_info.get("twilio_call_sid")
provider_name = provider_name or "twilio"
elif workflow_run.mode == WorkflowRunMode.VONAGE.value:
call_id = workflow_run.cost_info.get("vonage_call_uuid")
provider_name = provider_name or "vonage"
# Fallback to legacy provider-specific fields if needed
if not call_id:
if workflow_run.mode == WorkflowRunMode.TWILIO.value:
call_id = workflow_run.cost_info.get("twilio_call_sid")
elif workflow_run.mode == WorkflowRunMode.VONAGE.value:
call_id = workflow_run.cost_info.get("vonage_call_uuid")
# Provider name is derived from workflow run mode
provider_name = workflow_run.mode.lower() if workflow_run.mode else ""
if call_id:
try:
@ -108,19 +110,16 @@ async def calculate_workflow_run_cost(ctx, workflow_run_id: int):
cost_info["charge_usd"] = charge_usd
cost_info["price_per_second_usd"] = org.price_per_second_usd
# Preserve provider-specific call IDs and provider info
# Preserve call ID (provider-agnostic with backward compatibility)
if workflow_run.cost_info:
# Preserve Twilio call SID if it exists
if "twilio_call_sid" in workflow_run.cost_info:
# Preserve generic call_id if it exists
if "call_id" in workflow_run.cost_info:
cost_info["call_id"] = workflow_run.cost_info["call_id"]
# Also preserve legacy fields for backward compatibility
elif "twilio_call_sid" in workflow_run.cost_info:
cost_info["twilio_call_sid"] = workflow_run.cost_info["twilio_call_sid"]
# Preserve Vonage call UUID if it exists
if "vonage_call_uuid" in workflow_run.cost_info:
elif "vonage_call_uuid" in workflow_run.cost_info:
cost_info["vonage_call_uuid"] = workflow_run.cost_info["vonage_call_uuid"]
# Preserve provider info
if "provider" in workflow_run.cost_info:
cost_info["provider"] = workflow_run.cost_info["provider"]
# Update workflow run with cost information
await db_client.update_workflow_run(run_id=workflow_run_id, cost_info=cost_info)

View file

@ -6,6 +6,7 @@ import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { getTelephonyConfigurationApiV1OrganizationsTelephonyConfigGet, saveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPost } from "@/client/sdk.gen";
import type { TwilioConfigurationRequest, VonageConfigurationRequest } from "@/client/types.gen";
import { Button } from "@/components/ui/button";
import {
Card,
@ -113,23 +114,28 @@ export default function ConfigureTelephonyPage() {
try {
const accessToken = await getAccessToken();
// Build the request body based on provider
let requestBody: any = {
provider: data.provider,
from_numbers: [data.from_number],
};
let requestBody: TwilioConfigurationRequest | VonageConfigurationRequest;
if (data.provider === "twilio") {
requestBody.account_sid = data.account_sid;
requestBody.auth_token = data.auth_token;
} else if (data.provider === "vonage") {
requestBody.application_id = data.application_id;
requestBody.private_key = data.private_key;
requestBody.api_key = data.api_key;
requestBody.api_secret = data.api_secret;
requestBody = {
provider: data.provider,
from_numbers: [data.from_number],
account_sid: data.account_sid,
auth_token: data.auth_token,
} as TwilioConfigurationRequest;
} else {
requestBody = {
provider: data.provider,
from_numbers: [data.from_number],
application_id: data.application_id,
private_key: data.private_key,
api_key: data.api_key || undefined,
api_secret: data.api_secret || undefined,
} as VonageConfigurationRequest;
}
const response = await saveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPost({
headers: { Authorization: `Bearer ${accessToken}` },
body: requestBody,

View file

@ -153,9 +153,9 @@ const WorkflowHeader = ({ isDirty, workflowName, rfInstance, onRun, workflowId,
// Configuration exists, proceed with call initiation
const response = await initiateCallApiV1TelephonyInitiateCallPost({
body: {
body: {
workflow_id: workflowId,
phone_number: phoneNumber
phone_number: phoneNumber
},
headers: { 'Authorization': `Bearer ${accessToken}` },
});

View file

@ -1,8 +1,9 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { ClientOptions } from './types.gen';
import { type Config, type ClientOptions as DefaultClientOptions, createClient, createConfig } from '@hey-api/client-fetch';
import { type ClientOptions as DefaultClientOptions, type Config, createClient, createConfig } from '@hey-api/client-fetch';
import { createClientConfig } from '../lib/apiClient';
import type { ClientOptions } from './types.gen';
/**
* The `createClientConfig()` function will be called on client initialization
@ -16,4 +17,4 @@ export type CreateClientConfig<T extends DefaultClientOptions = ClientOptions> =
export const client = createClient(createClientConfig(createConfig<ClientOptions>({
baseUrl: 'http://127.0.0.1:8000'
})));
})));

View file

@ -1,3 +1,3 @@
// This file is auto-generated by @hey-api/openapi-ts
export * from './sdk.gen';
export * from './types.gen';
export * from './sdk.gen';

File diff suppressed because one or more lines are too long

View file

@ -261,6 +261,12 @@ export type ImpersonateResponse = {
access_token: string;
};
export type InitiateCallRequest = {
workflow_id: number;
workflow_run_id?: number | null;
phone_number?: string | null;
};
export type IntegrationResponse = {
id: number;
integration_id: string;
@ -656,19 +662,8 @@ export type WorkflowTemplateResponse = {
created_at: string;
};
export type ApiRoutesTelephonyInitiateCallRequest = {
workflow_id: number;
workflow_run_id?: number | null;
phone_number?: string | null;
};
export type ApiRoutesTwilioInitiateCallRequest = {
workflow_id: number;
workflow_run_id?: number | null;
};
export type InitiateCallApiV1TelephonyInitiateCallPostData = {
body: ApiRoutesTelephonyInitiateCallRequest;
body: InitiateCallRequest;
headers?: {
authorization?: string | null;
};
@ -697,19 +692,19 @@ export type InitiateCallApiV1TelephonyInitiateCallPostResponses = {
200: unknown;
};
export type HandleStatusCallbackApiV1TelephonyStatusCallbackWorkflowRunIdPostData = {
export type HandleTwilioStatusCallbackApiV1TelephonyTwilioStatusCallbackWorkflowRunIdPostData = {
body?: never;
headers?: {
'x-twilio-signature'?: string | null;
'x-webhook-signature'?: string | null;
};
path: {
workflow_run_id: number;
};
query?: never;
url: '/api/v1/telephony/status-callback/{workflow_run_id}';
url: '/api/v1/telephony/twilio/status-callback/{workflow_run_id}';
};
export type HandleStatusCallbackApiV1TelephonyStatusCallbackWorkflowRunIdPostErrors = {
export type HandleTwilioStatusCallbackApiV1TelephonyTwilioStatusCallbackWorkflowRunIdPostErrors = {
/**
* Not found
*/
@ -720,25 +715,25 @@ export type HandleStatusCallbackApiV1TelephonyStatusCallbackWorkflowRunIdPostErr
422: HttpValidationError;
};
export type HandleStatusCallbackApiV1TelephonyStatusCallbackWorkflowRunIdPostError = HandleStatusCallbackApiV1TelephonyStatusCallbackWorkflowRunIdPostErrors[keyof HandleStatusCallbackApiV1TelephonyStatusCallbackWorkflowRunIdPostErrors];
export type HandleTwilioStatusCallbackApiV1TelephonyTwilioStatusCallbackWorkflowRunIdPostError = HandleTwilioStatusCallbackApiV1TelephonyTwilioStatusCallbackWorkflowRunIdPostErrors[keyof HandleTwilioStatusCallbackApiV1TelephonyTwilioStatusCallbackWorkflowRunIdPostErrors];
export type HandleStatusCallbackApiV1TelephonyStatusCallbackWorkflowRunIdPostResponses = {
export type HandleTwilioStatusCallbackApiV1TelephonyTwilioStatusCallbackWorkflowRunIdPostResponses = {
/**
* Successful Response
*/
200: unknown;
};
export type HandleVonageEventsApiV1TelephonyEventsWorkflowRunIdPostData = {
export type HandleVonageEventsApiV1TelephonyVonageEventsWorkflowRunIdPostData = {
body?: never;
path: {
workflow_run_id: number;
};
query?: never;
url: '/api/v1/telephony/events/{workflow_run_id}';
url: '/api/v1/telephony/vonage/events/{workflow_run_id}';
};
export type HandleVonageEventsApiV1TelephonyEventsWorkflowRunIdPostErrors = {
export type HandleVonageEventsApiV1TelephonyVonageEventsWorkflowRunIdPostErrors = {
/**
* Not found
*/
@ -749,39 +744,9 @@ export type HandleVonageEventsApiV1TelephonyEventsWorkflowRunIdPostErrors = {
422: HttpValidationError;
};
export type HandleVonageEventsApiV1TelephonyEventsWorkflowRunIdPostError = HandleVonageEventsApiV1TelephonyEventsWorkflowRunIdPostErrors[keyof HandleVonageEventsApiV1TelephonyEventsWorkflowRunIdPostErrors];
export type HandleVonageEventsApiV1TelephonyVonageEventsWorkflowRunIdPostError = HandleVonageEventsApiV1TelephonyVonageEventsWorkflowRunIdPostErrors[keyof HandleVonageEventsApiV1TelephonyVonageEventsWorkflowRunIdPostErrors];
export type HandleVonageEventsApiV1TelephonyEventsWorkflowRunIdPostResponses = {
/**
* Successful Response
*/
200: unknown;
};
export type InitiateCallApiV1TwilioInitiateCallPostData = {
body: ApiRoutesTwilioInitiateCallRequest;
headers?: {
authorization?: string | null;
};
path?: never;
query?: never;
url: '/api/v1/twilio/initiate-call';
};
export type InitiateCallApiV1TwilioInitiateCallPostErrors = {
/**
* Not found
*/
404: unknown;
/**
* Validation Error
*/
422: HttpValidationError;
};
export type InitiateCallApiV1TwilioInitiateCallPostError = InitiateCallApiV1TwilioInitiateCallPostErrors[keyof InitiateCallApiV1TwilioInitiateCallPostErrors];
export type InitiateCallApiV1TwilioInitiateCallPostResponses = {
export type HandleVonageEventsApiV1TelephonyVonageEventsWorkflowRunIdPostResponses = {
/**
* Successful Response
*/
@ -2094,9 +2059,7 @@ export type GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetData =
authorization?: string | null;
};
path?: never;
query?: {
provider?: string | null;
};
query?: never;
url: '/api/v1/organizations/telephony-config';
};
@ -2948,4 +2911,4 @@ export type HealthApiV1HealthGetResponses = {
export type ClientOptions = {
baseUrl: 'http://127.0.0.1:8000' | (string & {});
};
};