mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-07 07:55:16 +02:00
feat: Enable telephony for OSS (#21)
* fix: fix tooltip bug * feat: add Twilio with CloudFlare configuration * chore: update Tella Video
This commit is contained in:
parent
d39a8111a6
commit
8e2e5c9327
21 changed files with 891 additions and 191 deletions
|
|
@ -62,7 +62,7 @@ class OrganizationConfigurationKey(Enum):
|
|||
DISPOSITION_CODE_MAPPING = "DISPOSITION_CODE_MAPPING"
|
||||
DISPOSITION_MESSAGE_TEMPLATE = "DISPOSITION_MESSAGE_TEMPLATE"
|
||||
CONCURRENT_CALL_LIMIT = "CONCURRENT_CALL_LIMIT"
|
||||
TWILIO_PHONE_NUMBERS = "TWILIO_PHONE_NUMBERS"
|
||||
TWILIO_CONFIGURATION = "TWILIO_CONFIGURATION"
|
||||
|
||||
|
||||
class WorkflowStatus(Enum):
|
||||
|
|
|
|||
|
|
@ -168,10 +168,10 @@ async def start_campaign(
|
|||
user: UserModel = Depends(get_user),
|
||||
) -> CampaignResponse:
|
||||
"""Start campaign execution"""
|
||||
# Check if organization has TWILIO_PHONE_NUMBERS configured
|
||||
# Check if organization has TWILIO_CONFIGURATION configured
|
||||
twilio_config = await db_client.get_configuration(
|
||||
user.selected_organization_id,
|
||||
OrganizationConfigurationKey.TWILIO_PHONE_NUMBERS.value,
|
||||
OrganizationConfigurationKey.TWILIO_CONFIGURATION.value,
|
||||
)
|
||||
|
||||
if (
|
||||
|
|
@ -280,10 +280,10 @@ async def resume_campaign(
|
|||
user: UserModel = Depends(get_user),
|
||||
) -> CampaignResponse:
|
||||
"""Resume a paused campaign"""
|
||||
# Check if organization has TWILIO_PHONE_NUMBERS configured
|
||||
# Check if organization has TWILIO_CONFIGURATION configured
|
||||
twilio_config = await db_client.get_configuration(
|
||||
user.selected_organization_id,
|
||||
OrganizationConfigurationKey.TWILIO_PHONE_NUMBERS.value,
|
||||
OrganizationConfigurationKey.TWILIO_CONFIGURATION.value,
|
||||
)
|
||||
|
||||
if (
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from loguru import logger
|
|||
from api.routes.campaign import router as campaign_router
|
||||
from api.routes.integration import router as integration_router
|
||||
from api.routes.looptalk import router as looptalk_router
|
||||
from api.routes.organization import router as organization_router
|
||||
from api.routes.organization_usage import router as organization_usage_router
|
||||
from api.routes.reports import router as reports_router
|
||||
from api.routes.rtc_offer import router as rtc_offer_router
|
||||
|
|
@ -27,6 +28,7 @@ router.include_router(workflow_router)
|
|||
router.include_router(user_router)
|
||||
router.include_router(campaign_router)
|
||||
router.include_router(integration_router)
|
||||
router.include_router(organization_router)
|
||||
router.include_router(s3_router)
|
||||
router.include_router(service_keys_router)
|
||||
router.include_router(looptalk_router)
|
||||
|
|
|
|||
85
api/routes/organization.py
Normal file
85
api/routes/organization.py
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from api.db import db_client
|
||||
from api.db.models import UserModel
|
||||
from api.enums import OrganizationConfigurationKey
|
||||
from api.schemas.telephony_config import (
|
||||
TelephonyConfigurationResponse,
|
||||
TwilioConfigurationRequest,
|
||||
TwilioConfigurationResponse,
|
||||
)
|
||||
from api.services.auth.depends import get_user
|
||||
from api.services.configuration.masking import is_mask_of, mask_key
|
||||
|
||||
router = APIRouter(prefix="/organizations", tags=["organizations"])
|
||||
|
||||
|
||||
@router.get("/telephony-config", response_model=TelephonyConfigurationResponse)
|
||||
async def get_telephony_configuration(user: UserModel = Depends(get_user)):
|
||||
"""Get telephony configuration for the user's organization with masked sensitive fields."""
|
||||
if not user.selected_organization_id:
|
||||
raise HTTPException(status_code=400, detail="No organization selected")
|
||||
|
||||
config = await db_client.get_configuration(
|
||||
user.selected_organization_id,
|
||||
OrganizationConfigurationKey.TWILIO_CONFIGURATION.value,
|
||||
)
|
||||
|
||||
if not config or not config.value:
|
||||
return TelephonyConfigurationResponse(twilio=None)
|
||||
|
||||
# Mask sensitive fields (account_sid and auth_token) before returning
|
||||
account_sid = config.value.get("account_sid", "")
|
||||
auth_token = config.value.get("auth_token", "")
|
||||
|
||||
return TelephonyConfigurationResponse(
|
||||
twilio=TwilioConfigurationResponse(
|
||||
provider="twilio",
|
||||
account_sid=mask_key(account_sid) if account_sid else "",
|
||||
auth_token=mask_key(auth_token) if auth_token else "",
|
||||
from_numbers=config.value.get("from_numbers", []),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/telephony-config")
|
||||
async def save_telephony_configuration(
|
||||
request: TwilioConfigurationRequest, user: UserModel = Depends(get_user)
|
||||
):
|
||||
"""Save telephony configuration for the user's organization."""
|
||||
if not user.selected_organization_id:
|
||||
raise HTTPException(status_code=400, detail="No organization selected")
|
||||
|
||||
# Fetch existing configuration to handle masked values
|
||||
existing_config = await db_client.get_configuration(
|
||||
user.selected_organization_id,
|
||||
OrganizationConfigurationKey.TWILIO_CONFIGURATION.value,
|
||||
)
|
||||
|
||||
# Build new configuration
|
||||
config_value = {
|
||||
"provider": request.provider,
|
||||
"account_sid": request.account_sid,
|
||||
"auth_token": request.auth_token,
|
||||
"from_numbers": request.from_numbers,
|
||||
}
|
||||
|
||||
# If incoming values are masked (same as stored masked value), keep the original
|
||||
if existing_config and existing_config.value:
|
||||
# Check if account_sid is unchanged (masked value matches)
|
||||
stored_account_sid = existing_config.value.get("account_sid", "")
|
||||
if stored_account_sid and is_mask_of(request.account_sid, stored_account_sid):
|
||||
config_value["account_sid"] = stored_account_sid # Keep original
|
||||
|
||||
# Check if auth_token is unchanged (masked value matches)
|
||||
stored_auth_token = existing_config.value.get("auth_token", "")
|
||||
if stored_auth_token and is_mask_of(request.auth_token, stored_auth_token):
|
||||
config_value["auth_token"] = stored_auth_token # Keep original
|
||||
|
||||
await db_client.upsert_configuration(
|
||||
user.selected_organization_id,
|
||||
OrganizationConfigurationKey.TWILIO_CONFIGURATION.value,
|
||||
config_value,
|
||||
)
|
||||
|
||||
return {"message": "Telephony configuration saved successfully"}
|
||||
|
|
@ -5,7 +5,6 @@ from typing import Annotated, Optional
|
|||
|
||||
from fastapi import APIRouter, Depends, Form, Header, HTTPException, Request, WebSocket
|
||||
from loguru import logger
|
||||
from pipecat.utils.context import set_current_run_id
|
||||
from pydantic import BaseModel
|
||||
from starlette.responses import HTMLResponse
|
||||
|
||||
|
|
@ -19,6 +18,7 @@ from api.services.campaign.campaign_event_publisher import (
|
|||
)
|
||||
from api.services.pipecat.run_pipeline import run_pipeline_twilio
|
||||
from api.services.telephony.twilio import TwilioService
|
||||
from pipecat.utils.context import set_current_run_id
|
||||
|
||||
router = APIRouter(prefix="/twilio")
|
||||
|
||||
|
|
@ -45,20 +45,16 @@ class TwilioStatusCallbackRequest(BaseModel):
|
|||
async def initiate_call(
|
||||
request: InitiateCallRequest, user: UserModel = Depends(get_user)
|
||||
):
|
||||
# Check if organization has TWILIO_PHONE_NUMBERS configured
|
||||
# Check if organization has TWILIO_CONFIGURATION configured
|
||||
twilio_config = await db_client.get_configuration(
|
||||
user.selected_organization_id,
|
||||
OrganizationConfigurationKey.TWILIO_PHONE_NUMBERS.value,
|
||||
OrganizationConfigurationKey.TWILIO_CONFIGURATION.value,
|
||||
)
|
||||
|
||||
if (
|
||||
not twilio_config
|
||||
or not twilio_config.value
|
||||
or not twilio_config.value.get("value")
|
||||
):
|
||||
if not twilio_config or not twilio_config.value:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Your organisation is not allowed to make phone call. Contact founders@dograh.com for further support.",
|
||||
status_code=400,
|
||||
detail="telephony_not_configured", # Special error code
|
||||
)
|
||||
|
||||
user_configuration = await db_client.get_user_configurations(user.id)
|
||||
|
|
@ -84,15 +80,16 @@ async def initiate_call(
|
|||
workflow_run_name = workflow_run.name
|
||||
|
||||
if user_configuration.test_phone_number:
|
||||
await TwilioService().initiate_call(
|
||||
twilio_service = TwilioService(user.selected_organization_id)
|
||||
await twilio_service.initiate_call(
|
||||
to_number=user_configuration.test_phone_number,
|
||||
url_args={
|
||||
"workflow_id": request.workflow_id,
|
||||
"user_id": user.id,
|
||||
"workflow_run_id": workflow_run_id,
|
||||
"organization_id": user.selected_organization_id,
|
||||
},
|
||||
workflow_run_id=workflow_run_id,
|
||||
organization_id=user.selected_organization_id,
|
||||
)
|
||||
return {
|
||||
"message": f"Call initiated successfully with run name {workflow_run_name}"
|
||||
|
|
@ -102,8 +99,10 @@ async def initiate_call(
|
|||
|
||||
|
||||
@router.post("/twiml", include_in_schema=False)
|
||||
async def start_call(workflow_id: int, user_id: int, workflow_run_id: int):
|
||||
twiml_content = await TwilioService().get_start_call_twiml(
|
||||
async def start_call(
|
||||
workflow_id: int, user_id: int, workflow_run_id: int, organization_id: int
|
||||
):
|
||||
twiml_content = await TwilioService(organization_id).get_start_call_twiml(
|
||||
workflow_id, user_id, workflow_run_id
|
||||
)
|
||||
return HTMLResponse(content=twiml_content, media_type="application/xml")
|
||||
|
|
|
|||
29
api/schemas/telephony_config.py
Normal file
29
api/schemas/telephony_config.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
from typing import List
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class TwilioConfigurationRequest(BaseModel):
|
||||
"""Request schema for Twilio configuration."""
|
||||
|
||||
provider: str = Field(default="twilio")
|
||||
account_sid: str = Field(..., description="Twilio Account SID")
|
||||
auth_token: str = Field(..., description="Twilio Auth Token")
|
||||
from_numbers: List[str] = Field(
|
||||
..., min_length=1, description="List of Twilio phone numbers"
|
||||
)
|
||||
|
||||
|
||||
class TwilioConfigurationResponse(BaseModel):
|
||||
"""Response schema for Twilio configuration with masked sensitive fields."""
|
||||
|
||||
provider: str
|
||||
account_sid: str # Masked (e.g., "****************def0")
|
||||
auth_token: str # Masked (e.g., "****************abc1")
|
||||
from_numbers: List[str]
|
||||
|
||||
|
||||
class TelephonyConfigurationResponse(BaseModel):
|
||||
"""Top-level telephony configuration response."""
|
||||
|
||||
twilio: TwilioConfigurationResponse | None = None
|
||||
|
|
@ -16,15 +16,11 @@ class CampaignCallDispatcher:
|
|||
"""Manages rate-limited and concurrent-limited call dispatching"""
|
||||
|
||||
def __init__(self):
|
||||
self._twilio_service = None
|
||||
self.default_concurrent_limit = 20
|
||||
|
||||
@property
|
||||
def twilio_service(self):
|
||||
"""Lazy initialization of TwilioService"""
|
||||
if self._twilio_service is None:
|
||||
self._twilio_service = TwilioService()
|
||||
return self._twilio_service
|
||||
def get_twilio_service(self, organization_id: int) -> TwilioService:
|
||||
"""Get TwilioService instance for specific organization"""
|
||||
return TwilioService(organization_id)
|
||||
|
||||
async def get_org_concurrent_limit(self, organization_id: int) -> int:
|
||||
"""Get the concurrent call limit for an organization."""
|
||||
|
|
@ -225,15 +221,16 @@ class CampaignCallDispatcher:
|
|||
|
||||
# Initiate call via Twilio
|
||||
try:
|
||||
call_result = await self.twilio_service.initiate_call(
|
||||
twilio_service = self.get_twilio_service(campaign.organization_id)
|
||||
call_result = await twilio_service.initiate_call(
|
||||
to_number=phone_number,
|
||||
workflow_run_id=workflow_run.id,
|
||||
organization_id=campaign.organization_id,
|
||||
url_args={
|
||||
"workflow_id": campaign.workflow_id,
|
||||
"user_id": campaign.created_by,
|
||||
"workflow_run_id": workflow_run.id,
|
||||
"campaign_id": campaign.id,
|
||||
"organization_id": campaign.organization_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -88,12 +88,13 @@ async def run_pipeline_twilio(
|
|||
# Create audio configuration for Twilio
|
||||
audio_config = create_audio_config(WorkflowRunMode.TWILIO.value)
|
||||
|
||||
transport = create_twilio_transport(
|
||||
transport = await create_twilio_transport(
|
||||
websocket_client,
|
||||
stream_sid,
|
||||
call_sid,
|
||||
workflow_run_id,
|
||||
audio_config,
|
||||
workflow.organization_id,
|
||||
vad_config,
|
||||
ambient_noise_config,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import os
|
|||
from fastapi import WebSocket
|
||||
|
||||
from api.constants import APP_ROOT_DIR, ENABLE_RNNOISE, ENABLE_SMART_TURN
|
||||
from api.db import db_client
|
||||
from api.enums import OrganizationConfigurationKey
|
||||
from api.services.looptalk.internal_transport import InternalTransport
|
||||
from api.services.pipecat.audio_config import AudioConfig
|
||||
from api.services.smart_turn.websocket_smart_turn import (
|
||||
|
|
@ -69,23 +71,43 @@ def create_turn_analyzer(workflow_run_id: int, audio_config: AudioConfig):
|
|||
return None
|
||||
|
||||
|
||||
def create_twilio_transport(
|
||||
async def create_twilio_transport(
|
||||
websocket_client: WebSocket,
|
||||
stream_sid: str,
|
||||
call_sid: str,
|
||||
workflow_run_id: int,
|
||||
audio_config: AudioConfig,
|
||||
organization_id: int,
|
||||
vad_config: dict | None = None,
|
||||
ambient_noise_config: dict | None = None,
|
||||
):
|
||||
"""Create a transport for Twilio connections"""
|
||||
|
||||
# Fetch Twilio credentials from organization config
|
||||
config = await db_client.get_configuration(
|
||||
organization_id, OrganizationConfigurationKey.TWILIO_CONFIGURATION.value
|
||||
)
|
||||
|
||||
if not config or not config.value:
|
||||
raise ValueError(
|
||||
f"Twilio credentials not configured for organization {organization_id}"
|
||||
)
|
||||
|
||||
account_sid = config.value.get("account_sid")
|
||||
auth_token = config.value.get("auth_token")
|
||||
|
||||
if not account_sid or not auth_token:
|
||||
raise ValueError(
|
||||
f"Incomplete Twilio configuration for organization {organization_id}"
|
||||
)
|
||||
|
||||
turn_analyzer = create_turn_analyzer(workflow_run_id, audio_config)
|
||||
|
||||
serializer = TwilioFrameSerializer(
|
||||
stream_sid=stream_sid,
|
||||
call_sid=call_sid,
|
||||
account_sid=os.environ["TWILIO_ACCOUNT_SID"],
|
||||
auth_token=os.environ["TWILIO_AUTH_TOKEN"],
|
||||
account_sid=account_sid,
|
||||
auth_token=auth_token,
|
||||
)
|
||||
|
||||
return FastAPIWebsocketTransport(
|
||||
|
|
|
|||
|
|
@ -7,72 +7,65 @@ from loguru import logger
|
|||
from pydantic import ValidationError
|
||||
from twilio.request_validator import RequestValidator
|
||||
|
||||
from api.constants import (
|
||||
BACKEND_API_ENDPOINT,
|
||||
TWILIO_ACCOUNT_SID,
|
||||
TWILIO_AUTH_TOKEN,
|
||||
TWILIO_DEFAULT_FROM_NUMBER,
|
||||
)
|
||||
from api.db import db_client
|
||||
from api.enums import OrganizationConfigurationKey
|
||||
from api.utils.tunnel import TunnelURLProvider
|
||||
|
||||
|
||||
class TwilioService:
|
||||
"""Service for interacting with Twilio API."""
|
||||
|
||||
def __init__(self):
|
||||
if (
|
||||
not TWILIO_DEFAULT_FROM_NUMBER
|
||||
or not TWILIO_ACCOUNT_SID
|
||||
or not TWILIO_AUTH_TOKEN
|
||||
):
|
||||
def __init__(self, organization_id: int):
|
||||
"""Initialize TwilioService with organization_id."""
|
||||
self.organization_id = organization_id
|
||||
self.account_sid = None
|
||||
self.auth_token = None
|
||||
self.from_numbers = []
|
||||
self.base_url = None
|
||||
|
||||
async def _ensure_credentials(self):
|
||||
"""Load credentials from organization configuration."""
|
||||
if self.account_sid and self.auth_token:
|
||||
return
|
||||
|
||||
# Fetch from organization config only - no env var fallback
|
||||
config = await db_client.get_configuration(
|
||||
self.organization_id,
|
||||
OrganizationConfigurationKey.TWILIO_CONFIGURATION.value,
|
||||
)
|
||||
|
||||
if not config or not config.value:
|
||||
raise ValidationError(
|
||||
"Please set TWILIO_DEFAULT_FROM_NUMBER, TWILIO_ACCOUNT_SID, and TWILIO_AUTH_TOKEN environment"
|
||||
"variables to use TwilioService"
|
||||
"Twilio credentials not configured for this organization. "
|
||||
"Please configure telephony settings."
|
||||
)
|
||||
|
||||
self.account_sid = TWILIO_ACCOUNT_SID
|
||||
self.auth_token = TWILIO_AUTH_TOKEN
|
||||
self.default_from_number = TWILIO_DEFAULT_FROM_NUMBER
|
||||
self.account_sid = config.value.get("account_sid")
|
||||
self.auth_token = config.value.get("auth_token")
|
||||
self.from_numbers = config.value.get("from_numbers", [])
|
||||
|
||||
if not self.account_sid or not self.auth_token or not self.from_numbers:
|
||||
raise ValidationError(
|
||||
"Incomplete Twilio configuration. Please update telephony settings."
|
||||
)
|
||||
|
||||
self.base_url = f"https://api.twilio.com/2010-04-01/Accounts/{self.account_sid}"
|
||||
|
||||
async def get_organization_phone_numbers(self, organization_id: int) -> List[str]:
|
||||
async def get_organization_phone_numbers(self) -> List[str]:
|
||||
"""
|
||||
Get the list of Twilio phone numbers configured for an organization.
|
||||
|
||||
Args:
|
||||
organization_id: The organization ID
|
||||
Get the list of Twilio phone numbers configured for the organization.
|
||||
|
||||
Returns:
|
||||
List of phone numbers, or default if none configured
|
||||
List of phone numbers
|
||||
"""
|
||||
try:
|
||||
from api.enums import OrganizationConfigurationKey
|
||||
|
||||
config = await db_client.get_configuration(
|
||||
organization_id,
|
||||
OrganizationConfigurationKey.TWILIO_PHONE_NUMBERS.value,
|
||||
)
|
||||
|
||||
if config and config.value:
|
||||
# Expect the value to be a list of phone numbers
|
||||
phone_numbers = config.value.get("value", [])
|
||||
if isinstance(phone_numbers, list) and phone_numbers:
|
||||
return phone_numbers
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Error getting phone numbers for org {organization_id}: {e}"
|
||||
)
|
||||
|
||||
# Fall back to default from environment
|
||||
return [self.default_from_number]
|
||||
await self._ensure_credentials()
|
||||
return self.from_numbers
|
||||
|
||||
async def initiate_call(
|
||||
self,
|
||||
to_number: str,
|
||||
url_args: Dict[str, Any] = {},
|
||||
workflow_run_id: Optional[int] = None,
|
||||
organization_id: Optional[int] = None,
|
||||
**kwargs: Any,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
|
|
@ -82,21 +75,20 @@ class TwilioService:
|
|||
to_number: The destination phone number
|
||||
url_args: Dictionary of URL parameters to append to the base URL
|
||||
workflow_run_id: The workflow run ID for tracking callbacks
|
||||
organization_id: The organization ID for selecting phone numbers
|
||||
**kwargs: Additional parameters to pass to the Twilio API
|
||||
|
||||
Returns:
|
||||
Dict containing the Twilio API response
|
||||
"""
|
||||
await self._ensure_credentials()
|
||||
|
||||
endpoint = f"{self.base_url}/Calls.json"
|
||||
|
||||
if not BACKEND_API_ENDPOINT:
|
||||
raise ValidationError(
|
||||
"Please set BACKEND_API_ENDPOINT environment variable to a tunnel or persistant URL"
|
||||
)
|
||||
# Get tunnel URL at runtime
|
||||
backend_endpoint = await TunnelURLProvider.get_tunnel_url()
|
||||
|
||||
# Construct the URL with parameters if any
|
||||
url: str = f"https://{BACKEND_API_ENDPOINT}/api/v1/twilio/twiml"
|
||||
url: str = f"https://{backend_endpoint}/api/v1/twilio/twiml"
|
||||
if url_args:
|
||||
query_string = urlencode(url_args)
|
||||
url = f"{url}?{query_string}"
|
||||
|
|
@ -104,27 +96,19 @@ class TwilioService:
|
|||
logger.debug(f"Initiating call with URL: {url}")
|
||||
|
||||
# Get phone numbers for organization and select one randomly
|
||||
if organization_id:
|
||||
phone_numbers = await self.get_organization_phone_numbers(organization_id)
|
||||
from_number = random.choice(phone_numbers)
|
||||
logger.info(
|
||||
f"Selected phone number {from_number} from {len(phone_numbers)} "
|
||||
f"available numbers for org {organization_id}"
|
||||
)
|
||||
else:
|
||||
from_number = self.default_from_number
|
||||
phone_numbers = await self.get_organization_phone_numbers()
|
||||
from_number = random.choice(phone_numbers)
|
||||
logger.info(
|
||||
f"Selected phone number {from_number} from {len(phone_numbers)} "
|
||||
f"available numbers for org {self.organization_id}"
|
||||
)
|
||||
|
||||
# Prepare call data
|
||||
data = {"To": to_number, "From": from_number, "Url": url}
|
||||
|
||||
if not BACKEND_API_ENDPOINT:
|
||||
raise ValidationError(
|
||||
"Please set BACKEND_API_ENDPOINT environment variable to a tunnel or persistant URL"
|
||||
)
|
||||
|
||||
# Add status callback configuration if workflow_run_id is provided
|
||||
if workflow_run_id:
|
||||
callback_url = f"https://{BACKEND_API_ENDPOINT}/api/v1/twilio/status-callback/{workflow_run_id}"
|
||||
callback_url = f"https://{backend_endpoint}/api/v1/twilio/status-callback/{workflow_run_id}"
|
||||
data.update(
|
||||
{
|
||||
"StatusCallback": callback_url,
|
||||
|
|
@ -154,15 +138,13 @@ class TwilioService:
|
|||
async def get_start_call_twiml(
|
||||
self, workflow_id: int, user_id: int, workflow_run_id: int
|
||||
) -> str:
|
||||
if not BACKEND_API_ENDPOINT:
|
||||
raise ValidationError(
|
||||
"Please set BACKEND_API_ENDPOINT environment variable to a tunnel or persistant URL"
|
||||
)
|
||||
# Get tunnel URL at runtime
|
||||
backend_endpoint = await TunnelURLProvider.get_tunnel_url()
|
||||
|
||||
twiml_content = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Response>
|
||||
<Connect>
|
||||
<Stream url="wss://{BACKEND_API_ENDPOINT}/api/v1/twilio/ws/{workflow_id}/{user_id}/{workflow_run_id}"></Stream>
|
||||
<Stream url="wss://{backend_endpoint}/api/v1/twilio/ws/{workflow_id}/{user_id}/{workflow_run_id}"></Stream>
|
||||
</Connect>
|
||||
<Pause length="40"/>
|
||||
</Response>"""
|
||||
|
|
@ -178,6 +160,8 @@ class TwilioService:
|
|||
Returns:
|
||||
Dict containing the call information
|
||||
"""
|
||||
await self._ensure_credentials()
|
||||
|
||||
endpoint = f"{self.base_url}/Calls/{call_sid}.json"
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
|
|
@ -189,7 +173,7 @@ class TwilioService:
|
|||
|
||||
return await response.json()
|
||||
|
||||
def verify_signature(
|
||||
async def verify_signature(
|
||||
self, url: str, params: Dict[str, Any], signature: str
|
||||
) -> bool:
|
||||
"""
|
||||
|
|
@ -203,5 +187,7 @@ class TwilioService:
|
|||
Returns:
|
||||
bool: True if signature is valid, False otherwise
|
||||
"""
|
||||
await self._ensure_credentials()
|
||||
|
||||
validator = RequestValidator(self.auth_token)
|
||||
return validator.validate(url, params, signature)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,15 @@
|
|||
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Optional, Union
|
||||
|
||||
from api.constants import DEPLOYMENT_MODE, ENABLE_TRACING, VOICEMAIL_RECORDING_DURATION
|
||||
from api.services.gender.gender_service import GenderService
|
||||
from api.services.workflow.disposition_mapper import (
|
||||
apply_disposition_mapping,
|
||||
get_organization_id_from_workflow_run,
|
||||
)
|
||||
from api.services.workflow.pipecat_engine_voicemail_detector import (
|
||||
VoicemailDetector,
|
||||
)
|
||||
from api.services.workflow.workflow import Node, WorkflowGraph
|
||||
from pipecat.frames.frames import (
|
||||
CancelFrame,
|
||||
EndFrame,
|
||||
|
|
@ -15,32 +25,18 @@ from pipecat.services.openai.llm import OpenAILLMContext
|
|||
from pipecat.transports.base_transport import BaseTransport
|
||||
from pipecat.utils.enums import EndTaskReason
|
||||
|
||||
from api.constants import DEPLOYMENT_MODE, ENABLE_TRACING, VOICEMAIL_RECORDING_DURATION
|
||||
from api.services.gender.gender_service import GenderService
|
||||
from api.services.workflow.disposition_mapper import (
|
||||
apply_disposition_mapping,
|
||||
get_organization_id_from_workflow_run,
|
||||
)
|
||||
from api.services.workflow.pipecat_engine_voicemail_detector import (
|
||||
VoicemailDetector,
|
||||
)
|
||||
from api.services.workflow.workflow import Node, WorkflowGraph
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from api.services.telephony.stasis_rtp_connection import StasisRTPConnection
|
||||
from pipecat.processors.audio.audio_buffer_processor import AudioBuffer
|
||||
from pipecat.services.anthropic.llm import AnthropicLLMService
|
||||
from pipecat.services.google.llm import GoogleLLMService
|
||||
from pipecat.services.openai.llm import OpenAILLMService
|
||||
|
||||
from api.services.telephony.stasis_rtp_connection import StasisRTPConnection
|
||||
|
||||
LLMService = Union[OpenAILLMService, AnthropicLLMService, GoogleLLMService]
|
||||
|
||||
import asyncio
|
||||
|
||||
from loguru import logger
|
||||
from pipecat.processors.filters.stt_mute_filter import STTMuteFilter
|
||||
from pipecat.utils.tracing.context_registry import get_current_turn_context
|
||||
|
||||
from api.services.workflow import pipecat_engine_callbacks as engine_callbacks
|
||||
from api.services.workflow.pipecat_engine_utils import (
|
||||
|
|
@ -57,6 +53,8 @@ from api.services.workflow.tools.timezone import (
|
|||
get_current_time,
|
||||
get_time_tools,
|
||||
)
|
||||
from pipecat.processors.filters.stt_mute_filter import STTMuteFilter
|
||||
from pipecat.utils.tracing.context_registry import get_current_turn_context
|
||||
|
||||
|
||||
class PipecatEngine:
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
from loguru import logger
|
||||
from pipecat.utils.context import set_current_run_id
|
||||
|
||||
from api.db import db_client
|
||||
from api.enums import WorkflowRunMode
|
||||
from api.services.pricing.cost_calculator import cost_calculator
|
||||
from api.services.telephony.twilio import TwilioService
|
||||
from pipecat.utils.context import set_current_run_id
|
||||
|
||||
|
||||
async def calculate_workflow_run_cost(ctx, workflow_run_id: int):
|
||||
|
|
@ -32,7 +32,15 @@ async def calculate_workflow_run_cost(ctx, workflow_run_id: int):
|
|||
twilio_call_sid = workflow_run.cost_info.get("twilio_call_sid")
|
||||
if twilio_call_sid:
|
||||
try:
|
||||
twilio_service = TwilioService()
|
||||
# Get workflow to access organization_id
|
||||
workflow = await db_client.get_workflow_by_id(
|
||||
workflow_run.workflow_id
|
||||
)
|
||||
if not workflow:
|
||||
logger.warning("Workflow not found for workflow run")
|
||||
raise Exception("Workflow not found")
|
||||
|
||||
twilio_service = TwilioService(workflow.organization_id)
|
||||
call_info = await twilio_service.get_call(twilio_call_sid)
|
||||
# Twilio returns price as a string with negative value (e.g., "-0.0085")
|
||||
if call_info.get("price"):
|
||||
|
|
|
|||
104
api/utils/tunnel.py
Normal file
104
api/utils/tunnel.py
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
"""Utility for getting the cloudflared tunnel URL at runtime."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
import aiohttp
|
||||
from loguru import logger
|
||||
|
||||
|
||||
class TunnelURLProvider:
|
||||
"""Provider for getting the tunnel URL from cloudflared or environment."""
|
||||
|
||||
@classmethod
|
||||
async def get_tunnel_url(cls) -> str:
|
||||
"""
|
||||
Get the tunnel URL for external access.
|
||||
|
||||
Priority:
|
||||
1. BACKEND_API_ENDPOINT environment variable (if set)
|
||||
2. Query cloudflared metrics endpoint
|
||||
3. Raise error if neither available
|
||||
|
||||
Returns:
|
||||
str: The tunnel domain (without protocol)
|
||||
|
||||
Raises:
|
||||
ValueError: If no tunnel URL can be determined
|
||||
"""
|
||||
# First priority: Check environment variable
|
||||
env_endpoint = os.getenv("BACKEND_API_ENDPOINT")
|
||||
if env_endpoint:
|
||||
logger.debug(f"Using BACKEND_API_ENDPOINT from environment: {env_endpoint}")
|
||||
return env_endpoint
|
||||
|
||||
# Second priority: Query cloudflared
|
||||
try:
|
||||
# Try to get URL from cloudflared metrics
|
||||
url = await cls._get_cloudflared_url()
|
||||
if url:
|
||||
logger.info(f"Retrieved tunnel URL from cloudflared: {url}")
|
||||
return url
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get tunnel URL from cloudflared: {e}")
|
||||
|
||||
raise ValueError(
|
||||
"No tunnel URL available. Please set BACKEND_API_ENDPOINT environment "
|
||||
"variable or ensure cloudflared service is running."
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def _get_cloudflared_url(cls) -> Optional[str]:
|
||||
"""
|
||||
Query cloudflared metrics endpoint to get the tunnel URL.
|
||||
|
||||
Returns:
|
||||
Optional[str]: The tunnel domain (without protocol), or None if not found
|
||||
"""
|
||||
try:
|
||||
# Try to connect to cloudflared metrics endpoint
|
||||
# The service name in docker-compose is 'cloudflared'
|
||||
metrics_url = "http://cloudflared:2000/metrics"
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(
|
||||
metrics_url, timeout=aiohttp.ClientTimeout(total=5)
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
logger.warning(
|
||||
f"Cloudflared metrics returned status {response.status}"
|
||||
)
|
||||
return None
|
||||
|
||||
text = await response.text()
|
||||
|
||||
# Look for the tunnel URL in metrics
|
||||
# Cloudflared exposes this in the userHostname metric
|
||||
match = re.search(r'userHostname="([^"]+)"', text)
|
||||
if match:
|
||||
hostname = match.group(1)
|
||||
# Remove https:// or wss:// if present
|
||||
hostname = hostname.replace("https://", "").replace(
|
||||
"wss://", ""
|
||||
)
|
||||
return hostname
|
||||
|
||||
# Alternative: Look for trycloudflare.com domain
|
||||
match = re.search(r"([a-z0-9-]+\.trycloudflare\.com)", text)
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
logger.warning("Could not find tunnel URL in cloudflared metrics")
|
||||
return None
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("Timeout connecting to cloudflared metrics endpoint")
|
||||
return None
|
||||
except aiohttp.ClientError as e:
|
||||
logger.warning(f"Error connecting to cloudflared: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error getting cloudflared URL: {e}")
|
||||
return None
|
||||
|
|
@ -102,6 +102,8 @@ services:
|
|||
condition: service_healthy
|
||||
minio:
|
||||
condition: service_healthy
|
||||
cloudflared:
|
||||
condition: service_started
|
||||
command: >
|
||||
bash -c "
|
||||
cd /app/api &&
|
||||
|
|
@ -201,6 +203,15 @@ services:
|
|||
networks:
|
||||
- app-network
|
||||
|
||||
cloudflared:
|
||||
image: cloudflare/cloudflared:latest
|
||||
container_name: cloudflared-tunnel
|
||||
command: tunnel --no-autoupdate --url http://api:8000 --metrics 0.0.0.0:2000
|
||||
ports:
|
||||
- "2000:2000" # Expose metrics endpoint
|
||||
networks:
|
||||
- app-network
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
|
|
|
|||
14
ui/src/app/configure-telephony/layout.tsx
Normal file
14
ui/src/app/configure-telephony/layout.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import BaseHeader from "@/components/header/BaseHeader"
|
||||
|
||||
export default function ConfigureTelephonyLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<BaseHeader />
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
270
ui/src/app/configure-telephony/page.tsx
Normal file
270
ui/src/app/configure-telephony/page.tsx
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
"use client";
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { getTelephonyConfigurationApiV1OrganizationsTelephonyConfigGet, saveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPost } from "@/client/sdk.gen";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
|
||||
interface TelephonyConfigForm {
|
||||
provider: string;
|
||||
account_sid: string;
|
||||
auth_token: string;
|
||||
from_number: string;
|
||||
}
|
||||
|
||||
export default function ConfigureTelephonyPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { user, getAccessToken, loading: authLoading } = useAuth();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [hasExistingConfig, setHasExistingConfig] = useState(false);
|
||||
|
||||
// Get returnTo parameter from URL
|
||||
const returnTo = searchParams.get("returnTo") || "/workflow";
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
setValue,
|
||||
watch,
|
||||
} = useForm<TelephonyConfigForm>({
|
||||
defaultValues: {
|
||||
provider: "twilio",
|
||||
},
|
||||
});
|
||||
|
||||
const selectedProvider = watch("provider");
|
||||
|
||||
useEffect(() => {
|
||||
// Don't fetch config while auth is still loading
|
||||
if (authLoading || !user) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch existing configuration with masked sensitive fields
|
||||
const fetchConfig = async () => {
|
||||
try {
|
||||
const accessToken = await getAccessToken();
|
||||
const response = await getTelephonyConfigurationApiV1OrganizationsTelephonyConfigGet({
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
|
||||
if (!response.error && response.data?.twilio) {
|
||||
setHasExistingConfig(true);
|
||||
// Masked values like "****************def0" from backend
|
||||
setValue("account_sid", response.data.twilio.account_sid);
|
||||
setValue("auth_token", response.data.twilio.auth_token);
|
||||
if (response.data.twilio.from_numbers?.length > 0) {
|
||||
setValue("from_number", response.data.twilio.from_numbers[0]);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch config:", error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchConfig();
|
||||
}, [setValue, getAccessToken, authLoading, user]);
|
||||
|
||||
const onSubmit = async (data: TelephonyConfigForm) => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const accessToken = await getAccessToken();
|
||||
const response = await saveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPost({
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
body: {
|
||||
provider: data.provider,
|
||||
account_sid: data.account_sid,
|
||||
auth_token: data.auth_token,
|
||||
from_numbers: [data.from_number],
|
||||
},
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
const errorMsg = typeof response.error === 'string'
|
||||
? response.error
|
||||
: (response.error as { detail?: string })?.detail || "Failed to save configuration";
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
toast.success("Telephony configuration saved successfully");
|
||||
|
||||
// Redirect back to the page that sent us here
|
||||
router.push(returnTo);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to save configuration"
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-2">Configure Telephony</h1>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Set up your telephony provider to make phone calls
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<Card className="h-full">
|
||||
<CardHeader>
|
||||
<CardTitle>Setup Guide</CardTitle>
|
||||
<CardDescription>
|
||||
Watch this video to learn how to setup telephony
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="aspect-video">
|
||||
<iframe
|
||||
style={{ border: 0 }}
|
||||
width="100%"
|
||||
height="100%"
|
||||
src="https://www.tella.tv/video/cmgbvzkrt00jk0clacu16blm3/embed?b=0&title=1&a=1&loop=0&t=0&muted=0&wt=0"
|
||||
allowFullScreen
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<div>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Provider Configuration</CardTitle>
|
||||
<CardDescription>
|
||||
Configure your telephony provider settings
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Provider Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label>Provider</Label>
|
||||
<Select
|
||||
value={selectedProvider}
|
||||
onValueChange={(value) => setValue("provider", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="twilio">Twilio</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Twilio-specific fields */}
|
||||
{selectedProvider === "twilio" && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="account_sid">Account SID</Label>
|
||||
<Input
|
||||
id="account_sid"
|
||||
autoComplete="username"
|
||||
placeholder="ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
{...register("account_sid", {
|
||||
required: "Account SID is required",
|
||||
})}
|
||||
/>
|
||||
{errors.account_sid && (
|
||||
<p className="text-sm text-red-500">
|
||||
{errors.account_sid.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="auth_token">Auth Token</Label>
|
||||
<Input
|
||||
id="auth_token"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
placeholder={
|
||||
hasExistingConfig
|
||||
? "Leave masked to keep existing"
|
||||
: "Enter your auth token"
|
||||
}
|
||||
{...register("auth_token", {
|
||||
required: !hasExistingConfig
|
||||
? "Auth token is required"
|
||||
: false,
|
||||
})}
|
||||
/>
|
||||
{errors.auth_token && (
|
||||
<p className="text-sm text-red-500">
|
||||
{errors.auth_token.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="from_number">From Phone Number</Label>
|
||||
<Input
|
||||
id="from_number"
|
||||
autoComplete="tel"
|
||||
placeholder="+1234567890"
|
||||
{...register("from_number", {
|
||||
required: "Phone number is required",
|
||||
pattern: {
|
||||
value: /^\+[1-9]\d{1,14}$/,
|
||||
message:
|
||||
"Enter a valid phone number with country code (e.g., +1234567890)",
|
||||
},
|
||||
})}
|
||||
/>
|
||||
{errors.from_number && (
|
||||
<p className="text-sm text-red-500">
|
||||
{errors.from_number.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="pt-4">
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? "Saving..." : "Save Configuration"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,10 +2,11 @@ import 'react-international-phone/style.css';
|
|||
|
||||
import { ReactFlowInstance, ReactFlowJsonObject } from "@xyflow/react";
|
||||
import { AlertTriangle, CheckCheck, Download, LoaderCircle, Phone, ShieldCheck } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { PhoneInput } from 'react-international-phone';
|
||||
|
||||
import { initiateCallApiV1TwilioInitiateCallPost } from '@/client/sdk.gen';
|
||||
import { getTelephonyConfigurationApiV1OrganizationsTelephonyConfigGet, initiateCallApiV1TwilioInitiateCallPost } from '@/client/sdk.gen';
|
||||
import { WorkflowError } from '@/client/types.gen';
|
||||
import { FlowEdge, FlowNode } from "@/components/flow/types";
|
||||
import { OnboardingTooltip } from '@/components/onboarding/OnboardingTooltip';
|
||||
|
|
@ -16,7 +17,6 @@ import { WORKFLOW_RUN_MODES } from '@/constants/workflowRunModes';
|
|||
import { useOnboarding } from '@/context/OnboardingContext';
|
||||
import { useUserConfig } from "@/context/UserConfigContext";
|
||||
import { useAuth } from '@/lib/auth';
|
||||
import logger from '@/lib/logger';
|
||||
|
||||
interface WorkflowHeaderProps {
|
||||
isDirty: boolean;
|
||||
|
|
@ -58,24 +58,22 @@ const handleExport = (workflow_name: string, workflow_definition: ReactFlowJsonO
|
|||
};
|
||||
|
||||
const WorkflowHeader = ({ isDirty, workflowName, rfInstance, onRun, workflowId, workflowValidationErrors, saveWorkflow }: WorkflowHeaderProps) => {
|
||||
const router = useRouter();
|
||||
const { userConfig, saveUserConfig } = useUserConfig();
|
||||
const { hasSeenTooltip, markTooltipSeen } = useOnboarding();
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [phoneNumber, setPhoneNumber] = useState(userConfig?.test_phone_number || "");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [savingWorkflow, setSavingWorkflow] = useState(false);
|
||||
const [callLoading, setCallLoading] = useState(false);
|
||||
const [callError, setCallError] = useState<string | null>(null);
|
||||
const [callSuccessMsg, setCallSuccessMsg] = useState<string | null>(null);
|
||||
const [phoneChanged, setPhoneChanged] = useState(false);
|
||||
const [validationDialogOpen, setValidationDialogOpen] = useState(false);
|
||||
const [configureDialogOpen, setConfigureDialogOpen] = useState(false);
|
||||
const { user, getAccessToken } = useAuth();
|
||||
const webCallButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const hasValidationErrors = workflowValidationErrors.length > 0;
|
||||
const isOSSDeployment = process.env.NEXT_PUBLIC_DEPLOYMENT_MODE === 'oss';
|
||||
|
||||
logger.info(`isOSSDeployment: ${isOSSDeployment}`);
|
||||
|
||||
// Reset call-related state whenever the dialog is closed so that a new call can be placed
|
||||
useEffect(() => {
|
||||
|
|
@ -95,7 +93,6 @@ const WorkflowHeader = ({ isDirty, workflowName, rfInstance, onRun, workflowId,
|
|||
setCallError(null);
|
||||
setCallSuccessMsg(null);
|
||||
setCallLoading(false);
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -111,31 +108,54 @@ const WorkflowHeader = ({ isDirty, workflowName, rfInstance, onRun, workflowId,
|
|||
setCallSuccessMsg(null);
|
||||
};
|
||||
|
||||
|
||||
const handleSavePhone = async () => {
|
||||
if (!userConfig) return;
|
||||
setSaving(true);
|
||||
const handlePhoneCallClick = async () => {
|
||||
// Check telephony configuration before opening dialog
|
||||
try {
|
||||
await saveUserConfig({ ...userConfig, test_phone_number: phoneNumber });
|
||||
setPhoneChanged(false);
|
||||
const accessToken = await getAccessToken();
|
||||
const configResponse = await getTelephonyConfigurationApiV1OrganizationsTelephonyConfigGet({
|
||||
headers: { 'Authorization': `Bearer ${accessToken}` },
|
||||
});
|
||||
|
||||
// If no configuration exists, show configure dialog
|
||||
if (configResponse.error || !configResponse.data?.twilio) {
|
||||
setConfigureDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Configuration exists, open the phone call dialog
|
||||
setDialogOpen(true);
|
||||
} catch (err: unknown) {
|
||||
setCallError(err instanceof Error ? err.message : "Failed to save phone number");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
console.error("Failed to check telephony config:", err);
|
||||
// Still open dialog to show the error
|
||||
setDialogOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfigureContinue = () => {
|
||||
setConfigureDialogOpen(false);
|
||||
router.push(`/configure-telephony?returnTo=/workflow/${workflowId}`);
|
||||
};
|
||||
|
||||
const handleStartCall = async () => {
|
||||
setCallLoading(true);
|
||||
setCallError(null);
|
||||
setCallSuccessMsg(null);
|
||||
try {
|
||||
if (!user) return;
|
||||
if (!user || !userConfig) return;
|
||||
const accessToken = await getAccessToken();
|
||||
|
||||
// Save phone number if it has changed
|
||||
if (phoneChanged) {
|
||||
await saveUserConfig({ ...userConfig, test_phone_number: phoneNumber });
|
||||
setPhoneChanged(false);
|
||||
}
|
||||
|
||||
// Configuration exists, proceed with call initiation
|
||||
const response = await initiateCallApiV1TwilioInitiateCallPost({
|
||||
body: { workflow_id: workflowId },
|
||||
headers: { 'Authorization': `Bearer ${accessToken}` },
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
let errMsg = "Failed to initiate call";
|
||||
if (typeof response.error === "string") {
|
||||
|
|
@ -211,17 +231,15 @@ const WorkflowHeader = ({ isDirty, workflowName, rfInstance, onRun, workflowId,
|
|||
<Phone className="mr-2 h-4 w-4" />
|
||||
Web Call
|
||||
</Button>
|
||||
{!isOSSDeployment && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setDialogOpen(true)}
|
||||
disabled={hasValidationErrors}
|
||||
>
|
||||
<Phone className="mr-2 h-4 w-4" />
|
||||
Phone Call
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handlePhoneCallClick}
|
||||
disabled={hasValidationErrors}
|
||||
>
|
||||
<Phone className="mr-2 h-4 w-4" />
|
||||
Phone Call
|
||||
</Button>
|
||||
|
||||
{isDirty ? (
|
||||
<Button
|
||||
|
|
@ -293,7 +311,7 @@ const WorkflowHeader = ({ isDirty, workflowName, rfInstance, onRun, workflowId,
|
|||
<DialogHeader>
|
||||
<DialogTitle>Phone Call</DialogTitle>
|
||||
<DialogDescription>
|
||||
Enter the phone number to call. This will be saved to your user config.
|
||||
Enter the phone number to call. The number will be saved automatically.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<PhoneInput
|
||||
|
|
@ -301,43 +319,65 @@ const WorkflowHeader = ({ isDirty, workflowName, rfInstance, onRun, workflowId,
|
|||
value={phoneNumber}
|
||||
onChange={handlePhoneInputChange}
|
||||
/>
|
||||
{phoneChanged && (
|
||||
<DialogFooter className="flex-col sm:flex-row gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleSavePhone}
|
||||
disabled={saving}
|
||||
onClick={() => {
|
||||
setDialogOpen(false);
|
||||
router.push(`/configure-telephony?returnTo=/workflow/${workflowId}`);
|
||||
}}
|
||||
>
|
||||
{saving ? "Saving..." : "Save Number"}
|
||||
Configure Telephony
|
||||
</Button>
|
||||
)}
|
||||
<DialogFooter>
|
||||
{!callSuccessMsg ? (
|
||||
<Button
|
||||
onClick={handleStartCall}
|
||||
disabled={callLoading || phoneChanged || !phoneNumber || saving}
|
||||
>
|
||||
{callLoading ? "Calling..." : "Start Call"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={() => setDialogOpen(false)}>
|
||||
Close
|
||||
</Button>
|
||||
)}
|
||||
<DialogClose asChild>
|
||||
<Button variant="ghost">Cancel</Button>
|
||||
</DialogClose>
|
||||
<div className="flex gap-2 flex-1 justify-end">
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
</DialogClose>
|
||||
{!callSuccessMsg ? (
|
||||
<Button
|
||||
onClick={handleStartCall}
|
||||
disabled={callLoading || !phoneNumber}
|
||||
>
|
||||
{callLoading ? "Calling..." : "Start Call"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={() => setDialogOpen(false)}>
|
||||
Close
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</DialogFooter>
|
||||
{callError && <div className="text-red-500 text-sm mt-2">{callError}</div>}
|
||||
{callSuccessMsg && <div className="text-green-600 text-sm mt-2">{callSuccessMsg}</div>}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Configure Telephony Dialog */}
|
||||
<Dialog open={configureDialogOpen} onOpenChange={setConfigureDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Configure Telephony</DialogTitle>
|
||||
<DialogDescription>
|
||||
You need to configure your telephony settings before making phone calls.
|
||||
You will be redirected to the telephony configuration page.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setConfigureDialogOpen(false)}>
|
||||
Do it Later
|
||||
</Button>
|
||||
<Button onClick={handleConfigureContinue}>
|
||||
Continue
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Onboarding Tooltip */}
|
||||
<OnboardingTooltip
|
||||
title='Test your Voice Agent'
|
||||
targetRef={webCallButtonRef}
|
||||
message="Test this workflow now in your browser (no phone required)"
|
||||
message="Test this workflow now in your browser using Web Call"
|
||||
onDismiss={() => markTooltipSeen('web_call')}
|
||||
showNext={false}
|
||||
isVisible={!hasSeenTooltip('web_call') && !hasValidationErrors}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -358,6 +358,13 @@ export type SuperuserWorkflowRunsListResponse = {
|
|||
total_pages: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Top-level telephony configuration response.
|
||||
*/
|
||||
export type TelephonyConfigurationResponse = {
|
||||
twilio?: TwilioConfigurationResponse | null;
|
||||
};
|
||||
|
||||
export type TestSessionResponse = {
|
||||
id: number;
|
||||
name: string;
|
||||
|
|
@ -378,6 +385,35 @@ export type TestSessionResponse = {
|
|||
completed_at: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Request schema for Twilio configuration.
|
||||
*/
|
||||
export type TwilioConfigurationRequest = {
|
||||
provider?: string;
|
||||
/**
|
||||
* Twilio Account SID
|
||||
*/
|
||||
account_sid: string;
|
||||
/**
|
||||
* Twilio Auth Token
|
||||
*/
|
||||
auth_token: string;
|
||||
/**
|
||||
* List of Twilio phone numbers
|
||||
*/
|
||||
from_numbers: Array<string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Response schema for Twilio configuration with masked sensitive fields.
|
||||
*/
|
||||
export type TwilioConfigurationResponse = {
|
||||
provider: string;
|
||||
account_sid: string;
|
||||
auth_token: string;
|
||||
from_numbers: Array<string>;
|
||||
};
|
||||
|
||||
export type UpdateIntegrationRequest = {
|
||||
selected_files: Array<{
|
||||
[key: string]: unknown;
|
||||
|
|
@ -1854,6 +1890,68 @@ export type GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGet
|
|||
|
||||
export type GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetResponse = GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetResponses[keyof GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetResponses];
|
||||
|
||||
export type GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/api/v1/organizations/telephony-config';
|
||||
};
|
||||
|
||||
export type GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetErrors = {
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
404: unknown;
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetError = GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetErrors[keyof GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetErrors];
|
||||
|
||||
export type GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: TelephonyConfigurationResponse;
|
||||
};
|
||||
|
||||
export type GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetResponse = GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetResponses[keyof GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetResponses];
|
||||
|
||||
export type SaveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPostData = {
|
||||
body: TwilioConfigurationRequest;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/api/v1/organizations/telephony-config';
|
||||
};
|
||||
|
||||
export type SaveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPostErrors = {
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
404: unknown;
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type SaveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPostError = SaveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPostErrors[keyof SaveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPostErrors];
|
||||
|
||||
export type SaveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPostResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: unknown;
|
||||
};
|
||||
|
||||
export type GetSignedUrlApiV1S3SignedUrlGetData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { CircleDollarSign, Loader2, Star } from 'lucide-react';
|
||||
import { CircleDollarSign, Star } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import React from 'react';
|
||||
|
|
@ -23,7 +23,7 @@ interface BaseHeaderProps {
|
|||
}
|
||||
|
||||
export default function BaseHeader({ headerActions, backButton, showFeaturesNav = true }: BaseHeaderProps) {
|
||||
const { loading, permissions } = useUserConfig();
|
||||
const { permissions } = useUserConfig();
|
||||
const { provider, user } = useAuth();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
|
|
@ -109,14 +109,21 @@ export default function BaseHeader({ headerActions, backButton, showFeaturesNav
|
|||
{/* Use key to force remount when user changes to avoid hooks issues */}
|
||||
<div className="flex items-center gap-5" key={user ? 'logged-in' : 'logged-out'}>
|
||||
{provider === 'stack' ? (
|
||||
<React.Suspense fallback={<Loader2 className="w-5 h-5 animate-spin text-gray-600" />}>
|
||||
{!loading && (
|
||||
<React.Suspense fallback={
|
||||
<div className="flex items-center gap-5">
|
||||
{/* Match StackTeamSwitcher's internal skeleton */}
|
||||
<div className="h-9 w-40 animate-pulse bg-gray-100 rounded" />
|
||||
{/* Match StackUserButton dimensions: h-[34px] w-[34px] */}
|
||||
<div className="h-[34px] w-[34px] animate-pulse bg-gray-100 rounded-full" />
|
||||
</div>
|
||||
}>
|
||||
<div className="w-40 shrink-0">
|
||||
<StackTeamSwitcher
|
||||
onChange={() => {
|
||||
router.refresh();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<StackUserButton
|
||||
extraItems={[{
|
||||
text: 'Usage',
|
||||
|
|
|
|||
|
|
@ -23,24 +23,27 @@ const defaultState: OnboardingState = {
|
|||
const OnboardingContext = createContext<OnboardingContextType | undefined>(undefined);
|
||||
|
||||
export const OnboardingProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const [onboardingState, setOnboardingState] = useState<OnboardingState>(defaultState);
|
||||
|
||||
// Load state from localStorage on mount
|
||||
useEffect(() => {
|
||||
const savedState = localStorage.getItem(ONBOARDING_STORAGE_KEY);
|
||||
if (savedState) {
|
||||
try {
|
||||
const parsed = JSON.parse(savedState);
|
||||
setOnboardingState({ ...defaultState, ...parsed });
|
||||
} catch (error) {
|
||||
console.error('Failed to parse onboarding state:', error);
|
||||
const [onboardingState, setOnboardingState] = useState<OnboardingState>(() => {
|
||||
// Initialize state from localStorage on first render
|
||||
if (typeof window !== 'undefined') {
|
||||
const savedState = localStorage.getItem(ONBOARDING_STORAGE_KEY);
|
||||
if (savedState) {
|
||||
try {
|
||||
const parsed = JSON.parse(savedState);
|
||||
return { ...defaultState, ...parsed };
|
||||
} catch (error) {
|
||||
console.error('Failed to parse onboarding state:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
return defaultState;
|
||||
});
|
||||
|
||||
// Save state to localStorage whenever it changes
|
||||
useEffect(() => {
|
||||
localStorage.setItem(ONBOARDING_STORAGE_KEY, JSON.stringify(onboardingState));
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem(ONBOARDING_STORAGE_KEY, JSON.stringify(onboardingState));
|
||||
}
|
||||
}, [onboardingState]);
|
||||
|
||||
const hasSeenTooltip = (key: TooltipKey): boolean => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue