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:
Abhishek 2025-10-04 12:22:50 +05:30 committed by GitHub
parent d39a8111a6
commit 8e2e5c9327
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 891 additions and 191 deletions

View file

@ -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):

View file

@ -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 (

View file

@ -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)

View 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"}

View file

@ -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")

View 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

View file

@ -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,
},
)

View file

@ -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,
)

View file

@ -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(

View file

@ -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)

View file

@ -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:

View file

@ -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
View 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