mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-07 07:55:16 +02:00
Feat/inbound telephony (#113)
* feat: inbound telephony (twilio & vobiz) * chore: add ruff and lint formatting * fix: add missing cloudonix interface compliance implementation
This commit is contained in:
parent
b79bc4221d
commit
97fbd9b37b
22 changed files with 1998 additions and 40 deletions
|
|
@ -0,0 +1,45 @@
|
|||
"""add call_type column to workflow_runs
|
||||
|
||||
Revision ID: b79f19f68157
|
||||
Revises: 488eb58e4e6e
|
||||
Create Date: 2026-01-08 21:20:17.298334
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "b79f19f68157"
|
||||
down_revision: Union[str, None] = "488eb58e4e6e"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Create the workflow_call_type enum
|
||||
sa.Enum("inbound", "outbound", name="workflow_call_type").create(op.get_bind())
|
||||
|
||||
# Add call_type column to workflow_runs table
|
||||
op.add_column(
|
||||
"workflow_runs",
|
||||
sa.Column(
|
||||
"call_type",
|
||||
postgresql.ENUM(
|
||||
"inbound", "outbound", name="workflow_call_type", create_type=False
|
||||
),
|
||||
server_default=sa.text("'outbound'::workflow_call_type"),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop the call_type column
|
||||
op.drop_column("workflow_runs", "call_type")
|
||||
|
||||
# Drop the workflow_call_type enum
|
||||
sa.Enum("inbound", "outbound", name="workflow_call_type").drop(op.get_bind())
|
||||
|
|
@ -51,3 +51,28 @@ SENTRY_DSN = os.getenv("SENTRY_DSN")
|
|||
ENABLE_ARI_STASIS = os.getenv("ENABLE_ARI_STASIS", "false").lower() == "true"
|
||||
SERIALIZE_LOG_OUTPUT = os.getenv("SERIALIZE_LOG_OUTPUT", "false").lower() == "true"
|
||||
ENABLE_TELEMETRY = os.getenv("ENABLE_TELEMETRY", "false").lower() == "true"
|
||||
|
||||
# Country code mapping: ISO country code -> international dialing prefix
|
||||
COUNTRY_CODES = {
|
||||
"US": "1", # United States
|
||||
"CA": "1", # Canada
|
||||
"GB": "44", # United Kingdom
|
||||
"IN": "91", # India
|
||||
"AU": "61", # Australia
|
||||
"DE": "49", # Germany
|
||||
"FR": "33", # France
|
||||
"BR": "55", # Brazil
|
||||
"MX": "52", # Mexico
|
||||
"IT": "39", # Italy
|
||||
"ES": "34", # Spain
|
||||
"NL": "31", # Netherlands
|
||||
"SE": "46", # Sweden
|
||||
"NO": "47", # Norway
|
||||
"DK": "45", # Denmark
|
||||
"FI": "358", # Finland
|
||||
"CH": "41", # Switzerland
|
||||
"AT": "43", # Austria
|
||||
"BE": "32", # Belgium
|
||||
"LU": "352", # Luxembourg
|
||||
"IE": "353", # Ireland
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ from sqlalchemy import (
|
|||
from sqlalchemy.orm import declarative_base, relationship
|
||||
|
||||
from ..enums import (
|
||||
CallType,
|
||||
IntegrationAction,
|
||||
ToolCategory,
|
||||
ToolStatus,
|
||||
|
|
@ -324,6 +325,12 @@ class WorkflowRunModel(Base):
|
|||
Enum(*[mode.value for mode in WorkflowRunMode], name="workflow_run_mode"),
|
||||
nullable=False,
|
||||
)
|
||||
call_type = Column(
|
||||
Enum(*[call_type.value for call_type in CallType], name="workflow_call_type"),
|
||||
nullable=False,
|
||||
default=CallType.OUTBOUND.value,
|
||||
server_default=text("'outbound'::workflow_call_type"),
|
||||
)
|
||||
state = Column(
|
||||
Enum(*[state.value for state in WorkflowRunState], name="workflow_run_state"),
|
||||
nullable=False,
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ from api.db.models import (
|
|||
WorkflowModel,
|
||||
WorkflowRunModel,
|
||||
)
|
||||
from api.enums import StorageBackend
|
||||
from api.enums import CallType, StorageBackend
|
||||
from api.schemas.workflow import WorkflowRunResponseSchema
|
||||
|
||||
|
||||
|
|
@ -25,6 +25,7 @@ class WorkflowRunClient(BaseDBClient):
|
|||
workflow_id: int,
|
||||
mode: str,
|
||||
user_id: int,
|
||||
call_type: CallType = CallType.OUTBOUND,
|
||||
initial_context: dict = None,
|
||||
campaign_id: int = None,
|
||||
queued_run_id: int = None,
|
||||
|
|
@ -80,6 +81,7 @@ class WorkflowRunClient(BaseDBClient):
|
|||
campaign_id=campaign_id,
|
||||
queued_run_id=queued_run_id,
|
||||
storage_backend=current_backend.value,
|
||||
call_type=call_type.value,
|
||||
)
|
||||
session.add(new_run)
|
||||
try:
|
||||
|
|
@ -288,6 +290,7 @@ class WorkflowRunClient(BaseDBClient):
|
|||
"definition_id": run.definition_id,
|
||||
"initial_context": run.initial_context,
|
||||
"gathered_context": run.gathered_context,
|
||||
"call_type": run.call_type,
|
||||
}
|
||||
)
|
||||
for run in result.scalars().all()
|
||||
|
|
|
|||
|
|
@ -12,6 +12,11 @@ class Environment(Enum):
|
|||
TEST = "test"
|
||||
|
||||
|
||||
class CallType(Enum):
|
||||
INBOUND = "inbound"
|
||||
OUTBOUND = "outbound"
|
||||
|
||||
|
||||
class WorkflowRunMode(Enum):
|
||||
TWILIO = "twilio"
|
||||
VONAGE = "vonage"
|
||||
|
|
|
|||
4
api/errors/__init__.py
Normal file
4
api/errors/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
"""
|
||||
Errors package for the Dograh API.
|
||||
Contains centralized error definitions and messages for various domains.
|
||||
"""
|
||||
31
api/errors/telephony_errors.py
Normal file
31
api/errors/telephony_errors.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
"""
|
||||
Telephony error constants and messages for inbound call validation.
|
||||
Centralizes error handling across all telephony providers.
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class TelephonyError(Enum):
|
||||
"""Telephony validation error types"""
|
||||
|
||||
PROVIDER_MISMATCH = "PROVIDER_MISMATCH"
|
||||
WORKFLOW_NOT_FOUND = "WORKFLOW_NOT_FOUND"
|
||||
ACCOUNT_VALIDATION_FAILED = "ACCOUNT_VALIDATION_FAILED"
|
||||
PHONE_NUMBER_NOT_CONFIGURED = "PHONE_NUMBER_NOT_CONFIGURED"
|
||||
SIGNATURE_VALIDATION_FAILED = "SIGNATURE_VALIDATION_FAILED"
|
||||
QUOTA_EXCEEDED = "QUOTA_EXCEEDED"
|
||||
GENERAL_AUTH_FAILED = "GENERAL_AUTH_FAILED"
|
||||
VALID = "VALID"
|
||||
|
||||
|
||||
# Error messages for organizations (debugging-focused)
|
||||
TELEPHONY_ERROR_MESSAGES = {
|
||||
TelephonyError.PROVIDER_MISMATCH: "Configuration error: This phone number is configured for a different telephony provider. Please check your dashboard settings and update your webhook URL configuration.",
|
||||
TelephonyError.WORKFLOW_NOT_FOUND: "Workflow not found. Please verify the workflow ID in your webhook URL is correct and the workflow exists in your dashboard.",
|
||||
TelephonyError.ACCOUNT_VALIDATION_FAILED: "Authentication error: Account credentials do not match. Please verify your account SID configuration in the dashboard matches your telephony provider settings.",
|
||||
TelephonyError.PHONE_NUMBER_NOT_CONFIGURED: "Phone number not configured: This number is not set up for inbound calls in your account. Please add this number to your telephony configuration.",
|
||||
TelephonyError.SIGNATURE_VALIDATION_FAILED: "Security error: Webhook signature validation failed. Please verify your auth token configuration and ensure requests are coming from your telephony provider.",
|
||||
TelephonyError.QUOTA_EXCEEDED: "Service temporarily unavailable: Your account has exceeded usage limits. Please contact your administrator or upgrade your plan to continue receiving calls.",
|
||||
TelephonyError.GENERAL_AUTH_FAILED: "Authentication failed: Please check your webhook URL configuration and ensure your telephony provider settings match your dashboard configuration.",
|
||||
}
|
||||
|
|
@ -1,27 +1,42 @@
|
|||
"""
|
||||
Generic telephony routes that work with any telephony provider.
|
||||
Telephony routes - handles all telephony-related endpoints.
|
||||
Consolidated from split modules for easier maintenance.
|
||||
"""
|
||||
|
||||
import json
|
||||
import random
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Request, WebSocket
|
||||
from loguru import logger
|
||||
from pipecat.utils.context import set_current_run_id
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.future import select
|
||||
from starlette.responses import HTMLResponse
|
||||
|
||||
from api.db import db_client
|
||||
from api.db.models import UserModel
|
||||
from api.enums import WorkflowRunState
|
||||
from api.db.models import OrganizationConfigurationModel, UserModel
|
||||
from api.db.workflow_client import WorkflowClient
|
||||
from api.db.workflow_run_client import WorkflowRunClient
|
||||
from api.enums import CallType, OrganizationConfigurationKey, WorkflowRunState
|
||||
from api.errors.telephony_errors import TelephonyError
|
||||
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.quota_service import check_dograh_quota
|
||||
from api.services.telephony.factory import get_telephony_provider
|
||||
from api.services.quota_service import check_dograh_quota, check_dograh_quota_by_user_id
|
||||
from api.services.telephony.factory import (
|
||||
get_all_telephony_providers,
|
||||
get_telephony_provider,
|
||||
)
|
||||
from api.utils.telephony_helper import (
|
||||
generic_hangup_response,
|
||||
normalize_webhook_data,
|
||||
numbers_match,
|
||||
parse_webhook_request,
|
||||
)
|
||||
from api.utils.tunnel import TunnelURLProvider
|
||||
from pipecat.utils.context import set_current_run_id
|
||||
|
||||
router = APIRouter(prefix="/telephony")
|
||||
|
||||
|
|
@ -122,15 +137,18 @@ async def initiate_call(
|
|||
workflow_run_id = request.workflow_run_id
|
||||
|
||||
if not workflow_run_id:
|
||||
workflow_run_name = f"WR-TEL-{random.randint(1000, 9999)}"
|
||||
numeric_suffix = int(str(uuid.uuid4()).replace("-", "")[:8], 16) % 100000000
|
||||
workflow_run_name = f"WR-TEL-OUT-{numeric_suffix:08d}"
|
||||
workflow_run = await db_client.create_workflow_run(
|
||||
workflow_run_name,
|
||||
request.workflow_id,
|
||||
workflow_run_mode,
|
||||
user_id=user.id,
|
||||
call_type=CallType.OUTBOUND,
|
||||
initial_context={
|
||||
"phone_number": phone_number,
|
||||
"provider": provider.PROVIDER_NAME,
|
||||
},
|
||||
user_id=user.id,
|
||||
)
|
||||
workflow_run_id = workflow_run.id
|
||||
else:
|
||||
|
|
@ -174,6 +192,255 @@ async def initiate_call(
|
|||
return {"message": f"Call initiated successfully with run name {workflow_run_name}"}
|
||||
|
||||
|
||||
async def _verify_organization_phone_number(
|
||||
phone_number: str,
|
||||
organization_id: int,
|
||||
to_country: str = None,
|
||||
from_country: str = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Verify that a phone number belongs to the specified organization.
|
||||
|
||||
Args:
|
||||
phone_number: The phone number to verify
|
||||
organization_id: The organization ID to check against
|
||||
to_country: ISO country code for the called number (e.g., "US", "IN")
|
||||
from_country: ISO country code for the caller (e.g., "IN", "GB")
|
||||
|
||||
Returns:
|
||||
True if the phone number belongs to the organization, False otherwise
|
||||
"""
|
||||
try:
|
||||
async with db_client.async_session() as session:
|
||||
result = await session.execute(
|
||||
select(OrganizationConfigurationModel).where(
|
||||
OrganizationConfigurationModel.organization_id == organization_id,
|
||||
OrganizationConfigurationModel.key
|
||||
== OrganizationConfigurationKey.TELEPHONY_CONFIGURATION.value,
|
||||
)
|
||||
)
|
||||
|
||||
config = result.scalars().first()
|
||||
|
||||
if not config or not config.value:
|
||||
logger.warning(
|
||||
f"No telephony configuration found for organization {organization_id}"
|
||||
)
|
||||
return False
|
||||
|
||||
from_numbers = config.value.get("from_numbers", [])
|
||||
logger.debug(
|
||||
f"Organization {organization_id} has from_numbers: {from_numbers}"
|
||||
)
|
||||
|
||||
for configured_number in from_numbers:
|
||||
if numbers_match(
|
||||
phone_number, configured_number, to_country, from_country
|
||||
):
|
||||
logger.info(
|
||||
f"Phone number {phone_number} verified for organization {organization_id} "
|
||||
f"(matches {configured_number}, to_country={to_country}, from_country={from_country})"
|
||||
)
|
||||
return True
|
||||
|
||||
logger.warning(
|
||||
f"Phone number {phone_number} not found in organization {organization_id} from_numbers: {from_numbers} "
|
||||
f"(to_country={to_country}, from_country={from_country})"
|
||||
)
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error verifying phone number {phone_number} for organization {organization_id}: {e}"
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
async def _detect_provider(webhook_data: dict, headers: dict):
|
||||
"""Detect which telephony provider can handle this webhook"""
|
||||
provider_classes = await get_all_telephony_providers()
|
||||
|
||||
for provider_class in provider_classes:
|
||||
if provider_class.can_handle_webhook(webhook_data, headers):
|
||||
return provider_class
|
||||
|
||||
logger.warning(f"No provider found for webhook data: {webhook_data.keys()}")
|
||||
return None
|
||||
|
||||
|
||||
async def _validate_inbound_request(
|
||||
workflow_id: int,
|
||||
provider_class,
|
||||
normalized_data,
|
||||
webhook_data: dict,
|
||||
webhook_body: str = "",
|
||||
x_twilio_signature: str = None,
|
||||
x_vobiz_signature: str = None,
|
||||
x_vobiz_timestamp: str = None,
|
||||
) -> tuple[bool, TelephonyError, dict, object]:
|
||||
"""
|
||||
Validate all aspects of inbound request.
|
||||
Returns: (is_valid, error_type, workflow_context, provider_instance)
|
||||
"""
|
||||
|
||||
workflow = await db_client.get_workflow(workflow_id)
|
||||
if not workflow:
|
||||
return False, TelephonyError.WORKFLOW_NOT_FOUND, {}, None
|
||||
|
||||
organization_id = workflow.organization_id
|
||||
user_id = workflow.user_id
|
||||
provider = normalized_data.provider
|
||||
|
||||
# Validate provider and account_id
|
||||
validation_result = await _validate_organization_provider_config(
|
||||
organization_id, provider_class, normalized_data.account_id
|
||||
)
|
||||
if validation_result != TelephonyError.VALID:
|
||||
return False, validation_result, {}, None
|
||||
|
||||
# Verify phone number belongs to organization
|
||||
is_valid = await _verify_organization_phone_number(
|
||||
normalized_data.to_number,
|
||||
organization_id,
|
||||
normalized_data.to_country,
|
||||
normalized_data.from_country,
|
||||
)
|
||||
if not is_valid:
|
||||
return False, TelephonyError.PHONE_NUMBER_NOT_CONFIGURED, {}, None
|
||||
|
||||
# Verify webhook signature if provided
|
||||
provider_instance = None
|
||||
if x_twilio_signature or x_vobiz_signature:
|
||||
backend_endpoint = await TunnelURLProvider.get_tunnel_url()
|
||||
webhook_url = (
|
||||
f"https://{backend_endpoint}/api/v1/telephony/inbound/{workflow_id}"
|
||||
)
|
||||
|
||||
# Get the real telephony provider with actual credentials for signature verification
|
||||
provider_instance = await get_telephony_provider(organization_id)
|
||||
|
||||
if provider_class.PROVIDER_NAME == "twilio" and x_twilio_signature:
|
||||
logger.info(f"Verifying Twilio signature for URL: {webhook_url}")
|
||||
signature_valid = await provider_instance.verify_inbound_signature(
|
||||
webhook_url, webhook_data, x_twilio_signature
|
||||
)
|
||||
elif provider_class.PROVIDER_NAME == "vobiz" and x_vobiz_signature:
|
||||
logger.info(f"Verifying Vobiz signature for URL: {webhook_url}")
|
||||
signature_valid = await provider_instance.verify_inbound_signature(
|
||||
webhook_url,
|
||||
webhook_data,
|
||||
x_vobiz_signature,
|
||||
x_vobiz_timestamp,
|
||||
webhook_body,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"No signature validation for provider {provider_class.PROVIDER_NAME}"
|
||||
)
|
||||
signature_valid = True
|
||||
|
||||
logger.info(f"Signature validation result: {signature_valid}")
|
||||
if not signature_valid:
|
||||
return (
|
||||
False,
|
||||
TelephonyError.SIGNATURE_VALIDATION_FAILED,
|
||||
{},
|
||||
provider_instance,
|
||||
)
|
||||
|
||||
# Return success with workflow context
|
||||
workflow_context = {
|
||||
"workflow": workflow,
|
||||
"organization_id": organization_id,
|
||||
"user_id": user_id,
|
||||
"provider": provider,
|
||||
}
|
||||
return (
|
||||
True,
|
||||
"",
|
||||
workflow_context,
|
||||
provider_instance,
|
||||
) # TODO: do we still need instance in the client code
|
||||
|
||||
|
||||
async def _create_inbound_workflow_run(
|
||||
workflow_id: int, user_id: int, provider: str, normalized_data, data_source: str
|
||||
) -> int:
|
||||
"""Create workflow run for inbound call and return run ID"""
|
||||
call_id = normalized_data.call_id
|
||||
numeric_suffix = int(str(uuid.uuid4()).replace("-", "")[:8], 16) % 100000000
|
||||
workflow_run_name = f"WR-TEL-IN-{numeric_suffix:08d}"
|
||||
|
||||
workflow_run = await db_client.create_workflow_run(
|
||||
workflow_run_name,
|
||||
workflow_id,
|
||||
provider, # Use detected provider as mode
|
||||
user_id=user_id,
|
||||
call_type=CallType.INBOUND,
|
||||
initial_context={
|
||||
"caller_number": normalized_data.from_number,
|
||||
"called_number": normalized_data.to_number,
|
||||
"direction": "inbound",
|
||||
"call_id": call_id,
|
||||
"account_id": normalized_data.account_id,
|
||||
"provider": provider,
|
||||
"data_source": data_source,
|
||||
"from_country": normalized_data.from_country,
|
||||
"to_country": normalized_data.to_country,
|
||||
"raw_webhook_data": normalized_data.raw_data,
|
||||
},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Created inbound workflow run {workflow_run.id} for {provider} call {call_id}"
|
||||
)
|
||||
return workflow_run.id
|
||||
|
||||
|
||||
async def _validate_organization_provider_config(
|
||||
organization_id: int, provider_class, account_id: str
|
||||
) -> TelephonyError:
|
||||
"""Validate provider and account_id, returning specific error type"""
|
||||
if not account_id:
|
||||
logger.warning(
|
||||
f"No account_id provided for provider {provider_class.PROVIDER_NAME}"
|
||||
)
|
||||
return TelephonyError.ACCOUNT_VALIDATION_FAILED
|
||||
|
||||
try:
|
||||
config = await db_client.get_configuration(
|
||||
organization_id,
|
||||
OrganizationConfigurationKey.TELEPHONY_CONFIGURATION.value,
|
||||
)
|
||||
|
||||
if not config or not config.value:
|
||||
logger.warning(
|
||||
f"No telephony configuration found for organization {organization_id}"
|
||||
)
|
||||
return TelephonyError.ACCOUNT_VALIDATION_FAILED
|
||||
|
||||
stored_provider = config.value.get("provider")
|
||||
if stored_provider != provider_class.PROVIDER_NAME:
|
||||
logger.warning(
|
||||
f"Provider mismatch: webhook={provider_class.PROVIDER_NAME}, config={stored_provider}"
|
||||
)
|
||||
return TelephonyError.PROVIDER_MISMATCH
|
||||
|
||||
# Use provider-specific validation
|
||||
is_valid = provider_class.validate_account_id(config.value, account_id)
|
||||
if not is_valid:
|
||||
logger.warning(
|
||||
f"Account validation failed for {provider_class.PROVIDER_NAME}: webhook={account_id}"
|
||||
)
|
||||
return TelephonyError.ACCOUNT_VALIDATION_FAILED
|
||||
|
||||
return TelephonyError.VALID
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Exception during account validation: {e}")
|
||||
return TelephonyError.ACCOUNT_VALIDATION_FAILED
|
||||
|
||||
|
||||
@router.post("/twiml", include_in_schema=False)
|
||||
async def handle_twiml_webhook(
|
||||
workflow_id: int, user_id: int, workflow_run_id: int, organization_id: int
|
||||
|
|
@ -250,8 +517,14 @@ async def websocket_endpoint(
|
|||
|
||||
# Extract provider type from workflow run context
|
||||
provider_type = None
|
||||
if workflow_run.gathered_context:
|
||||
provider_type = workflow_run.gathered_context.get("provider")
|
||||
logger.info(
|
||||
f"Workflow run {workflow_run_id} gathered_context: {workflow_run.gathered_context}"
|
||||
)
|
||||
logger.info(f"Workflow run {workflow_run_id} mode: {workflow_run.mode}")
|
||||
|
||||
if workflow_run.initial_context:
|
||||
provider_type = workflow_run.initial_context.get("provider")
|
||||
logger.info(f"Extracted provider_type: {provider_type}")
|
||||
|
||||
if not provider_type:
|
||||
logger.error(
|
||||
|
|
@ -546,21 +819,78 @@ async def handle_vobiz_xml_webhook(
|
|||
async def handle_vobiz_hangup_callback(
|
||||
workflow_run_id: int,
|
||||
request: Request,
|
||||
x_vobiz_signature: Optional[str] = Header(None),
|
||||
x_vobiz_timestamp: Optional[str] = Header(None),
|
||||
):
|
||||
"""Handle Vobiz hangup callback (sent when call ends).
|
||||
|
||||
Vobiz sends callbacks to hangup_url when the call terminates.
|
||||
This includes call duration, status, and billing information.
|
||||
"""
|
||||
# TODO: Remove this debug logging after Vobiz team clarifies webhook authentication
|
||||
# Logging all headers and body to understand what Vobiz actually sends
|
||||
all_headers = dict(request.headers)
|
||||
logger.info(
|
||||
f"[run {workflow_run_id}] Vobiz hangup callback - Headers: {json.dumps(all_headers)}"
|
||||
)
|
||||
|
||||
# Parse the callback data (Vobiz sends form data or JSON)
|
||||
form_data = await request.form()
|
||||
callback_data = dict(form_data)
|
||||
|
||||
# TODO: Remove this debug logging after Vobiz team clarifies webhook authentication
|
||||
logger.info(
|
||||
f"[run {workflow_run_id}] Vobiz hangup callback - Body: {json.dumps(callback_data)}"
|
||||
)
|
||||
logger.info(
|
||||
f"[run {workflow_run_id}] Received Vobiz hangup callback {json.dumps(callback_data)}"
|
||||
)
|
||||
|
||||
# Get workflow run for processing
|
||||
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
|
||||
# Verify signature if provided
|
||||
if x_vobiz_signature:
|
||||
# We need the workflow run to get organization for provider credentials
|
||||
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
|
||||
if not workflow_run:
|
||||
logger.warning(
|
||||
f"[run {workflow_run_id}] Workflow run not found for signature verification"
|
||||
)
|
||||
return {"status": "error", "reason": "workflow_run_not_found"}
|
||||
|
||||
workflow = await db_client.get_workflow_by_id(workflow_run.workflow_id)
|
||||
if not workflow:
|
||||
logger.warning(
|
||||
f"[run {workflow_run_id}] Workflow not found for signature verification"
|
||||
)
|
||||
return {"status": "error", "reason": "workflow_not_found"}
|
||||
|
||||
provider = await get_telephony_provider(workflow.organization_id)
|
||||
|
||||
# Get raw body for signature verification
|
||||
raw_body = await request.body()
|
||||
webhook_body = raw_body.decode("utf-8")
|
||||
|
||||
# Verify signature
|
||||
backend_endpoint = await TunnelURLProvider.get_tunnel_url()
|
||||
webhook_url = f"https://{backend_endpoint}/api/v1/telephony/vobiz/hangup-callback/{workflow_run_id}"
|
||||
|
||||
is_valid = await provider.verify_webhook_signature(
|
||||
webhook_url,
|
||||
callback_data,
|
||||
x_vobiz_signature,
|
||||
x_vobiz_timestamp,
|
||||
webhook_body,
|
||||
)
|
||||
|
||||
if not is_valid:
|
||||
logger.warning(
|
||||
f"[run {workflow_run_id}] Invalid Vobiz hangup callback signature"
|
||||
)
|
||||
return {"status": "error", "reason": "invalid_signature"}
|
||||
|
||||
logger.info(f"[run {workflow_run_id}] Vobiz hangup callback signature verified")
|
||||
else:
|
||||
# Get workflow run for processing (signature verification already got it if needed)
|
||||
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
|
||||
if not workflow_run:
|
||||
logger.warning(
|
||||
f"[run {workflow_run_id}] Workflow run not found for Vobiz hangup callback"
|
||||
|
|
@ -609,21 +939,79 @@ async def handle_vobiz_hangup_callback(
|
|||
async def handle_vobiz_ring_callback(
|
||||
workflow_run_id: int,
|
||||
request: Request,
|
||||
x_vobiz_signature: Optional[str] = Header(None),
|
||||
x_vobiz_timestamp: Optional[str] = Header(None),
|
||||
):
|
||||
"""Handle Vobiz ring callback (sent when call starts ringing).
|
||||
|
||||
Vobiz can send callbacks to ring_url when the call starts ringing.
|
||||
This is optional and used for tracking ringing status.
|
||||
"""
|
||||
# TODO: Remove this debug logging after Vobiz team clarifies webhook authentication
|
||||
# Logging all headers and body to understand what Vobiz actually sends
|
||||
all_headers = dict(request.headers)
|
||||
logger.info(
|
||||
f"[run {workflow_run_id}] Vobiz ring callback - Headers: {json.dumps(all_headers)}"
|
||||
)
|
||||
|
||||
# Parse the callback data
|
||||
form_data = await request.form()
|
||||
callback_data = dict(form_data)
|
||||
|
||||
# TODO: Remove this debug logging after Vobiz team clarifies webhook authentication
|
||||
logger.info(
|
||||
f"[run {workflow_run_id}] Vobiz ring callback - Body: {json.dumps(callback_data)}"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"[run {workflow_run_id}] Received Vobiz ring callback {json.dumps(callback_data)}"
|
||||
)
|
||||
|
||||
# Get workflow run for processing
|
||||
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
|
||||
# Verify signature if provided
|
||||
if x_vobiz_signature:
|
||||
# We need the workflow run to get organization for provider credentials
|
||||
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
|
||||
if not workflow_run:
|
||||
logger.warning(
|
||||
f"[run {workflow_run_id}] Workflow run not found for signature verification"
|
||||
)
|
||||
return {"status": "error", "reason": "workflow_run_not_found"}
|
||||
|
||||
workflow = await db_client.get_workflow_by_id(workflow_run.workflow_id)
|
||||
if not workflow:
|
||||
logger.warning(
|
||||
f"[run {workflow_run_id}] Workflow not found for signature verification"
|
||||
)
|
||||
return {"status": "error", "reason": "workflow_not_found"}
|
||||
|
||||
provider = await get_telephony_provider(workflow.organization_id)
|
||||
|
||||
# Get raw body for signature verification
|
||||
raw_body = await request.body()
|
||||
webhook_body = raw_body.decode("utf-8")
|
||||
|
||||
# Verify signature
|
||||
backend_endpoint = await TunnelURLProvider.get_tunnel_url()
|
||||
webhook_url = f"https://{backend_endpoint}/api/v1/telephony/vobiz/ring-callback/{workflow_run_id}"
|
||||
|
||||
is_valid = await provider.verify_webhook_signature(
|
||||
webhook_url,
|
||||
callback_data,
|
||||
x_vobiz_signature,
|
||||
x_vobiz_timestamp,
|
||||
webhook_body,
|
||||
)
|
||||
|
||||
if not is_valid:
|
||||
logger.warning(
|
||||
f"[run {workflow_run_id}] Invalid Vobiz ring callback signature"
|
||||
)
|
||||
return {"status": "error", "reason": "invalid_signature"}
|
||||
|
||||
logger.info(f"[run {workflow_run_id}] Vobiz ring callback signature verified")
|
||||
else:
|
||||
# Get workflow run for processing (signature verification already got it if needed)
|
||||
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
|
||||
if not workflow_run:
|
||||
logger.warning(
|
||||
f"[run {workflow_run_id}] Workflow run not found for Vobiz ring callback"
|
||||
|
|
@ -707,3 +1095,272 @@ async def handle_cloudonix_status_callback(
|
|||
await _process_status_update(workflow_run_id, status_update, workflow_run)
|
||||
|
||||
return {"status": "success"}
|
||||
|
||||
|
||||
@router.post("/vobiz/hangup-callback/workflow/{workflow_id}")
|
||||
async def handle_vobiz_hangup_callback_by_workflow(
|
||||
workflow_id: int,
|
||||
request: Request,
|
||||
x_vobiz_signature: Optional[str] = Header(None),
|
||||
x_vobiz_timestamp: Optional[str] = Header(None),
|
||||
):
|
||||
"""Handle Vobiz hangup callback with workflow_id - finds workflow run by call_id."""
|
||||
|
||||
all_headers = dict(request.headers)
|
||||
logger.info(
|
||||
f"[workflow {workflow_id}] Vobiz hangup callback - Headers: {json.dumps(all_headers)}"
|
||||
)
|
||||
|
||||
try:
|
||||
callback_data, _ = await parse_webhook_request(request)
|
||||
except ValueError:
|
||||
callback_data = {}
|
||||
|
||||
call_uuid = callback_data.get("CallUUID") or callback_data.get("call_uuid")
|
||||
logger.info(
|
||||
f"[workflow {workflow_id}] Received Vobiz hangup callback for call {call_uuid}: {json.dumps(callback_data)}"
|
||||
)
|
||||
|
||||
if not call_uuid:
|
||||
logger.warning(
|
||||
f"[workflow {workflow_id}] No call_uuid found in Vobiz hangup callback"
|
||||
)
|
||||
return {"status": "error", "message": "No call_uuid found"}
|
||||
|
||||
workflow_client = WorkflowClient()
|
||||
workflow = await workflow_client.get_workflow_by_id(workflow_id)
|
||||
if not workflow:
|
||||
logger.warning(f"[workflow {workflow_id}] Workflow not found")
|
||||
return {"status": "error", "message": "workflow_not_found"}
|
||||
|
||||
provider = await get_telephony_provider(workflow.organization_id)
|
||||
|
||||
if x_vobiz_signature:
|
||||
raw_body = await request.body()
|
||||
webhook_body = raw_body.decode("utf-8")
|
||||
backend_endpoint = await TunnelURLProvider.get_tunnel_url()
|
||||
webhook_url = f"https://{backend_endpoint}/api/v1/telephony/vobiz/hangup-callback/workflow/{workflow_id}"
|
||||
|
||||
is_valid = await provider.verify_webhook_signature(
|
||||
webhook_url,
|
||||
callback_data,
|
||||
x_vobiz_signature,
|
||||
x_vobiz_timestamp,
|
||||
webhook_body,
|
||||
)
|
||||
|
||||
if not is_valid:
|
||||
logger.warning(
|
||||
f"[workflow {workflow_id}] Invalid Vobiz hangup callback signature"
|
||||
)
|
||||
return {"status": "error", "message": "invalid_signature"}
|
||||
|
||||
logger.info(
|
||||
f"[workflow {workflow_id}] Vobiz hangup callback signature verified"
|
||||
)
|
||||
|
||||
try:
|
||||
db_client = WorkflowRunClient()
|
||||
async with db_client.async_session() as session:
|
||||
# Fetch workflow run with matching call_id in initial_context
|
||||
query = text("""
|
||||
SELECT id FROM workflow_runs
|
||||
WHERE workflow_id = :workflow_id
|
||||
AND CAST(initial_context AS jsonb) @> CAST(:call_id_json AS jsonb)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
""")
|
||||
|
||||
result = await session.execute(
|
||||
query,
|
||||
{
|
||||
"workflow_id": workflow_id,
|
||||
"call_id_json": json.dumps({"call_id": call_uuid}),
|
||||
},
|
||||
)
|
||||
workflow_run_row = result.fetchone()
|
||||
|
||||
if not workflow_run_row:
|
||||
logger.warning(
|
||||
f"[workflow {workflow_id}] No workflow run found for call {call_uuid}"
|
||||
)
|
||||
return {"status": "ignored", "reason": "workflow_run_not_found"}
|
||||
|
||||
workflow_run_id = workflow_run_row[0]
|
||||
logger.info(
|
||||
f"[workflow {workflow_id}] Found workflow run {workflow_run_id} for call {call_uuid}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"[workflow {workflow_id}] Error finding workflow run for call {call_uuid}: {e}"
|
||||
)
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
try:
|
||||
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
|
||||
if not workflow_run:
|
||||
logger.warning(f"[run {workflow_run_id}] Workflow run not found")
|
||||
return {"status": "ignored", "reason": "workflow_run_not_found"}
|
||||
|
||||
parsed_data = provider.parse_status_callback(callback_data)
|
||||
|
||||
status = 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", {}),
|
||||
)
|
||||
|
||||
await _process_status_update(workflow_run_id, status, workflow_run)
|
||||
|
||||
logger.info(
|
||||
f"[run {workflow_run_id}] Vobiz hangup callback processed successfully"
|
||||
)
|
||||
return {"status": "success"}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"[run {workflow_run_id}] Error processing Vobiz hangup callback: {e}"
|
||||
)
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
|
||||
@router.post("/inbound/{workflow_id}")
|
||||
async def handle_inbound_telephony(
|
||||
workflow_id: int,
|
||||
request: Request,
|
||||
x_twilio_signature: Optional[str] = Header(None),
|
||||
x_vobiz_signature: Optional[str] = Header(None),
|
||||
x_vobiz_timestamp: Optional[str] = Header(None),
|
||||
):
|
||||
"""Handle inbound telephony calls from any supported provider with common processing"""
|
||||
logger.info(f"Inbound call received for workflow_id: {workflow_id}")
|
||||
|
||||
try:
|
||||
webhook_data, data_source = await parse_webhook_request(request)
|
||||
headers = dict(request.headers)
|
||||
|
||||
# Detect provider and normalize data
|
||||
provider_class = await _detect_provider(webhook_data, headers)
|
||||
if not provider_class:
|
||||
logger.error("Unable to detect provider for webhook")
|
||||
return generic_hangup_response()
|
||||
|
||||
normalized_data = normalize_webhook_data(provider_class, webhook_data)
|
||||
|
||||
logger.info(
|
||||
f"Inbound call - Provider: {normalized_data.provider}, Data source: {data_source}"
|
||||
)
|
||||
logger.info(f"Normalized data: {normalized_data}")
|
||||
|
||||
# Validate inbound direction
|
||||
if normalized_data.direction != "inbound":
|
||||
logger.warning(f"Non-inbound call received: {normalized_data.direction}")
|
||||
return generic_hangup_response()
|
||||
|
||||
logger.info(f"Inbound call headers: {dict(request.headers)}")
|
||||
logger.info(f"Twilio signature header: {x_twilio_signature}")
|
||||
logger.info(f"Vobiz signature header: {x_vobiz_signature}")
|
||||
logger.info(f"Vobiz timestamp header: {x_vobiz_timestamp}")
|
||||
|
||||
webhook_body = ""
|
||||
if provider_class.PROVIDER_NAME == "vobiz":
|
||||
webhook_body = data_source
|
||||
logger.info(f"Vobiz inbound call - Body: {json.dumps(webhook_data)}")
|
||||
|
||||
(
|
||||
is_valid,
|
||||
error_type,
|
||||
workflow_context,
|
||||
provider_instance,
|
||||
) = await _validate_inbound_request(
|
||||
workflow_id,
|
||||
provider_class,
|
||||
normalized_data,
|
||||
webhook_data,
|
||||
webhook_body,
|
||||
x_twilio_signature,
|
||||
x_vobiz_signature,
|
||||
x_vobiz_timestamp,
|
||||
)
|
||||
|
||||
if not is_valid:
|
||||
logger.error(f"Request validation failed: {error_type}")
|
||||
return provider_class.generate_validation_error_response(error_type)
|
||||
|
||||
# Check quota before processing
|
||||
user_id = workflow_context["user_id"]
|
||||
quota_result = await check_dograh_quota_by_user_id(user_id)
|
||||
if not quota_result.has_quota:
|
||||
logger.warning(
|
||||
f"User {user_id} has exceeded quota for inbound calls: {quota_result.error_message}"
|
||||
)
|
||||
return provider_class.generate_validation_error_response(
|
||||
TelephonyError.QUOTA_EXCEEDED
|
||||
)
|
||||
|
||||
# Create workflow run
|
||||
workflow_run_id = await _create_inbound_workflow_run(
|
||||
workflow_id,
|
||||
workflow_context["user_id"],
|
||||
workflow_context["provider"],
|
||||
normalized_data,
|
||||
data_source,
|
||||
)
|
||||
|
||||
# Generate response URLs
|
||||
backend_endpoint = await TunnelURLProvider.get_tunnel_url()
|
||||
websocket_url = f"wss://{backend_endpoint}/api/v1/telephony/ws/{workflow_id}/{workflow_context['user_id']}/{workflow_run_id}"
|
||||
response = await provider_class.generate_inbound_response(
|
||||
websocket_url, workflow_run_id
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Generated {normalized_data.provider} response for call {normalized_data.call_id}"
|
||||
)
|
||||
return response
|
||||
|
||||
except ValueError as e:
|
||||
logger.error(f"Request parsing error: {e}")
|
||||
return generic_hangup_response()
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing inbound call: {e}")
|
||||
return generic_hangup_response()
|
||||
|
||||
|
||||
@router.post("/inbound/fallback")
|
||||
async def handle_inbound_fallback(request: Request):
|
||||
"""Fallback endpoint that returns audio message when calls cannot be processed."""
|
||||
|
||||
webhook_data, _ = await parse_webhook_request(request)
|
||||
headers = dict(request.headers)
|
||||
|
||||
# Detect provider
|
||||
provider_class = await _detect_provider(webhook_data, headers)
|
||||
|
||||
if provider_class:
|
||||
# Use provider-specific error response
|
||||
call_id = (
|
||||
webhook_data.get("CallSid")
|
||||
or webhook_data.get("CallUUID")
|
||||
or webhook_data.get("call_uuid")
|
||||
)
|
||||
logger.info(
|
||||
f"[fallback] Received {provider_class.PROVIDER_NAME} callback for call {call_id}: {json.dumps(webhook_data)}"
|
||||
)
|
||||
|
||||
return provider_class.generate_error_response(
|
||||
"SYSTEM_UNAVAILABLE",
|
||||
"Our system is temporarily unavailable. Please try again later.",
|
||||
)
|
||||
else:
|
||||
# Unknown provider - return generic XML
|
||||
logger.info(
|
||||
f"[fallback] Received unknown provider callback: {json.dumps(webhook_data)} and request headers: {json.dumps(headers)}"
|
||||
)
|
||||
|
||||
return generic_hangup_response()
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ from api.constants import DEPLOYMENT_MODE
|
|||
from api.db import db_client
|
||||
from api.db.models import UserModel
|
||||
from api.db.workflow_template_client import WorkflowTemplateClient
|
||||
from api.enums import CallType
|
||||
from api.schemas.workflow import WorkflowRunResponseSchema
|
||||
from api.services.auth.depends import get_user
|
||||
from api.services.mps_service_key_client import mps_service_key_client
|
||||
|
|
@ -141,7 +142,7 @@ class CreateWorkflowRunResponse(BaseModel):
|
|||
|
||||
|
||||
class CreateWorkflowTemplateRequest(BaseModel):
|
||||
call_type: Literal["INBOUND", "OUTBOUND"]
|
||||
call_type: Literal[CallType.INBOUND.value, CallType.OUTBOUND.value]
|
||||
use_case: str
|
||||
activity_description: str
|
||||
|
||||
|
|
@ -289,7 +290,7 @@ async def create_workflow_from_template(
|
|||
# Call MPS API to generate workflow using the client
|
||||
if DEPLOYMENT_MODE == "oss":
|
||||
workflow_data = await mps_service_key_client.call_workflow_api(
|
||||
call_type=request.call_type,
|
||||
call_type=request.call_type.upper(),
|
||||
use_case=request.use_case,
|
||||
activity_description=request.activity_description,
|
||||
created_by=str(user.provider_id),
|
||||
|
|
@ -299,7 +300,7 @@ async def create_workflow_from_template(
|
|||
raise HTTPException(status_code=400, detail="No organization selected")
|
||||
|
||||
workflow_data = await mps_service_key_client.call_workflow_api(
|
||||
call_type=request.call_type,
|
||||
call_type=request.call_type.upper(),
|
||||
use_case=request.use_case,
|
||||
activity_description=request.activity_description,
|
||||
organization_id=user.selected_organization_id,
|
||||
|
|
@ -609,6 +610,7 @@ async def get_workflow_run(
|
|||
"definition_id": run.definition_id,
|
||||
"initial_context": run.initial_context,
|
||||
"gathered_context": run.gathered_context,
|
||||
"call_type": run.call_type,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ from typing import Any, Dict
|
|||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from api.enums import CallType
|
||||
|
||||
|
||||
class WorkflowRunResponseSchema(BaseModel):
|
||||
id: int
|
||||
|
|
@ -17,3 +19,4 @@ class WorkflowRunResponseSchema(BaseModel):
|
|||
definition_id: int | None # This is for backward compatibility
|
||||
initial_context: dict | None = None
|
||||
gathered_context: dict | None = None
|
||||
call_type: CallType
|
||||
|
|
|
|||
|
|
@ -26,6 +26,22 @@ class CallInitiationResult:
|
|||
) # Full provider response for debugging
|
||||
|
||||
|
||||
@dataclass
|
||||
class NormalizedInboundData:
|
||||
"""Standardized inbound call data across all providers."""
|
||||
|
||||
provider: str # Provider name (twilio, vobiz, etc.)
|
||||
call_id: str # Provider's call identifier
|
||||
from_number: str # Caller phone number (E.164 format)
|
||||
to_number: str # Called phone number (E.164 format)
|
||||
direction: str # Call direction (should be "inbound")
|
||||
call_status: str # Call status (ringing, answered, etc.)
|
||||
account_id: Optional[str] = None # Provider account ID
|
||||
from_country: Optional[str] = None # Country code of caller
|
||||
to_country: Optional[str] = None # Country code of called number
|
||||
raw_data: Dict[str, Any] = field(default_factory=dict) # Original webhook data
|
||||
|
||||
|
||||
class TelephonyProvider(ABC):
|
||||
"""
|
||||
Abstract base class for telephony providers.
|
||||
|
|
@ -181,3 +197,109 @@ class TelephonyProvider(ABC):
|
|||
workflow_run_id: The workflow run ID
|
||||
"""
|
||||
pass
|
||||
|
||||
# ======== INBOUND CALL METHODS ========
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def can_handle_webhook(
|
||||
cls, webhook_data: Dict[str, Any], headers: Dict[str, str]
|
||||
) -> bool:
|
||||
"""
|
||||
Determine if this provider can handle the incoming webhook.
|
||||
|
||||
Args:
|
||||
webhook_data: The parsed webhook payload
|
||||
headers: HTTP headers from the webhook request
|
||||
|
||||
Returns:
|
||||
True if this provider should handle this webhook, False otherwise
|
||||
"""
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
@abstractmethod
|
||||
def parse_inbound_webhook(webhook_data: Dict[str, Any]) -> NormalizedInboundData:
|
||||
"""
|
||||
Parse provider-specific inbound webhook data into normalized format.
|
||||
|
||||
Args:
|
||||
webhook_data: Raw webhook data from the provider
|
||||
|
||||
Returns:
|
||||
NormalizedInboundData with standardized fields
|
||||
"""
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
@abstractmethod
|
||||
def validate_account_id(config_data: dict, webhook_account_id: str) -> bool:
|
||||
"""
|
||||
Validate that the account_id from webhook matches the provider configuration.
|
||||
|
||||
Args:
|
||||
config_data: Provider configuration data from organization
|
||||
webhook_account_id: Account ID from the webhook
|
||||
|
||||
Returns:
|
||||
True if account_id matches, False otherwise
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def normalize_phone_number(self, phone_number: str) -> str:
|
||||
"""
|
||||
Normalize a phone number to E.164 format for this provider.
|
||||
|
||||
Args:
|
||||
phone_number: Raw phone number from webhook
|
||||
|
||||
Returns:
|
||||
Phone number in E.164 format (+country_code_number)
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def verify_inbound_signature(
|
||||
self, url: str, webhook_data: Dict[str, Any], signature: str
|
||||
) -> bool:
|
||||
"""
|
||||
Verify the signature of an inbound webhook for security.
|
||||
|
||||
Args:
|
||||
url: The full webhook URL
|
||||
webhook_data: The webhook payload
|
||||
signature: The signature header from the provider
|
||||
|
||||
Returns:
|
||||
True if signature is valid, False otherwise
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def generate_inbound_response(self, websocket_url: str) -> tuple:
|
||||
"""
|
||||
Generate the appropriate response for an inbound webhook.
|
||||
|
||||
Args:
|
||||
websocket_url: WebSocket URL for audio streaming
|
||||
|
||||
Returns:
|
||||
Tuple of (Response, media_type) - Response object and content type
|
||||
"""
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
@abstractmethod
|
||||
def generate_error_response(error_type: str, message: str) -> tuple:
|
||||
"""
|
||||
Generate a provider-specific error response.
|
||||
|
||||
Args:
|
||||
error_type: Type of error (auth_failed, not_configured, etc.)
|
||||
message: Error message
|
||||
|
||||
Returns:
|
||||
Tuple of (Response, media_type) - Response object and content type
|
||||
"""
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ Handles configuration loading from environment (OSS) or database (SaaS).
|
|||
The providers themselves don't know or care where config comes from.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict
|
||||
from typing import Any, Dict, List, Type
|
||||
|
||||
from loguru import logger
|
||||
|
||||
|
|
@ -116,3 +116,13 @@ async def get_telephony_provider(organization_id: int) -> TelephonyProvider:
|
|||
|
||||
else:
|
||||
raise ValueError(f"Unknown telephony provider: {provider_type}")
|
||||
|
||||
|
||||
async def get_all_telephony_providers() -> List[Type[TelephonyProvider]]:
|
||||
"""
|
||||
Get all available telephony provider classes for webhook detection.
|
||||
|
||||
Returns:
|
||||
List of provider classes that can be used for webhook detection
|
||||
"""
|
||||
return [TwilioProvider, VobizProvider, VonageProvider]
|
||||
|
|
|
|||
|
|
@ -10,7 +10,11 @@ import aiohttp
|
|||
from loguru import logger
|
||||
|
||||
from api.enums import WorkflowRunMode
|
||||
from api.services.telephony.base import CallInitiationResult, TelephonyProvider
|
||||
from api.services.telephony.base import (
|
||||
CallInitiationResult,
|
||||
NormalizedInboundData,
|
||||
TelephonyProvider,
|
||||
)
|
||||
from api.utils.tunnel import TunnelURLProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -416,3 +420,170 @@ class CloudonixProvider(TelephonyProvider):
|
|||
except Exception as e:
|
||||
logger.error(f"Error in Cloudonix WebSocket handler: {e}")
|
||||
raise
|
||||
|
||||
# ======== INBOUND CALL METHODS ========
|
||||
|
||||
@classmethod
|
||||
def can_handle_webhook(
|
||||
cls, webhook_data: Dict[str, Any], headers: Dict[str, str]
|
||||
) -> bool:
|
||||
"""
|
||||
Determine if this provider can handle the incoming webhook.
|
||||
|
||||
Cloudonix uses TwiML-compatible format, so look for Twilio-like identifiers
|
||||
but also check for Cloudonix-specific headers or fields if they exist.
|
||||
"""
|
||||
# Check for Cloudonix-specific headers
|
||||
if headers.get("User-Agent", "").lower().startswith("cloudonix"):
|
||||
return True
|
||||
|
||||
# Check for session token (Cloudonix equivalent of CallSid)
|
||||
if "token" in webhook_data or "session_token" in webhook_data:
|
||||
return True
|
||||
|
||||
# If it looks like TwiML format but no other providers claimed it,
|
||||
# it could be Cloudonix (TwiML-compatible)
|
||||
if "CallSid" in webhook_data and "AccountSid" in webhook_data:
|
||||
# Let Twilio provider handle this first, only handle if unclaimed
|
||||
return False
|
||||
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def parse_inbound_webhook(webhook_data: Dict[str, Any]) -> NormalizedInboundData:
|
||||
"""
|
||||
Parse Cloudonix-specific inbound webhook data into normalized format.
|
||||
|
||||
Cloudonix is TwiML-compatible so the webhook format should be similar to Twilio.
|
||||
"""
|
||||
return NormalizedInboundData(
|
||||
provider=CloudonixProvider.PROVIDER_NAME,
|
||||
call_id=webhook_data.get("token") or webhook_data.get("CallSid", ""),
|
||||
from_number=webhook_data.get("From", ""),
|
||||
to_number=webhook_data.get("To", ""),
|
||||
direction="inbound", # This is an inbound webhook
|
||||
call_status=webhook_data.get("CallStatus", "ringing"),
|
||||
account_id=webhook_data.get("AccountSid") or webhook_data.get("domain_id"),
|
||||
from_country=webhook_data.get("FromCountry"),
|
||||
to_country=webhook_data.get("ToCountry"),
|
||||
raw_data=webhook_data,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def validate_account_id(config_data: dict, webhook_account_id: str) -> bool:
|
||||
"""
|
||||
Validate that the account_id from webhook matches the Cloudonix configuration.
|
||||
"""
|
||||
if not webhook_account_id:
|
||||
return False
|
||||
|
||||
# Cloudonix uses domain_id as the account identifier
|
||||
stored_domain_id = config_data.get("domain_id")
|
||||
if not stored_domain_id:
|
||||
return False
|
||||
|
||||
return webhook_account_id == stored_domain_id
|
||||
|
||||
def normalize_phone_number(self, phone_number: str) -> str:
|
||||
"""
|
||||
Normalize a phone number to E.164 format for Cloudonix.
|
||||
|
||||
Cloudonix typically provides numbers in E.164 format already,
|
||||
but we'll ensure proper formatting.
|
||||
"""
|
||||
if not phone_number:
|
||||
return ""
|
||||
|
||||
# Remove any spaces or formatting
|
||||
clean_number = (
|
||||
phone_number.replace(" ", "")
|
||||
.replace("-", "")
|
||||
.replace("(", "")
|
||||
.replace(")", "")
|
||||
)
|
||||
|
||||
# If already in E.164 format (+...), return as-is
|
||||
if clean_number.startswith("+"):
|
||||
return clean_number
|
||||
|
||||
# If starts with country code but no +, add it
|
||||
if len(clean_number) >= 10:
|
||||
return f"+{clean_number}"
|
||||
|
||||
return clean_number
|
||||
|
||||
async def verify_inbound_signature(
|
||||
self, url: str, webhook_data: Dict[str, Any], signature: str
|
||||
) -> bool:
|
||||
"""
|
||||
Verify the signature of an inbound Cloudonix webhook for security.
|
||||
|
||||
Note: Cloudonix signature verification details need to be implemented
|
||||
based on their specific authentication method. For now, we'll log
|
||||
and return True (similar to current webhook verification).
|
||||
"""
|
||||
logger.info(
|
||||
f"Cloudonix inbound signature verification not fully implemented. "
|
||||
f"Webhook URL: {url}, Signature present: {bool(signature)}"
|
||||
)
|
||||
|
||||
# TODO: Implement actual Cloudonix signature verification
|
||||
# This would depend on Cloudonix's specific signing method
|
||||
return True
|
||||
|
||||
def generate_inbound_response(self, websocket_url: str) -> tuple:
|
||||
"""
|
||||
Generate the appropriate TwiML response for an inbound Cloudonix webhook.
|
||||
|
||||
Since Cloudonix is TwiML-compatible, we generate TwiML response.
|
||||
"""
|
||||
from fastapi import Response
|
||||
|
||||
# Generate TwiML response to connect to WebSocket
|
||||
twiml = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Response>
|
||||
<Connect>
|
||||
<Stream url="{websocket_url}"></Stream>
|
||||
</Connect>
|
||||
<Pause length="40"/>
|
||||
</Response>"""
|
||||
|
||||
return Response(content=twiml, media_type="application/xml"), "application/xml"
|
||||
|
||||
@staticmethod
|
||||
def generate_error_response(error_type: str, message: str) -> tuple:
|
||||
"""
|
||||
Generate a Cloudonix-specific error response.
|
||||
|
||||
Since Cloudonix is TwiML-compatible, we use TwiML format.
|
||||
"""
|
||||
from fastapi import Response
|
||||
|
||||
# Map error types to appropriate TwiML responses
|
||||
if error_type == "auth_failed":
|
||||
twiml = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Response>
|
||||
<Say>Authentication failed. This call cannot be processed.</Say>
|
||||
<Hangup/>
|
||||
</Response>"""
|
||||
elif error_type == "not_configured":
|
||||
twiml = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Response>
|
||||
<Say>Service not configured. Please contact support.</Say>
|
||||
<Hangup/>
|
||||
</Response>"""
|
||||
elif error_type == "invalid_number":
|
||||
twiml = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Response>
|
||||
<Say>Invalid phone number. This call cannot be processed.</Say>
|
||||
<Hangup/>
|
||||
</Response>"""
|
||||
else:
|
||||
# Generic error
|
||||
twiml = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Response>
|
||||
<Say>An error occurred: {message}</Say>
|
||||
<Hangup/>
|
||||
</Response>"""
|
||||
|
||||
return Response(content=twiml, media_type="application/xml"), "application/xml"
|
||||
|
|
|
|||
|
|
@ -11,7 +11,11 @@ from loguru import logger
|
|||
from twilio.request_validator import RequestValidator
|
||||
|
||||
from api.enums import WorkflowRunMode
|
||||
from api.services.telephony.base import CallInitiationResult, TelephonyProvider
|
||||
from api.services.telephony.base import (
|
||||
CallInitiationResult,
|
||||
NormalizedInboundData,
|
||||
TelephonyProvider,
|
||||
)
|
||||
from api.utils.tunnel import TunnelURLProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -284,3 +288,139 @@ class TwilioProvider(TelephonyProvider):
|
|||
except Exception as e:
|
||||
logger.error(f"Error in Twilio WebSocket handler: {e}")
|
||||
raise
|
||||
|
||||
# ======== INBOUND CALL METHODS ========
|
||||
|
||||
@classmethod
|
||||
def can_handle_webhook(
|
||||
cls, webhook_data: Dict[str, Any], headers: Dict[str, str]
|
||||
) -> bool:
|
||||
"""
|
||||
Determine if this provider can handle the incoming webhook.
|
||||
Twilio webhooks contain CallSid field.
|
||||
"""
|
||||
return "CallSid" in webhook_data
|
||||
|
||||
@staticmethod
|
||||
def parse_inbound_webhook(webhook_data: Dict[str, Any]) -> NormalizedInboundData:
|
||||
"""
|
||||
Parse Twilio-specific inbound webhook data into normalized format.
|
||||
"""
|
||||
return NormalizedInboundData(
|
||||
provider=TwilioProvider.PROVIDER_NAME,
|
||||
call_id=webhook_data.get("CallSid", ""),
|
||||
from_number=TwilioProvider.normalize_phone_number(
|
||||
webhook_data.get("From", "")
|
||||
),
|
||||
to_number=TwilioProvider.normalize_phone_number(webhook_data.get("To", "")),
|
||||
direction=webhook_data.get("Direction", ""),
|
||||
call_status=webhook_data.get("CallStatus", ""),
|
||||
account_id=webhook_data.get("AccountSid"),
|
||||
from_country=webhook_data.get("FromCountry")
|
||||
or webhook_data.get("CallerCountry"),
|
||||
to_country=webhook_data.get("ToCountry")
|
||||
or webhook_data.get("CalledCountry"),
|
||||
raw_data=webhook_data,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def normalize_phone_number(phone_number: str) -> str:
|
||||
"""
|
||||
Normalize a phone number to E.164 format for Twilio.
|
||||
Twilio already provides numbers in E.164 format.
|
||||
"""
|
||||
if not phone_number:
|
||||
return ""
|
||||
|
||||
# Twilio numbers are already in E.164 format (+1234567890)
|
||||
if phone_number.startswith("+"):
|
||||
return phone_number
|
||||
|
||||
# If for some reason it doesn't have +, assume US and add +1
|
||||
if phone_number.startswith("1") and len(phone_number) == 11:
|
||||
return f"+{phone_number}"
|
||||
elif len(phone_number) == 10:
|
||||
return f"+1{phone_number}"
|
||||
|
||||
return phone_number
|
||||
|
||||
@staticmethod
|
||||
def validate_account_id(config_data: dict, webhook_account_id: str) -> bool:
|
||||
"""Validate Twilio account_sid from webhook matches configuration"""
|
||||
if not webhook_account_id:
|
||||
return False
|
||||
|
||||
stored_account_sid = config_data.get("account_sid")
|
||||
return stored_account_sid == webhook_account_id
|
||||
|
||||
async def verify_inbound_signature(
|
||||
self, url: str, webhook_data: Dict[str, Any], signature: str
|
||||
) -> bool:
|
||||
"""
|
||||
Verify the signature of an inbound Twilio webhook for security.
|
||||
"""
|
||||
return await self.verify_webhook_signature(url, webhook_data, signature)
|
||||
|
||||
@staticmethod
|
||||
async def generate_inbound_response(
|
||||
websocket_url: str, workflow_run_id: int = None
|
||||
) -> tuple:
|
||||
"""
|
||||
Generate TwiML response for an inbound Twilio webhook.
|
||||
|
||||
Uses the same StatusCallback URL pattern as outbound calls for consistency.
|
||||
"""
|
||||
from fastapi import Response
|
||||
|
||||
# Generate StatusCallback URL using same pattern as outbound calls
|
||||
status_callback_attr = ""
|
||||
if workflow_run_id:
|
||||
backend_endpoint = await TunnelURLProvider.get_tunnel_url()
|
||||
status_callback_url = f"https://{backend_endpoint}/api/v1/telephony/twilio/status-callback/{workflow_run_id}"
|
||||
status_callback_attr = f' statusCallback="{status_callback_url}"'
|
||||
|
||||
twiml_content = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Response>
|
||||
<Connect>
|
||||
<Stream url="{websocket_url}"{status_callback_attr}></Stream>
|
||||
</Connect>
|
||||
<Pause length="40"/>
|
||||
</Response>"""
|
||||
|
||||
return Response(content=twiml_content, media_type="application/xml")
|
||||
|
||||
@staticmethod
|
||||
def generate_error_response(error_type: str, message: str) -> tuple:
|
||||
"""
|
||||
Generate a Twilio-specific error response.
|
||||
"""
|
||||
from fastapi import Response
|
||||
|
||||
twiml_content = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Response>
|
||||
<Say voice="alice">Sorry, there was an error processing your call. {message}</Say>
|
||||
<Hangup/>
|
||||
</Response>"""
|
||||
|
||||
return Response(content=twiml_content, media_type="application/xml")
|
||||
|
||||
@staticmethod
|
||||
def generate_validation_error_response(error_type) -> tuple:
|
||||
"""
|
||||
Generate Twilio-specific error response for validation failures with organizational debugging info.
|
||||
"""
|
||||
from fastapi import Response
|
||||
|
||||
from api.errors.telephony_errors import TELEPHONY_ERROR_MESSAGES, TelephonyError
|
||||
|
||||
message = TELEPHONY_ERROR_MESSAGES.get(
|
||||
error_type, TELEPHONY_ERROR_MESSAGES[TelephonyError.GENERAL_AUTH_FAILED]
|
||||
)
|
||||
|
||||
twiml_content = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Response>
|
||||
<Say voice="alice">{message}</Say>
|
||||
<Hangup/>
|
||||
</Response>"""
|
||||
|
||||
return Response(content=twiml_content, media_type="application/xml")
|
||||
|
|
|
|||
|
|
@ -10,7 +10,11 @@ import aiohttp
|
|||
from loguru import logger
|
||||
|
||||
from api.enums import WorkflowRunMode
|
||||
from api.services.telephony.base import CallInitiationResult, TelephonyProvider
|
||||
from api.services.telephony.base import (
|
||||
CallInitiationResult,
|
||||
NormalizedInboundData,
|
||||
TelephonyProvider,
|
||||
)
|
||||
from api.utils.tunnel import TunnelURLProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -179,20 +183,65 @@ class VobizProvider(TelephonyProvider):
|
|||
return bool(self.auth_id and self.auth_token and self.from_numbers)
|
||||
|
||||
async def verify_webhook_signature(
|
||||
self, url: str, params: Dict[str, Any], signature: str
|
||||
self,
|
||||
url: str,
|
||||
params: Dict[str, Any],
|
||||
signature: str,
|
||||
timestamp: str = None,
|
||||
body: str = "",
|
||||
) -> bool:
|
||||
"""
|
||||
Verify Vobiz webhook signature for security.
|
||||
|
||||
Vobiz uses Plivo-compatible signature verification (HMAC-SHA256).
|
||||
For now, returning True to allow testing.
|
||||
TODO: Implement proper signature verification based on Vobiz docs.
|
||||
Vobiz uses HMAC-SHA256 signature verification with timestamp validation:
|
||||
- Header: x-vobiz-signature (HMAC-SHA256 hash)
|
||||
- Header: x-vobiz-timestamp (timestamp for replay protection)
|
||||
- Signature = HMAC-SHA256(auth_token, timestamp + '.' + body)
|
||||
"""
|
||||
# Plivo/Vobiz signature verification would go here
|
||||
# For development, we can skip signature verification
|
||||
# In production, implement HMAC-SHA256 verification
|
||||
logger.warning("Vobiz webhook signature verification not yet implemented")
|
||||
return True
|
||||
import hashlib
|
||||
import hmac
|
||||
from datetime import datetime, timezone
|
||||
|
||||
if not signature or not timestamp:
|
||||
logger.warning("Missing signature or timestamp headers for Vobiz webhook")
|
||||
return False
|
||||
|
||||
if not self.auth_token:
|
||||
logger.error(
|
||||
"No auth_token available for Vobiz webhook signature verification"
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
# 1. Timestamp validation (within 5 minutes)
|
||||
webhook_timestamp = int(timestamp)
|
||||
current_timestamp = int(datetime.now(timezone.utc).timestamp())
|
||||
time_diff = abs(current_timestamp - webhook_timestamp)
|
||||
|
||||
if time_diff > 300: # 5 minutes = 300 seconds
|
||||
logger.warning(f"Vobiz webhook timestamp too old: {time_diff}s > 300s")
|
||||
return False
|
||||
|
||||
# 2. Signature verification
|
||||
# Create expected signature: HMAC-SHA256(auth_token, timestamp + '.' + body)
|
||||
payload = f"{timestamp}.{body}"
|
||||
expected_signature = hmac.new(
|
||||
self.auth_token.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
# 3. Compare signatures (timing-safe comparison)
|
||||
is_valid = hmac.compare_digest(expected_signature, signature)
|
||||
|
||||
if not is_valid:
|
||||
logger.warning(
|
||||
f"Vobiz webhook signature mismatch. Expected: {expected_signature[:8]}..., Got: {signature[:8]}..."
|
||||
)
|
||||
|
||||
return is_valid
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error verifying Vobiz webhook signature: {e}")
|
||||
return False
|
||||
|
||||
async def get_webhook_response(
|
||||
self, workflow_id: int, user_id: int, workflow_run_id: int
|
||||
|
|
@ -339,3 +388,140 @@ class VobizProvider(TelephonyProvider):
|
|||
f"[run {workflow_run_id}] Error in Vobiz WebSocket handler: {e}"
|
||||
)
|
||||
raise
|
||||
|
||||
# ======== INBOUND CALL METHODS ========
|
||||
|
||||
@classmethod
|
||||
def can_handle_webhook(
|
||||
cls, webhook_data: Dict[str, Any], headers: Dict[str, str]
|
||||
) -> bool:
|
||||
"""
|
||||
Determine if this provider can handle the incoming webhook.
|
||||
Vobiz webhooks contain CallUUID field.
|
||||
"""
|
||||
return "CallUUID" in webhook_data
|
||||
|
||||
@staticmethod
|
||||
def parse_inbound_webhook(webhook_data: Dict[str, Any]) -> NormalizedInboundData:
|
||||
"""
|
||||
Parse Vobiz-specific inbound webhook data into normalized format.
|
||||
"""
|
||||
return NormalizedInboundData(
|
||||
provider=VobizProvider.PROVIDER_NAME,
|
||||
call_id=webhook_data.get("CallUUID", ""),
|
||||
from_number=VobizProvider.normalize_phone_number(
|
||||
webhook_data.get("From", "")
|
||||
),
|
||||
to_number=VobizProvider.normalize_phone_number(webhook_data.get("To", "")),
|
||||
direction=webhook_data.get("Direction", ""),
|
||||
call_status=webhook_data.get("CallStatus", ""),
|
||||
account_id=webhook_data.get("ParentAuthID"),
|
||||
from_country=None, # Vobiz doesn't provide country information
|
||||
to_country=None, # Vobiz doesn't provide country information
|
||||
raw_data=webhook_data,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def normalize_phone_number(phone_number: str) -> str:
|
||||
"""
|
||||
Normalize a phone number to E.164 format for Vobiz.
|
||||
Vobiz sends numbers in various formats - normalize to E.164 with +.
|
||||
"""
|
||||
if not phone_number:
|
||||
return ""
|
||||
|
||||
# Remove any existing + prefix
|
||||
clean_number = phone_number.lstrip("+")
|
||||
|
||||
# If it starts with 1 and has 11 digits, it's a US number
|
||||
if clean_number.startswith("1") and len(clean_number) == 11:
|
||||
return f"+{clean_number}"
|
||||
elif len(clean_number) == 10:
|
||||
# Assume US number if 10 digits
|
||||
return f"+1{clean_number}"
|
||||
elif len(clean_number) > 10:
|
||||
# International number without country code detection
|
||||
return f"+{clean_number}"
|
||||
|
||||
return phone_number
|
||||
|
||||
@staticmethod
|
||||
def validate_account_id(config_data: dict, webhook_account_id: str) -> bool:
|
||||
"""Validate Vobiz auth_id from webhook matches configuration"""
|
||||
if not webhook_account_id:
|
||||
return False
|
||||
|
||||
stored_auth_id = config_data.get("auth_id")
|
||||
return stored_auth_id == webhook_account_id
|
||||
|
||||
async def verify_inbound_signature(
|
||||
self,
|
||||
url: str,
|
||||
webhook_data: Dict[str, Any],
|
||||
signature: str,
|
||||
timestamp: str = None,
|
||||
body: str = "",
|
||||
) -> bool:
|
||||
"""
|
||||
Verify the signature of an inbound Vobiz webhook for security.
|
||||
Uses the same HMAC-SHA256 verification as other Vobiz webhooks.
|
||||
"""
|
||||
return await self.verify_webhook_signature(
|
||||
url, webhook_data, signature, timestamp, body
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def generate_inbound_response(
|
||||
websocket_url: str, workflow_run_id: int = None
|
||||
) -> tuple:
|
||||
"""
|
||||
Generate Vobiz XML response for an inbound webhook.
|
||||
|
||||
Note: For hangup callbacks, configure the hangup_url manually in Vobiz dashboard
|
||||
to point to: /api/v1/telephony/vobiz/hangup-callback/workflow/{workflow_id}
|
||||
"""
|
||||
from fastapi import Response
|
||||
|
||||
vobiz_xml = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Response>
|
||||
<Stream bidirectional="true" keepCallAlive="true" contentType="audio/x-mulaw;rate=8000">{websocket_url}</Stream>
|
||||
</Response>"""
|
||||
|
||||
return Response(content=vobiz_xml, media_type="application/xml")
|
||||
|
||||
@staticmethod
|
||||
def generate_error_response(error_type: str, message: str) -> tuple:
|
||||
"""
|
||||
Generate a Vobiz-specific error response.
|
||||
"""
|
||||
from fastapi import Response
|
||||
|
||||
# Vobiz error responses should be valid XML like Plivo
|
||||
vobiz_xml = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Response>
|
||||
<Speak voice="WOMAN">Sorry, there was an error processing your call. {message}</Speak>
|
||||
<Hangup/>
|
||||
</Response>"""
|
||||
|
||||
return Response(content=vobiz_xml, media_type="application/xml")
|
||||
|
||||
@staticmethod
|
||||
def generate_validation_error_response(error_type) -> tuple:
|
||||
"""
|
||||
Generate Vobiz-specific error response for validation failures with organizational debugging info.
|
||||
"""
|
||||
from fastapi import Response
|
||||
|
||||
from api.errors.telephony_errors import TELEPHONY_ERROR_MESSAGES, TelephonyError
|
||||
|
||||
message = TELEPHONY_ERROR_MESSAGES.get(
|
||||
error_type, TELEPHONY_ERROR_MESSAGES[TelephonyError.GENERAL_AUTH_FAILED]
|
||||
)
|
||||
|
||||
vobiz_xml_content = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Response>
|
||||
<Speak voice="WOMAN">{message}</Speak>
|
||||
<Hangup/>
|
||||
</Response>"""
|
||||
|
||||
return Response(content=vobiz_xml_content, media_type="application/xml")
|
||||
|
|
|
|||
|
|
@ -9,10 +9,15 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional
|
|||
|
||||
import aiohttp
|
||||
import jwt
|
||||
from fastapi import Response
|
||||
from loguru import logger
|
||||
|
||||
from api.enums import WorkflowRunMode
|
||||
from api.services.telephony.base import CallInitiationResult, TelephonyProvider
|
||||
from api.services.telephony.base import (
|
||||
CallInitiationResult,
|
||||
NormalizedInboundData,
|
||||
TelephonyProvider,
|
||||
)
|
||||
from api.utils.tunnel import TunnelURLProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -378,3 +383,99 @@ class VonageProvider(TelephonyProvider):
|
|||
except Exception as e:
|
||||
logger.error(f"Error in Vonage WebSocket handler: {e}")
|
||||
raise
|
||||
|
||||
# ======== INBOUND CALL METHODS ========
|
||||
|
||||
@classmethod
|
||||
def can_handle_webhook(
|
||||
cls, webhook_data: Dict[str, Any], headers: Dict[str, str]
|
||||
) -> bool:
|
||||
"""
|
||||
Determine if this provider can handle the incoming webhook.
|
||||
"""
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def parse_inbound_webhook(webhook_data: Dict[str, Any]) -> NormalizedInboundData:
|
||||
"""
|
||||
Parse Vonage-specific inbound webhook data into normalized format.
|
||||
"""
|
||||
return NormalizedInboundData(
|
||||
provider=VonageProvider.PROVIDER_NAME,
|
||||
call_id=webhook_data.get("uuid", ""),
|
||||
from_number=webhook_data.get("from", ""),
|
||||
to_number=webhook_data.get("to", ""),
|
||||
direction=webhook_data.get("direction", ""),
|
||||
call_status=webhook_data.get("status", ""),
|
||||
account_id=webhook_data.get("account_id"),
|
||||
from_country=None,
|
||||
to_country=None,
|
||||
raw_data=webhook_data,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def normalize_phone_number(phone_number: str) -> str:
|
||||
"""
|
||||
Normalize a phone number to E.164 format for Vonage.
|
||||
"""
|
||||
if not phone_number:
|
||||
return ""
|
||||
|
||||
if phone_number.startswith("+"):
|
||||
return phone_number
|
||||
|
||||
return f"+{phone_number}"
|
||||
|
||||
@staticmethod
|
||||
def validate_account_id(config_data: dict, webhook_account_id: str) -> bool:
|
||||
"""Validate Vonage account_id from webhook matches configuration"""
|
||||
if not webhook_account_id:
|
||||
return False
|
||||
|
||||
stored_api_key = config_data.get("api_key")
|
||||
return stored_api_key == webhook_account_id
|
||||
|
||||
async def verify_inbound_signature(
|
||||
self, url: str, webhook_data: Dict[str, Any], signature: str
|
||||
) -> bool:
|
||||
"""
|
||||
Vonage inbound signature verification - minimalist implementation.
|
||||
"""
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
async def generate_inbound_response(
|
||||
websocket_url: str, workflow_run_id: int = None
|
||||
) -> tuple:
|
||||
"""
|
||||
Generate NCCO response for inbound Vonage webhook.
|
||||
"""
|
||||
# Minimalist NCCO response for interface compliance
|
||||
ncco_response = [
|
||||
{
|
||||
"action": "talk",
|
||||
"text": "Vonage inbound calls are not currently supported.",
|
||||
},
|
||||
{"action": "hangup"},
|
||||
]
|
||||
|
||||
return Response(
|
||||
content=json.dumps(ncco_response), media_type="application/json"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def generate_error_response(error_type: str, message: str) -> tuple:
|
||||
"""
|
||||
Generate a Vonage-specific error response.
|
||||
"""
|
||||
from fastapi import Response
|
||||
|
||||
error_ncco = [
|
||||
{
|
||||
"action": "talk",
|
||||
"text": f"Sorry, there was an error processing your call. {message}",
|
||||
},
|
||||
{"action": "hangup"},
|
||||
]
|
||||
|
||||
return Response(content=json.dumps(error_ncco), media_type="application/json")
|
||||
|
|
|
|||
232
api/utils/telephony_helper.py
Normal file
232
api/utils/telephony_helper.py
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
"""
|
||||
Telephony helper utilities.
|
||||
Common functions used across telephony operations.
|
||||
"""
|
||||
|
||||
from dograh.api.constants import COUNTRY_CODES
|
||||
from fastapi import Request
|
||||
from loguru import logger
|
||||
from starlette.responses import HTMLResponse
|
||||
|
||||
|
||||
def numbers_match(
|
||||
incoming_number: str,
|
||||
configured_number: str,
|
||||
to_country: str = None,
|
||||
from_country: str = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Check if two phone numbers match, handling different formats with country context.
|
||||
|
||||
Args:
|
||||
incoming_number: Phone number from webhook
|
||||
configured_number: Phone number from organization config
|
||||
to_country: ISO country code for the called number (e.g., "US", "IN")
|
||||
from_country: ISO country code for the caller (e.g., "IN", "GB")
|
||||
|
||||
Examples:
|
||||
- incoming: "+08043071383", configured: "918043071383", to_country="IN" -> True
|
||||
- incoming: "+918043071383", configured: "918043071383" -> True
|
||||
- incoming: "+19781899185", configured: "+19781899185" -> True
|
||||
"""
|
||||
if not incoming_number or not configured_number:
|
||||
return False
|
||||
|
||||
# Remove spaces and normalize
|
||||
incoming_clean = incoming_number.replace(" ", "").replace("-", "")
|
||||
configured_clean = configured_number.replace(" ", "").replace("-", "")
|
||||
|
||||
# Direct match
|
||||
if incoming_clean == configured_clean:
|
||||
return True
|
||||
|
||||
# Remove + from both and compare
|
||||
incoming_no_plus = incoming_clean.lstrip("+")
|
||||
configured_no_plus = configured_clean.lstrip("+")
|
||||
|
||||
if incoming_no_plus == configured_no_plus:
|
||||
return True
|
||||
|
||||
if to_country:
|
||||
country_code = get_country_code(to_country)
|
||||
if country_code:
|
||||
if _test_number_formats_with_country_code(
|
||||
incoming_no_plus, configured_no_plus, country_code
|
||||
):
|
||||
return True
|
||||
|
||||
# Fallback to caller country if available
|
||||
if from_country and from_country != to_country:
|
||||
country_code = get_country_code(from_country)
|
||||
if country_code:
|
||||
if _test_number_formats_with_country_code(
|
||||
incoming_no_plus, configured_no_plus, country_code
|
||||
):
|
||||
return True
|
||||
|
||||
# Legacy fallback for common country codes (when no country info available)
|
||||
if not to_country and not from_country:
|
||||
common_codes = ["91", "1", "44"] # India, US/Canada, UK
|
||||
for code in common_codes:
|
||||
if _test_number_formats_with_country_code(
|
||||
incoming_no_plus, configured_no_plus, code
|
||||
):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _test_number_formats_with_country_code(
|
||||
incoming_no_plus: str, configured_no_plus: str, country_code: str
|
||||
) -> bool:
|
||||
"""
|
||||
Test different phone number format variations with the given country code to find matches.
|
||||
|
||||
This function handles various international phone number formatting scenarios:
|
||||
- Numbers with/without country codes
|
||||
- Numbers with leading zeros vs country codes
|
||||
- Different representations of the same number across formats
|
||||
|
||||
Args:
|
||||
incoming_no_plus: Incoming number without + prefix
|
||||
configured_no_plus: Configured number without + prefix
|
||||
country_code: International dialing code (e.g., "91", "1")
|
||||
|
||||
Returns:
|
||||
True if any format variation produces a match
|
||||
"""
|
||||
# Case 1: Incoming has no country code, configured has it
|
||||
if f"{country_code}{incoming_no_plus}" == configured_no_plus:
|
||||
return True
|
||||
|
||||
# Case 2: Incoming has leading 0, need to replace with country code
|
||||
if incoming_no_plus.startswith("0"):
|
||||
local_part = incoming_no_plus[1:] # Remove leading 0
|
||||
if f"{country_code}{local_part}" == configured_no_plus:
|
||||
return True
|
||||
|
||||
# Case 3: Configured has no country code, incoming has it
|
||||
if f"{country_code}{configured_no_plus}" == incoming_no_plus:
|
||||
return True
|
||||
|
||||
# Case 4: Configured has leading 0, need to replace with country code
|
||||
if configured_no_plus.startswith("0"):
|
||||
local_part = configured_no_plus[1:] # Remove leading 0
|
||||
if f"{country_code}{local_part}" == incoming_no_plus:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def normalize_phone_number(phone_number: str, country_code: str = None) -> str:
|
||||
"""
|
||||
Normalize a phone number to E.164 format using country context.
|
||||
|
||||
Args:
|
||||
phone_number: Phone number to normalize
|
||||
country_code: ISO country code (e.g., "US", "IN") for context
|
||||
|
||||
Returns:
|
||||
Phone number in E.164 format (e.g., "+14155552671", "+919876543210")
|
||||
"""
|
||||
if not phone_number:
|
||||
return ""
|
||||
|
||||
# Remove spaces, hyphens, and other formatting
|
||||
clean_number = (
|
||||
phone_number.replace(" ", "").replace("-", "").replace("(", "").replace(")", "")
|
||||
)
|
||||
|
||||
# Already in E.164 format
|
||||
if clean_number.startswith("+"):
|
||||
return clean_number
|
||||
|
||||
# Get dialing code for the country
|
||||
if country_code:
|
||||
dialing_code = get_country_code(country_code)
|
||||
if dialing_code:
|
||||
# Remove leading 0 if present (common in many countries)
|
||||
if clean_number.startswith("0"):
|
||||
clean_number = clean_number[1:]
|
||||
|
||||
# Add country code if not already present
|
||||
if not clean_number.startswith(dialing_code):
|
||||
return f"+{dialing_code}{clean_number}"
|
||||
else:
|
||||
return f"+{clean_number}"
|
||||
|
||||
# Fallback: try to guess common formats
|
||||
if clean_number.startswith("0") and len(clean_number) == 11:
|
||||
# Without country context, prefer India for now
|
||||
return f"+91{clean_number[1:]}"
|
||||
elif len(clean_number) == 10:
|
||||
# Without context, this is ambiguous - return as-is with + prefix
|
||||
return f"+{clean_number}"
|
||||
elif not clean_number.startswith("+"):
|
||||
# Add + prefix if missing
|
||||
return f"+{clean_number}"
|
||||
|
||||
return clean_number
|
||||
|
||||
|
||||
def normalize_webhook_data(provider_class, webhook_data):
|
||||
"""Normalize webhook data using the provider's parse method"""
|
||||
return provider_class.parse_inbound_webhook(webhook_data)
|
||||
|
||||
|
||||
def generic_hangup_response():
|
||||
"""Return a generic hangup response for unknown/error cases"""
|
||||
return HTMLResponse(
|
||||
content="<Response><Hangup/></Response>", media_type="application/xml"
|
||||
)
|
||||
|
||||
|
||||
async def parse_webhook_request(request: Request) -> tuple[dict, str]:
|
||||
"""Parse webhook request data from either JSON or form"""
|
||||
try:
|
||||
# Try JSON first
|
||||
webhook_data = await request.json()
|
||||
data_source = "JSON"
|
||||
except Exception:
|
||||
try:
|
||||
# Fallback to form data
|
||||
form_data = await request.form()
|
||||
webhook_data = dict(form_data)
|
||||
data_source = "FORM"
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to parse webhook data: {e}")
|
||||
raise ValueError("Unable to parse webhook data")
|
||||
|
||||
return webhook_data, data_source
|
||||
|
||||
|
||||
def get_country_code(country_iso: str) -> str:
|
||||
"""
|
||||
Get the international dialing code for a country.
|
||||
|
||||
Args:
|
||||
country_iso: ISO 3166-1 alpha-2 country code (e.g., "US", "IN", "GB")
|
||||
|
||||
Returns:
|
||||
International dialing code (e.g., "1", "91", "44") or empty string if not found
|
||||
"""
|
||||
if not country_iso:
|
||||
return ""
|
||||
|
||||
return COUNTRY_CODES.get(country_iso.upper(), "")
|
||||
|
||||
|
||||
def get_countries_for_code(dialing_code: str) -> list[str]:
|
||||
"""
|
||||
Get all countries that use a specific dialing code.
|
||||
|
||||
Args:
|
||||
dialing_code: International dialing code (e.g., "1", "91")
|
||||
|
||||
Returns:
|
||||
List of ISO country codes that use this dialing code
|
||||
"""
|
||||
if not dialing_code:
|
||||
return []
|
||||
|
||||
return [country for country, code in COUNTRY_CODES.items() if code == dialing_code]
|
||||
|
|
@ -7,7 +7,7 @@
|
|||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"fix-lint": "npx eslint --fix . --ignore-pattern '.next/*' --ignore-pattern 'node_modules/*'",
|
||||
"fix-lint": "npx eslint --fix . --ignore-pattern '.next/*' --ignore-pattern 'node_modules/*' --ignore-pattern 'next-env.d.ts'",
|
||||
"generate-client": "openapi-ts"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
|
|||
|
|
@ -216,6 +216,7 @@ export function WorkflowExecutions({ workflowId, searchParams }: WorkflowExecuti
|
|||
<TableHead className="font-semibold">ID</TableHead>
|
||||
<TableHead className="font-semibold">Status</TableHead>
|
||||
<TableHead className="font-semibold">Created At</TableHead>
|
||||
<TableHead className="font-semibold">Call Type</TableHead>
|
||||
<TableHead className="font-semibold">Duration</TableHead>
|
||||
<TableHead className="font-semibold">Disposition</TableHead>
|
||||
<TableHead className="font-semibold">Dograh Token</TableHead>
|
||||
|
|
@ -236,6 +237,11 @@ export function WorkflowExecutions({ workflowId, searchParams }: WorkflowExecuti
|
|||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{formatDate(run.created_at)}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={run.call_type === 'inbound' ? "secondary" : "default"}>
|
||||
{run.call_type === 'inbound' ? 'Inbound' : 'Outbound'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{typeof run.cost_info?.call_duration_seconds === 'number'
|
||||
? `${run.cost_info.call_duration_seconds.toFixed(1)}s`
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ export default function CreateWorkflowPage() {
|
|||
const [showSuccessModal, setShowSuccessModal] = useState(false);
|
||||
const [workflowId, setWorkflowId] = useState<string | null>(null);
|
||||
|
||||
const [callType, setCallType] = useState<'INBOUND' | 'OUTBOUND'>('INBOUND');
|
||||
const [callType, setCallType] = useState<'inbound' | 'outbound'>('inbound');
|
||||
const [useCase, setUseCase] = useState('');
|
||||
const [activityDescription, setActivityDescription] = useState('');
|
||||
|
||||
|
|
@ -128,15 +128,15 @@ export default function CreateWorkflowPage() {
|
|||
<CardContent className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="call-type">Call Type</Label>
|
||||
<Select value={callType} onValueChange={(value) => setCallType(value as 'INBOUND' | 'OUTBOUND')}>
|
||||
<Select value={callType} onValueChange={(value) => setCallType(value as 'inbound' | 'outbound')}>
|
||||
<SelectTrigger id="call-type">
|
||||
<SelectValue placeholder="Select type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="INBOUND">
|
||||
<SelectItem value="inbound">
|
||||
Inbound (Users call AI)
|
||||
</SelectItem>
|
||||
<SelectItem value="OUTBOUND">
|
||||
<SelectItem value="outbound">
|
||||
Outbound (AI calls users)
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -41,6 +41,8 @@ export type AuthUserResponse = {
|
|||
is_superuser: boolean;
|
||||
};
|
||||
|
||||
export type CallType = 'inbound' | 'outbound';
|
||||
|
||||
export type CampaignProgressResponse = {
|
||||
campaign_id: number;
|
||||
state: string;
|
||||
|
|
@ -210,7 +212,7 @@ export type CreateWorkflowRunResponse = {
|
|||
};
|
||||
|
||||
export type CreateWorkflowTemplateRequest = {
|
||||
call_type: 'INBOUND' | 'OUTBOUND';
|
||||
call_type: 'inbound' | 'outbound';
|
||||
use_case: string;
|
||||
activity_description: string;
|
||||
};
|
||||
|
|
@ -933,6 +935,7 @@ export type WorkflowRunResponseSchema = {
|
|||
gathered_context?: {
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
call_type: CallType;
|
||||
};
|
||||
|
||||
export type WorkflowRunUsageResponse = {
|
||||
|
|
@ -986,6 +989,7 @@ export type InitiateCallApiV1TelephonyInitiateCallPostData = {
|
|||
body: InitiateCallRequest;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query?: never;
|
||||
|
|
@ -1075,6 +1079,10 @@ export type HandleVonageEventsApiV1TelephonyVonageEventsWorkflowRunIdPostRespons
|
|||
|
||||
export type HandleVobizHangupCallbackApiV1TelephonyVobizHangupCallbackWorkflowRunIdPostData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
'x-vobiz-signature'?: string | null;
|
||||
'x-vobiz-timestamp'?: string | null;
|
||||
};
|
||||
path: {
|
||||
workflow_run_id: number;
|
||||
};
|
||||
|
|
@ -1104,6 +1112,10 @@ export type HandleVobizHangupCallbackApiV1TelephonyVobizHangupCallbackWorkflowRu
|
|||
|
||||
export type HandleVobizRingCallbackApiV1TelephonyVobizRingCallbackWorkflowRunIdPostData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
'x-vobiz-signature'?: string | null;
|
||||
'x-vobiz-timestamp'?: string | null;
|
||||
};
|
||||
path: {
|
||||
workflow_run_id: number;
|
||||
};
|
||||
|
|
@ -1160,10 +1172,99 @@ export type HandleCloudonixStatusCallbackApiV1TelephonyCloudonixStatusCallbackWo
|
|||
200: unknown;
|
||||
};
|
||||
|
||||
export type HandleVobizHangupCallbackByWorkflowApiV1TelephonyVobizHangupCallbackWorkflowWorkflowIdPostData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
'x-vobiz-signature'?: string | null;
|
||||
'x-vobiz-timestamp'?: string | null;
|
||||
};
|
||||
path: {
|
||||
workflow_id: number;
|
||||
};
|
||||
query?: never;
|
||||
url: '/api/v1/telephony/vobiz/hangup-callback/workflow/{workflow_id}';
|
||||
};
|
||||
|
||||
export type HandleVobizHangupCallbackByWorkflowApiV1TelephonyVobizHangupCallbackWorkflowWorkflowIdPostErrors = {
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
404: unknown;
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type HandleVobizHangupCallbackByWorkflowApiV1TelephonyVobizHangupCallbackWorkflowWorkflowIdPostError = HandleVobizHangupCallbackByWorkflowApiV1TelephonyVobizHangupCallbackWorkflowWorkflowIdPostErrors[keyof HandleVobizHangupCallbackByWorkflowApiV1TelephonyVobizHangupCallbackWorkflowWorkflowIdPostErrors];
|
||||
|
||||
export type HandleVobizHangupCallbackByWorkflowApiV1TelephonyVobizHangupCallbackWorkflowWorkflowIdPostResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: unknown;
|
||||
};
|
||||
|
||||
export type HandleInboundTelephonyApiV1TelephonyInboundWorkflowIdPostData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
'x-twilio-signature'?: string | null;
|
||||
'x-vobiz-signature'?: string | null;
|
||||
'x-vobiz-timestamp'?: string | null;
|
||||
};
|
||||
path: {
|
||||
workflow_id: number;
|
||||
};
|
||||
query?: never;
|
||||
url: '/api/v1/telephony/inbound/{workflow_id}';
|
||||
};
|
||||
|
||||
export type HandleInboundTelephonyApiV1TelephonyInboundWorkflowIdPostErrors = {
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
404: unknown;
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type HandleInboundTelephonyApiV1TelephonyInboundWorkflowIdPostError = HandleInboundTelephonyApiV1TelephonyInboundWorkflowIdPostErrors[keyof HandleInboundTelephonyApiV1TelephonyInboundWorkflowIdPostErrors];
|
||||
|
||||
export type HandleInboundTelephonyApiV1TelephonyInboundWorkflowIdPostResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: unknown;
|
||||
};
|
||||
|
||||
export type HandleInboundFallbackApiV1TelephonyInboundFallbackPostData = {
|
||||
body?: never;
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/api/v1/telephony/inbound/fallback';
|
||||
};
|
||||
|
||||
export type HandleInboundFallbackApiV1TelephonyInboundFallbackPostErrors = {
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
404: unknown;
|
||||
};
|
||||
|
||||
export type HandleInboundFallbackApiV1TelephonyInboundFallbackPostResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: unknown;
|
||||
};
|
||||
|
||||
export type OfferApiV1PipecatRtcOfferPostData = {
|
||||
body: RtcOfferRequest;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query?: never;
|
||||
|
|
@ -1194,6 +1295,7 @@ export type ImpersonateApiV1SuperuserImpersonatePostData = {
|
|||
body: ImpersonateRequest;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query?: never;
|
||||
|
|
@ -1226,6 +1328,7 @@ export type GetWorkflowRunsApiV1SuperuserWorkflowRunsGetData = {
|
|||
body?: never;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query?: {
|
||||
|
|
@ -1271,6 +1374,7 @@ export type SetAdminCommentApiV1SuperuserWorkflowRunsRunIdCommentPostData = {
|
|||
body: AdminCommentRequest;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path: {
|
||||
run_id: number;
|
||||
|
|
@ -1305,6 +1409,7 @@ export type ValidateWorkflowApiV1WorkflowWorkflowIdValidatePostData = {
|
|||
body?: never;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path: {
|
||||
workflow_id: number;
|
||||
|
|
@ -1339,6 +1444,7 @@ export type CreateWorkflowApiV1WorkflowCreateDefinitionPostData = {
|
|||
body: CreateWorkflowRequest;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query?: never;
|
||||
|
|
@ -1371,6 +1477,7 @@ export type CreateWorkflowFromTemplateApiV1WorkflowCreateTemplatePostData = {
|
|||
body: CreateWorkflowTemplateRequest;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query?: never;
|
||||
|
|
@ -1403,6 +1510,7 @@ export type GetWorkflowsApiV1WorkflowFetchGetData = {
|
|||
body?: never;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query?: {
|
||||
|
|
@ -1440,6 +1548,7 @@ export type GetWorkflowApiV1WorkflowFetchWorkflowIdGetData = {
|
|||
body?: never;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path: {
|
||||
workflow_id: number;
|
||||
|
|
@ -1474,6 +1583,7 @@ export type GetWorkflowsSummaryApiV1WorkflowSummaryGetData = {
|
|||
body?: never;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query?: never;
|
||||
|
|
@ -1506,6 +1616,7 @@ export type UpdateWorkflowStatusApiV1WorkflowWorkflowIdStatusPutData = {
|
|||
body: UpdateWorkflowStatusRequest;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path: {
|
||||
workflow_id: number;
|
||||
|
|
@ -1540,6 +1651,7 @@ export type UpdateWorkflowApiV1WorkflowWorkflowIdPutData = {
|
|||
body: UpdateWorkflowRequest;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path: {
|
||||
workflow_id: number;
|
||||
|
|
@ -1574,6 +1686,7 @@ export type GetWorkflowRunsApiV1WorkflowWorkflowIdRunsGetData = {
|
|||
body?: never;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path: {
|
||||
workflow_id: number;
|
||||
|
|
@ -1615,6 +1728,7 @@ export type CreateWorkflowRunApiV1WorkflowWorkflowIdRunsPostData = {
|
|||
body: CreateWorkflowRunRequest;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path: {
|
||||
workflow_id: number;
|
||||
|
|
@ -1649,6 +1763,7 @@ export type GetWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGetData = {
|
|||
body?: never;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path: {
|
||||
workflow_id: number;
|
||||
|
|
@ -1707,6 +1822,7 @@ export type DuplicateWorkflowTemplateApiV1WorkflowTemplatesDuplicatePostData = {
|
|||
body: DuplicateTemplateRequest;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query?: never;
|
||||
|
|
@ -1762,6 +1878,7 @@ export type GetAuthUserApiV1UserAuthUserGetData = {
|
|||
body?: never;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query?: never;
|
||||
|
|
@ -1794,6 +1911,7 @@ export type GetUserConfigurationsApiV1UserConfigurationsUserGetData = {
|
|||
body?: never;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query?: never;
|
||||
|
|
@ -1826,6 +1944,7 @@ export type UpdateUserConfigurationsApiV1UserConfigurationsUserPutData = {
|
|||
body: UserConfigurationRequestResponseSchema;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query?: never;
|
||||
|
|
@ -1858,6 +1977,7 @@ export type ValidateUserConfigurationsApiV1UserConfigurationsUserValidateGetData
|
|||
body?: never;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query?: {
|
||||
|
|
@ -1892,6 +2012,7 @@ export type GetApiKeysApiV1UserApiKeysGetData = {
|
|||
body?: never;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query?: {
|
||||
|
|
@ -1926,6 +2047,7 @@ export type CreateApiKeyApiV1UserApiKeysPostData = {
|
|||
body: CreateApiKeyRequest;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query?: never;
|
||||
|
|
@ -1958,6 +2080,7 @@ export type ArchiveApiKeyApiV1UserApiKeysApiKeyIdDeleteData = {
|
|||
body?: never;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path: {
|
||||
api_key_id: number;
|
||||
|
|
@ -1994,6 +2117,7 @@ export type ReactivateApiKeyApiV1UserApiKeysApiKeyIdReactivatePutData = {
|
|||
body?: never;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path: {
|
||||
api_key_id: number;
|
||||
|
|
@ -2030,6 +2154,7 @@ export type GetVoicesApiV1UserConfigurationsVoicesProviderGetData = {
|
|||
body?: never;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path: {
|
||||
provider: 'elevenlabs' | 'deepgram' | 'sarvam' | 'cartesia' | 'dograh';
|
||||
|
|
@ -2064,6 +2189,7 @@ export type CreateCampaignApiV1CampaignCreatePostData = {
|
|||
body: CreateCampaignRequest;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query?: never;
|
||||
|
|
@ -2096,6 +2222,7 @@ export type GetCampaignsApiV1CampaignGetData = {
|
|||
body?: never;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query?: never;
|
||||
|
|
@ -2128,6 +2255,7 @@ export type GetCampaignApiV1CampaignCampaignIdGetData = {
|
|||
body?: never;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path: {
|
||||
campaign_id: number;
|
||||
|
|
@ -2162,6 +2290,7 @@ export type StartCampaignApiV1CampaignCampaignIdStartPostData = {
|
|||
body?: never;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path: {
|
||||
campaign_id: number;
|
||||
|
|
@ -2196,6 +2325,7 @@ export type PauseCampaignApiV1CampaignCampaignIdPausePostData = {
|
|||
body?: never;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path: {
|
||||
campaign_id: number;
|
||||
|
|
@ -2230,6 +2360,7 @@ export type GetCampaignRunsApiV1CampaignCampaignIdRunsGetData = {
|
|||
body?: never;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path: {
|
||||
campaign_id: number;
|
||||
|
|
@ -2264,6 +2395,7 @@ export type ResumeCampaignApiV1CampaignCampaignIdResumePostData = {
|
|||
body?: never;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path: {
|
||||
campaign_id: number;
|
||||
|
|
@ -2298,6 +2430,7 @@ export type GetCampaignProgressApiV1CampaignCampaignIdProgressGetData = {
|
|||
body?: never;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path: {
|
||||
campaign_id: number;
|
||||
|
|
@ -2332,6 +2465,7 @@ export type GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrl
|
|||
body?: never;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path: {
|
||||
campaign_id: number;
|
||||
|
|
@ -2366,6 +2500,7 @@ export type ListCredentialsApiV1CredentialsGetData = {
|
|||
body?: never;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query?: never;
|
||||
|
|
@ -2398,6 +2533,7 @@ export type CreateCredentialApiV1CredentialsPostData = {
|
|||
body: CreateCredentialRequest;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query?: never;
|
||||
|
|
@ -2430,6 +2566,7 @@ export type DeleteCredentialApiV1CredentialsCredentialUuidDeleteData = {
|
|||
body?: never;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path: {
|
||||
credential_uuid: string;
|
||||
|
|
@ -2466,6 +2603,7 @@ export type GetCredentialApiV1CredentialsCredentialUuidGetData = {
|
|||
body?: never;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path: {
|
||||
credential_uuid: string;
|
||||
|
|
@ -2500,6 +2638,7 @@ export type UpdateCredentialApiV1CredentialsCredentialUuidPutData = {
|
|||
body: UpdateCredentialRequest;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path: {
|
||||
credential_uuid: string;
|
||||
|
|
@ -2534,6 +2673,7 @@ export type ListToolsApiV1ToolsGetData = {
|
|||
body?: never;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query?: {
|
||||
|
|
@ -2569,6 +2709,7 @@ export type CreateToolApiV1ToolsPostData = {
|
|||
body: CreateToolRequest;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query?: never;
|
||||
|
|
@ -2601,6 +2742,7 @@ export type DeleteToolApiV1ToolsToolUuidDeleteData = {
|
|||
body?: never;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path: {
|
||||
tool_uuid: string;
|
||||
|
|
@ -2637,6 +2779,7 @@ export type GetToolApiV1ToolsToolUuidGetData = {
|
|||
body?: never;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path: {
|
||||
tool_uuid: string;
|
||||
|
|
@ -2671,6 +2814,7 @@ export type UpdateToolApiV1ToolsToolUuidPutData = {
|
|||
body: UpdateToolRequest;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path: {
|
||||
tool_uuid: string;
|
||||
|
|
@ -2705,6 +2849,7 @@ export type GetIntegrationsApiV1IntegrationGetData = {
|
|||
body?: never;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query?: never;
|
||||
|
|
@ -2737,6 +2882,7 @@ export type CreateSessionApiV1IntegrationSessionPostData = {
|
|||
body?: never;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query?: never;
|
||||
|
|
@ -2769,6 +2915,7 @@ export type UpdateIntegrationApiV1IntegrationIntegrationIdPutData = {
|
|||
body: UpdateIntegrationRequest;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path: {
|
||||
integration_id: number;
|
||||
|
|
@ -2803,6 +2950,7 @@ export type GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGet
|
|||
body?: never;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path: {
|
||||
integration_id: number;
|
||||
|
|
@ -2837,6 +2985,7 @@ export type GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetData =
|
|||
body?: never;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query?: never;
|
||||
|
|
@ -2869,6 +3018,7 @@ export type SaveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPostData
|
|||
body: TwilioConfigurationRequest | VonageConfigurationRequest | VobizConfigurationRequest | CloudonixConfigurationRequest;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query?: never;
|
||||
|
|
@ -2899,6 +3049,7 @@ export type GetSignedUrlApiV1S3SignedUrlGetData = {
|
|||
body?: never;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query: {
|
||||
|
|
@ -2938,6 +3089,7 @@ export type GetFileMetadataApiV1S3FileMetadataGetData = {
|
|||
body?: never;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query: {
|
||||
|
|
@ -2975,6 +3127,7 @@ export type GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostData = {
|
|||
body: PresignedUploadUrlRequest;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query?: never;
|
||||
|
|
@ -3007,6 +3160,7 @@ export type GetServiceKeysApiV1UserServiceKeysGetData = {
|
|||
body?: never;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query?: {
|
||||
|
|
@ -3041,6 +3195,7 @@ export type CreateServiceKeyApiV1UserServiceKeysPostData = {
|
|||
body: CreateServiceKeyRequest;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query?: never;
|
||||
|
|
@ -3073,6 +3228,7 @@ export type ArchiveServiceKeyApiV1UserServiceKeysServiceKeyIdDeleteData = {
|
|||
body?: never;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path: {
|
||||
service_key_id: string;
|
||||
|
|
@ -3105,6 +3261,7 @@ export type ReactivateServiceKeyApiV1UserServiceKeysServiceKeyIdReactivatePutDat
|
|||
body?: never;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path: {
|
||||
service_key_id: string;
|
||||
|
|
@ -3137,6 +3294,7 @@ export type ListTestSessionsApiV1LooptalkTestSessionsGetData = {
|
|||
body?: never;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query?: {
|
||||
|
|
@ -3174,6 +3332,7 @@ export type CreateTestSessionApiV1LooptalkTestSessionsPostData = {
|
|||
body: CreateTestSessionRequest;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query?: never;
|
||||
|
|
@ -3206,6 +3365,7 @@ export type GetTestSessionApiV1LooptalkTestSessionsTestSessionIdGetData = {
|
|||
body?: never;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path: {
|
||||
test_session_id: number;
|
||||
|
|
@ -3240,6 +3400,7 @@ export type StartTestSessionApiV1LooptalkTestSessionsTestSessionIdStartPostData
|
|||
body?: never;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path: {
|
||||
test_session_id: number;
|
||||
|
|
@ -3272,6 +3433,7 @@ export type StopTestSessionApiV1LooptalkTestSessionsTestSessionIdStopPostData =
|
|||
body?: never;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path: {
|
||||
test_session_id: number;
|
||||
|
|
@ -3304,6 +3466,7 @@ export type GetTestSessionConversationApiV1LooptalkTestSessionsTestSessionIdConv
|
|||
body?: never;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path: {
|
||||
test_session_id: number;
|
||||
|
|
@ -3336,6 +3499,7 @@ export type CreateLoadTestApiV1LooptalkLoadTestsPostData = {
|
|||
body: CreateLoadTestRequest;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query?: never;
|
||||
|
|
@ -3370,6 +3534,7 @@ export type GetLoadTestStatsApiV1LooptalkLoadTestsLoadTestGroupIdStatsGetData =
|
|||
body?: never;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path: {
|
||||
load_test_group_id: string;
|
||||
|
|
@ -3404,6 +3569,7 @@ export type GetActiveTestsApiV1LooptalkActiveTestsGetData = {
|
|||
body?: never;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query?: never;
|
||||
|
|
@ -3434,6 +3600,7 @@ export type GetCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGetData = {
|
|||
body?: never;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query?: never;
|
||||
|
|
@ -3466,6 +3633,7 @@ export type GetUsageHistoryApiV1OrganizationsUsageRunsGetData = {
|
|||
body?: never;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query?: {
|
||||
|
|
@ -3513,6 +3681,7 @@ export type GetDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGetData =
|
|||
body?: never;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query?: {
|
||||
|
|
@ -3550,6 +3719,7 @@ export type GetDailyReportApiV1OrganizationsReportsDailyGetData = {
|
|||
body?: never;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query: {
|
||||
|
|
@ -3595,6 +3765,7 @@ export type GetWorkflowOptionsApiV1OrganizationsReportsWorkflowsGetData = {
|
|||
body?: never;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query?: never;
|
||||
|
|
@ -3627,6 +3798,7 @@ export type GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetData = {
|
|||
body?: never;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query: {
|
||||
|
|
@ -3816,6 +3988,7 @@ export type DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteData = {
|
|||
body?: never;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path: {
|
||||
workflow_id: number;
|
||||
|
|
@ -3852,6 +4025,7 @@ export type GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetData = {
|
|||
body?: never;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path: {
|
||||
workflow_id: number;
|
||||
|
|
@ -3886,6 +4060,7 @@ export type CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostData =
|
|||
body: EmbedTokenRequest;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path: {
|
||||
workflow_id: number;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue