feat: enable workflows to be embedded in websites as a script tag (#47)

* feat: add deployment configuration options

* Simplify EmbedDialog

* Add options for inline vs floating embedding of agent
This commit is contained in:
Abhishek 2025-11-15 17:32:37 +05:30 committed by GitHub
parent 5e4aef346d
commit 99a768f291
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 3551 additions and 645 deletions

View file

@ -105,53 +105,6 @@ async def get_user(
return user_model
async def _handle_oss_auth(authorization: str | None) -> UserModel:
"""
Handle authentication for OSS deployment mode.
Uses the authorization token as provider_id and creates user/org if needed.
"""
if not authorization:
raise HTTPException(status_code=401, detail="Authorization header required")
# Remove "Bearer " prefix if present
token = (
authorization.replace("Bearer ", "")
if authorization.startswith("Bearer ")
else authorization
)
if not token:
raise HTTPException(status_code=401, detail="Invalid authorization token")
try:
# Use token as provider_id for OSS mode
user_model = await db_client.get_or_create_user_by_provider_id(
provider_id=token
)
# Create or get organization for OSS user
# Each OSS user gets their own organization using their token as org ID
organization = await db_client.get_or_create_organization_by_provider_id(
provider_id=f"org_{token}"
)
# Ensure user is mapped to their organization
if user_model.selected_organization_id != organization.id:
# add_user_to_organization now handles race conditions with ON CONFLICT DO NOTHING
await db_client.add_user_to_organization(user_model.id, organization.id)
await db_client.update_user_selected_organization(
user_model.id, organization.id
)
user_model.selected_organization_id = organization.id
return user_model
except Exception as e:
raise HTTPException(
status_code=500, detail=f"Error while handling OSS authentication: {e}"
)
async def get_user_optional(
authorization: Annotated[str | None, Header()] = None,
) -> UserModel | None:

View file

@ -7,10 +7,10 @@ from loguru import logger
from api.db import db_client
from api.db.models import QueuedRunModel, WorkflowRunModel
from api.enums import OrganizationConfigurationKey, WorkflowRunMode
from api.enums import OrganizationConfigurationKey
from api.services.campaign.rate_limiter import rate_limiter
from api.services.telephony.factory import get_telephony_provider
from api.services.telephony.base import TelephonyProvider
from api.services.telephony.factory import get_telephony_provider
from api.utils.tunnel import TunnelURLProvider
@ -238,7 +238,7 @@ class CampaignCallDispatcher:
f"&campaign_id={campaign.id}"
f"&organization_id={campaign.organization_id}"
)
call_result = await provider.initiate_call(
to_number=phone_number,
webhook_url=webhook_url,
@ -255,7 +255,9 @@ class CampaignCallDispatcher:
)
# Update workflow run as failed
telephony_callback_logs = workflow_run.logs.get("telephony_status_callbacks", [])
telephony_callback_logs = workflow_run.logs.get(
"telephony_status_callbacks", []
)
telephony_callback_log = {
"status": "failed",
"timestamp": datetime.now(UTC).isoformat(),

View file

@ -119,7 +119,7 @@ async def run_pipeline_vonage(
user_id: int,
):
"""Run pipeline for Vonage WebSocket connections.
Vonage uses raw PCM audio over WebSocket instead of base64-encoded μ-law.
The audio is transmitted as binary frames at 16kHz by default.
"""
@ -137,7 +137,9 @@ async def run_pipeline_vonage(
if "vad_configuration" in workflow.workflow_configurations:
vad_config = workflow.workflow_configurations["vad_configuration"]
if "ambient_noise_configuration" in workflow.workflow_configurations:
ambient_noise_config = workflow.workflow_configurations["ambient_noise_configuration"]
ambient_noise_config = workflow.workflow_configurations[
"ambient_noise_configuration"
]
try:
# Setup audio config for Vonage using the centralized config

View file

@ -165,14 +165,15 @@ async def create_vonage_transport(
# Use the factory to load config from database
from api.services.telephony.factory import load_telephony_config
config = await load_telephony_config(organization_id)
if config.get("provider") != "vonage":
raise ValueError(f"Expected Vonage provider, got {config.get('provider')}")
application_id = config.get("application_id")
private_key = config.get("private_key")
if not application_id or not private_key:
raise ValueError(
f"Incomplete Vonage configuration for organization {organization_id}"
@ -186,8 +187,8 @@ async def create_vonage_transport(
private_key=private_key,
params=VonageFrameSerializer.InputParams(
vonage_sample_rate=audio_config.transport_in_sample_rate,
sample_rate=audio_config.pipeline_sample_rate
)
sample_rate=audio_config.pipeline_sample_rate,
),
)
# Important: Vonage uses binary WebSocket mode, not text

View file

@ -3,6 +3,7 @@ Base telephony provider interface for abstracting telephony services.
This allows easy switching between different providers (Twilio, Vonage, etc.)
while keeping business logic decoupled from specific implementations.
"""
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Dict, List, Optional
@ -14,10 +15,15 @@ if TYPE_CHECKING:
@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
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):
@ -25,6 +31,7 @@ class TelephonyProvider(ABC):
Abstract base class for telephony providers.
All telephony providers must implement these core methods.
"""
PROVIDER_NAME = None
WEBHOOK_ENDPOINT = None
@ -38,13 +45,13 @@ class TelephonyProvider(ABC):
) -> CallInitiationResult:
"""
Initiate an outbound call.
Args:
to_number: The destination phone number
webhook_url: The URL to receive call events
workflow_run_id: Optional workflow run ID for tracking
**kwargs: Provider-specific additional parameters
Returns:
CallInitiationResult with standardized call details
"""
@ -54,10 +61,10 @@ class TelephonyProvider(ABC):
async def get_call_status(self, call_id: str) -> Dict[str, Any]:
"""
Get the current status of a call.
Args:
call_id: The provider-specific call identifier
Returns:
Dict containing call status information
"""
@ -67,7 +74,7 @@ class TelephonyProvider(ABC):
async def get_available_phone_numbers(self) -> List[str]:
"""
Get list of available phone numbers for this provider.
Returns:
List of phone numbers that can be used for outbound calls
"""
@ -77,7 +84,7 @@ class TelephonyProvider(ABC):
def validate_config(self) -> bool:
"""
Validate that the provider is properly configured.
Returns:
True if configuration is valid, False otherwise
"""
@ -89,12 +96,12 @@ class TelephonyProvider(ABC):
) -> bool:
"""
Verify webhook signature for security.
Args:
url: The webhook URL
params: The webhook parameters
signature: The signature to verify
Returns:
True if signature is valid, False otherwise
"""
@ -106,12 +113,12 @@ class TelephonyProvider(ABC):
) -> str:
"""
Generate the initial webhook response for starting a call session.
Args:
workflow_id: The workflow ID
user_id: The user ID
workflow_run_id: The workflow run ID
Returns:
Provider-specific response (e.g., TwiML for Twilio)
"""
@ -121,10 +128,10 @@ class TelephonyProvider(ABC):
async def get_call_cost(self, call_id: str) -> Dict[str, Any]:
"""
Get cost information for a completed call.
Args:
call_id: Provider-specific call identifier (SID for Twilio, UUID for Vonage)
Returns:
Dict containing:
- cost_usd: The cost in USD as float
@ -138,10 +145,10 @@ class TelephonyProvider(ABC):
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
@ -163,14 +170,14 @@ class TelephonyProvider(ABC):
) -> 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
pass

View file

@ -3,8 +3,8 @@ Factory for creating telephony providers.
Handles configuration loading from environment (OSS) or database (SaaS).
The providers themselves don't know or care where config comes from.
"""
import os
from typing import Any, Dict, Optional
from typing import Any, Dict
from loguru import logger
@ -18,36 +18,36 @@ from api.services.telephony.providers.vonage_provider import VonageProvider
async def load_telephony_config(organization_id: int) -> Dict[str, Any]:
"""
Load telephony configuration from database.
Args:
organization_id: Organization ID for database config
Returns:
Configuration dictionary with provider type and credentials
Raises:
ValueError: If no configuration found for the organization
"""
if not organization_id:
raise ValueError("Organization ID is required to load telephony configuration")
logger.debug(f"Loading telephony config from database for org {organization_id}")
config = await db_client.get_configuration(
organization_id,
OrganizationConfigurationKey.TELEPHONY_CONFIGURATION.value,
)
if config and config.value:
# Simple single-provider format
provider = config.value.get("provider", "twilio")
if provider == "twilio":
return {
"provider": "twilio",
"account_sid": config.value.get("account_sid"),
"auth_token": config.value.get("auth_token"),
"from_numbers": config.value.get("from_numbers", [])
"from_numbers": config.value.get("from_numbers", []),
}
elif provider == "vonage":
return {
@ -56,41 +56,41 @@ async def load_telephony_config(organization_id: int) -> Dict[str, Any]:
"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", [])
"from_numbers": config.value.get("from_numbers", []),
}
else:
raise ValueError(f"Unknown provider in config: {provider}")
raise ValueError(f"No telephony configuration found for organization {organization_id}")
raise ValueError(
f"No telephony configuration found for organization {organization_id}"
)
async def get_telephony_provider(
organization_id: int
) -> TelephonyProvider:
async def get_telephony_provider(organization_id: int) -> TelephonyProvider:
"""
Factory function to create telephony providers.
Args:
organization_id: Organization ID (required)
Returns:
Configured telephony provider instance
Raises:
ValueError: If provider type is unknown or configuration is invalid
"""
# Load configuration
config = await load_telephony_config(organization_id)
provider_type = config.get("provider", "twilio")
logger.info(f"Creating {provider_type} telephony provider")
# Create provider instance with configuration
if provider_type == "twilio":
return TwilioProvider(config)
elif provider_type == "vonage":
return VonageProvider(config)
else:
raise ValueError(f"Unknown telephony provider: {provider_type}")

