mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-16 08:25:18 +02:00
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:
parent
5e4aef346d
commit
99a768f291
40 changed files with 3551 additions and 645 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
# Telephony provider implementations
|
||||
# Telephony provider implementations
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue