From d58f37ff42c19797771305cf6c59ec381ac90e44 Mon Sep 17 00:00:00 2001 From: Sabiha Khan <87858386+chewwbaka@users.noreply.github.com> Date: Tue, 4 Nov 2025 18:12:06 +0530 Subject: [PATCH] fix: telephony bugs and improve code structure (#38) - improved code structure for touch points - corrected db migrations --- api/.env.example | 2 +- ...25b75117_add_provider_info_to_cost_info.py | 122 -------- ...7d25b75117_add_vonage_and_rename_config.py | 100 +++++++ api/routes/main.py | 4 +- api/routes/organization.py | 100 ++----- api/routes/telephony.py | 268 ++++++++---------- api/routes/twilio.py | 264 ----------------- api/routes/webrtc_signaling.py | 2 +- api/schemas/telephony_config.py | 1 - api/services/campaign/call_dispatcher.py | 21 +- api/services/campaign/runner.py | 4 +- api/services/pipecat/run_pipeline.py | 8 +- api/services/telephony/base.py | 62 +++- api/services/telephony/factory.py | 17 +- .../telephony/providers/twilio_provider.py | 99 ++++++- .../telephony/providers/vonage_provider.py | 146 +++++++++- api/tasks/campaign_tasks.py | 2 +- api/tasks/workflow_run_cost.py | 37 ++- ui/src/app/configure-telephony/page.tsx | 34 ++- .../components/WorkflowHeader.tsx | 4 +- ui/src/client/client.gen.ts | 7 +- ui/src/client/index.ts | 2 +- ui/src/client/sdk.gen.ts | 43 +-- ui/src/client/types.gen.ts | 77 ++--- 24 files changed, 633 insertions(+), 793 deletions(-) delete mode 100644 api/alembic/versions/a57d25b75117_add_provider_info_to_cost_info.py create mode 100644 api/alembic/versions/a57d25b75117_add_vonage_and_rename_config.py delete mode 100644 api/routes/twilio.py diff --git a/api/.env.example b/api/.env.example index 1496a87..9361d25 100644 --- a/api/.env.example +++ b/api/.env.example @@ -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 diff --git a/api/alembic/versions/a57d25b75117_add_provider_info_to_cost_info.py b/api/alembic/versions/a57d25b75117_add_provider_info_to_cost_info.py deleted file mode 100644 index a3895b3..0000000 --- a/api/alembic/versions/a57d25b75117_add_provider_info_to_cost_info.py +++ /dev/null @@ -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") \ No newline at end of file diff --git a/api/alembic/versions/a57d25b75117_add_vonage_and_rename_config.py b/api/alembic/versions/a57d25b75117_add_vonage_and_rename_config.py new file mode 100644 index 0000000..d37b292 --- /dev/null +++ b/api/alembic/versions/a57d25b75117_add_vonage_and_rename_config.py @@ -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") \ No newline at end of file diff --git a/api/routes/main.py b/api/routes/main.py index f418512..88cfab0 100644 --- a/api/routes/main.py +++ b/api/routes/main.py @@ -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) diff --git a/api/routes/organization.py b/api/routes/organization.py index e0df9c1..15e2e5e 100644 --- a/api/routes/organization.py +++ b/api/routes/organization.py @@ -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] diff --git a/api/routes/telephony.py b/api/routes/telephony.py index 6b2d833..1bdadcb 100644 --- a/api/routes/telephony.py +++ b/api/routes/telephony.py @@ -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) diff --git a/api/routes/twilio.py b/api/routes/twilio.py deleted file mode 100644 index 182370d..0000000 --- a/api/routes/twilio.py +++ /dev/null @@ -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)} diff --git a/api/routes/webrtc_signaling.py b/api/routes/webrtc_signaling.py index 45bff78..fedee8b 100644 --- a/api/routes/webrtc_signaling.py +++ b/api/routes/webrtc_signaling.py @@ -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 diff --git a/api/schemas/telephony_config.py b/api/schemas/telephony_config.py index f66be66..5f65bbb 100644 --- a/api/schemas/telephony_config.py +++ b/api/schemas/telephony_config.py @@ -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.""" diff --git a/api/services/campaign/call_dispatcher.py b/api/services/campaign/call_dispatcher.py index f42281f..ba091e9 100644 --- a/api/services/campaign/call_dispatcher.py +++ b/api/services/campaign/call_dispatcher.py @@ -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, }, ) diff --git a/api/services/campaign/runner.py b/api/services/campaign/runner.py index d4e08e1..008ee80 100644 --- a/api/services/campaign/runner.py +++ b/api/services/campaign/runner.py @@ -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() diff --git a/api/services/pipecat/run_pipeline.py b/api/services/pipecat/run_pipeline.py index ce981be..6a92969 100644 --- a/api/services/pipecat/run_pipeline.py +++ b/api/services/pipecat/run_pipeline.py @@ -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 diff --git a/api/services/telephony/base.py b/api/services/telephony/base.py index 28b55e3..598081e 100644 --- a/api/services/telephony/base.py +++ b/api/services/telephony/base.py @@ -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 \ No newline at end of file diff --git a/api/services/telephony/factory.py b/api/services/telephony/factory.py index 983a475..4762572 100644 --- a/api/services/telephony/factory.py +++ b/api/services/telephony/factory.py @@ -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}") \ No newline at end of file + raise ValueError(f"Unknown telephony provider: {provider_type}") diff --git a/api/services/telephony/providers/twilio_provider.py b/api/services/telephony/providers/twilio_provider.py index 261e0d6..cb901e2 100644 --- a/api/services/telephony/providers/twilio_provider.py +++ b/api/services/telephony/providers/twilio_provider.py @@ -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) - } \ No newline at end of file + } + + 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 \ No newline at end of file diff --git a/api/services/telephony/providers/vonage_provider.py b/api/services/telephony/providers/vonage_provider.py index b60daef..35e1e2d 100644 --- a/api/services/telephony/providers/vonage_provider.py +++ b/api/services/telephony/providers/vonage_provider.py @@ -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) - } \ No newline at end of file + } + + 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 \ No newline at end of file diff --git a/api/tasks/campaign_tasks.py b/api/tasks/campaign_tasks.py index c02b36b..964933f 100644 --- a/api/tasks/campaign_tasks.py +++ b/api/tasks/campaign_tasks.py @@ -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": diff --git a/api/tasks/workflow_run_cost.py b/api/tasks/workflow_run_cost.py index 5d4ace5..4b3dc1b 100644 --- a/api/tasks/workflow_run_cost.py +++ b/api/tasks/workflow_run_cost.py @@ -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) diff --git a/ui/src/app/configure-telephony/page.tsx b/ui/src/app/configure-telephony/page.tsx index 830bc4e..202ba09 100644 --- a/ui/src/app/configure-telephony/page.tsx +++ b/ui/src/app/configure-telephony/page.tsx @@ -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, diff --git a/ui/src/app/workflow/[workflowId]/components/WorkflowHeader.tsx b/ui/src/app/workflow/[workflowId]/components/WorkflowHeader.tsx index 254472a..949d9d2 100644 --- a/ui/src/app/workflow/[workflowId]/components/WorkflowHeader.tsx +++ b/ui/src/app/workflow/[workflowId]/components/WorkflowHeader.tsx @@ -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}` }, }); diff --git a/ui/src/client/client.gen.ts b/ui/src/client/client.gen.ts index cbfc632..779eafa 100644 --- a/ui/src/client/client.gen.ts +++ b/ui/src/client/client.gen.ts @@ -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 = export const client = createClient(createClientConfig(createConfig({ baseUrl: 'http://127.0.0.1:8000' -}))); \ No newline at end of file +}))); diff --git a/ui/src/client/index.ts b/ui/src/client/index.ts index e64537d..688e3c9 100644 --- a/ui/src/client/index.ts +++ b/ui/src/client/index.ts @@ -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'; \ No newline at end of file diff --git a/ui/src/client/sdk.gen.ts b/ui/src/client/sdk.gen.ts index 334c7f3..b511193 100644 --- a/ui/src/client/sdk.gen.ts +++ b/ui/src/client/sdk.gen.ts @@ -1,8 +1,9 @@ // This file is auto-generated by @hey-api/openapi-ts -import type { Options as ClientOptions, TDataShape, Client } from '@hey-api/client-fetch'; -import type { InitiateCallApiV1TelephonyInitiateCallPostData, InitiateCallApiV1TelephonyInitiateCallPostError, HandleStatusCallbackApiV1TelephonyStatusCallbackWorkflowRunIdPostData, HandleStatusCallbackApiV1TelephonyStatusCallbackWorkflowRunIdPostError, HandleVonageEventsApiV1TelephonyEventsWorkflowRunIdPostData, HandleVonageEventsApiV1TelephonyEventsWorkflowRunIdPostError, InitiateCallApiV1TwilioInitiateCallPostData, InitiateCallApiV1TwilioInitiateCallPostError, OfferApiV1PipecatRtcOfferPostData, OfferApiV1PipecatRtcOfferPostError, ImpersonateApiV1SuperuserImpersonatePostData, ImpersonateApiV1SuperuserImpersonatePostResponse, ImpersonateApiV1SuperuserImpersonatePostError, GetWorkflowRunsApiV1SuperuserWorkflowRunsGetData, GetWorkflowRunsApiV1SuperuserWorkflowRunsGetResponse, GetWorkflowRunsApiV1SuperuserWorkflowRunsGetError, SetAdminCommentApiV1SuperuserWorkflowRunsRunIdCommentPostData, SetAdminCommentApiV1SuperuserWorkflowRunsRunIdCommentPostResponse, SetAdminCommentApiV1SuperuserWorkflowRunsRunIdCommentPostError, ValidateWorkflowApiV1WorkflowWorkflowIdValidatePostData, ValidateWorkflowApiV1WorkflowWorkflowIdValidatePostResponse, ValidateWorkflowApiV1WorkflowWorkflowIdValidatePostError, CreateWorkflowApiV1WorkflowCreateDefinitionPostData, CreateWorkflowApiV1WorkflowCreateDefinitionPostResponse, CreateWorkflowApiV1WorkflowCreateDefinitionPostError, CreateWorkflowFromTemplateApiV1WorkflowCreateTemplatePostData, CreateWorkflowFromTemplateApiV1WorkflowCreateTemplatePostResponse, CreateWorkflowFromTemplateApiV1WorkflowCreateTemplatePostError, GetWorkflowsApiV1WorkflowFetchGetData, GetWorkflowsApiV1WorkflowFetchGetResponse, GetWorkflowsApiV1WorkflowFetchGetError, GetWorkflowApiV1WorkflowFetchWorkflowIdGetData, GetWorkflowApiV1WorkflowFetchWorkflowIdGetResponse, GetWorkflowApiV1WorkflowFetchWorkflowIdGetError, GetWorkflowsSummaryApiV1WorkflowSummaryGetData, GetWorkflowsSummaryApiV1WorkflowSummaryGetResponse, GetWorkflowsSummaryApiV1WorkflowSummaryGetError, UpdateWorkflowStatusApiV1WorkflowWorkflowIdStatusPutData, UpdateWorkflowStatusApiV1WorkflowWorkflowIdStatusPutResponse, UpdateWorkflowStatusApiV1WorkflowWorkflowIdStatusPutError, UpdateWorkflowApiV1WorkflowWorkflowIdPutData, UpdateWorkflowApiV1WorkflowWorkflowIdPutResponse, UpdateWorkflowApiV1WorkflowWorkflowIdPutError, GetWorkflowRunsApiV1WorkflowWorkflowIdRunsGetData, GetWorkflowRunsApiV1WorkflowWorkflowIdRunsGetResponse, GetWorkflowRunsApiV1WorkflowWorkflowIdRunsGetError, CreateWorkflowRunApiV1WorkflowWorkflowIdRunsPostData, CreateWorkflowRunApiV1WorkflowWorkflowIdRunsPostResponse, CreateWorkflowRunApiV1WorkflowWorkflowIdRunsPostError, GetWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGetData, GetWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGetResponse, GetWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGetError, GetWorkflowTemplatesApiV1WorkflowTemplatesGetData, GetWorkflowTemplatesApiV1WorkflowTemplatesGetResponse, DuplicateWorkflowTemplateApiV1WorkflowTemplatesDuplicatePostData, DuplicateWorkflowTemplateApiV1WorkflowTemplatesDuplicatePostResponse, DuplicateWorkflowTemplateApiV1WorkflowTemplatesDuplicatePostError, GetDefaultConfigurationsApiV1UserConfigurationsDefaultsGetData, GetDefaultConfigurationsApiV1UserConfigurationsDefaultsGetResponse, GetAuthUserApiV1UserAuthUserGetData, GetAuthUserApiV1UserAuthUserGetResponse, GetAuthUserApiV1UserAuthUserGetError, GetUserConfigurationsApiV1UserConfigurationsUserGetData, GetUserConfigurationsApiV1UserConfigurationsUserGetResponse, GetUserConfigurationsApiV1UserConfigurationsUserGetError, UpdateUserConfigurationsApiV1UserConfigurationsUserPutData, UpdateUserConfigurationsApiV1UserConfigurationsUserPutResponse, UpdateUserConfigurationsApiV1UserConfigurationsUserPutError, ValidateUserConfigurationsApiV1UserConfigurationsUserValidateGetData, ValidateUserConfigurationsApiV1UserConfigurationsUserValidateGetResponse, ValidateUserConfigurationsApiV1UserConfigurationsUserValidateGetError, GetApiKeysApiV1UserApiKeysGetData, GetApiKeysApiV1UserApiKeysGetResponse, GetApiKeysApiV1UserApiKeysGetError, CreateApiKeyApiV1UserApiKeysPostData, CreateApiKeyApiV1UserApiKeysPostResponse, CreateApiKeyApiV1UserApiKeysPostError, ArchiveApiKeyApiV1UserApiKeysApiKeyIdDeleteData, ArchiveApiKeyApiV1UserApiKeysApiKeyIdDeleteResponse, ArchiveApiKeyApiV1UserApiKeysApiKeyIdDeleteError, ReactivateApiKeyApiV1UserApiKeysApiKeyIdReactivatePutData, ReactivateApiKeyApiV1UserApiKeysApiKeyIdReactivatePutResponse, ReactivateApiKeyApiV1UserApiKeysApiKeyIdReactivatePutError, CreateCampaignApiV1CampaignCreatePostData, CreateCampaignApiV1CampaignCreatePostResponse, CreateCampaignApiV1CampaignCreatePostError, GetCampaignsApiV1CampaignGetData, GetCampaignsApiV1CampaignGetResponse, GetCampaignsApiV1CampaignGetError, GetCampaignApiV1CampaignCampaignIdGetData, GetCampaignApiV1CampaignCampaignIdGetResponse, GetCampaignApiV1CampaignCampaignIdGetError, StartCampaignApiV1CampaignCampaignIdStartPostData, StartCampaignApiV1CampaignCampaignIdStartPostResponse, StartCampaignApiV1CampaignCampaignIdStartPostError, PauseCampaignApiV1CampaignCampaignIdPausePostData, PauseCampaignApiV1CampaignCampaignIdPausePostResponse, PauseCampaignApiV1CampaignCampaignIdPausePostError, GetCampaignRunsApiV1CampaignCampaignIdRunsGetData, GetCampaignRunsApiV1CampaignCampaignIdRunsGetResponse, GetCampaignRunsApiV1CampaignCampaignIdRunsGetError, ResumeCampaignApiV1CampaignCampaignIdResumePostData, ResumeCampaignApiV1CampaignCampaignIdResumePostResponse, ResumeCampaignApiV1CampaignCampaignIdResumePostError, GetCampaignProgressApiV1CampaignCampaignIdProgressGetData, GetCampaignProgressApiV1CampaignCampaignIdProgressGetResponse, GetCampaignProgressApiV1CampaignCampaignIdProgressGetError, GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetData, GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetResponse, GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetError, GetIntegrationsApiV1IntegrationGetData, GetIntegrationsApiV1IntegrationGetResponse, GetIntegrationsApiV1IntegrationGetError, CreateSessionApiV1IntegrationSessionPostData, CreateSessionApiV1IntegrationSessionPostResponse, CreateSessionApiV1IntegrationSessionPostError, UpdateIntegrationApiV1IntegrationIntegrationIdPutData, UpdateIntegrationApiV1IntegrationIntegrationIdPutResponse, UpdateIntegrationApiV1IntegrationIntegrationIdPutError, GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetData, GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetResponse, GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetError, GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetData, GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetResponse, GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetError, SaveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPostData, SaveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPostError, GetSignedUrlApiV1S3SignedUrlGetData, GetSignedUrlApiV1S3SignedUrlGetResponse, GetSignedUrlApiV1S3SignedUrlGetError, GetFileMetadataApiV1S3FileMetadataGetData, GetFileMetadataApiV1S3FileMetadataGetResponse, GetFileMetadataApiV1S3FileMetadataGetError, GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostData, GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostResponse, GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostError, GetServiceKeysApiV1UserServiceKeysGetData, GetServiceKeysApiV1UserServiceKeysGetResponse, GetServiceKeysApiV1UserServiceKeysGetError, CreateServiceKeyApiV1UserServiceKeysPostData, CreateServiceKeyApiV1UserServiceKeysPostResponse, CreateServiceKeyApiV1UserServiceKeysPostError, ArchiveServiceKeyApiV1UserServiceKeysServiceKeyIdDeleteData, ArchiveServiceKeyApiV1UserServiceKeysServiceKeyIdDeleteError, ReactivateServiceKeyApiV1UserServiceKeysServiceKeyIdReactivatePutData, ReactivateServiceKeyApiV1UserServiceKeysServiceKeyIdReactivatePutError, ListTestSessionsApiV1LooptalkTestSessionsGetData, ListTestSessionsApiV1LooptalkTestSessionsGetResponse, ListTestSessionsApiV1LooptalkTestSessionsGetError, CreateTestSessionApiV1LooptalkTestSessionsPostData, CreateTestSessionApiV1LooptalkTestSessionsPostResponse, CreateTestSessionApiV1LooptalkTestSessionsPostError, GetTestSessionApiV1LooptalkTestSessionsTestSessionIdGetData, GetTestSessionApiV1LooptalkTestSessionsTestSessionIdGetResponse, GetTestSessionApiV1LooptalkTestSessionsTestSessionIdGetError, StartTestSessionApiV1LooptalkTestSessionsTestSessionIdStartPostData, StartTestSessionApiV1LooptalkTestSessionsTestSessionIdStartPostError, StopTestSessionApiV1LooptalkTestSessionsTestSessionIdStopPostData, StopTestSessionApiV1LooptalkTestSessionsTestSessionIdStopPostError, GetTestSessionConversationApiV1LooptalkTestSessionsTestSessionIdConversationGetData, GetTestSessionConversationApiV1LooptalkTestSessionsTestSessionIdConversationGetError, CreateLoadTestApiV1LooptalkLoadTestsPostData, CreateLoadTestApiV1LooptalkLoadTestsPostResponse, CreateLoadTestApiV1LooptalkLoadTestsPostError, GetLoadTestStatsApiV1LooptalkLoadTestsLoadTestGroupIdStatsGetData, GetLoadTestStatsApiV1LooptalkLoadTestsLoadTestGroupIdStatsGetResponse, GetLoadTestStatsApiV1LooptalkLoadTestsLoadTestGroupIdStatsGetError, GetActiveTestsApiV1LooptalkActiveTestsGetData, GetActiveTestsApiV1LooptalkActiveTestsGetError, GetCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGetData, GetCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGetResponse, GetCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGetError, GetUsageHistoryApiV1OrganizationsUsageRunsGetData, GetUsageHistoryApiV1OrganizationsUsageRunsGetResponse, GetUsageHistoryApiV1OrganizationsUsageRunsGetError, GetDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGetData, GetDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGetResponse, GetDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGetError, GetDailyReportApiV1OrganizationsReportsDailyGetData, GetDailyReportApiV1OrganizationsReportsDailyGetResponse, GetDailyReportApiV1OrganizationsReportsDailyGetError, GetWorkflowOptionsApiV1OrganizationsReportsWorkflowsGetData, GetWorkflowOptionsApiV1OrganizationsReportsWorkflowsGetResponse, GetWorkflowOptionsApiV1OrganizationsReportsWorkflowsGetError, GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetData, GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetResponse, GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetError, HealthApiV1HealthGetData } from './types.gen'; +import type { Client,Options as ClientOptions, TDataShape } from '@hey-api/client-fetch'; + import { client as _heyApiClient } from './client.gen'; +import type { ArchiveApiKeyApiV1UserApiKeysApiKeyIdDeleteData, ArchiveApiKeyApiV1UserApiKeysApiKeyIdDeleteError, ArchiveApiKeyApiV1UserApiKeysApiKeyIdDeleteResponse, ArchiveServiceKeyApiV1UserServiceKeysServiceKeyIdDeleteData, ArchiveServiceKeyApiV1UserServiceKeysServiceKeyIdDeleteError, CreateApiKeyApiV1UserApiKeysPostData, CreateApiKeyApiV1UserApiKeysPostError, CreateApiKeyApiV1UserApiKeysPostResponse, CreateCampaignApiV1CampaignCreatePostData, CreateCampaignApiV1CampaignCreatePostError, CreateCampaignApiV1CampaignCreatePostResponse, CreateLoadTestApiV1LooptalkLoadTestsPostData, CreateLoadTestApiV1LooptalkLoadTestsPostError, CreateLoadTestApiV1LooptalkLoadTestsPostResponse, CreateServiceKeyApiV1UserServiceKeysPostData, CreateServiceKeyApiV1UserServiceKeysPostError, CreateServiceKeyApiV1UserServiceKeysPostResponse, CreateSessionApiV1IntegrationSessionPostData, CreateSessionApiV1IntegrationSessionPostError, CreateSessionApiV1IntegrationSessionPostResponse, CreateTestSessionApiV1LooptalkTestSessionsPostData, CreateTestSessionApiV1LooptalkTestSessionsPostError, CreateTestSessionApiV1LooptalkTestSessionsPostResponse, CreateWorkflowApiV1WorkflowCreateDefinitionPostData, CreateWorkflowApiV1WorkflowCreateDefinitionPostError, CreateWorkflowApiV1WorkflowCreateDefinitionPostResponse, CreateWorkflowFromTemplateApiV1WorkflowCreateTemplatePostData, CreateWorkflowFromTemplateApiV1WorkflowCreateTemplatePostError, CreateWorkflowFromTemplateApiV1WorkflowCreateTemplatePostResponse, CreateWorkflowRunApiV1WorkflowWorkflowIdRunsPostData, CreateWorkflowRunApiV1WorkflowWorkflowIdRunsPostError, CreateWorkflowRunApiV1WorkflowWorkflowIdRunsPostResponse, DuplicateWorkflowTemplateApiV1WorkflowTemplatesDuplicatePostData, DuplicateWorkflowTemplateApiV1WorkflowTemplatesDuplicatePostError, DuplicateWorkflowTemplateApiV1WorkflowTemplatesDuplicatePostResponse, GetActiveTestsApiV1LooptalkActiveTestsGetData, GetActiveTestsApiV1LooptalkActiveTestsGetError, GetApiKeysApiV1UserApiKeysGetData, GetApiKeysApiV1UserApiKeysGetError, GetApiKeysApiV1UserApiKeysGetResponse, GetAuthUserApiV1UserAuthUserGetData, GetAuthUserApiV1UserAuthUserGetError, GetAuthUserApiV1UserAuthUserGetResponse, GetCampaignApiV1CampaignCampaignIdGetData, GetCampaignApiV1CampaignCampaignIdGetError, GetCampaignApiV1CampaignCampaignIdGetResponse, GetCampaignProgressApiV1CampaignCampaignIdProgressGetData, GetCampaignProgressApiV1CampaignCampaignIdProgressGetError, GetCampaignProgressApiV1CampaignCampaignIdProgressGetResponse, GetCampaignRunsApiV1CampaignCampaignIdRunsGetData, GetCampaignRunsApiV1CampaignCampaignIdRunsGetError, GetCampaignRunsApiV1CampaignCampaignIdRunsGetResponse, GetCampaignsApiV1CampaignGetData, GetCampaignsApiV1CampaignGetError, GetCampaignsApiV1CampaignGetResponse, GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetData, GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetError, GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetResponse, GetCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGetData, GetCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGetError, GetCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGetResponse, GetDailyReportApiV1OrganizationsReportsDailyGetData, GetDailyReportApiV1OrganizationsReportsDailyGetError, GetDailyReportApiV1OrganizationsReportsDailyGetResponse, GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetData, GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetError, GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetResponse, GetDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGetData, GetDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGetError, GetDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGetResponse, GetDefaultConfigurationsApiV1UserConfigurationsDefaultsGetData, GetDefaultConfigurationsApiV1UserConfigurationsDefaultsGetResponse, GetFileMetadataApiV1S3FileMetadataGetData, GetFileMetadataApiV1S3FileMetadataGetError, GetFileMetadataApiV1S3FileMetadataGetResponse, GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetData, GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetError, GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetResponse, GetIntegrationsApiV1IntegrationGetData, GetIntegrationsApiV1IntegrationGetError, GetIntegrationsApiV1IntegrationGetResponse, GetLoadTestStatsApiV1LooptalkLoadTestsLoadTestGroupIdStatsGetData, GetLoadTestStatsApiV1LooptalkLoadTestsLoadTestGroupIdStatsGetError, GetLoadTestStatsApiV1LooptalkLoadTestsLoadTestGroupIdStatsGetResponse, GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostData, GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostError, GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostResponse, GetServiceKeysApiV1UserServiceKeysGetData, GetServiceKeysApiV1UserServiceKeysGetError, GetServiceKeysApiV1UserServiceKeysGetResponse, GetSignedUrlApiV1S3SignedUrlGetData, GetSignedUrlApiV1S3SignedUrlGetError, GetSignedUrlApiV1S3SignedUrlGetResponse, GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetData, GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetError, GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetResponse, GetTestSessionApiV1LooptalkTestSessionsTestSessionIdGetData, GetTestSessionApiV1LooptalkTestSessionsTestSessionIdGetError, GetTestSessionApiV1LooptalkTestSessionsTestSessionIdGetResponse, GetTestSessionConversationApiV1LooptalkTestSessionsTestSessionIdConversationGetData, GetTestSessionConversationApiV1LooptalkTestSessionsTestSessionIdConversationGetError, GetUsageHistoryApiV1OrganizationsUsageRunsGetData, GetUsageHistoryApiV1OrganizationsUsageRunsGetError, GetUsageHistoryApiV1OrganizationsUsageRunsGetResponse, GetUserConfigurationsApiV1UserConfigurationsUserGetData, GetUserConfigurationsApiV1UserConfigurationsUserGetError, GetUserConfigurationsApiV1UserConfigurationsUserGetResponse, GetWorkflowApiV1WorkflowFetchWorkflowIdGetData, GetWorkflowApiV1WorkflowFetchWorkflowIdGetError, GetWorkflowApiV1WorkflowFetchWorkflowIdGetResponse, GetWorkflowOptionsApiV1OrganizationsReportsWorkflowsGetData, GetWorkflowOptionsApiV1OrganizationsReportsWorkflowsGetError, GetWorkflowOptionsApiV1OrganizationsReportsWorkflowsGetResponse, GetWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGetData, GetWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGetError, GetWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGetResponse, GetWorkflowRunsApiV1SuperuserWorkflowRunsGetData, GetWorkflowRunsApiV1SuperuserWorkflowRunsGetError, GetWorkflowRunsApiV1SuperuserWorkflowRunsGetResponse, GetWorkflowRunsApiV1WorkflowWorkflowIdRunsGetData, GetWorkflowRunsApiV1WorkflowWorkflowIdRunsGetError, GetWorkflowRunsApiV1WorkflowWorkflowIdRunsGetResponse, GetWorkflowsApiV1WorkflowFetchGetData, GetWorkflowsApiV1WorkflowFetchGetError, GetWorkflowsApiV1WorkflowFetchGetResponse, GetWorkflowsSummaryApiV1WorkflowSummaryGetData, GetWorkflowsSummaryApiV1WorkflowSummaryGetError, GetWorkflowsSummaryApiV1WorkflowSummaryGetResponse, GetWorkflowTemplatesApiV1WorkflowTemplatesGetData, GetWorkflowTemplatesApiV1WorkflowTemplatesGetResponse, HandleTwilioStatusCallbackApiV1TelephonyTwilioStatusCallbackWorkflowRunIdPostData, HandleTwilioStatusCallbackApiV1TelephonyTwilioStatusCallbackWorkflowRunIdPostError, HandleVonageEventsApiV1TelephonyVonageEventsWorkflowRunIdPostData, HandleVonageEventsApiV1TelephonyVonageEventsWorkflowRunIdPostError, HealthApiV1HealthGetData,ImpersonateApiV1SuperuserImpersonatePostData, ImpersonateApiV1SuperuserImpersonatePostError, ImpersonateApiV1SuperuserImpersonatePostResponse, InitiateCallApiV1TelephonyInitiateCallPostData, InitiateCallApiV1TelephonyInitiateCallPostError, ListTestSessionsApiV1LooptalkTestSessionsGetData, ListTestSessionsApiV1LooptalkTestSessionsGetError, ListTestSessionsApiV1LooptalkTestSessionsGetResponse, OfferApiV1PipecatRtcOfferPostData, OfferApiV1PipecatRtcOfferPostError, PauseCampaignApiV1CampaignCampaignIdPausePostData, PauseCampaignApiV1CampaignCampaignIdPausePostError, PauseCampaignApiV1CampaignCampaignIdPausePostResponse, ReactivateApiKeyApiV1UserApiKeysApiKeyIdReactivatePutData, ReactivateApiKeyApiV1UserApiKeysApiKeyIdReactivatePutError, ReactivateApiKeyApiV1UserApiKeysApiKeyIdReactivatePutResponse, ReactivateServiceKeyApiV1UserServiceKeysServiceKeyIdReactivatePutData, ReactivateServiceKeyApiV1UserServiceKeysServiceKeyIdReactivatePutError, ResumeCampaignApiV1CampaignCampaignIdResumePostData, ResumeCampaignApiV1CampaignCampaignIdResumePostError, ResumeCampaignApiV1CampaignCampaignIdResumePostResponse, SaveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPostData, SaveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPostError, SetAdminCommentApiV1SuperuserWorkflowRunsRunIdCommentPostData, SetAdminCommentApiV1SuperuserWorkflowRunsRunIdCommentPostError, SetAdminCommentApiV1SuperuserWorkflowRunsRunIdCommentPostResponse, StartCampaignApiV1CampaignCampaignIdStartPostData, StartCampaignApiV1CampaignCampaignIdStartPostError, StartCampaignApiV1CampaignCampaignIdStartPostResponse, StartTestSessionApiV1LooptalkTestSessionsTestSessionIdStartPostData, StartTestSessionApiV1LooptalkTestSessionsTestSessionIdStartPostError, StopTestSessionApiV1LooptalkTestSessionsTestSessionIdStopPostData, StopTestSessionApiV1LooptalkTestSessionsTestSessionIdStopPostError, UpdateIntegrationApiV1IntegrationIntegrationIdPutData, UpdateIntegrationApiV1IntegrationIntegrationIdPutError, UpdateIntegrationApiV1IntegrationIntegrationIdPutResponse, UpdateUserConfigurationsApiV1UserConfigurationsUserPutData, UpdateUserConfigurationsApiV1UserConfigurationsUserPutError, UpdateUserConfigurationsApiV1UserConfigurationsUserPutResponse, UpdateWorkflowApiV1WorkflowWorkflowIdPutData, UpdateWorkflowApiV1WorkflowWorkflowIdPutError, UpdateWorkflowApiV1WorkflowWorkflowIdPutResponse, UpdateWorkflowStatusApiV1WorkflowWorkflowIdStatusPutData, UpdateWorkflowStatusApiV1WorkflowWorkflowIdStatusPutError, UpdateWorkflowStatusApiV1WorkflowWorkflowIdStatusPutResponse, ValidateUserConfigurationsApiV1UserConfigurationsUserValidateGetData, ValidateUserConfigurationsApiV1UserConfigurationsUserValidateGetError, ValidateUserConfigurationsApiV1UserConfigurationsUserValidateGetResponse, ValidateWorkflowApiV1WorkflowWorkflowIdValidatePostData, ValidateWorkflowApiV1WorkflowWorkflowIdValidatePostError, ValidateWorkflowApiV1WorkflowWorkflowIdValidatePostResponse } from './types.gen'; export type Options = ClientOptions & { /** @@ -34,44 +35,30 @@ export const initiateCallApiV1TelephonyInitiateCallPost = (options: Options) => { - return (options.client ?? _heyApiClient).post({ - url: '/api/v1/telephony/status-callback/{workflow_run_id}', +export const handleTwilioStatusCallbackApiV1TelephonyTwilioStatusCallbackWorkflowRunIdPost = (options: Options) => { + return (options.client ?? _heyApiClient).post({ + url: '/api/v1/telephony/twilio/status-callback/{workflow_run_id}', ...options }); }; /** * Handle Vonage Events - * 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. */ -export const handleVonageEventsApiV1TelephonyEventsWorkflowRunIdPost = (options: Options) => { - return (options.client ?? _heyApiClient).post({ - url: '/api/v1/telephony/events/{workflow_run_id}', +export const handleVonageEventsApiV1TelephonyVonageEventsWorkflowRunIdPost = (options: Options) => { + return (options.client ?? _heyApiClient).post({ + url: '/api/v1/telephony/vonage/events/{workflow_run_id}', ...options }); }; -/** - * Initiate Call - */ -export const initiateCallApiV1TwilioInitiateCallPost = (options: Options) => { - return (options.client ?? _heyApiClient).post({ - url: '/api/v1/twilio/initiate-call', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options?.headers - } - }); -}; - /** * Offer */ @@ -644,10 +631,6 @@ export const getIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGe /** * Get Telephony Configuration * 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. */ export const getTelephonyConfigurationApiV1OrganizationsTelephonyConfigGet = (options?: Options) => { return (options?.client ?? _heyApiClient).get({ @@ -964,4 +947,4 @@ export const healthApiV1HealthGet = (optio url: '/api/v1/health', ...options }); -}; \ No newline at end of file +}; diff --git a/ui/src/client/types.gen.ts b/ui/src/client/types.gen.ts index d6d4aca..c0eb462 100644 --- a/ui/src/client/types.gen.ts +++ b/ui/src/client/types.gen.ts @@ -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 & {}); -}; \ No newline at end of file +};