View file

@ -1 +1 @@
# Telephony provider implementations
# Telephony provider implementations

View file

@ -1,6 +1,7 @@
"""
Twilio implementation of the TelephonyProvider interface.
"""
import json
import random
from typing import TYPE_CHECKING, Any, Dict, List, Optional
@ -9,9 +10,9 @@ import aiohttp
from loguru import logger
from twilio.request_validator import RequestValidator
from api.enums import WorkflowRunMode
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
@ -22,14 +23,14 @@ 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]):
"""
Initialize TwilioProvider with configuration.
Args:
config: Dictionary containing:
- account_sid: Twilio Account SID
@ -39,11 +40,11 @@ class TwilioProvider(TelephonyProvider):
self.account_sid = config.get("account_sid")
self.auth_token = config.get("auth_token")
self.from_numbers = config.get("from_numbers", [])
# Handle both single number (string) and multiple numbers (list)
if isinstance(self.from_numbers, str):
self.from_numbers = [self.from_numbers]
self.base_url = f"https://api.twilio.com/2010-04-01/Accounts/{self.account_sid}"
async def initiate_call(
@ -58,32 +59,35 @@ class TwilioProvider(TelephonyProvider):
"""
if not self.validate_config():
raise ValueError("Twilio provider not properly configured")
endpoint = f"{self.base_url}/Calls.json"
# Select a random phone number
from_number = random.choice(self.from_numbers)
logger.info(f"Selected phone number {from_number} for outbound call")
# Prepare call data
data = {
"To": to_number,
"From": from_number,
"Url": webhook_url
}
data = {"To": to_number, "From": from_number, "Url": webhook_url}
# 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/twilio/status-callback/{workflow_run_id}"
data.update({
"StatusCallback": callback_url,
"StatusCallbackEvent": ["initiated", "ringing", "answered", "completed"],
"StatusCallbackMethod": "POST"
})
data.update(
{
"StatusCallback": callback_url,
"StatusCallbackEvent": [
"initiated",
"ringing",
"answered",
"completed",
],
"StatusCallbackMethod": "POST",
}
)
data.update(kwargs)
# Make the API request
async with aiohttp.ClientSession() as session:
auth = aiohttp.BasicAuth(self.account_sid, self.auth_token)
@ -91,14 +95,14 @@ class TwilioProvider(TelephonyProvider):
if response.status != 201:
error_data = await response.json()
raise Exception(f"Failed to initiate call: {error_data}")
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
raw_response=response_data,
)
async def get_call_status(self, call_id: str) -> Dict[str, Any]:
@ -107,16 +111,16 @@ class TwilioProvider(TelephonyProvider):
"""
if not self.validate_config():
raise ValueError("Twilio provider not properly configured")
endpoint = f"{self.base_url}/Calls/{call_id}.json"
async with aiohttp.ClientSession() as session:
auth = aiohttp.BasicAuth(self.account_sid, self.auth_token)
async with session.get(endpoint, auth=auth) as response:
if response.status != 200:
error_data = await response.json()
raise Exception(f"Failed to get call status: {error_data}")
return await response.json()
async def get_available_phone_numbers(self) -> List[str]:
@ -129,11 +133,7 @@ class TwilioProvider(TelephonyProvider):
"""
Validate Twilio configuration.
"""
return bool(
self.account_sid and
self.auth_token and
self.from_numbers
)
return bool(self.account_sid and self.auth_token and self.from_numbers)
async def verify_webhook_signature(
self, url: str, params: Dict[str, Any], signature: str
@ -144,7 +144,7 @@ class TwilioProvider(TelephonyProvider):
if not self.auth_token:
logger.error("No auth token available for webhook signature verification")
return False
validator = RequestValidator(self.auth_token)
return validator.validate(url, params, signature)
@ -155,7 +155,7 @@ class TwilioProvider(TelephonyProvider):
Generate TwiML response for starting a call session.
"""
backend_endpoint = await TunnelURLProvider.get_tunnel_url()
twiml_content = f"""<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Connect>
@ -168,15 +168,15 @@ class TwilioProvider(TelephonyProvider):
async def get_call_cost(self, call_id: str) -> Dict[str, Any]:
"""
Get cost information for a completed Twilio call.
Args:
call_id: The Twilio Call SID
Returns:
Dict containing cost information
"""
endpoint = f"{self.base_url}/Calls/{call_id}.json"
try:
async with aiohttp.ClientSession() as session:
auth = aiohttp.BasicAuth(self.account_sid, self.auth_token)
@ -188,34 +188,29 @@ class TwilioProvider(TelephonyProvider):
"cost_usd": 0.0,
"duration": 0,
"status": "error",
"error": str(error_data)
"error": str(error_data),
}
call_data = await response.json()
# Twilio returns price as a negative string (e.g., "-0.0085")
price_str = call_data.get("price", "0")
cost_usd = abs(float(price_str)) if price_str else 0.0
# Duration is in seconds as a string
duration = int(call_data.get("duration", "0"))
return {
"cost_usd": cost_usd,
"duration": duration,
"status": call_data.get("status", "unknown"),
"price_unit": call_data.get("price_unit", "USD"),
"raw_response": call_data
"raw_response": call_data,
}
except Exception as e:
logger.error(f"Exception fetching Twilio call cost: {e}")
return {
"cost_usd": 0.0,
"duration": 0,
"status": "error",
"error": str(e)
}
return {"cost_usd": 0.0, "duration": 0, "status": "error", "error": str(e)}
def parse_status_callback(self, data: Dict[str, Any]) -> Dict[str, Any]:
"""
@ -228,7 +223,7 @@ class TwilioProvider(TelephonyProvider):
"to_number": data.get("To"),
"direction": data.get("Direction"),
"duration": data.get("CallDuration") or data.get("Duration"),
"extra": data # Include all original data
"extra": data, # Include all original data
}
async def handle_websocket(
@ -240,36 +235,38 @@ class TwilioProvider(TelephonyProvider):
) -> 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}")
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"]
@ -278,12 +275,12 @@ class TwilioProvider(TelephonyProvider):
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
raise

