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

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