mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-07 07:55:16 +02:00
fix: telephony bugs and improve code structure
This commit is contained in:
parent
491e6edd36
commit
f080c563b1
24 changed files with 633 additions and 793 deletions
|
|
@ -34,7 +34,7 @@ STACK_SECRET_SERVER_KEY="ssk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
|||
STACK_PUBLISHABLE_CLIENT_KEY="pck_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
|
||||
# Telephony Configuration
|
||||
# Telephony providers are configured via UI/database only. Navigate to: Settings → Integrations → Telephony
|
||||
# Telephony providers are configured via UI/database only. Navigate to: Workflow → Phone Call -> Configure Telephony
|
||||
|
||||
# Tracing and Analytics
|
||||
ENABLE_TRACING=true
|
||||
|
|
|
|||
|
|
@ -1,122 +0,0 @@
|
|||
"""add_provider_info_to_cost_info
|
||||
|
||||
Revision ID: a57d25b75117
|
||||
Revises: 982ec8e434be
|
||||
Create Date: 2025-10-21 12:28:06.053318
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from alembic_postgresql_enum import TableReference
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'a57d25b75117'
|
||||
down_revision: Union[str, None] = '982ec8e434be'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""
|
||||
Add provider info to existing cost_info JSON for backward compatibility.
|
||||
This migration:
|
||||
1. Adds 'vonage' to workflow_run_mode enum
|
||||
2. Adds 'provider' field to cost_info for existing records
|
||||
3. Migrates TWILIO_CONFIGURATION key to TELEPHONY_CONFIGURATION
|
||||
"""
|
||||
|
||||
# Add 'vonage' to the workflow_run_mode enum using sync_enum_values like other migrations
|
||||
op.sync_enum_values(
|
||||
enum_schema="public",
|
||||
enum_name="workflow_run_mode",
|
||||
new_values=["twilio", "stasis", "webrtc", "smallwebrtc", "VOICE", "CHAT", "vonage"],
|
||||
affected_columns=[
|
||||
TableReference(
|
||||
table_schema="public", table_name="workflow_runs", column_name="mode"
|
||||
)
|
||||
],
|
||||
enum_values_to_rename=[],
|
||||
)
|
||||
|
||||
# Update workflow_runs to add provider info based on mode
|
||||
# Use jsonb_set() to add provider field while preserving existing data
|
||||
op.execute("""
|
||||
UPDATE workflow_runs
|
||||
SET cost_info = jsonb_set(
|
||||
CASE
|
||||
WHEN cost_info IS NULL OR cost_info::text = '{}'
|
||||
THEN '{}'::jsonb
|
||||
ELSE cost_info::jsonb
|
||||
END,
|
||||
'{provider}',
|
||||
'"twilio"'::jsonb,
|
||||
true
|
||||
)::json
|
||||
WHERE mode = 'twilio'
|
||||
AND (cost_info IS NULL OR cost_info::text NOT LIKE '%provider%')
|
||||
""")
|
||||
|
||||
op.execute("""
|
||||
UPDATE workflow_runs
|
||||
SET cost_info = jsonb_set(
|
||||
CASE
|
||||
WHEN cost_info IS NULL OR cost_info::text = '{}'
|
||||
THEN '{}'::jsonb
|
||||
ELSE cost_info::jsonb
|
||||
END,
|
||||
'{provider}',
|
||||
'"vonage"'::jsonb,
|
||||
true
|
||||
)::json
|
||||
WHERE mode = 'vonage'
|
||||
AND (cost_info IS NULL OR cost_info::text NOT LIKE '%provider%')
|
||||
""")
|
||||
|
||||
# Simply rename the key from TWILIO_CONFIGURATION to TELEPHONY_CONFIGURATION
|
||||
# Keep the same single-provider format
|
||||
op.execute("""
|
||||
UPDATE organization_configurations
|
||||
SET key = 'TELEPHONY_CONFIGURATION'
|
||||
WHERE key = 'TWILIO_CONFIGURATION';
|
||||
""")
|
||||
|
||||
print("Migration complete: Added vonage to enum, provider info to cost_info, and renamed configuration key")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""
|
||||
Remove provider info and revert key name.
|
||||
Revert enum to previous state (removing 'vonage').
|
||||
"""
|
||||
|
||||
# Remove provider field from cost_info while preserving other data
|
||||
op.execute("""
|
||||
UPDATE workflow_runs
|
||||
SET cost_info = (cost_info::jsonb - 'provider')::json
|
||||
WHERE cost_info::text LIKE '%provider%'
|
||||
""")
|
||||
|
||||
# Revert key name
|
||||
op.execute("""
|
||||
UPDATE organization_configurations
|
||||
SET key = 'TWILIO_CONFIGURATION'
|
||||
WHERE key = 'TELEPHONY_CONFIGURATION';
|
||||
""")
|
||||
|
||||
# Revert enum to previous state
|
||||
op.sync_enum_values(
|
||||
enum_schema="public",
|
||||
enum_name="workflow_run_mode",
|
||||
new_values=["twilio", "stasis", "webrtc", "smallwebrtc", "VOICE", "CHAT"],
|
||||
affected_columns=[
|
||||
TableReference(
|
||||
table_schema="public", table_name="workflow_runs", column_name="mode"
|
||||
)
|
||||
],
|
||||
enum_values_to_rename=[],
|
||||
)
|
||||
|
||||
print("Downgrade complete: Removed provider info and reverted key name")
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
"""add_vonage_and_rename_config
|
||||
|
||||
Revision ID: a57d25b75117
|
||||
Revises: 982ec8e434be
|
||||
Create Date: 2025-10-21 12:28:06.053318
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
from alembic_postgresql_enum import TableReference
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'a57d25b75117'
|
||||
down_revision: Union[str, None] = '982ec8e434be'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""
|
||||
Add vonage support and rename configuration keys.
|
||||
This migration:
|
||||
1. Adds 'vonage' to workflow_run_mode enum
|
||||
2. Migrates TWILIO_CONFIGURATION key to TELEPHONY_CONFIGURATION
|
||||
3. Renames twilio_status_callbacks to telephony_status_callbacks in workflow_run logs
|
||||
"""
|
||||
|
||||
# Add 'vonage' to the workflow_run_mode enum
|
||||
op.sync_enum_values(
|
||||
enum_schema="public",
|
||||
enum_name="workflow_run_mode",
|
||||
new_values=["twilio", "stasis", "webrtc", "smallwebrtc", "VOICE", "CHAT", "vonage"],
|
||||
affected_columns=[
|
||||
TableReference(
|
||||
table_schema="public", table_name="workflow_runs", column_name="mode"
|
||||
)
|
||||
],
|
||||
enum_values_to_rename=[],
|
||||
)
|
||||
|
||||
# Rename the key from TWILIO_CONFIGURATION to TELEPHONY_CONFIGURATION
|
||||
op.execute("""
|
||||
UPDATE organization_configurations
|
||||
SET key = 'TELEPHONY_CONFIGURATION'
|
||||
WHERE key = 'TWILIO_CONFIGURATION';
|
||||
""")
|
||||
|
||||
# Rename twilio_status_callbacks to telephony_status_callbacks in workflow_run logs
|
||||
op.execute("""
|
||||
UPDATE workflow_runs
|
||||
SET logs = jsonb_set(
|
||||
logs::jsonb - 'twilio_status_callbacks',
|
||||
'{telephony_status_callbacks}',
|
||||
COALESCE(logs::jsonb->'twilio_status_callbacks', '[]'::jsonb)
|
||||
)
|
||||
WHERE logs::jsonb ? 'twilio_status_callbacks';
|
||||
""")
|
||||
|
||||
print("Migration complete: Added vonage to enum, renamed configuration key, and updated status callback keys")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""
|
||||
Revert configuration key names and enum.
|
||||
"""
|
||||
|
||||
# Revert telephony_status_callbacks to twilio_status_callbacks in workflow_run logs
|
||||
op.execute("""
|
||||
UPDATE workflow_runs
|
||||
SET logs = jsonb_set(
|
||||
logs::jsonb - 'telephony_status_callbacks',
|
||||
'{twilio_status_callbacks}',
|
||||
COALESCE(logs::jsonb->'telephony_status_callbacks', '[]'::jsonb)
|
||||
)
|
||||
WHERE logs::jsonb ? 'telephony_status_callbacks';
|
||||
""")
|
||||
|
||||
# Revert key name
|
||||
op.execute("""
|
||||
UPDATE organization_configurations
|
||||
SET key = 'TWILIO_CONFIGURATION'
|
||||
WHERE key = 'TELEPHONY_CONFIGURATION';
|
||||
""")
|
||||
|
||||
# Revert enum to previous state
|
||||
op.sync_enum_values(
|
||||
enum_schema="public",
|
||||
enum_name="workflow_run_mode",
|
||||
new_values=["twilio", "stasis", "webrtc", "smallwebrtc", "VOICE", "CHAT"],
|
||||
affected_columns=[
|
||||
TableReference(
|
||||
table_schema="public", table_name="workflow_runs", column_name="mode"
|
||||
)
|
||||
],
|
||||
enum_values_to_rename=[],
|
||||
)
|
||||
|
||||
print("Downgrade complete: Reverted configuration key names and enum")
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ from typing import List, Optional
|
|||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
# TODO: Make schemas provider-agnostic
|
||||
|
||||
class TwilioConfigurationRequest(BaseModel):
|
||||
"""Request schema for Twilio configuration."""
|
||||
|
|
|
|||
|
|
@ -189,11 +189,15 @@ class CampaignCallDispatcher:
|
|||
# Create workflow run with queued_run_id tracking
|
||||
workflow_run_name = f"WR-CAMPAIGN-{campaign.id}-{queued_run.id}"
|
||||
|
||||
# Get provider first to determine the mode
|
||||
provider = await self.get_telephony_provider(campaign.organization_id)
|
||||
workflow_run_mode = provider.PROVIDER_NAME
|
||||
|
||||
try:
|
||||
workflow_run = await db_client.create_workflow_run(
|
||||
name=workflow_run_name,
|
||||
workflow_id=campaign.workflow_id,
|
||||
mode=WorkflowRunMode.TWILIO.value,
|
||||
mode=workflow_run_mode,
|
||||
user_id=campaign.created_by,
|
||||
initial_context=initial_context,
|
||||
campaign_id=campaign.id,
|
||||
|
|
@ -223,12 +227,11 @@ class CampaignCallDispatcher:
|
|||
|
||||
# Initiate call via telephony provider
|
||||
try:
|
||||
provider = await self.get_telephony_provider(campaign.organization_id)
|
||||
|
||||
# Construct webhook URL with parameters
|
||||
backend_endpoint = await TunnelURLProvider.get_tunnel_url()
|
||||
webhook_endpoint = provider.WEBHOOK_ENDPOINT
|
||||
webhook_url = (
|
||||
f"https://{backend_endpoint}/api/v1/telephony/twiml"
|
||||
f"https://{backend_endpoint}/api/v1/telephony/{webhook_endpoint}"
|
||||
f"?workflow_id={campaign.workflow_id}"
|
||||
f"&user_id={campaign.created_by}"
|
||||
f"&workflow_run_id={workflow_run.id}"
|
||||
|
|
@ -243,7 +246,7 @@ class CampaignCallDispatcher:
|
|||
)
|
||||
|
||||
logger.info(
|
||||
f"Call initiated for workflow run {workflow_run.id}, SID: {call_result.get('sid')}"
|
||||
f"Call initiated for workflow run {workflow_run.id}, Call ID: {call_result.call_id}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
|
|
@ -252,13 +255,13 @@ class CampaignCallDispatcher:
|
|||
)
|
||||
|
||||
# Update workflow run as failed
|
||||
twilio_callback_logs = workflow_run.logs.get("twilio_status_callbacks", [])
|
||||
twilio_callback_log = {
|
||||
telephony_callback_logs = workflow_run.logs.get("telephony_status_callbacks", [])
|
||||
telephony_callback_log = {
|
||||
"status": "failed",
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"data": {"error": str(e)},
|
||||
}
|
||||
twilio_callback_logs.append(twilio_callback_log)
|
||||
telephony_callback_logs.append(telephony_callback_log)
|
||||
await db_client.update_workflow_run(
|
||||
run_id=workflow_run.id,
|
||||
is_completed=True,
|
||||
|
|
@ -266,7 +269,7 @@ class CampaignCallDispatcher:
|
|||
"error": str(e),
|
||||
},
|
||||
logs={
|
||||
"twilio_status_callbacks": twilio_callback_logs,
|
||||
"telephony_status_callbacks": telephony_callback_logs,
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -102,13 +102,13 @@ class CampaignRunnerService:
|
|||
}
|
||||
|
||||
async def _count_failed_campaign_calls(self, campaign_id: int) -> int:
|
||||
"""Count failed calls by examining workflow_run Twilio callbacks"""
|
||||
"""Count failed calls by examining workflow_run telephony callbacks"""
|
||||
# Get all workflow runs for this campaign
|
||||
workflow_runs = await db_client.get_workflow_runs_by_campaign(campaign_id)
|
||||
|
||||
failed_count = 0
|
||||
for run in workflow_runs:
|
||||
callbacks = run.logs.get("twilio_status_callbacks", [])
|
||||
callbacks = run.logs.get("telephony_status_callbacks", [])
|
||||
if callbacks:
|
||||
# Check final status
|
||||
final_status = callbacks[-1].get("status", "").lower()
|
||||
|
|
|
|||
|
|
@ -71,8 +71,8 @@ async def run_pipeline_twilio(
|
|||
)
|
||||
set_current_run_id(workflow_run_id)
|
||||
|
||||
# Store Twilio call SID in cost_info for later cost calculation
|
||||
cost_info = {"twilio_call_sid": call_sid, "provider": "twilio"}
|
||||
# Store call ID in cost_info for later cost calculation (provider-agnostic)
|
||||
cost_info = {"call_id": call_sid}
|
||||
await db_client.update_workflow_run(workflow_run_id, cost_info=cost_info)
|
||||
|
||||
# Get workflow to extract all pipeline configurations
|
||||
|
|
@ -126,8 +126,8 @@ async def run_pipeline_vonage(
|
|||
logger.info(f"Starting Vonage pipeline for workflow run {workflow_run_id}")
|
||||
set_current_run_id(workflow_run_id)
|
||||
|
||||
# Store Vonage call UUID in cost_info for later cost calculation
|
||||
cost_info = {"vonage_call_uuid": call_uuid, "provider": "vonage"}
|
||||
# Store call ID in cost_info for later cost calculation (provider-agnostic)
|
||||
cost_info = {"call_id": call_uuid}
|
||||
await db_client.update_workflow_run(workflow_run_id, cost_info=cost_info)
|
||||
|
||||
# Extract VAD and ambient noise config from workflow
|
||||
|
|
|
|||
|
|
@ -4,7 +4,20 @@ This allows easy switching between different providers (Twilio, Vonage, etc.)
|
|||
while keeping business logic decoupled from specific implementations.
|
||||
"""
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Dict, List, Optional
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastapi import WebSocket
|
||||
|
||||
|
||||
@dataclass
|
||||
class CallInitiationResult:
|
||||
"""Standardized response from initiate_call across all providers."""
|
||||
call_id: str # Provider's call identifier (SID for Twilio, UUID for Vonage)
|
||||
status: str # Initial status (e.g., "queued", "initiated", "started")
|
||||
provider_metadata: Dict[str, Any] = field(default_factory=dict) # Data that needs to be persisted
|
||||
raw_response: Dict[str, Any] = field(default_factory=dict) # Full provider response for debugging
|
||||
|
||||
|
||||
class TelephonyProvider(ABC):
|
||||
|
|
@ -12,6 +25,8 @@ class TelephonyProvider(ABC):
|
|||
Abstract base class for telephony providers.
|
||||
All telephony providers must implement these core methods.
|
||||
"""
|
||||
PROVIDER_NAME = None
|
||||
WEBHOOK_ENDPOINT = None
|
||||
|
||||
@abstractmethod
|
||||
async def initiate_call(
|
||||
|
|
@ -20,7 +35,7 @@ class TelephonyProvider(ABC):
|
|||
webhook_url: str,
|
||||
workflow_run_id: Optional[int] = None,
|
||||
**kwargs: Any,
|
||||
) -> Dict[str, Any]:
|
||||
) -> CallInitiationResult:
|
||||
"""
|
||||
Initiate an outbound call.
|
||||
|
||||
|
|
@ -31,7 +46,7 @@ class TelephonyProvider(ABC):
|
|||
**kwargs: Provider-specific additional parameters
|
||||
|
||||
Returns:
|
||||
Dict containing call details (provider-specific format)
|
||||
CallInitiationResult with standardized call details
|
||||
"""
|
||||
pass
|
||||
|
||||
|
|
@ -117,4 +132,45 @@ class TelephonyProvider(ABC):
|
|||
- status: Call completion status
|
||||
- raw_response: Full provider response for debugging
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def parse_status_callback(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Parse provider-specific status callback data into generic format.
|
||||
|
||||
Args:
|
||||
data: Raw callback data from the provider
|
||||
|
||||
Returns:
|
||||
Dict with standardized fields:
|
||||
- call_id: Provider's call identifier
|
||||
- status: Standardized status (completed, failed, busy, etc.)
|
||||
- from_number: Optional caller number
|
||||
- to_number: Optional recipient number
|
||||
- duration: Optional call duration
|
||||
- extra: Provider-specific additional data
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def handle_websocket(
|
||||
self,
|
||||
websocket: "WebSocket",
|
||||
workflow_id: int,
|
||||
user_id: int,
|
||||
workflow_run_id: int,
|
||||
) -> None:
|
||||
"""
|
||||
Handle provider-specific WebSocket connection for real-time call audio.
|
||||
|
||||
This method encapsulates all provider-specific WebSocket handshake and
|
||||
message routing logic, keeping the main websocket endpoint clean.
|
||||
|
||||
Args:
|
||||
websocket: The WebSocket connection
|
||||
workflow_id: The workflow ID
|
||||
user_id: The user ID
|
||||
workflow_run_id: The workflow run ID
|
||||
"""
|
||||
pass
|
||||
|
|
@ -33,19 +33,11 @@ async def load_telephony_config(organization_id: int) -> Dict[str, Any]:
|
|||
|
||||
logger.debug(f"Loading telephony config from database for org {organization_id}")
|
||||
|
||||
# Try new key first
|
||||
config = await db_client.get_configuration(
|
||||
organization_id,
|
||||
OrganizationConfigurationKey.TELEPHONY_CONFIGURATION.value,
|
||||
)
|
||||
|
||||
# Fallback to old key for backward compatibility
|
||||
if not config:
|
||||
config = await db_client.get_configuration(
|
||||
organization_id,
|
||||
OrganizationConfigurationKey.TWILIO_CONFIGURATION.value,
|
||||
)
|
||||
|
||||
if config and config.value:
|
||||
# Simple single-provider format
|
||||
provider = config.value.get("provider", "twilio")
|
||||
|
|
@ -87,23 +79,18 @@ async def get_telephony_provider(
|
|||
Raises:
|
||||
ValueError: If provider type is unknown or configuration is invalid
|
||||
"""
|
||||
# Load configuration from appropriate source
|
||||
# Load configuration
|
||||
config = await load_telephony_config(organization_id)
|
||||
|
||||
provider_type = config.get("provider", "twilio")
|
||||
logger.info(f"Creating {provider_type} telephony provider")
|
||||
|
||||
# Create provider instance with configuration
|
||||
# Provider doesn't know or care if config came from env or database
|
||||
if provider_type == "twilio":
|
||||
return TwilioProvider(config)
|
||||
|
||||
elif provider_type == "vonage":
|
||||
return VonageProvider(config)
|
||||
|
||||
# Future providers can be added here
|
||||
# elif provider_type == "plivo":
|
||||
# return PlivoProvider(config)
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unknown telephony provider: {provider_type}")
|
||||
raise ValueError(f"Unknown telephony provider: {provider_type}")
|
||||
|
|
|
|||
|
|
@ -1,15 +1,20 @@
|
|||
"""
|
||||
Twilio implementation of the TelephonyProvider interface.
|
||||
"""
|
||||
import json
|
||||
import random
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional
|
||||
|
||||
import aiohttp
|
||||
from loguru import logger
|
||||
from twilio.request_validator import RequestValidator
|
||||
|
||||
from api.services.telephony.base import TelephonyProvider
|
||||
from api.services.telephony.base import CallInitiationResult, TelephonyProvider
|
||||
from api.utils.tunnel import TunnelURLProvider
|
||||
from api.enums import WorkflowRunMode
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastapi import WebSocket
|
||||
|
||||
|
||||
class TwilioProvider(TelephonyProvider):
|
||||
|
|
@ -17,6 +22,9 @@ class TwilioProvider(TelephonyProvider):
|
|||
Twilio implementation of TelephonyProvider.
|
||||
Accepts configuration and works the same regardless of OSS/SaaS mode.
|
||||
"""
|
||||
|
||||
PROVIDER_NAME = WorkflowRunMode.TWILIO.value
|
||||
WEBHOOK_ENDPOINT = "twiml"
|
||||
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
"""
|
||||
|
|
@ -44,7 +52,7 @@ class TwilioProvider(TelephonyProvider):
|
|||
webhook_url: str,
|
||||
workflow_run_id: Optional[int] = None,
|
||||
**kwargs: Any,
|
||||
) -> Dict[str, Any]:
|
||||
) -> CallInitiationResult:
|
||||
"""
|
||||
Initiate an outbound call via Twilio.
|
||||
"""
|
||||
|
|
@ -67,14 +75,13 @@ class TwilioProvider(TelephonyProvider):
|
|||
# Add status callback if workflow_run_id provided
|
||||
if workflow_run_id:
|
||||
backend_endpoint = await TunnelURLProvider.get_tunnel_url()
|
||||
callback_url = f"https://{backend_endpoint}/api/v1/telephony/status-callback/{workflow_run_id}"
|
||||
callback_url = f"https://{backend_endpoint}/api/v1/telephony/twilio/status-callback/{workflow_run_id}"
|
||||
data.update({
|
||||
"StatusCallback": callback_url,
|
||||
"StatusCallbackEvent": ["initiated", "ringing", "answered", "completed"],
|
||||
"StatusCallbackMethod": "POST"
|
||||
})
|
||||
|
||||
# Add any additional kwargs
|
||||
data.update(kwargs)
|
||||
|
||||
# Make the API request
|
||||
|
|
@ -85,7 +92,14 @@ class TwilioProvider(TelephonyProvider):
|
|||
error_data = await response.json()
|
||||
raise Exception(f"Failed to initiate call: {error_data}")
|
||||
|
||||
return await response.json()
|
||||
response_data = await response.json()
|
||||
|
||||
return CallInitiationResult(
|
||||
call_id=response_data["sid"],
|
||||
status=response_data.get("status", "queued"),
|
||||
provider_metadata={}, # Twilio doesn't need to persist extra data
|
||||
raw_response=response_data
|
||||
)
|
||||
|
||||
async def get_call_status(self, call_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
|
|
@ -201,4 +215,75 @@ class TwilioProvider(TelephonyProvider):
|
|||
"duration": 0,
|
||||
"status": "error",
|
||||
"error": str(e)
|
||||
}
|
||||
}
|
||||
|
||||
def parse_status_callback(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Parse Twilio status callback data into generic format.
|
||||
"""
|
||||
return {
|
||||
"call_id": data.get("CallSid", ""),
|
||||
"status": data.get("CallStatus", ""),
|
||||
"from_number": data.get("From"),
|
||||
"to_number": data.get("To"),
|
||||
"direction": data.get("Direction"),
|
||||
"duration": data.get("CallDuration") or data.get("Duration"),
|
||||
"extra": data # Include all original data
|
||||
}
|
||||
|
||||
async def handle_websocket(
|
||||
self,
|
||||
websocket: "WebSocket",
|
||||
workflow_id: int,
|
||||
user_id: int,
|
||||
workflow_run_id: int,
|
||||
) -> None:
|
||||
"""
|
||||
Handle Twilio-specific WebSocket connection.
|
||||
|
||||
Twilio sends:
|
||||
1. "connected" event first
|
||||
2. "start" event with streamSid and callSid
|
||||
3. Then audio messages
|
||||
"""
|
||||
from api.services.pipecat.run_pipeline import run_pipeline_twilio
|
||||
|
||||
try:
|
||||
# Wait for "connected" event
|
||||
first_msg = await websocket.receive_text()
|
||||
msg = json.loads(first_msg)
|
||||
|
||||
if msg.get("event") != "connected":
|
||||
logger.error(f"Expected 'connected' event, got: {msg.get('event')}")
|
||||
await websocket.close(code=4400, reason="Expected connected event")
|
||||
return
|
||||
|
||||
logger.debug(f"Twilio WebSocket connected for workflow_run {workflow_run_id}")
|
||||
|
||||
# Wait for "start" event with stream details
|
||||
start_msg = await websocket.receive_text()
|
||||
logger.debug(f"Received start message: {start_msg}")
|
||||
|
||||
start_msg = json.loads(start_msg)
|
||||
if start_msg.get("event") != "start":
|
||||
logger.error("Expected 'start' event second")
|
||||
await websocket.close(code=4400, reason="Expected start event")
|
||||
return
|
||||
|
||||
# Extract Twilio-specific identifiers
|
||||
try:
|
||||
stream_sid = start_msg["start"]["streamSid"]
|
||||
call_sid = start_msg["start"]["callSid"]
|
||||
except KeyError:
|
||||
logger.error("Missing streamSid or callSid in start message")
|
||||
await websocket.close(code=4400, reason="Missing stream identifiers")
|
||||
return
|
||||
|
||||
# Run the Twilio pipeline
|
||||
await run_pipeline_twilio(
|
||||
websocket, stream_sid, call_sid, workflow_id, workflow_run_id, user_id
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in Twilio WebSocket handler: {e}")
|
||||
raise
|
||||
|
|
@ -4,14 +4,18 @@ Vonage (Nexmo) implementation of the TelephonyProvider interface.
|
|||
import json
|
||||
import random
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional
|
||||
|
||||
import aiohttp
|
||||
import jwt
|
||||
from loguru import logger
|
||||
|
||||
from api.services.telephony.base import TelephonyProvider
|
||||
from api.services.telephony.base import CallInitiationResult, TelephonyProvider
|
||||
from api.utils.tunnel import TunnelURLProvider
|
||||
from api.enums import WorkflowRunMode
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastapi import WebSocket
|
||||
|
||||
|
||||
class VonageProvider(TelephonyProvider):
|
||||
|
|
@ -19,7 +23,10 @@ class VonageProvider(TelephonyProvider):
|
|||
Vonage implementation of TelephonyProvider.
|
||||
Uses JWT authentication and NCCO for call control.
|
||||
"""
|
||||
|
||||
|
||||
PROVIDER_NAME = WorkflowRunMode.VONAGE.value
|
||||
WEBHOOK_ENDPOINT = "ncco"
|
||||
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
"""
|
||||
Initialize VonageProvider with configuration.
|
||||
|
|
@ -52,8 +59,8 @@ class VonageProvider(TelephonyProvider):
|
|||
claims = {
|
||||
"application_id": self.application_id,
|
||||
"iat": int(time.time()),
|
||||
"exp": int(time.time()) + 3600, # 1 hour expiry
|
||||
"jti": str(time.time()) # Unique token ID
|
||||
"exp": int(time.time()) + 3600,
|
||||
"jti": str(time.time())
|
||||
}
|
||||
|
||||
return jwt.encode(claims, self.private_key, algorithm="RS256")
|
||||
|
|
@ -64,7 +71,7 @@ class VonageProvider(TelephonyProvider):
|
|||
webhook_url: str,
|
||||
workflow_run_id: Optional[int] = None,
|
||||
**kwargs: Any,
|
||||
) -> Dict[str, Any]:
|
||||
) -> CallInitiationResult:
|
||||
"""
|
||||
Initiate an outbound call via Vonage Voice API.
|
||||
"""
|
||||
|
|
@ -75,7 +82,7 @@ class VonageProvider(TelephonyProvider):
|
|||
|
||||
# Select a random phone number
|
||||
from_number = random.choice(self.from_numbers)
|
||||
# Remove + prefix for Vonage
|
||||
# Remove '+' prefix for Vonage
|
||||
from_number = from_number.replace("+", "")
|
||||
to_number = to_number.replace("+", "")
|
||||
|
||||
|
|
@ -98,13 +105,12 @@ class VonageProvider(TelephonyProvider):
|
|||
# Add event webhook if workflow_run_id provided
|
||||
if workflow_run_id:
|
||||
backend_endpoint = await TunnelURLProvider.get_tunnel_url()
|
||||
event_url = f"https://{backend_endpoint}/api/v1/telephony/events/{workflow_run_id}"
|
||||
event_url = f"https://{backend_endpoint}/api/v1/telephony/vonage/events/{workflow_run_id}"
|
||||
data.update({
|
||||
"event_url": [event_url],
|
||||
"event_method": "POST"
|
||||
})
|
||||
|
||||
# Add any additional kwargs
|
||||
data.update(kwargs)
|
||||
|
||||
# Generate JWT token
|
||||
|
|
@ -118,7 +124,7 @@ class VonageProvider(TelephonyProvider):
|
|||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
endpoint,
|
||||
json=data, # Use json parameter for proper encoding
|
||||
json=data,
|
||||
headers=headers
|
||||
) as response:
|
||||
response_data = await response.json()
|
||||
|
|
@ -126,7 +132,14 @@ class VonageProvider(TelephonyProvider):
|
|||
if response.status != 201:
|
||||
raise Exception(f"Failed to initiate call: {response_data}")
|
||||
|
||||
return response_data
|
||||
return CallInitiationResult(
|
||||
call_id=response_data["uuid"],
|
||||
status=response_data.get("status", "started"),
|
||||
provider_metadata={
|
||||
"call_uuid": response_data["uuid"] # Vonage needs UUID persisted for WebSocket
|
||||
},
|
||||
raw_response=response_data
|
||||
)
|
||||
|
||||
async def get_call_status(self, call_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
|
|
@ -179,8 +192,7 @@ class VonageProvider(TelephonyProvider):
|
|||
return False
|
||||
|
||||
try:
|
||||
# Vonage sends JWT in Authorization header
|
||||
# Verify the JWT signature
|
||||
# Vonage sends JWT in Authorization header. Verify the JWT signature
|
||||
decoded = jwt.decode(
|
||||
signature,
|
||||
self.api_secret,
|
||||
|
|
@ -213,9 +225,16 @@ class VonageProvider(TelephonyProvider):
|
|||
}
|
||||
]
|
||||
|
||||
# Return JSON instead of XML
|
||||
return json.dumps(ncco)
|
||||
|
||||
def _get_auth_headers(self) -> Dict[str, str]:
|
||||
"""Generate authorization headers for Vonage API."""
|
||||
token = self._generate_jwt()
|
||||
return {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
async def get_call_cost(self, call_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get cost information for a completed Vonage call.
|
||||
|
|
@ -271,4 +290,101 @@ class VonageProvider(TelephonyProvider):
|
|||
"duration": 0,
|
||||
"status": "error",
|
||||
"error": str(e)
|
||||
}
|
||||
}
|
||||
|
||||
def parse_status_callback(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Parse Vonage event callback data into generic format.
|
||||
"""
|
||||
# Map Vonage status to common format
|
||||
status_map = {
|
||||
"started": "initiated",
|
||||
"ringing": "ringing",
|
||||
"answered": "answered",
|
||||
"complete": "completed",
|
||||
"failed": "failed",
|
||||
"busy": "busy",
|
||||
"timeout": "no-answer",
|
||||
"rejected": "busy"
|
||||
}
|
||||
|
||||
return {
|
||||
"call_id": data.get("uuid", ""),
|
||||
"status": status_map.get(data.get("status", ""), data.get("status", "")),
|
||||
"from_number": data.get("from"),
|
||||
"to_number": data.get("to"),
|
||||
"direction": data.get("direction"),
|
||||
"duration": data.get("duration"),
|
||||
"extra": data # Include all original data
|
||||
}
|
||||
|
||||
async def handle_websocket(
|
||||
self,
|
||||
websocket: "WebSocket",
|
||||
workflow_id: int,
|
||||
user_id: int,
|
||||
workflow_run_id: int,
|
||||
) -> None:
|
||||
"""
|
||||
Handle Vonage-specific WebSocket connection.
|
||||
|
||||
Vonage can send:
|
||||
1. JSON metadata first (websocket:connected event)
|
||||
2. Or directly start with binary audio
|
||||
"""
|
||||
from api.db import db_client
|
||||
from api.services.pipecat.run_pipeline import run_pipeline_vonage
|
||||
|
||||
try:
|
||||
# Get workflow run to extract call UUID
|
||||
workflow_run = await db_client.get_workflow_run(workflow_run_id)
|
||||
if not workflow_run:
|
||||
logger.error(f"Workflow run {workflow_run_id} not found")
|
||||
await websocket.close(code=4404, reason="Workflow run not found")
|
||||
return
|
||||
|
||||
# Get workflow for organization info
|
||||
workflow = await db_client.get_workflow(workflow_id, user_id)
|
||||
if not workflow:
|
||||
logger.error(f"Workflow {workflow_id} not found")
|
||||
await websocket.close(code=4404, reason="Workflow not found")
|
||||
return
|
||||
|
||||
# Extract call UUID from workflow run context
|
||||
call_uuid = workflow_run.gathered_context.get("call_uuid") if workflow_run.gathered_context else None
|
||||
|
||||
if not call_uuid:
|
||||
logger.error(f"No call UUID found for Vonage connection in workflow run {workflow_run_id}")
|
||||
await websocket.close(code=4400, reason="Missing call UUID")
|
||||
return
|
||||
|
||||
logger.info(f"Vonage WebSocket connected for workflow_run {workflow_run_id}, call_uuid: {call_uuid}")
|
||||
|
||||
# Peek at first message to see if it's metadata or audio
|
||||
first_msg = await websocket.receive()
|
||||
|
||||
if "text" in first_msg:
|
||||
# JSON metadata - check if it's the connection event
|
||||
msg = json.loads(first_msg["text"])
|
||||
if msg.get("event") == "websocket:connected":
|
||||
logger.debug(f"Received Vonage connection confirmation for {workflow_run_id}")
|
||||
# Continue to pipeline regardless of message type
|
||||
elif "bytes" in first_msg:
|
||||
# Binary audio - Vonage started with audio immediately
|
||||
logger.debug(f"Vonage started with binary audio for {workflow_run_id}")
|
||||
# The pipeline will handle this first audio chunk
|
||||
|
||||
# Run the Vonage pipeline
|
||||
await run_pipeline_vonage(
|
||||
websocket,
|
||||
call_uuid,
|
||||
workflow,
|
||||
workflow.organization_id,
|
||||
workflow_id,
|
||||
workflow_run_id,
|
||||
user_id
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in Vonage WebSocket handler: {e}")
|
||||
raise
|
||||
|
|
@ -177,7 +177,7 @@ async def monitor_campaign_progress(ctx: Dict, campaign_id: int) -> None:
|
|||
failed_calls = 0
|
||||
|
||||
for run in workflow_runs:
|
||||
callbacks = run.logs.get("twilio_status_callbacks", [])
|
||||
callbacks = run.logs.get("telephony_status_callbacks", [])
|
||||
if callbacks:
|
||||
final_status = callbacks[-1].get("status", "").lower()
|
||||
if final_status == "completed":
|
||||
|
|
|
|||
|
|
@ -29,16 +29,18 @@ async def calculate_workflow_run_cost(ctx, workflow_run_id: int):
|
|||
# Fetch telephony call cost for both Twilio and Vonage
|
||||
telephony_cost_usd = 0.0
|
||||
if workflow_run.mode in [WorkflowRunMode.TWILIO.value, WorkflowRunMode.VONAGE.value] and workflow_run.cost_info:
|
||||
# Get the call ID based on provider
|
||||
call_id = None
|
||||
provider_name = workflow_run.cost_info.get("provider", "")
|
||||
# Get the call ID (provider-agnostic approach with backward compatibility)
|
||||
call_id = workflow_run.cost_info.get("call_id")
|
||||
|
||||
if workflow_run.mode == WorkflowRunMode.TWILIO.value:
|
||||
call_id = workflow_run.cost_info.get("twilio_call_sid")
|
||||
provider_name = provider_name or "twilio"
|
||||
elif workflow_run.mode == WorkflowRunMode.VONAGE.value:
|
||||
call_id = workflow_run.cost_info.get("vonage_call_uuid")
|
||||
provider_name = provider_name or "vonage"
|
||||
# Fallback to legacy provider-specific fields if needed
|
||||
if not call_id:
|
||||
if workflow_run.mode == WorkflowRunMode.TWILIO.value:
|
||||
call_id = workflow_run.cost_info.get("twilio_call_sid")
|
||||
elif workflow_run.mode == WorkflowRunMode.VONAGE.value:
|
||||
call_id = workflow_run.cost_info.get("vonage_call_uuid")
|
||||
|
||||
# Provider name is derived from workflow run mode
|
||||
provider_name = workflow_run.mode.lower() if workflow_run.mode else ""
|
||||
|
||||
if call_id:
|
||||
try:
|
||||
|
|
@ -108,19 +110,16 @@ async def calculate_workflow_run_cost(ctx, workflow_run_id: int):
|
|||
cost_info["charge_usd"] = charge_usd
|
||||
cost_info["price_per_second_usd"] = org.price_per_second_usd
|
||||
|
||||
# Preserve provider-specific call IDs and provider info
|
||||
# Preserve call ID (provider-agnostic with backward compatibility)
|
||||
if workflow_run.cost_info:
|
||||
# Preserve Twilio call SID if it exists
|
||||
if "twilio_call_sid" in workflow_run.cost_info:
|
||||
# Preserve generic call_id if it exists
|
||||
if "call_id" in workflow_run.cost_info:
|
||||
cost_info["call_id"] = workflow_run.cost_info["call_id"]
|
||||
# Also preserve legacy fields for backward compatibility
|
||||
elif "twilio_call_sid" in workflow_run.cost_info:
|
||||
cost_info["twilio_call_sid"] = workflow_run.cost_info["twilio_call_sid"]
|
||||
|
||||
# Preserve Vonage call UUID if it exists
|
||||
if "vonage_call_uuid" in workflow_run.cost_info:
|
||||
elif "vonage_call_uuid" in workflow_run.cost_info:
|
||||
cost_info["vonage_call_uuid"] = workflow_run.cost_info["vonage_call_uuid"]
|
||||
|
||||
# Preserve provider info
|
||||
if "provider" in workflow_run.cost_info:
|
||||
cost_info["provider"] = workflow_run.cost_info["provider"]
|
||||
|
||||
# Update workflow run with cost information
|
||||
await db_client.update_workflow_run(run_id=workflow_run_id, cost_info=cost_info)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { useForm } from "react-hook-form";
|
|||
import { toast } from "sonner";
|
||||
|
||||
import { getTelephonyConfigurationApiV1OrganizationsTelephonyConfigGet, saveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPost } from "@/client/sdk.gen";
|
||||
import type { TwilioConfigurationRequest, VonageConfigurationRequest } from "@/client/types.gen";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
|
|
@ -113,23 +114,28 @@ export default function ConfigureTelephonyPage() {
|
|||
|
||||
try {
|
||||
const accessToken = await getAccessToken();
|
||||
|
||||
|
||||
// Build the request body based on provider
|
||||
let requestBody: any = {
|
||||
provider: data.provider,
|
||||
from_numbers: [data.from_number],
|
||||
};
|
||||
|
||||
let requestBody: TwilioConfigurationRequest | VonageConfigurationRequest;
|
||||
|
||||
if (data.provider === "twilio") {
|
||||
requestBody.account_sid = data.account_sid;
|
||||
requestBody.auth_token = data.auth_token;
|
||||
} else if (data.provider === "vonage") {
|
||||
requestBody.application_id = data.application_id;
|
||||
requestBody.private_key = data.private_key;
|
||||
requestBody.api_key = data.api_key;
|
||||
requestBody.api_secret = data.api_secret;
|
||||
requestBody = {
|
||||
provider: data.provider,
|
||||
from_numbers: [data.from_number],
|
||||
account_sid: data.account_sid,
|
||||
auth_token: data.auth_token,
|
||||
} as TwilioConfigurationRequest;
|
||||
} else {
|
||||
requestBody = {
|
||||
provider: data.provider,
|
||||
from_numbers: [data.from_number],
|
||||
application_id: data.application_id,
|
||||
private_key: data.private_key,
|
||||
api_key: data.api_key || undefined,
|
||||
api_secret: data.api_secret || undefined,
|
||||
} as VonageConfigurationRequest;
|
||||
}
|
||||
|
||||
|
||||
const response = await saveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPost({
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
body: requestBody,
|
||||
|
|
|
|||
|
|
@ -153,9 +153,9 @@ const WorkflowHeader = ({ isDirty, workflowName, rfInstance, onRun, workflowId,
|
|||
|
||||
// Configuration exists, proceed with call initiation
|
||||
const response = await initiateCallApiV1TelephonyInitiateCallPost({
|
||||
body: {
|
||||
body: {
|
||||
workflow_id: workflowId,
|
||||
phone_number: phoneNumber
|
||||
phone_number: phoneNumber
|
||||
},
|
||||
headers: { 'Authorization': `Bearer ${accessToken}` },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import type { ClientOptions } from './types.gen';
|
||||
import { type Config, type ClientOptions as DefaultClientOptions, createClient, createConfig } from '@hey-api/client-fetch';
|
||||
import { type ClientOptions as DefaultClientOptions, type Config, createClient, createConfig } from '@hey-api/client-fetch';
|
||||
|
||||
import { createClientConfig } from '../lib/apiClient';
|
||||
import type { ClientOptions } from './types.gen';
|
||||
|
||||
/**
|
||||
* The `createClientConfig()` function will be called on client initialization
|
||||
|
|
@ -16,4 +17,4 @@ export type CreateClientConfig<T extends DefaultClientOptions = ClientOptions> =
|
|||
|
||||
export const client = createClient(createClientConfig(createConfig<ClientOptions>({
|
||||
baseUrl: 'http://127.0.0.1:8000'
|
||||
})));
|
||||
})));
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
export * from './sdk.gen';
|
||||
export * from './types.gen';
|
||||
export * from './sdk.gen';
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -261,6 +261,12 @@ export type ImpersonateResponse = {
|
|||
access_token: string;
|
||||
};
|
||||
|
||||
export type InitiateCallRequest = {
|
||||
workflow_id: number;
|
||||
workflow_run_id?: number | null;
|
||||
phone_number?: string | null;
|
||||
};
|
||||
|
||||
export type IntegrationResponse = {
|
||||
id: number;
|
||||
integration_id: string;
|
||||
|
|
@ -656,19 +662,8 @@ export type WorkflowTemplateResponse = {
|
|||
created_at: string;
|
||||
};
|
||||
|
||||
export type ApiRoutesTelephonyInitiateCallRequest = {
|
||||
workflow_id: number;
|
||||
workflow_run_id?: number | null;
|
||||
phone_number?: string | null;
|
||||
};
|
||||
|
||||
export type ApiRoutesTwilioInitiateCallRequest = {
|
||||
workflow_id: number;
|
||||
workflow_run_id?: number | null;
|
||||
};
|
||||
|
||||
export type InitiateCallApiV1TelephonyInitiateCallPostData = {
|
||||
body: ApiRoutesTelephonyInitiateCallRequest;
|
||||
body: InitiateCallRequest;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
};
|
||||
|
|
@ -697,19 +692,19 @@ export type InitiateCallApiV1TelephonyInitiateCallPostResponses = {
|
|||
200: unknown;
|
||||
};
|
||||
|
||||
export type HandleStatusCallbackApiV1TelephonyStatusCallbackWorkflowRunIdPostData = {
|
||||
export type HandleTwilioStatusCallbackApiV1TelephonyTwilioStatusCallbackWorkflowRunIdPostData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
'x-twilio-signature'?: string | null;
|
||||
'x-webhook-signature'?: string | null;
|
||||
};
|
||||
path: {
|
||||
workflow_run_id: number;
|
||||
};
|
||||
query?: never;
|
||||
url: '/api/v1/telephony/status-callback/{workflow_run_id}';
|
||||
url: '/api/v1/telephony/twilio/status-callback/{workflow_run_id}';
|
||||
};
|
||||
|
||||
export type HandleStatusCallbackApiV1TelephonyStatusCallbackWorkflowRunIdPostErrors = {
|
||||
export type HandleTwilioStatusCallbackApiV1TelephonyTwilioStatusCallbackWorkflowRunIdPostErrors = {
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
|
|
@ -720,25 +715,25 @@ export type HandleStatusCallbackApiV1TelephonyStatusCallbackWorkflowRunIdPostErr
|
|||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type HandleStatusCallbackApiV1TelephonyStatusCallbackWorkflowRunIdPostError = HandleStatusCallbackApiV1TelephonyStatusCallbackWorkflowRunIdPostErrors[keyof HandleStatusCallbackApiV1TelephonyStatusCallbackWorkflowRunIdPostErrors];
|
||||
export type HandleTwilioStatusCallbackApiV1TelephonyTwilioStatusCallbackWorkflowRunIdPostError = HandleTwilioStatusCallbackApiV1TelephonyTwilioStatusCallbackWorkflowRunIdPostErrors[keyof HandleTwilioStatusCallbackApiV1TelephonyTwilioStatusCallbackWorkflowRunIdPostErrors];
|
||||
|
||||
export type HandleStatusCallbackApiV1TelephonyStatusCallbackWorkflowRunIdPostResponses = {
|
||||
export type HandleTwilioStatusCallbackApiV1TelephonyTwilioStatusCallbackWorkflowRunIdPostResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: unknown;
|
||||
};
|
||||
|
||||
export type HandleVonageEventsApiV1TelephonyEventsWorkflowRunIdPostData = {
|
||||
export type HandleVonageEventsApiV1TelephonyVonageEventsWorkflowRunIdPostData = {
|
||||
body?: never;
|
||||
path: {
|
||||
workflow_run_id: number;
|
||||
};
|
||||
query?: never;
|
||||
url: '/api/v1/telephony/events/{workflow_run_id}';
|
||||
url: '/api/v1/telephony/vonage/events/{workflow_run_id}';
|
||||
};
|
||||
|
||||
export type HandleVonageEventsApiV1TelephonyEventsWorkflowRunIdPostErrors = {
|
||||
export type HandleVonageEventsApiV1TelephonyVonageEventsWorkflowRunIdPostErrors = {
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
|
|
@ -749,39 +744,9 @@ export type HandleVonageEventsApiV1TelephonyEventsWorkflowRunIdPostErrors = {
|
|||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type HandleVonageEventsApiV1TelephonyEventsWorkflowRunIdPostError = HandleVonageEventsApiV1TelephonyEventsWorkflowRunIdPostErrors[keyof HandleVonageEventsApiV1TelephonyEventsWorkflowRunIdPostErrors];
|
||||
export type HandleVonageEventsApiV1TelephonyVonageEventsWorkflowRunIdPostError = HandleVonageEventsApiV1TelephonyVonageEventsWorkflowRunIdPostErrors[keyof HandleVonageEventsApiV1TelephonyVonageEventsWorkflowRunIdPostErrors];
|
||||
|
||||
export type HandleVonageEventsApiV1TelephonyEventsWorkflowRunIdPostResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: unknown;
|
||||
};
|
||||
|
||||
export type InitiateCallApiV1TwilioInitiateCallPostData = {
|
||||
body: ApiRoutesTwilioInitiateCallRequest;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/api/v1/twilio/initiate-call';
|
||||
};
|
||||
|
||||
export type InitiateCallApiV1TwilioInitiateCallPostErrors = {
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
404: unknown;
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type InitiateCallApiV1TwilioInitiateCallPostError = InitiateCallApiV1TwilioInitiateCallPostErrors[keyof InitiateCallApiV1TwilioInitiateCallPostErrors];
|
||||
|
||||
export type InitiateCallApiV1TwilioInitiateCallPostResponses = {
|
||||
export type HandleVonageEventsApiV1TelephonyVonageEventsWorkflowRunIdPostResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
|
|
@ -2094,9 +2059,7 @@ export type GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetData =
|
|||
authorization?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query?: {
|
||||
provider?: string | null;
|
||||
};
|
||||
query?: never;
|
||||
url: '/api/v1/organizations/telephony-config';
|
||||
};
|
||||
|
||||
|
|
@ -2948,4 +2911,4 @@ export type HealthApiV1HealthGetResponses = {
|
|||
|
||||
export type ClientOptions = {
|
||||
baseUrl: 'http://127.0.0.1:8000' | (string & {});
|
||||
};
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue