diff --git a/api/enums.py b/api/enums.py index 7cc019e..7e7e9c6 100644 --- a/api/enums.py +++ b/api/enums.py @@ -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): diff --git a/api/routes/campaign.py b/api/routes/campaign.py index 97f9cd6..195ce57 100644 --- a/api/routes/campaign.py +++ b/api/routes/campaign.py @@ -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 ( diff --git a/api/routes/main.py b/api/routes/main.py index b819e68..9c6b647 100644 --- a/api/routes/main.py +++ b/api/routes/main.py @@ -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) diff --git a/api/routes/organization.py b/api/routes/organization.py new file mode 100644 index 0000000..8b30860 --- /dev/null +++ b/api/routes/organization.py @@ -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"} diff --git a/api/routes/twilio.py b/api/routes/twilio.py index 603fde0..5532e0e 100644 --- a/api/routes/twilio.py +++ b/api/routes/twilio.py @@ -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") diff --git a/api/schemas/telephony_config.py b/api/schemas/telephony_config.py new file mode 100644 index 0000000..907a8c4 --- /dev/null +++ b/api/schemas/telephony_config.py @@ -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 diff --git a/api/services/campaign/call_dispatcher.py b/api/services/campaign/call_dispatcher.py index adedb02..6488ae2 100644 --- a/api/services/campaign/call_dispatcher.py +++ b/api/services/campaign/call_dispatcher.py @@ -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, }, ) diff --git a/api/services/pipecat/run_pipeline.py b/api/services/pipecat/run_pipeline.py index b4c2c84..768817a 100644 --- a/api/services/pipecat/run_pipeline.py +++ b/api/services/pipecat/run_pipeline.py @@ -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, ) diff --git a/api/services/pipecat/transport_setup.py b/api/services/pipecat/transport_setup.py index 9225163..7c96c51 100644 --- a/api/services/pipecat/transport_setup.py +++ b/api/services/pipecat/transport_setup.py @@ -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( diff --git a/api/services/telephony/twilio.py b/api/services/telephony/twilio.py index c599362..ea7dd2f 100644 --- a/api/services/telephony/twilio.py +++ b/api/services/telephony/twilio.py @@ -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""" - + """ @@ -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) diff --git a/api/services/workflow/pipecat_engine.py b/api/services/workflow/pipecat_engine.py index 54adf4c..7ea4bea 100644 --- a/api/services/workflow/pipecat_engine.py +++ b/api/services/workflow/pipecat_engine.py @@ -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: diff --git a/api/tasks/workflow_run_cost.py b/api/tasks/workflow_run_cost.py index e9967ff..ad5844c 100644 --- a/api/tasks/workflow_run_cost.py +++ b/api/tasks/workflow_run_cost.py @@ -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"): diff --git a/api/utils/tunnel.py b/api/utils/tunnel.py new file mode 100644 index 0000000..31a4588 --- /dev/null +++ b/api/utils/tunnel.py @@ -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 diff --git a/docker-compose.yaml b/docker-compose.yaml index a9867b3..b0f599e 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -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: diff --git a/ui/src/app/configure-telephony/layout.tsx b/ui/src/app/configure-telephony/layout.tsx new file mode 100644 index 0000000..5b3bae8 --- /dev/null +++ b/ui/src/app/configure-telephony/layout.tsx @@ -0,0 +1,14 @@ +import BaseHeader from "@/components/header/BaseHeader" + +export default function ConfigureTelephonyLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + <> + + {children} + + ) +} diff --git a/ui/src/app/configure-telephony/page.tsx b/ui/src/app/configure-telephony/page.tsx new file mode 100644 index 0000000..b6c09bf --- /dev/null +++ b/ui/src/app/configure-telephony/page.tsx @@ -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({ + 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 ( +
+
+

Configure Telephony

+

+ Set up your telephony provider to make phone calls +

+ +
+
+ + + Setup Guide + + Watch this video to learn how to setup telephony + + + +
+