Skip to content

Commit 8e2e5c9

Browse files
authored
feat: Enable telephony for OSS (#21)
* fix: fix tooltip bug * feat: add Twilio with CloudFlare configuration * chore: update Tella Video
1 parent d39a811 commit 8e2e5c9

21 files changed

Lines changed: 891 additions & 191 deletions

File tree

api/enums.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ class OrganizationConfigurationKey(Enum):
6262
DISPOSITION_CODE_MAPPING = "DISPOSITION_CODE_MAPPING"
6363
DISPOSITION_MESSAGE_TEMPLATE = "DISPOSITION_MESSAGE_TEMPLATE"
6464
CONCURRENT_CALL_LIMIT = "CONCURRENT_CALL_LIMIT"
65-
TWILIO_PHONE_NUMBERS = "TWILIO_PHONE_NUMBERS"
65+
TWILIO_CONFIGURATION = "TWILIO_CONFIGURATION"
6666

6767

6868
class WorkflowStatus(Enum):

api/routes/campaign.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -168,10 +168,10 @@ async def start_campaign(
168168
user: UserModel = Depends(get_user),
169169
) -> CampaignResponse:
170170
"""Start campaign execution"""
171-
# Check if organization has TWILIO_PHONE_NUMBERS configured
171+
# Check if organization has TWILIO_CONFIGURATION configured
172172
twilio_config = await db_client.get_configuration(
173173
user.selected_organization_id,
174-
OrganizationConfigurationKey.TWILIO_PHONE_NUMBERS.value,
174+
OrganizationConfigurationKey.TWILIO_CONFIGURATION.value,
175175
)
176176

177177
if (
@@ -280,10 +280,10 @@ async def resume_campaign(
280280
user: UserModel = Depends(get_user),
281281
) -> CampaignResponse:
282282
"""Resume a paused campaign"""
283-
# Check if organization has TWILIO_PHONE_NUMBERS configured
283+
# Check if organization has TWILIO_CONFIGURATION configured
284284
twilio_config = await db_client.get_configuration(
285285
user.selected_organization_id,
286-
OrganizationConfigurationKey.TWILIO_PHONE_NUMBERS.value,
286+
OrganizationConfigurationKey.TWILIO_CONFIGURATION.value,
287287
)
288288

289289
if (

api/routes/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from api.routes.campaign import router as campaign_router
55
from api.routes.integration import router as integration_router
66
from api.routes.looptalk import router as looptalk_router
7+
from api.routes.organization import router as organization_router
78
from api.routes.organization_usage import router as organization_usage_router
89
from api.routes.reports import router as reports_router
910
from api.routes.rtc_offer import router as rtc_offer_router
@@ -27,6 +28,7 @@
2728
router.include_router(user_router)
2829
router.include_router(campaign_router)
2930
router.include_router(integration_router)
31+
router.include_router(organization_router)
3032
router.include_router(s3_router)
3133
router.include_router(service_keys_router)
3234
router.include_router(looptalk_router)

api/routes/organization.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
from fastapi import APIRouter, Depends, HTTPException
2+
3+
from api.db import db_client
4+
from api.db.models import UserModel
5+
from api.enums import OrganizationConfigurationKey
6+
from api.schemas.telephony_config import (
7+
TelephonyConfigurationResponse,
8+
TwilioConfigurationRequest,
9+
TwilioConfigurationResponse,
10+
)
11+
from api.services.auth.depends import get_user
12+
from api.services.configuration.masking import is_mask_of, mask_key
13+
14+
router = APIRouter(prefix="/organizations", tags=["organizations"])
15+
16+
17+
@router.get("/telephony-config", response_model=TelephonyConfigurationResponse)
18+
async def get_telephony_configuration(user: UserModel = Depends(get_user)):
19+
"""Get telephony configuration for the user's organization with masked sensitive fields."""
20+
if not user.selected_organization_id:
21+
raise HTTPException(status_code=400, detail="No organization selected")
22+
23+
config = await db_client.get_configuration(
24+
user.selected_organization_id,
25+
OrganizationConfigurationKey.TWILIO_CONFIGURATION.value,
26+
)
27+
28+
if not config or not config.value:
29+
return TelephonyConfigurationResponse(twilio=None)
30+
31+
# Mask sensitive fields (account_sid and auth_token) before returning
32+
account_sid = config.value.get("account_sid", "")
33+
auth_token = config.value.get("auth_token", "")
34+
35+
return TelephonyConfigurationResponse(
36+
twilio=TwilioConfigurationResponse(
37+
provider="twilio",
38+
account_sid=mask_key(account_sid) if account_sid else "",
39+
auth_token=mask_key(auth_token) if auth_token else "",
40+
from_numbers=config.value.get("from_numbers", []),
41+
)
42+
)
43+
44+
45+
@router.post("/telephony-config")
46+
async def save_telephony_configuration(
47+
request: TwilioConfigurationRequest, user: UserModel = Depends(get_user)
48+
):
49+
"""Save telephony configuration for the user's organization."""
50+
if not user.selected_organization_id:
51+
raise HTTPException(status_code=400, detail="No organization selected")
52+
53+
# Fetch existing configuration to handle masked values
54+
existing_config = await db_client.get_configuration(
55+
user.selected_organization_id,
56+
OrganizationConfigurationKey.TWILIO_CONFIGURATION.value,
57+
)
58+
59+
# Build new configuration
60+
config_value = {
61+
"provider": request.provider,
62+
"account_sid": request.account_sid,
63+
"auth_token": request.auth_token,
64+
"from_numbers": request.from_numbers,
65+
}
66+
67+
# If incoming values are masked (same as stored masked value), keep the original
68+
if existing_config and existing_config.value:
69+
# Check if account_sid is unchanged (masked value matches)
70+
stored_account_sid = existing_config.value.get("account_sid", "")
71+
if stored_account_sid and is_mask_of(request.account_sid, stored_account_sid):
72+
config_value["account_sid"] = stored_account_sid # Keep original
73+
74+
# Check if auth_token is unchanged (masked value matches)
75+
stored_auth_token = existing_config.value.get("auth_token", "")
76+
if stored_auth_token and is_mask_of(request.auth_token, stored_auth_token):
77+
config_value["auth_token"] = stored_auth_token # Keep original
78+
79+
await db_client.upsert_configuration(
80+
user.selected_organization_id,
81+
OrganizationConfigurationKey.TWILIO_CONFIGURATION.value,
82+
config_value,
83+
)
84+
85+
return {"message": "Telephony configuration saved successfully"}

api/routes/twilio.py

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55

66
from fastapi import APIRouter, Depends, Form, Header, HTTPException, Request, WebSocket
77
from loguru import logger
8-
from pipecat.utils.context import set_current_run_id
98
from pydantic import BaseModel
109
from starlette.responses import HTMLResponse
1110

@@ -19,6 +18,7 @@
1918
)
2019
from api.services.pipecat.run_pipeline import run_pipeline_twilio
2120
from api.services.telephony.twilio import TwilioService
21+
from pipecat.utils.context import set_current_run_id
2222

2323
router = APIRouter(prefix="/twilio")
2424

@@ -45,20 +45,16 @@ class TwilioStatusCallbackRequest(BaseModel):
4545
async def initiate_call(
4646
request: InitiateCallRequest, user: UserModel = Depends(get_user)
4747
):
48-
# Check if organization has TWILIO_PHONE_NUMBERS configured
48+
# Check if organization has TWILIO_CONFIGURATION configured
4949
twilio_config = await db_client.get_configuration(
5050
user.selected_organization_id,
51-
OrganizationConfigurationKey.TWILIO_PHONE_NUMBERS.value,
51+
OrganizationConfigurationKey.TWILIO_CONFIGURATION.value,
5252
)
5353

54-
if (
55-
not twilio_config
56-
or not twilio_config.value
57-
or not twilio_config.value.get("value")
58-
):
54+
if not twilio_config or not twilio_config.value:
5955
raise HTTPException(
60-
status_code=401,
61-
detail="Your organisation is not allowed to make phone call. Contact founders@dograh.com for further support.",
56+
status_code=400,
57+
detail="telephony_not_configured", # Special error code
6258
)
6359

6460
user_configuration = await db_client.get_user_configurations(user.id)
@@ -84,15 +80,16 @@ async def initiate_call(
8480
workflow_run_name = workflow_run.name
8581

8682
if user_configuration.test_phone_number:
87-
await TwilioService().initiate_call(
83+
twilio_service = TwilioService(user.selected_organization_id)
84+
await twilio_service.initiate_call(
8885
to_number=user_configuration.test_phone_number,
8986
url_args={
9087
"workflow_id": request.workflow_id,
9188
"user_id": user.id,
9289
"workflow_run_id": workflow_run_id,
90+
"organization_id": user.selected_organization_id,
9391
},
9492
workflow_run_id=workflow_run_id,
95-
organization_id=user.selected_organization_id,
9693
)
9794
return {
9895
"message": f"Call initiated successfully with run name {workflow_run_name}"
@@ -102,8 +99,10 @@ async def initiate_call(
10299

103100

104101
@router.post("/twiml", include_in_schema=False)
105-
async def start_call(workflow_id: int, user_id: int, workflow_run_id: int):
106-
twiml_content = await TwilioService().get_start_call_twiml(
102+
async def start_call(
103+
workflow_id: int, user_id: int, workflow_run_id: int, organization_id: int
104+
):
105+
twiml_content = await TwilioService(organization_id).get_start_call_twiml(
107106
workflow_id, user_id, workflow_run_id
108107
)
109108
return HTMLResponse(content=twiml_content, media_type="application/xml")

api/schemas/telephony_config.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
from typing import List
2+
3+
from pydantic import BaseModel, Field
4+
5+
6+
class TwilioConfigurationRequest(BaseModel):
7+
"""Request schema for Twilio configuration."""
8+
9+
provider: str = Field(default="twilio")
10+
account_sid: str = Field(..., description="Twilio Account SID")
11+
auth_token: str = Field(..., description="Twilio Auth Token")
12+
from_numbers: List[str] = Field(
13+
..., min_length=1, description="List of Twilio phone numbers"
14+
)
15+
16+
17+
class TwilioConfigurationResponse(BaseModel):
18+
"""Response schema for Twilio configuration with masked sensitive fields."""
19+
20+
provider: str
21+
account_sid: str # Masked (e.g., "****************def0")
22+
auth_token: str # Masked (e.g., "****************abc1")
23+
from_numbers: List[str]
24+
25+
26+
class TelephonyConfigurationResponse(BaseModel):
27+
"""Top-level telephony configuration response."""
28+
29+
twilio: TwilioConfigurationResponse | None = None

api/services/campaign/call_dispatcher.py

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,11 @@ class CampaignCallDispatcher:
1616
"""Manages rate-limited and concurrent-limited call dispatching"""
1717

1818
def __init__(self):
19-
self._twilio_service = None
2019
self.default_concurrent_limit = 20
2120

22-
@property
23-
def twilio_service(self):
24-
"""Lazy initialization of TwilioService"""
25-
if self._twilio_service is None:
26-
self._twilio_service = TwilioService()
27-
return self._twilio_service
21+
def get_twilio_service(self, organization_id: int) -> TwilioService:
22+
"""Get TwilioService instance for specific organization"""
23+
return TwilioService(organization_id)
2824

2925
async def get_org_concurrent_limit(self, organization_id: int) -> int:
3026
"""Get the concurrent call limit for an organization."""
@@ -225,15 +221,16 @@ async def dispatch_call(
225221

226222
# Initiate call via Twilio
227223
try:
228-
call_result = await self.twilio_service.initiate_call(
224+
twilio_service = self.get_twilio_service(campaign.organization_id)
225+
call_result = await twilio_service.initiate_call(
229226
to_number=phone_number,
230227
workflow_run_id=workflow_run.id,
231-
organization_id=campaign.organization_id,
232228
url_args={
233229
"workflow_id": campaign.workflow_id,
234230
"user_id": campaign.created_by,
235231
"workflow_run_id": workflow_run.id,
236232
"campaign_id": campaign.id,
233+
"organization_id": campaign.organization_id,
237234
},
238235
)
239236

api/services/pipecat/run_pipeline.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,12 +88,13 @@ async def run_pipeline_twilio(
8888
# Create audio configuration for Twilio
8989
audio_config = create_audio_config(WorkflowRunMode.TWILIO.value)
9090

91-
transport = create_twilio_transport(
91+
transport = await create_twilio_transport(
9292
websocket_client,
9393
stream_sid,
9494
call_sid,
9595
workflow_run_id,
9696
audio_config,
97+
workflow.organization_id,
9798
vad_config,
9899
ambient_noise_config,
99100
)

api/services/pipecat/transport_setup.py

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
from fastapi import WebSocket
44

55
from api.constants import APP_ROOT_DIR, ENABLE_RNNOISE, ENABLE_SMART_TURN
6+
from api.db import db_client
7+
from api.enums import OrganizationConfigurationKey
68
from api.services.looptalk.internal_transport import InternalTransport
79
from api.services.pipecat.audio_config import AudioConfig
810
from api.services.smart_turn.websocket_smart_turn import (
@@ -69,23 +71,43 @@ def create_turn_analyzer(workflow_run_id: int, audio_config: AudioConfig):
6971
return None
7072

7173

72-
def create_twilio_transport(
74+
async def create_twilio_transport(
7375
websocket_client: WebSocket,
7476
stream_sid: str,
7577
call_sid: str,
7678
workflow_run_id: int,
7779
audio_config: AudioConfig,
80+
organization_id: int,
7881
vad_config: dict | None = None,
7982
ambient_noise_config: dict | None = None,
8083
):
8184
"""Create a transport for Twilio connections"""
85+
86+
# Fetch Twilio credentials from organization config
87+
config = await db_client.get_configuration(
88+
organization_id, OrganizationConfigurationKey.TWILIO_CONFIGURATION.value
89+
)
90+
91+
if not config or not config.value:
92+
raise ValueError(
93+
f"Twilio credentials not configured for organization {organization_id}"
94+
)
95+
96+
account_sid = config.value.get("account_sid")
97+
auth_token = config.value.get("auth_token")
98+
99+
if not account_sid or not auth_token:
100+
raise ValueError(
101+
f"Incomplete Twilio configuration for organization {organization_id}"
102+
)
103+
82104
turn_analyzer = create_turn_analyzer(workflow_run_id, audio_config)
83105

84106
serializer = TwilioFrameSerializer(
85107
stream_sid=stream_sid,
86108
call_sid=call_sid,
87-
account_sid=os.environ["TWILIO_ACCOUNT_SID"],
88-
auth_token=os.environ["TWILIO_AUTH_TOKEN"],
109+
account_sid=account_sid,
110+
auth_token=auth_token,
89111
)
90112

91113
return FastAPIWebsocketTransport(

0 commit comments

Comments
 (0)