mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-19 08:28:10 +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
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue