mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-07 07:55:16 +02:00
refactor: telephony integration
This commit is contained in:
parent
b9d1720d94
commit
a01f2df7ea
26 changed files with 1583 additions and 28 deletions
|
|
@ -33,8 +33,12 @@ STACK_AUTH_PROJECT_ID="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
|||
STACK_SECRET_SERVER_KEY="ssk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
STACK_PUBLISHABLE_CLIENT_KEY="pck_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
|
||||
# Twilio Configuration
|
||||
TWILIO_ACCOUNT_SID="SKxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
# Telephony Configuration
|
||||
# Provider selection (default: twilio, future options: vonage, plivo, etc.)
|
||||
TELEPHONY_PROVIDER=twilio
|
||||
|
||||
# Twilio Configuration (when TELEPHONY_PROVIDER=twilio)
|
||||
TWILIO_ACCOUNT_SID="ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
TWILIO_AUTH_TOKEN="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
TWILIO_FROM_NUMBER="+1234567890"
|
||||
|
||||
|
|
|
|||
|
|
@ -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_CONFIGURATION = "TWILIO_CONFIGURATION"
|
||||
TWILIO_CONFIGURATION = "TWILIO_CONFIGURATION" # TODO: Rename to TELEPHONY_CONFIGURATION
|
||||
|
||||
|
||||
class WorkflowStatus(Enum):
|
||||
|
|
|
|||
|
|
@ -11,7 +11,8 @@ from api.routes.rtc_offer import router as rtc_offer_router
|
|||
from api.routes.s3_signed_url import router as s3_router
|
||||
from api.routes.service_keys import router as service_keys_router
|
||||
from api.routes.superuser import router as superuser_router
|
||||
from api.routes.twilio import router as twilio_router
|
||||
from api.routes.telephony import router as telephony_router
|
||||
from api.routes.twilio import router as twilio_router # TODO: Remove after migrating workflow_run_cost.py
|
||||
from api.routes.user import router as user_router
|
||||
from api.routes.webrtc_signaling import router as webrtc_signaling_router
|
||||
from api.routes.workflow import router as workflow_router
|
||||
|
|
@ -21,7 +22,8 @@ router = APIRouter(
|
|||
responses={404: {"description": "Not found"}},
|
||||
)
|
||||
|
||||
router.include_router(twilio_router)
|
||||
router.include_router(telephony_router) # New generic telephony routes
|
||||
router.include_router(twilio_router) # TODO: Remove after migrating workflow_run_cost.py
|
||||
router.include_router(rtc_offer_router)
|
||||
router.include_router(superuser_router)
|
||||
router.include_router(workflow_router)
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ from api.services.configuration.masking import is_mask_of, mask_key
|
|||
router = APIRouter(prefix="/organizations", tags=["organizations"])
|
||||
|
||||
|
||||
# TODO: Make endpoints provider-agnostic
|
||||
@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."""
|
||||
|
|
@ -22,7 +23,7 @@ async def get_telephony_configuration(user: UserModel = Depends(get_user)):
|
|||
|
||||
config = await db_client.get_configuration(
|
||||
user.selected_organization_id,
|
||||
OrganizationConfigurationKey.TWILIO_CONFIGURATION.value,
|
||||
OrganizationConfigurationKey.TWILIO_CONFIGURATION.value, # TODO: Use TELEPHONY_CONFIGURATION
|
||||
)
|
||||
|
||||
if not config or not config.value:
|
||||
|
|
@ -53,7 +54,7 @@ async def save_telephony_configuration(
|
|||
# Fetch existing configuration to handle masked values
|
||||
existing_config = await db_client.get_configuration(
|
||||
user.selected_organization_id,
|
||||
OrganizationConfigurationKey.TWILIO_CONFIGURATION.value,
|
||||
OrganizationConfigurationKey.TWILIO_CONFIGURATION.value, # TODO: Use TELEPHONY_CONFIGURATION
|
||||
)
|
||||
|
||||
# Build new configuration
|
||||
|
|
|
|||
315
api/routes/telephony.py
Normal file
315
api/routes/telephony.py
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
"""
|
||||
Generic telephony routes that work with any telephony provider.
|
||||
"""
|
||||
import json
|
||||
import random
|
||||
from datetime import UTC, datetime
|
||||
from typing import Annotated, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, Header, HTTPException, Request, WebSocket
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel
|
||||
from starlette.responses import HTMLResponse
|
||||
|
||||
from api.db import db_client
|
||||
from api.db.models import UserModel
|
||||
from api.enums import WorkflowRunMode
|
||||
from api.services.auth.depends import get_user
|
||||
from api.services.campaign.call_dispatcher import campaign_call_dispatcher
|
||||
from api.services.campaign.campaign_event_publisher import get_campaign_event_publisher
|
||||
from api.services.pipecat.run_pipeline import run_pipeline_twilio
|
||||
from api.services.telephony.factory import get_telephony_provider
|
||||
from api.utils.tunnel import TunnelURLProvider
|
||||
from pipecat.utils.context import set_current_run_id
|
||||
|
||||
router = APIRouter(prefix="/telephony")
|
||||
|
||||
|
||||
class InitiateCallRequest(BaseModel):
|
||||
workflow_id: int
|
||||
workflow_run_id: int | None = None
|
||||
|
||||
|
||||
class StatusCallbackRequest(BaseModel):
|
||||
"""Generic status callback that can handle different providers"""
|
||||
# Common fields
|
||||
call_id: str
|
||||
status: str
|
||||
from_number: Optional[str] = None
|
||||
to_number: Optional[str] = None
|
||||
direction: Optional[str] = None
|
||||
duration: Optional[str] = None
|
||||
|
||||
# Provider-specific fields stored as extra
|
||||
extra: dict = {}
|
||||
|
||||
@classmethod
|
||||
def from_twilio(cls, data: dict):
|
||||
"""Convert Twilio callback to generic format"""
|
||||
return cls(
|
||||
call_id=data.get("CallSid", ""),
|
||||
status=data.get("CallStatus", ""),
|
||||
from_number=data.get("From"),
|
||||
to_number=data.get("To"),
|
||||
direction=data.get("Direction"),
|
||||
duration=data.get("CallDuration") or data.get("Duration"),
|
||||
extra=data
|
||||
)
|
||||
|
||||
|
||||
@router.post("/initiate-call")
|
||||
async def initiate_call(
|
||||
request: InitiateCallRequest, user: UserModel = Depends(get_user)
|
||||
):
|
||||
"""Initiate a call using the configured telephony provider."""
|
||||
|
||||
# Get the telephony provider for the organization
|
||||
provider = await get_telephony_provider(user.selected_organization_id)
|
||||
|
||||
# Validate provider is configured
|
||||
if not provider.validate_config():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="telephony_not_configured",
|
||||
)
|
||||
|
||||
user_configuration = await db_client.get_user_configurations(user.id)
|
||||
|
||||
workflow_run_id = request.workflow_run_id
|
||||
|
||||
if not workflow_run_id:
|
||||
workflow_run_name = f"WR-TEL-{random.randint(1000, 9999)}"
|
||||
workflow_run = await db_client.create_workflow_run(
|
||||
workflow_run_name,
|
||||
request.workflow_id,
|
||||
WorkflowRunMode.TWILIO.value, # TODO: Make this provider-agnostic
|
||||
initial_context={
|
||||
"phone_number": user_configuration.test_phone_number,
|
||||
},
|
||||
user_id=user.id,
|
||||
)
|
||||
workflow_run_id = workflow_run.id
|
||||
else:
|
||||
workflow_run = await db_client.get_workflow_run(workflow_run_id, user.id)
|
||||
if not workflow_run:
|
||||
raise HTTPException(status_code=400, detail="Workflow run not found")
|
||||
workflow_run_name = workflow_run.name
|
||||
|
||||
if not user_configuration.test_phone_number:
|
||||
raise HTTPException(status_code=400, detail="Test phone number not set")
|
||||
|
||||
# Construct webhook URL
|
||||
backend_endpoint = await TunnelURLProvider.get_tunnel_url()
|
||||
webhook_url = (
|
||||
f"https://{backend_endpoint}/api/v1/telephony/twiml"
|
||||
f"?workflow_id={request.workflow_id}"
|
||||
f"&user_id={user.id}"
|
||||
f"&workflow_run_id={workflow_run_id}"
|
||||
f"&organization_id={user.selected_organization_id}"
|
||||
)
|
||||
|
||||
# Initiate call via provider
|
||||
await provider.initiate_call(
|
||||
to_number=user_configuration.test_phone_number,
|
||||
webhook_url=webhook_url,
|
||||
workflow_run_id=workflow_run_id,
|
||||
)
|
||||
|
||||
return {
|
||||
"message": f"Call initiated successfully with run name {workflow_run_name}"
|
||||
}
|
||||
|
||||
|
||||
@router.post("/twiml", include_in_schema=False)
|
||||
async def handle_twiml_webhook(
|
||||
workflow_id: int,
|
||||
user_id: int,
|
||||
workflow_run_id: int,
|
||||
organization_id: int
|
||||
):
|
||||
"""
|
||||
Handle initial webhook from telephony provider.
|
||||
Returns provider-specific response (e.g., TwiML for Twilio).
|
||||
"""
|
||||
# Get provider for organization - exactly like original gets TwilioService
|
||||
provider = await get_telephony_provider(organization_id)
|
||||
|
||||
# Generate provider-specific response (TwiML for Twilio)
|
||||
response_content = await provider.get_webhook_response(
|
||||
workflow_id, user_id, workflow_run_id
|
||||
)
|
||||
|
||||
# Return exactly like original - HTMLResponse with application/xml
|
||||
return HTMLResponse(content=response_content, media_type="application/xml")
|
||||
|
||||
|
||||
@router.websocket("/ws/{workflow_id}/{user_id}/{workflow_run_id}")
|
||||
async def websocket_endpoint(
|
||||
websocket: WebSocket, workflow_id: int, user_id: int, workflow_run_id: int
|
||||
):
|
||||
"""WebSocket endpoint for real-time call handling - matches original Twilio implementation."""
|
||||
await websocket.accept()
|
||||
|
||||
try:
|
||||
# "connected" (ignore)
|
||||
msg = json.loads(await websocket.receive_text())
|
||||
if msg.get("event") != "connected":
|
||||
raise RuntimeError("Expected connected message first")
|
||||
|
||||
# "start" – this has everything we need
|
||||
start_msg = await websocket.receive_text()
|
||||
|
||||
# set the run context
|
||||
set_current_run_id(workflow_run_id)
|
||||
|
||||
logger.debug(f"Received start message: {start_msg}")
|
||||
|
||||
start_msg = json.loads(start_msg)
|
||||
if start_msg.get("event") != "start":
|
||||
raise RuntimeError("Expected start message second")
|
||||
|
||||
try:
|
||||
stream_sid = start_msg["start"]["streamSid"]
|
||||
call_sid = start_msg["start"]["callSid"]
|
||||
except KeyError:
|
||||
logger.error(
|
||||
"Missing callSID and streamSID in start message. Closing connection."
|
||||
)
|
||||
await websocket.close(code=4400, reason="Missing or bad start message")
|
||||
return
|
||||
|
||||
# Run your Pipecat bot
|
||||
await run_pipeline_twilio(
|
||||
websocket, stream_sid, call_sid, workflow_id, workflow_run_id, user_id
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in Twilio WebSocket connection: {e}")
|
||||
await websocket.close(1011, "Internal server error")
|
||||
|
||||
|
||||
@router.post("/status-callback/{workflow_run_id}")
|
||||
async def handle_status_callback(
|
||||
workflow_run_id: int,
|
||||
request: Request,
|
||||
x_twilio_signature: Optional[str] = Header(None),
|
||||
):
|
||||
"""Handle status callbacks from telephony providers."""
|
||||
|
||||
# Parse form data
|
||||
form_data = await request.form()
|
||||
callback_data = dict(form_data)
|
||||
|
||||
logger.info(
|
||||
f"[run {workflow_run_id}] Received status callback: {json.dumps(callback_data)}"
|
||||
)
|
||||
|
||||
# Get workflow run to find organization
|
||||
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
|
||||
if not workflow_run:
|
||||
logger.warning(f"Workflow run {workflow_run_id} not found for status callback")
|
||||
return {"status": "ignored", "reason": "workflow_run_not_found"}
|
||||
|
||||
# Get provider for verification (if signature provided)
|
||||
if x_twilio_signature:
|
||||
# Get organization from workflow run
|
||||
workflow = await db_client.get_workflow_by_id(workflow_run.workflow_id)
|
||||
if workflow:
|
||||
provider = await get_telephony_provider(workflow.organization_id)
|
||||
|
||||
# Verify signature
|
||||
backend_endpoint = await TunnelURLProvider.get_tunnel_url()
|
||||
full_url = f"https://{backend_endpoint}/api/v1/telephony/status-callback/{workflow_run_id}"
|
||||
|
||||
is_valid = await provider.verify_webhook_signature(
|
||||
full_url, callback_data, x_twilio_signature
|
||||
)
|
||||
|
||||
if not is_valid:
|
||||
logger.warning(f"Invalid status callback signature for run {workflow_run_id}")
|
||||
return {"status": "error", "reason": "invalid_signature"}
|
||||
|
||||
# Convert provider-specific callback to generic format
|
||||
# (Currently assumes Twilio format, will be extended for other providers)
|
||||
status_update = StatusCallbackRequest.from_twilio(callback_data)
|
||||
|
||||
# Process the status update
|
||||
await _process_status_update(workflow_run_id, status_update, workflow_run)
|
||||
|
||||
return {"status": "success"}
|
||||
|
||||
|
||||
async def _process_status_update(
|
||||
workflow_run_id: int,
|
||||
status: StatusCallbackRequest,
|
||||
workflow_run: any
|
||||
):
|
||||
"""Process status updates from telephony providers."""
|
||||
|
||||
# Log the status callback
|
||||
twilio_callback_logs = workflow_run.logs.get("twilio_status_callbacks", [])
|
||||
twilio_callback_log = {
|
||||
"status": status.status,
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"call_id": status.call_id,
|
||||
"duration": status.duration,
|
||||
**status.extra # Include provider-specific data
|
||||
}
|
||||
twilio_callback_logs.append(twilio_callback_log)
|
||||
|
||||
# Update workflow run logs
|
||||
await db_client.update_workflow_run(
|
||||
run_id=workflow_run_id,
|
||||
logs={"twilio_status_callbacks": twilio_callback_logs},
|
||||
)
|
||||
|
||||
# Handle call completion
|
||||
if status.status == "completed":
|
||||
logger.info(
|
||||
f"[run {workflow_run_id}] Call completed with duration: {status.duration}s"
|
||||
)
|
||||
|
||||
# Release concurrent slot if this was a campaign call
|
||||
if workflow_run.campaign_id:
|
||||
await campaign_call_dispatcher.release_call_slot(workflow_run_id)
|
||||
|
||||
# Mark workflow run as completed
|
||||
await db_client.update_workflow_run(
|
||||
run_id=workflow_run_id, is_completed=True
|
||||
)
|
||||
|
||||
# Publish campaign event if applicable
|
||||
if workflow_run.campaign_id:
|
||||
publisher = await get_campaign_event_publisher()
|
||||
await publisher.publish_call_completed(
|
||||
campaign_id=workflow_run.campaign_id,
|
||||
workflow_run_id=workflow_run_id,
|
||||
queued_run_id=workflow_run.queued_run_id,
|
||||
call_duration=int(status.duration) if status.duration else 0,
|
||||
)
|
||||
|
||||
elif status.status in ["failed", "busy", "no-answer", "canceled"]:
|
||||
logger.warning(f"[run {workflow_run_id}] Call failed with status: {status.status}")
|
||||
|
||||
# Release concurrent slot for terminal statuses if this was a campaign call
|
||||
if workflow_run.campaign_id:
|
||||
await campaign_call_dispatcher.release_call_slot(workflow_run_id)
|
||||
|
||||
# Check if retry is needed for campaign calls (busy/no-answer)
|
||||
if status.status in ["busy", "no-answer"] and workflow_run.campaign_id:
|
||||
publisher = await get_campaign_event_publisher()
|
||||
await publisher.publish_retry_needed(
|
||||
workflow_run_id=workflow_run_id,
|
||||
reason=status.status.replace("-", "_"), # Convert no-answer to no_answer
|
||||
campaign_id=workflow_run.campaign_id,
|
||||
queued_run_id=workflow_run.queued_run_id,
|
||||
)
|
||||
|
||||
# Mark workflow run as completed with failure tags
|
||||
call_tags = workflow_run.gathered_context.get("call_tags", []) if workflow_run.gathered_context else []
|
||||
call_tags.extend(["not_connected", f"telephony_{status.status.lower()}"])
|
||||
|
||||
await db_client.update_workflow_run(
|
||||
run_id=workflow_run_id,
|
||||
is_completed=True,
|
||||
gathered_context={"call_tags": call_tags}
|
||||
)
|
||||
|
|
@ -1,3 +1,6 @@
|
|||
# TODO: Remove this entire file after migrating workflow_run_cost.py to use telephony abstraction
|
||||
# All endpoints here are deprecated - use /api/v1/telephony/* instead
|
||||
|
||||
import json
|
||||
import random
|
||||
from datetime import UTC, datetime
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ from typing import List
|
|||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
# TODO: Make schemas provider-agnostic
|
||||
|
||||
class TwilioConfigurationRequest(BaseModel):
|
||||
"""Request schema for Twilio configuration."""
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
167
api/services/telephony/README.md
Normal file
167
api/services/telephony/README.md
Normal 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)
|
||||
103
api/services/telephony/base.py
Normal file
103
api/services/telephony/base.py
Normal 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
|
||||
120
api/services/telephony/factory.py
Normal file
120
api/services/telephony/factory.py
Normal 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}")
|
||||
1
api/services/telephony/providers/__init__.py
Normal file
1
api/services/telephony/providers/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
# Telephony provider implementations
|
||||
151
api/services/telephony/providers/twilio_provider.py
Normal file
151
api/services/telephony/providers/twilio_provider.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -40,6 +40,9 @@ async def calculate_workflow_run_cost(ctx, workflow_run_id: int):
|
|||
logger.warning("Workflow not found for workflow run")
|
||||
raise Exception("Workflow not found")
|
||||
|
||||
# TODO: Migrate to use telephony provider abstraction
|
||||
# provider = await get_telephony_provider(workflow.organization_id)
|
||||
# call_info = await provider.get_call_status(twilio_call_sid)
|
||||
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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue