chore: refactor telephony

This commit is contained in:
Sabiha Khan 2026-02-14 07:28:36 +05:30
parent e485f649bd
commit 942a20bd14
14 changed files with 52 additions and 252 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -35,7 +35,7 @@ from api.services.auth.depends import get_user
from api.services.campaign.campaign_call_dispatcher import campaign_call_dispatcher
from api.services.campaign.campaign_event_publisher import get_campaign_event_publisher
from api.services.quota_service import check_dograh_quota, check_dograh_quota_by_user_id
from api.services.telephony.transfer_coordination import get_transfer_coordinator
from api.services.telephony.call_transfer_manager import get_call_transfer_manager
from api.services.telephony.transfer_event_protocol import TransferContext
from api.services.telephony.factory import (
get_all_telephony_providers,
@ -53,13 +53,6 @@ from pipecat.utils.run_context import set_current_run_id
router = APIRouter(prefix="/telephony")
# Module-level storage for webhook-driven function call completion
# Stores function call contexts that are waiting for webhook completion
pending_function_calls: Dict[str, tuple[FunctionCallParams, float]] = {}
# Note: Transfer contexts now stored in Redis via TransferCoordinator
# pending_transfers dictionary removed in favor of Redis-based coordination
class InitiateCallRequest(BaseModel):
workflow_id: int
@ -493,13 +486,12 @@ async def _validate_organization_provider_config(
@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, CallSid: str = Form(...)
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).
"""
logger.info(f"[TWIML-DEBUG] CallSid received: {CallSid}")
provider = await get_telephony_provider(organization_id)
@ -1426,7 +1418,6 @@ async def handle_inbound_telephony(
logger.info(
f"Generated {normalized_data.provider} response for call {normalized_data.call_id}"
)
logger.info(f"response is {response}")
return response
except ValueError as e:
@ -1522,19 +1513,6 @@ async def handle_cloudonix_cdr(request: Request):
return {"status": "success"}
class CallTransferRequest(BaseModel):
"""Request model for call transfer"""
target_phone_number: Optional[str] = None
phone_number: Optional[str] = None # Alternative field name
number: Optional[str] = None # Another alternative
current_call_sid: Optional[str] = None
def get_target_number(self) -> str:
"""Get the target phone number from any of the possible fields"""
return self.target_phone_number or self.phone_number or self.number or ""
class TransferCallRequest(BaseModel):
"""Request model for initiating call transfer using webhook-driven completion"""
destination: str # E.164 format phone number (required)
@ -1553,8 +1531,8 @@ class TransferCallRequest(BaseModel):
if not destination or not destination.strip():
raise ValueError("Destination phone number is required")
e164_pattern = r"^\+[1-9]\d{1,14}$"
if not re.match(e164_pattern, destination.strip()):
E164_PHONE_REGEX = r"^\+[1-9]\d{1,14}$"
if not re.match(E164_PHONE_REGEX, destination.strip()):
raise ValueError(f"Invalid phone number format: {destination}. Must be E.164 format (e.g., +1234567890)")
return destination.strip()
@ -1566,27 +1544,18 @@ class TransferCallRequest(BaseModel):
async def initiate_call_transfer(request: TransferCallRequest):
"""Initiate call transfer without blocking the pipeline"""
import aiohttp
logger.info(f"Received call transfer request: {request}")
# Generate tool_call_id if not provided
if not request.tool_call_id:
request.tool_call_id = f"transfer_{int(time.time())}_{uuid.uuid4().hex[:8]}"
# Log tool details for tracing
logger.info(f"Starting non-blocking call transfer to {request.destination} with tool_call_id: {request.tool_call_id}, tool_uuid: {request.tool_uuid}")
# TODO: Add tool UUID validation here if needed
# For example: Validate that the tool UUID corresponds to a valid transfer call tool
# and that the destination matches the tool's configured destination pattern
logger.info(f"Starting call transfer to {request.destination} with tool_call_id: {request.tool_call_id}, tool_uuid: {request.tool_uuid}")
try:
# Get provider that supports transfers (validates Twilio-only requirement)
from api.services.telephony.factory import get_transfer_provider
try:
provider = await get_transfer_provider(request.organization_id)
except ValueError as e:
# Provider doesn't support transfers or organization not configured
logger.error(f"Transfer provider validation failed: {e}")
raise HTTPException(
status_code=400,
@ -1597,7 +1566,7 @@ async def initiate_call_transfer(request: TransferCallRequest):
if not provider.validate_config():
logger.error(f"Provider {provider.PROVIDER_NAME} configuration is invalid")
raise HTTPException(
status_code=500,
status_code=400,
detail=f"Telephony provider '{provider.PROVIDER_NAME}' is not properly configured for transfers"
)
@ -1610,7 +1579,7 @@ async def initiate_call_transfer(request: TransferCallRequest):
timeout=request.timeout
)
except NotImplementedError as e:
# This shouldn't happen due to get_transfer_provider validation, but safety check
# fallback for get_transfer_provider validation
logger.error(f"Provider {provider.PROVIDER_NAME} doesn't support transfers: {e}")
raise HTTPException(
status_code=400,
@ -1626,10 +1595,10 @@ async def initiate_call_transfer(request: TransferCallRequest):
call_sid = transfer_result.get("call_sid")
logger.info(f"Transfer call initiated successfully: {call_sid}")
logger.info(f"Transfer result: {transfer_result}")
logger.debug(f"Transfer result: {transfer_result}")
# Store the transfer context in Redis for webhook completion
transfer_coordinator = await get_transfer_coordinator()
call_transfer_manager = await get_call_transfer_manager()
transfer_context = TransferContext(
tool_call_id=request.tool_call_id,
call_sid=call_sid,
@ -1637,12 +1606,10 @@ async def initiate_call_transfer(request: TransferCallRequest):
tool_uuid=request.tool_uuid,
original_call_sid=request.original_call_sid,
caller_number=request.caller_number,
initiated_at=time.time(),
workflow_run_id=0 # TODO: Add workflow_run_id to request if needed
initiated_at=time.time()
)
await transfer_coordinator.store_transfer_context(transfer_context)
await call_transfer_manager.store_transfer_context(transfer_context)
# Return immediately without blocking
return {
"status": "transfer_initiated",
"call_id": call_sid,
@ -1673,11 +1640,10 @@ async def handle_transfer_call_answered(tool_call_id: str, request: Request):
call_sid = data.get("CallSid", "")
# Get transfer context from Redis
transfer_coordinator = await get_transfer_coordinator()
transfer_context = await transfer_coordinator.get_transfer_context(tool_call_id)
call_transfer_manager = await get_call_transfer_manager()
transfer_context = await call_transfer_manager.get_transfer_context(tool_call_id)
original_call_sid = transfer_context.original_call_sid if transfer_context else None
caller_number = transfer_context.caller_number if transfer_context else None
# Use original call SID for conference name if available, otherwise fall back to transfer call SID
base_call_sid = original_call_sid or call_sid
@ -1688,8 +1654,8 @@ async def handle_transfer_call_answered(tool_call_id: str, request: Request):
# Publish Redis event for transfer answer completion
try:
# Get transfer coordinator and context
transfer_coordinator = await get_transfer_coordinator()
transfer_context = await transfer_coordinator.get_transfer_context(tool_call_id)
call_transfer_manager = await get_call_transfer_manager()
transfer_context = await call_transfer_manager.get_transfer_context(tool_call_id)
if transfer_context:
# Create transfer answered event
@ -1698,17 +1664,16 @@ async def handle_transfer_call_answered(tool_call_id: str, request: Request):
transfer_event = TransferEvent(
type=TransferEventType.TRANSFER_ANSWERED,
tool_call_id=tool_call_id,
workflow_run_id=transfer_context.workflow_run_id,
original_call_sid=original_call_sid,
transfer_call_sid=call_sid,
conference_name=conference_name,
message="Great! The person answered. Let me transfer you now.",
message="Great! The destination number answered. Let me transfer you now.",
status="success",
action="transfer_success"
)
# Publish the event to Redis
await transfer_coordinator.publish_transfer_event(transfer_event)
await call_transfer_manager.publish_transfer_event(transfer_event)
logger.info(f"Published TRANSFER_ANSWERED event for {tool_call_id}")
else:
@ -1738,9 +1703,7 @@ async def complete_transfer_function_call(tool_call_id: str, request: Request):
call_status = data.get("CallStatus", "")
call_sid = data.get("CallSid", "")
logger.info(f"Transfer result webhook: {tool_call_id} status={call_status}")
# Note: All transfer coordination now handled via Redis events
logger.info(f"Transfer result(call status) webhook: {tool_call_id} status={call_status}")
# Skip "completed" status to avoid overriding successful transfer results
# The "answered" status already handled the success case
@ -1752,13 +1715,12 @@ async def complete_transfer_function_call(tool_call_id: str, request: Request):
from api.services.telephony.transfer_event_protocol import TransferEvent, TransferEventType
# Get transfer context from Redis for additional information
transfer_coordinator = await get_transfer_coordinator()
transfer_context = await transfer_coordinator.get_transfer_context(tool_call_id)
call_transfer_manager = await get_call_transfer_manager()
transfer_context = await call_transfer_manager.get_transfer_context(tool_call_id)
original_call_sid = transfer_context.original_call_sid if transfer_context else None
caller_number = transfer_context.caller_number if transfer_context else None
# Determine the result based on call status with user-friendly messaging
if call_status == "answered":
# Use original call SID for conference name if available, otherwise fall back to transfer call SID
@ -1767,7 +1729,7 @@ async def complete_transfer_function_call(tool_call_id: str, request: Request):
result = {
"status": "success",
"message": "Great! The person answered. Let me transfer you now.",
"message": "Great! The destination number answered. Let me transfer you now.",
"action": "transfer_success",
"conference_id": conference_name,
"transfer_call_sid": call_sid, # The outbound transfer call SID
@ -1825,7 +1787,6 @@ async def complete_transfer_function_call(tool_call_id: str, request: Request):
transfer_event = TransferEvent(
type=event_type,
tool_call_id=tool_call_id,
workflow_run_id=0, # TODO: Extract from context if needed
original_call_sid=original_call_sid or "",
transfer_call_sid=call_sid,
conference_name=result.get("conference_id"),
@ -1837,12 +1798,12 @@ async def complete_transfer_function_call(tool_call_id: str, request: Request):
)
# Publish the event via Redis
await transfer_coordinator.publish_transfer_event(transfer_event)
await call_transfer_manager.publish_transfer_event(transfer_event)
logger.info(f"Published {event_type} event for {tool_call_id}")
# Clean up transfer context from Redis
await transfer_coordinator.remove_transfer_context(tool_call_id)
await call_transfer_manager.remove_transfer_context(tool_call_id)
logger.info(f"Function call {tool_call_id} completed with result: {result['status']}")
@ -1852,23 +1813,6 @@ async def complete_transfer_function_call(tool_call_id: str, request: Request):
return {"status": "completed", "result": result}
@router.post("/register-transfer-tool-call")
async def register_transfer_tool_call(request: Request):
"""Register a pending transfer function call for webhook completion"""
data = await request.json()
tool_call_id = data.get("tool_call_id")
function_call_params = data.get("function_call_params")
if not tool_call_id or not function_call_params:
raise HTTPException(status_code=400, detail="Missing required fields")
# Store the function call context for webhook completion
pending_function_calls[tool_call_id] = (function_call_params, time.time())
logger.info(f"Registered transfer tool call: {tool_call_id}")
return {"status": "registered", "tool_call_id": tool_call_id}

View file

@ -19,8 +19,8 @@ from api.services.telephony.transfer_event_protocol import (
)
class TransferCoordinator:
"""Coordinates transfer events and context storage using Redis."""
class CallTransferManager:
"""Manages call transfer events and context storage using Redis."""
def __init__(self, redis_client: Optional[aioredis.Redis] = None):
self._redis_client = redis_client
@ -155,7 +155,6 @@ class TransferCoordinator:
timeout_event = TransferEvent(
type=TransferEventType.TRANSFER_TIMEOUT,
tool_call_id=tool_call_id,
workflow_run_id=0, # Will be updated by caller
original_call_sid="",
status="failed",
reason="timeout",
@ -192,13 +191,13 @@ class TransferCoordinator:
logger.error(f"Error during transfer coordinator cleanup: {e}")
# Global transfer coordinator instance
_transfer_coordinator: Optional[TransferCoordinator] = None
# Global call transfer manager instance
_call_transfer_manager: Optional[CallTransferManager] = None
async def get_transfer_coordinator() -> TransferCoordinator:
"""Get or create the global transfer coordinator instance."""
global _transfer_coordinator
if not _transfer_coordinator:
_transfer_coordinator = TransferCoordinator()
return _transfer_coordinator
async def get_call_transfer_manager() -> CallTransferManager:
"""Get or create the global call transfer manager instance."""
global _call_transfer_manager
if not _call_transfer_manager:
_call_transfer_manager = CallTransferManager()
return _call_transfer_manager

View file

@ -27,7 +27,6 @@ class TransferEvent:
type: TransferEventType
tool_call_id: str
workflow_run_id: int
original_call_sid: str
transfer_call_sid: Optional[str] = None
target_number: Optional[str] = None
@ -75,7 +74,6 @@ class TransferContext:
original_call_sid: str
caller_number: Optional[str]
initiated_at: float
workflow_run_id: int
def to_json(self) -> str:
"""Convert context to JSON string."""

View file

@ -11,7 +11,7 @@ from typing import Optional
from loguru import logger
from api.services.telephony.transfer_coordination import get_transfer_coordinator
from api.services.telephony.call_transfer_manager import get_call_transfer_manager
from api.services.telephony.transfer_event_protocol import (
TransferContext,
TransferEvent,
@ -23,7 +23,7 @@ async def test_redis_coordination():
"""Test basic Redis pub/sub coordination for transfers."""
logger.info("Testing Redis-based transfer coordination...")
transfer_coordinator = await get_transfer_coordinator()
call_transfer_manager = await get_call_transfer_manager()
# Test 1: Store and retrieve transfer context
tool_call_id = str(uuid.uuid4())
@ -34,15 +34,14 @@ async def test_redis_coordination():
tool_uuid="test_tool_uuid",
original_call_sid="original_call_123",
caller_number="+0987654321",
initiated_at=time.time(),
workflow_run_id=123
initiated_at=time.time()
)
logger.info("Test 1: Storing transfer context...")
await transfer_coordinator.store_transfer_context(test_context)
await call_transfer_manager.store_transfer_context(test_context)
logger.info("Test 1: Retrieving transfer context...")
retrieved_context = await transfer_coordinator.get_transfer_context(tool_call_id)
retrieved_context = await call_transfer_manager.get_transfer_context(tool_call_id)
if retrieved_context and retrieved_context.tool_call_id == tool_call_id:
logger.info("✅ Test 1 PASSED: Context storage/retrieval works")
@ -55,7 +54,7 @@ async def test_redis_coordination():
# Start waiting for completion in background
async def wait_for_completion():
return await transfer_coordinator.wait_for_transfer_completion(tool_call_id, 5.0)
return await call_transfer_manager.wait_for_transfer_completion(tool_call_id, 5.0)
wait_task = asyncio.create_task(wait_for_completion())
@ -66,7 +65,6 @@ async def test_redis_coordination():
test_event = TransferEvent(
type=TransferEventType.TRANSFER_COMPLETED,
tool_call_id=tool_call_id,
workflow_run_id=123,
original_call_sid="original_call_123",
transfer_call_sid="transfer_call_456",
conference_name="test-conference",
@ -76,7 +74,7 @@ async def test_redis_coordination():
)
logger.info("Test 2: Publishing completion event...")
await transfer_coordinator.publish_transfer_event(test_event)
await call_transfer_manager.publish_transfer_event(test_event)
# Wait for the completion
received_event = await wait_task
@ -89,9 +87,9 @@ async def test_redis_coordination():
# Test 3: Cleanup
logger.info("Test 3: Testing cleanup...")
await transfer_coordinator.remove_transfer_context(tool_call_id)
await call_transfer_manager.remove_transfer_context(tool_call_id)
cleanup_context = await transfer_coordinator.get_transfer_context(tool_call_id)
cleanup_context = await call_transfer_manager.get_transfer_context(tool_call_id)
if cleanup_context is None:
logger.info("✅ Test 3 PASSED: Cleanup works")
else:
@ -106,12 +104,12 @@ async def test_timeout_handling():
"""Test timeout handling in transfer coordination."""
logger.info("Testing timeout handling...")
transfer_coordinator = await get_transfer_coordinator()
call_transfer_manager = await get_call_transfer_manager()
tool_call_id = str(uuid.uuid4())
# Wait for completion with short timeout (should timeout)
start_time = time.time()
result = await transfer_coordinator.wait_for_transfer_completion(tool_call_id, 2.0)
result = await call_transfer_manager.wait_for_transfer_completion(tool_call_id, 2.0)
elapsed = time.time() - start_time
if result is None and elapsed >= 2.0:

View file

@ -35,7 +35,7 @@ from pipecat.utils.enums import EndTaskReason
from pipecat.transports.websocket.fastapi import FastAPIWebsocketClient
from api.utils.hold_audio import load_hold_audio
from api.services.telephony.transfer_coordination import get_transfer_coordinator
from api.services.telephony.call_transfer_manager import get_call_transfer_manager
from api.services.telephony.transfer_event_protocol import (
TransferEvent,
TransferContext,
@ -359,7 +359,7 @@ class CustomToolManager:
backend_url, _ = await get_backend_endpoints()
# Get transfer coordinator for Redis-based coordination
transfer_coordinator = await get_transfer_coordinator()
call_transfer_manager = await get_call_transfer_manager()
# Now initiate the transfer call
transfer_url = f"{backend_url}/api/v1/telephony/call-transfer"
@ -415,7 +415,7 @@ class CustomToolManager:
"Waiting for transfer completion via Redis pub/sub..."
)
transfer_event = (
await transfer_coordinator.wait_for_transfer_completion(
await call_transfer_manager.wait_for_transfer_completion(
transfer_data["tool_call_id"], timeout_seconds
)
)
@ -435,7 +435,7 @@ class CustomToolManager:
# Get transfer context for caller number
transfer_context = (
await transfer_coordinator.get_transfer_context(
await call_transfer_manager.get_transfer_context(
transfer_data["tool_call_id"]
)
)

View file

@ -51,7 +51,6 @@ from api.tasks.s3_upload import (
process_workflow_completion,
upload_voicemail_audio_to_s3,
)
from api.tasks.transfer_handler import handle_transfer_redirect
class WorkerSettings:
@ -63,7 +62,6 @@ class WorkerSettings:
sync_campaign_source,
process_campaign_batch,
process_knowledge_base_document,
handle_transfer_redirect,
]
cron_jobs = []
redis_settings = REDIS_SETTINGS

View file

@ -6,4 +6,4 @@ class FunctionNames:
SYNC_CAMPAIGN_SOURCE = "sync_campaign_source"
PROCESS_CAMPAIGN_BATCH = "process_campaign_batch"
PROCESS_KNOWLEDGE_BASE_DOCUMENT = "process_knowledge_base_document"
HANDLE_TRANSFER_REDIRECT = "handle_transfer_redirect"

View file

@ -1,137 +0,0 @@
"""
ARQ task to handle call transfer redirect independently from pipeline.
This task runs in a separate worker process, ensuring the transfer logic
is completely decoupled from the real-time audio pipeline.
"""
import asyncio
import aiohttp
from loguru import logger
from api.utils.common import get_backend_endpoints
async def handle_transfer_redirect(
ctx,
original_call_sid: str,
conference_id: str,
transfer_call_sid: str
):
"""
Handle call transfer redirect in ARQ worker, independent of pipeline.
Following the test bench approach:
1. Wait for WebSocket closure to complete
2. Verify conference state (destination still connected)
3. Redirect original caller to conference using TwiML endpoint
4. Handle any failures gracefully
Args:
original_call_sid: The original caller's Twilio call SID
conference_id: The conference name to join caller to
transfer_call_sid: The destination call SID (for verification)
"""
logger.info("=" * 60)
logger.info("🚀 ARQ TRANSFER REDIRECT STARTED")
logger.info(f" Original Caller SID: {original_call_sid}")
logger.info(f" Conference ID: {conference_id}")
logger.info(f" Destination Call SID: {transfer_call_sid}")
logger.info("=" * 60)
try:
# Step 1: Wait for WebSocket closure to complete (test bench approach)
logger.info("⏱️ Step 1: Waiting for WebSocket closure to complete...")
await asyncio.sleep(2.0) # Test bench uses 1.5s, we use 2s for safety
logger.info(" WebSocket closure wait completed")
# Step 2: Verify destination is still in conference (test bench approach)
logger.info("🔍 Step 2: Verifying destination is still in conference...")
try:
# TODO: Add actual Twilio conference verification here
# For now, assume destination is still connected
logger.info(" Destination verification completed (assuming connected)")
except Exception as e:
logger.warning(f" Could not verify destination: {e}")
# Step 3: Redirect caller to conference (test bench approach)
logger.info("📞 Step 3: Redirecting caller to conference...")
success = await _redirect_caller_to_conference(original_call_sid, conference_id)
if success:
logger.info("✅ TRANSFER REDIRECT SUCCESSFUL!")
logger.info(" Caller should now be in conference with destination")
else:
logger.error("❌ TRANSFER REDIRECT FAILED!")
except Exception as e:
logger.exception(f"❌ Transfer redirect error: {e}")
logger.info("=" * 60)
logger.info("🏁 ARQ TRANSFER REDIRECT COMPLETED")
logger.info("=" * 60)
async def _redirect_caller_to_conference(call_sid: str, conference_name: str) -> bool:
"""
Redirect caller to conference using Twilio API.
Exactly following the test bench approach.
Args:
call_sid: Twilio call SID to redirect
conference_name: Name of the conference to join
Returns:
bool: True if redirect was successful, False otherwise
"""
logger.info(f"[TRANSFER-DEBUG] _redirect_caller_to_conference called with: {call_sid} and {conference_name}")
# TODO: Use provider service in production instead of hardcoded credentials
account_sid = ""
auth_token = ""
try:
# Get public backend endpoint for TwiML URL
backend_endpoint, _ = await get_backend_endpoints()
# Construct TwiML endpoint URL
transfer_url = f"{backend_endpoint}/api/v1/telephony/transfer-twiml/{conference_name}"
logger.info(f"[TRANSFER-DEBUG] Transfer URL: {transfer_url}")
# Twilio API endpoint for updating calls
api_endpoint = f"https://api.twilio.com/2010-04-01/Accounts/{account_sid}/Calls/{call_sid}.json"
# Redirect data - exactly like test bench
redirect_data = {
"url": transfer_url,
"method": "POST"
}
logger.info(f"[TRANSFER-DEBUG] Redirecting caller {call_sid} to conference {conference_name}")
logger.info(f"[TRANSFER-DEBUG] API endpoint: {api_endpoint}")
logger.info(f"[TRANSFER-DEBUG] Redirect data: {redirect_data}")
# Make the redirect API call
async with aiohttp.ClientSession() as session:
logger.info(f"[TRANSFER-DEBUG] Created aiohttp session")
auth = aiohttp.BasicAuth(account_sid, auth_token)
logger.info(f"[TRANSFER-DEBUG] Making POST request to Twilio API for redirect")
async with session.post(api_endpoint, data=redirect_data, auth=auth) as response:
logger.info(f"[TRANSFER-DEBUG] Received response from Twilio API")
if response.status == 200:
logger.info(f"[TRANSFER-DEBUG] API response status: 200")
logger.info(f"[TRANSFER-DEBUG] Successfully redirected caller to conference {conference_name}")
return True
else:
error_text = await response.text()
logger.error(f"[TRANSFER-DEBUG] Redirect failed - Status: {response.status}, Response: {error_text}")
return False
except Exception as e:
logger.exception(f"[TRANSFER-DEBUG] Exception during redirect: {e}")
return False