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

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}