refactor: telephony integration

This commit is contained in:
Sabiha Khan 2025-10-13 17:55:10 +05:30
parent b9d1720d94
commit a01f2df7ea
26 changed files with 1583 additions and 28 deletions

View file

@ -9,7 +9,9 @@ from api.db import db_client
from api.db.models import QueuedRunModel, WorkflowRunModel
from api.enums import OrganizationConfigurationKey, WorkflowRunMode
from api.services.campaign.rate_limiter import rate_limiter
from api.services.telephony.twilio import TwilioService
from api.services.telephony.factory import get_telephony_provider
from api.services.telephony.base import TelephonyProvider
from api.utils.tunnel import TunnelURLProvider
class CampaignCallDispatcher:
@ -18,9 +20,9 @@ class CampaignCallDispatcher:
def __init__(self):
self.default_concurrent_limit = 20
def get_twilio_service(self, organization_id: int) -> TwilioService:
"""Get TwilioService instance for specific organization"""
return TwilioService(organization_id)
async def get_telephony_provider(self, organization_id: int) -> TelephonyProvider:
"""Get telephony provider instance for specific organization"""
return await get_telephony_provider(organization_id)
async def get_org_concurrent_limit(self, organization_id: int) -> int:
"""Get the concurrent call limit for an organization."""
@ -219,19 +221,25 @@ class CampaignCallDispatcher:
},
)
# Initiate call via Twilio
# Initiate call via telephony provider
try:
twilio_service = self.get_twilio_service(campaign.organization_id)
call_result = await twilio_service.initiate_call(
provider = await self.get_telephony_provider(campaign.organization_id)
# Construct webhook URL with parameters
backend_endpoint = await TunnelURLProvider.get_tunnel_url()
webhook_url = (
f"https://{backend_endpoint}/api/v1/telephony/twiml"
f"?workflow_id={campaign.workflow_id}"
f"&user_id={campaign.created_by}"
f"&workflow_run_id={workflow_run.id}"
f"&campaign_id={campaign.id}"
f"&organization_id={campaign.organization_id}"
)
call_result = await provider.initiate_call(
to_number=phone_number,
webhook_url=webhook_url,
workflow_run_id=workflow_run.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,
},
)
logger.info(

View file

@ -0,0 +1,167 @@
# Telephony Provider Implementation
This module implements the telephony provider abstraction for Dograh AI. For user-facing documentation, see the [Mintlify docs](https://docs.dograh.com/integrations/telephony/overview).
## Architecture
```
Business Logic → TelephonyProvider (Interface) → Concrete Provider (Twilio, Vonage, etc.)
```
## Developer Quick Reference
### Using the Provider in Code
```python
from api.services.telephony.factory import get_telephony_provider
# Get provider based on environment/config
provider = await get_telephony_provider(organization_id)
# Initiate a call
result = await provider.initiate_call(
to_number="+1987654321",
webhook_url="https://your-app.com/webhook",
workflow_run_id=123
)
```
## File Structure
```
telephony/
├── __init__.py
├── base.py # Abstract TelephonyProvider interface
├── factory.py # Provider creation and config loading
├── providers/
│ ├── __init__.py
│ └── twilio_provider.py # Twilio implementation
├── twilio.py # Legacy TwilioService (backward compat)
└── README.md # This file
```
## Implementing a New Provider
See the [Custom Provider Guide](https://docs.dograh.com/integrations/telephony/custom) in the documentation for detailed implementation instructions.
Quick checklist:
1. Create `providers/your_provider.py` implementing `TelephonyProvider`
2. Update `factory.py` to include your provider
3. Add environment variable support in `factory.py`
4. Write unit tests
5. Update documentation
## Key Interfaces
```python
class TelephonyProvider(ABC):
@abstractmethod
async def initiate_call(self, to_number: str, webhook_url: str, workflow_run_id: Optional[int] = None, **kwargs: Any) -> Dict[str, Any]
@abstractmethod
async def get_call_status(self, call_id: str) -> Dict[str, Any]
@abstractmethod
async def get_available_phone_numbers(self) -> List[str]
@abstractmethod
def validate_config(self) -> bool
@abstractmethod
async def verify_webhook_signature(self, url: str, params: Dict[str, Any], signature: str) -> bool
@abstractmethod
async def get_webhook_response(self, workflow_id: int, user_id: int, workflow_run_id: int) -> str
```
## Configuration Loading
The `factory.py` handles configuration from two sources:
1. **OSS Mode** (default): Environment variables
```python
TELEPHONY_PROVIDER=twilio
TWILIO_ACCOUNT_SID=xxx
TWILIO_AUTH_TOKEN=xxx
TWILIO_FROM_NUMBER=+1234567890
```
2. **SaaS Mode**: Database configuration per organization
```python
# Loaded from organization_configuration table
key: "TWILIO_CONFIGURATION"
value: {"account_sid": "xxx", "auth_token": "xxx", "from_numbers": [...]}
```
## Testing
### Unit Testing with Mock Provider
```python
class MockProvider(TelephonyProvider):
async def initiate_call(self, to_number, webhook_url, **kwargs):
return {"call_id": "mock_123", "status": "initiated"}
async def get_call_status(self, call_id):
return {"call_id": call_id, "status": "completed"}
# Implement other required methods...
# In tests
@patch('api.services.telephony.factory.get_telephony_provider')
async def test_call_initiation(mock_get_provider):
mock_get_provider.return_value = MockProvider()
# Test your business logic
```
### Integration Testing
Run against actual providers in development:
```bash
# Set test credentials
export TELEPHONY_PROVIDER=twilio
export TWILIO_ACCOUNT_SID=test_sid
export TWILIO_AUTH_TOKEN=test_token
export TWILIO_FROM_NUMBER=+15005550006 # Twilio test number
# Run integration tests
pytest tests/integration/test_telephony.py
```
## Migration Notes
### From Direct TwilioService Usage
Old code:
```python
from api.services.telephony.twilio import TwilioService
service = TwilioService(org_id)
await service.initiate_call(...)
```
New code:
```python
from api.services.telephony.factory import get_telephony_provider
provider = await get_telephony_provider(org_id)
await provider.initiate_call(...)
```
### Backward Compatibility
- Old `/api/v1/twilio/*` endpoints still work (redirect to `/api/v1/telephony/*`)
- `TwilioService` class remains for legacy code
- Database configuration key `TWILIO_CONFIGURATION` unchanged
## Common Issues
1. **Import Error**: Always import from `factory`, not directly from providers
2. **Config Not Found**: Check environment variables or database configuration
3. **Signature Verification**: Ensure auth tokens match between provider and config
4. **WebSocket Issues**: Verify audio format compatibility (MULAW for Twilio)
## Related Documentation
- [User Documentation](https://docs.dograh.com/integrations/telephony/overview)
- [Twilio Integration](https://docs.dograh.com/integrations/telephony/twilio)
- [Custom Providers](https://docs.dograh.com/integrations/telephony/custom)
- [Webhooks Guide](https://docs.dograh.com/integrations/telephony/webhooks)

View file

@ -0,0 +1,103 @@
"""
Base telephony provider interface for abstracting telephony services.
This allows easy switching between different providers (Twilio, Vonage, etc.)
while keeping business logic decoupled from specific implementations.
"""
from abc import ABC, abstractmethod
from typing import Any, Dict, List, Optional
class TelephonyProvider(ABC):
"""
Abstract base class for telephony providers.
All telephony providers must implement these core methods.
"""
@abstractmethod
async def initiate_call(
self,
to_number: str,
webhook_url: str,
workflow_run_id: Optional[int] = None,
**kwargs: Any,
) -> Dict[str, Any]:
"""
Initiate an outbound call.
Args:
to_number: The destination phone number
webhook_url: The URL to receive call events
workflow_run_id: Optional workflow run ID for tracking
**kwargs: Provider-specific additional parameters
Returns:
Dict containing call details (provider-specific format)
"""
pass
@abstractmethod
async def get_call_status(self, call_id: str) -> Dict[str, Any]:
"""
Get the current status of a call.
Args:
call_id: The provider-specific call identifier
Returns:
Dict containing call status information
"""
pass
@abstractmethod
async def get_available_phone_numbers(self) -> List[str]:
"""
Get list of available phone numbers for this provider.
Returns:
List of phone numbers that can be used for outbound calls
"""
pass
@abstractmethod
def validate_config(self) -> bool:
"""
Validate that the provider is properly configured.
Returns:
True if configuration is valid, False otherwise
"""
pass
@abstractmethod
async def verify_webhook_signature(
self, url: str, params: Dict[str, Any], signature: str
) -> bool:
"""
Verify webhook signature for security.
Args:
url: The webhook URL
params: The webhook parameters
signature: The signature to verify
Returns:
True if signature is valid, False otherwise
"""
pass
@abstractmethod
async def get_webhook_response(
self, workflow_id: int, user_id: int, workflow_run_id: int
) -> str:
"""
Generate the initial webhook response for starting a call session.
Args:
workflow_id: The workflow ID
user_id: The user ID
workflow_run_id: The workflow run ID
Returns:
Provider-specific response (e.g., TwiML for Twilio)
"""
pass

View file

@ -0,0 +1,120 @@
"""
Factory for creating telephony providers.
Handles configuration loading from environment (OSS) or database (SaaS).
The providers themselves don't know or care where config comes from.
"""
import os
from typing import Any, Dict, Optional
from loguru import logger
from api.db import db_client
from api.enums import OrganizationConfigurationKey
from api.services.telephony.base import TelephonyProvider
from api.services.telephony.providers.twilio_provider import TwilioProvider
async def load_telephony_config(organization_id: Optional[int] = None) -> Dict[str, Any]:
"""
Load telephony configuration from appropriate source.
Args:
organization_id: Organization ID for database config (SaaS mode)
None for environment config (OSS mode)
Returns:
Configuration dictionary with provider type and credentials
"""
if organization_id:
# SaaS mode: Load from database
logger.debug(f"Loading telephony config from database for org {organization_id}")
# TODO: Use TELEPHONY_CONFIGURATION
twilio_config = await db_client.get_configuration(
organization_id,
OrganizationConfigurationKey.TWILIO_CONFIGURATION.value,
)
if twilio_config and twilio_config.value:
# TODO: Get provider from config
return {
"provider": "twilio",
"account_sid": twilio_config.value.get("account_sid"),
"auth_token": twilio_config.value.get("auth_token"),
"from_numbers": twilio_config.value.get("from_numbers", [])
}
raise ValueError(f"No telephony configuration found for organization {organization_id}")
else:
# OSS mode: Load from environment variables
logger.debug("Loading telephony config from environment variables")
provider = os.getenv("TELEPHONY_PROVIDER", "twilio").lower()
if provider == "twilio":
# Load Twilio config from env
account_sid = os.getenv("TWILIO_ACCOUNT_SID")
auth_token = os.getenv("TWILIO_AUTH_TOKEN")
from_number = os.getenv("TWILIO_FROM_NUMBER")
if not all([account_sid, auth_token, from_number]):
raise ValueError(
"Missing Twilio configuration. Please set TWILIO_ACCOUNT_SID, "
"TWILIO_AUTH_TOKEN, and TWILIO_FROM_NUMBER environment variables."
)
return {
"provider": "twilio",
"account_sid": account_sid,
"auth_token": auth_token,
"from_numbers": [from_number] if isinstance(from_number, str) else from_number
}
# Future providers can be added here
# elif provider == "vonage":
# return {
# "provider": "vonage",
# "api_key": os.getenv("VONAGE_API_KEY"),
# "api_secret": os.getenv("VONAGE_API_SECRET"),
# "from_numbers": [os.getenv("VONAGE_FROM_NUMBER")]
# }
else:
raise ValueError(f"Unknown telephony provider: {provider}")
async def get_telephony_provider(
organization_id: Optional[int] = None
) -> TelephonyProvider:
"""
Factory function to create telephony providers.
Args:
organization_id: Organization ID for SaaS mode (optional)
Returns:
Configured telephony provider instance
Raises:
ValueError: If provider type is unknown or configuration is invalid
"""
# Load configuration from appropriate source
config = await load_telephony_config(organization_id)
provider_type = config.get("provider", "twilio")
logger.info(f"Creating {provider_type} telephony provider")
# Create provider instance with configuration
# Provider doesn't know or care if config came from env or database
if provider_type == "twilio":
return TwilioProvider(config)
# Future providers can be added here
# elif provider_type == "vonage":
# return VonageProvider(config)
# elif provider_type == "plivo":
# return PlivoProvider(config)
else:
raise ValueError(f"Unknown telephony provider: {provider_type}")

View file

@ -0,0 +1 @@
# Telephony provider implementations

View file

@ -0,0 +1,151 @@
"""
Twilio implementation of the TelephonyProvider interface.
"""
import random
from typing import Any, Dict, List, Optional
import aiohttp
from loguru import logger
from twilio.request_validator import RequestValidator
from api.services.telephony.base import TelephonyProvider
from api.utils.tunnel import TunnelURLProvider
class TwilioProvider(TelephonyProvider):
"""
Twilio implementation of TelephonyProvider.
Accepts configuration and works the same regardless of OSS/SaaS mode.
"""
def __init__(self, config: Dict[str, Any]):
"""
Initialize TwilioProvider with configuration.
Args:
config: Dictionary containing:
- account_sid: Twilio Account SID
- auth_token: Twilio Auth Token
- from_numbers: List of phone numbers to use
"""
self.account_sid = config.get("account_sid")
self.auth_token = config.get("auth_token")
self.from_numbers = config.get("from_numbers", [])
# Handle both single number (string) and multiple numbers (list)
if isinstance(self.from_numbers, str):
self.from_numbers = [self.from_numbers]
self.base_url = f"https://api.twilio.com/2010-04-01/Accounts/{self.account_sid}"
async def initiate_call(
self,
to_number: str,
webhook_url: str,
workflow_run_id: Optional[int] = None,
**kwargs: Any,
) -> Dict[str, Any]:
"""
Initiate an outbound call via Twilio.
"""
if not self.validate_config():
raise ValueError("Twilio provider not properly configured")
endpoint = f"{self.base_url}/Calls.json"
# Select a random phone number
from_number = random.choice(self.from_numbers)
logger.info(f"Selected phone number {from_number} for outbound call")
# Prepare call data
data = {
"To": to_number,
"From": from_number,
"Url": webhook_url
}
# Add status callback if workflow_run_id provided
if workflow_run_id:
backend_endpoint = await TunnelURLProvider.get_tunnel_url()
callback_url = f"https://{backend_endpoint}/api/v1/telephony/status-callback/{workflow_run_id}"
data.update({
"StatusCallback": callback_url,
"StatusCallbackEvent": ["initiated", "ringing", "answered", "completed"],
"StatusCallbackMethod": "POST"
})
# Add any additional kwargs
data.update(kwargs)
# Make the API request
async with aiohttp.ClientSession() as session:
auth = aiohttp.BasicAuth(self.account_sid, self.auth_token)
async with session.post(endpoint, data=data, auth=auth) as response:
if response.status != 201:
error_data = await response.json()
raise Exception(f"Failed to initiate call: {error_data}")
return await response.json()
async def get_call_status(self, call_id: str) -> Dict[str, Any]:
"""
Get the current status of a Twilio call.
"""
if not self.validate_config():
raise ValueError("Twilio provider not properly configured")
endpoint = f"{self.base_url}/Calls/{call_id}.json"
async with aiohttp.ClientSession() as session:
auth = aiohttp.BasicAuth(self.account_sid, self.auth_token)
async with session.get(endpoint, auth=auth) as response:
if response.status != 200:
error_data = await response.json()
raise Exception(f"Failed to get call status: {error_data}")
return await response.json()
async def get_available_phone_numbers(self) -> List[str]:
"""
Get list of available Twilio phone numbers.
"""
return self.from_numbers
def validate_config(self) -> bool:
"""
Validate Twilio configuration.
"""
return bool(
self.account_sid and
self.auth_token and
self.from_numbers
)
async def verify_webhook_signature(
self, url: str, params: Dict[str, Any], signature: str
) -> bool:
"""
Verify Twilio webhook signature for security.
"""
if not self.auth_token:
return False
validator = RequestValidator(self.auth_token)
return validator.validate(url, params, signature)
async def get_webhook_response(
self, workflow_id: int, user_id: int, workflow_run_id: int
) -> str:
"""
Generate TwiML response for starting a call session.
"""
backend_endpoint = await TunnelURLProvider.get_tunnel_url()
twiml_content = f"""<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Connect>
<Stream url="wss://{backend_endpoint}/api/v1/telephony/ws/{workflow_id}/{user_id}/{workflow_run_id}"></Stream>
</Connect>
<Pause length="40"/>
</Response>"""
return twiml_content

View file

@ -1,3 +1,6 @@
# TODO: Remove this file after migrating workflow_run_cost.py to use telephony abstraction
# Deprecated - use api/services/telephony/providers/twilio_provider.py instead
import random
from typing import Any, Dict, List, Optional
from urllib.parse import urlencode