View file

@ -1,6 +1,7 @@
"""
Vonage (Nexmo) implementation of the TelephonyProvider interface.
"""
import json
import random
import time
@ -10,9 +11,9 @@ import aiohttp
import jwt
from loguru import logger
from api.enums import WorkflowRunMode
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
@ -23,14 +24,14 @@ 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.
Args:
config: Dictionary containing:
- api_key: Vonage API Key
@ -44,25 +45,27 @@ class VonageProvider(TelephonyProvider):
self.application_id = config.get("application_id")
self.private_key = config.get("private_key")
self.from_numbers = config.get("from_numbers", [])
# Handle both single number (string) and multiple numbers (list)
if isinstance(self.from_numbers, str):
self.from_numbers = [self.from_numbers]
self.base_url = "https://api.nexmo.com"
def _generate_jwt(self) -> str:
"""Generate JWT token for Vonage API authentication."""
if not self.application_id or not self.private_key:
raise ValueError("Application ID and private key required for JWT generation")
raise ValueError(
"Application ID and private key required for JWT generation"
)
claims = {
"application_id": self.application_id,
"iat": int(time.time()),
"exp": int(time.time()) + 3600,
"jti": str(time.time())
"jti": str(time.time()),
}
return jwt.encode(claims, self.private_key, algorithm="RS256")
async def initiate_call(
@ -77,68 +80,57 @@ class VonageProvider(TelephonyProvider):
"""
if not self.validate_config():
raise ValueError("Vonage provider not properly configured")
endpoint = f"{self.base_url}/v1/calls"
# Select a random phone number
from_number = random.choice(self.from_numbers)
# Remove '+' prefix for Vonage
from_number = from_number.replace("+", "")
to_number = to_number.replace("+", "")
logger.info(f"Selected phone number {from_number} for outbound call")
# Prepare call data
data = {
"to": [{
"type": "phone",
"number": to_number
}],
"from": {
"type": "phone",
"number": from_number
},
"to": [{"type": "phone", "number": to_number}],
"from": {"type": "phone", "number": from_number},
"answer_url": [webhook_url],
"answer_method": "GET"
"answer_method": "GET",
}
# 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/vonage/events/{workflow_run_id}"
data.update({
"event_url": [event_url],
"event_method": "POST"
})
data.update({"event_url": [event_url], "event_method": "POST"})
data.update(kwargs)
# Generate JWT token
token = self._generate_jwt()
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
"Content-Type": "application/json",
}
# Make the API request
async with aiohttp.ClientSession() as session:
async with session.post(
endpoint,
json=data,
headers=headers
) as response:
async with session.post(endpoint, json=data, headers=headers) as response:
response_data = await response.json()
if response.status != 201:
raise Exception(f"Failed to initiate call: {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
"call_uuid": response_data[
"uuid"
] # Vonage needs UUID persisted for WebSocket
},
raw_response=response_data
raw_response=response_data,
)
async def get_call_status(self, call_id: str) -> Dict[str, Any]:
@ -147,21 +139,19 @@ class VonageProvider(TelephonyProvider):
"""
if not self.validate_config():
raise ValueError("Vonage provider not properly configured")
endpoint = f"{self.base_url}/v1/calls/{call_id}"
# Generate JWT token
token = self._generate_jwt()
headers = {
"Authorization": f"Bearer {token}"
}
headers = {"Authorization": f"Bearer {token}"}
async with aiohttp.ClientSession() as session:
async with session.get(endpoint, headers=headers) as response:
if response.status != 200:
error_data = await response.json()
raise Exception(f"Failed to get call status: {error_data}")
return await response.json()
async def get_available_phone_numbers(self) -> List[str]:
@ -174,11 +164,7 @@ class VonageProvider(TelephonyProvider):
"""
Validate Vonage configuration.
"""
return bool(
self.application_id and
self.private_key and
self.from_numbers
)
return bool(self.application_id and self.private_key and self.from_numbers)
async def verify_webhook_signature(
self, url: str, params: Dict[str, Any], signature: str
@ -190,14 +176,14 @@ class VonageProvider(TelephonyProvider):
if not self.api_secret:
logger.error("No API secret available for webhook signature verification")
return False
try:
# Vonage sends JWT in Authorization header. Verify the JWT signature
decoded = jwt.decode(
signature,
self.api_secret,
signature,
self.api_secret,
algorithms=["HS256"],
options={"verify_signature": True}
options={"verify_signature": True},
)
return True
except jwt.InvalidTokenError:
@ -211,43 +197,42 @@ class VonageProvider(TelephonyProvider):
NCCO (Nexmo Call Control Objects) is JSON-based, unlike TwiML which is XML.
"""
backend_endpoint = await TunnelURLProvider.get_tunnel_url()
# NCCO for WebSocket connection
ncco = [
{
"action": "connect",
"endpoint": [{
"type": "websocket",
"uri": f"wss://{backend_endpoint}/api/v1/telephony/ws/{workflow_id}/{user_id}/{workflow_run_id}",
"content-type": "audio/l16;rate=16000", # 16kHz Linear PCM
"headers": {}
}]
"endpoint": [
{
"type": "websocket",
"uri": f"wss://{backend_endpoint}/api/v1/telephony/ws/{workflow_id}/{user_id}/{workflow_run_id}",
"content-type": "audio/l16;rate=16000", # 16kHz Linear PCM
"headers": {},
}
],
}
]
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"
}
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.
Args:
call_id: The Vonage Call UUID
Returns:
Dict containing cost information
"""
headers = self._get_auth_headers()
endpoint = f"https://api.nexmo.com/v1/calls/{call_id}"
try:
async with aiohttp.ClientSession() as session:
async with session.get(endpoint, headers=headers) as response:
@ -258,39 +243,34 @@ class VonageProvider(TelephonyProvider):
"cost_usd": 0.0,
"duration": 0,
"status": "error",
"error": str(error_data)
"error": str(error_data),
}
call_data = await response.json()
# Vonage returns price and rate
# Price is the total cost, rate is the per-minute rate
price = float(call_data.get("price", 0))
cost_usd = price # Vonage returns positive values
# Duration is in seconds
duration = int(call_data.get("duration", 0))
# Get the call status
status = call_data.get("status", "unknown")
return {
"cost_usd": cost_usd,
"duration": duration,
"status": status,
"price_unit": "USD", # Vonage uses USD by default
"rate": call_data.get("rate", 0), # Per-minute rate
"raw_response": call_data
"raw_response": call_data,
}
except Exception as e:
logger.error(f"Exception fetching Vonage call cost: {e}")
return {
"cost_usd": 0.0,
"duration": 0,
"status": "error",
"error": str(e)
}
return {"cost_usd": 0.0, "duration": 0, "status": "error", "error": str(e)}
def parse_status_callback(self, data: Dict[str, Any]) -> Dict[str, Any]:
"""
@ -300,14 +280,14 @@ class VonageProvider(TelephonyProvider):
status_map = {
"started": "initiated",
"ringing": "ringing",
"answered": "answered",
"answered": "answered",
"complete": "completed",
"failed": "failed",
"busy": "busy",
"timeout": "no-answer",
"rejected": "busy"
"rejected": "busy",
}
return {
"call_id": data.get("uuid", ""),
"status": status_map.get(data.get("status", ""), data.get("status", "")),
@ -315,7 +295,7 @@ class VonageProvider(TelephonyProvider):
"to_number": data.get("to"),
"direction": data.get("direction"),
"duration": data.get("duration"),
"extra": data # Include all original data
"extra": data, # Include all original data
}
async def handle_websocket(
@ -327,14 +307,14 @@ class VonageProvider(TelephonyProvider):
) -> 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)
@ -342,38 +322,48 @@ class VonageProvider(TelephonyProvider):
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
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}")
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}")
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}")
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,
@ -382,9 +372,9 @@ class VonageProvider(TelephonyProvider):
workflow.organization_id,
workflow_id,
workflow_run_id,
user_id
user_id,
)
except Exception as e:
logger.error(f"Error in Vonage WebSocket handler: {e}")
raise
raise

View file

@ -22,9 +22,7 @@ from pipecat.frames.frames import (
)
from pipecat.serializers.base_serializer import FrameSerializer
from pipecat.transports.base_input import BaseInputTransport
from pipecat.transports.base_output import (
BaseOutputTransport
)
from pipecat.transports.base_output import BaseOutputTransport
from pipecat.transports.base_transport import BaseTransport, TransportParams