chore: code refactor

This commit is contained in:
Abhishek Kumar 2026-02-14 13:43:20 +05:30
parent c0cbc65de3
commit c7812bf189
29 changed files with 538 additions and 800 deletions

View file

@ -316,16 +316,18 @@ class TelephonyProvider(ABC):
async def transfer_call(
self,
destination: str,
tool_call_id: str,
transfer_id: str,
conference_name: str,
timeout: int = 30,
**kwargs: Any
**kwargs: Any,
) -> Dict[str, Any]:
"""
Initiate a call transfer to a destination number.
Args:
destination: The destination phone number (E.164 format)
tool_call_id: Unique identifier for tracking this transfer
transfer_id: Unique identifier for tracking this transfer
conference_name: Name of the conference to join the destination into
timeout: Transfer timeout in seconds
**kwargs: Provider-specific additional parameters
@ -334,7 +336,6 @@ class TelephonyProvider(ABC):
- call_sid: Provider's call identifier
- status: Transfer initiation status
- provider: Provider name
- webhook_urls: Dict with answer and status callback URLs
Raises:
NotImplementedError: If provider doesn't support transfers

View file

@ -5,26 +5,27 @@ Handles transfer event publishing, subscription, and context storage
import asyncio
import time
from typing import Optional, Dict, Any
from loguru import logger
from typing import Dict, Optional
import redis.asyncio as aioredis
from loguru import logger
from api.constants import REDIS_URL
from api.services.telephony.transfer_event_protocol import (
TransferEvent,
TransferContext,
TransferEvent,
TransferEventType,
TransferRedisChannels
TransferRedisChannels,
)
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
self._pubsub_connections: Dict[str, aioredis.client.PubSub] = {}
async def _get_redis(self) -> aioredis.Redis:
"""Get Redis client instance."""
if not self._redis_client:
@ -32,34 +33,36 @@ class CallTransferManager:
REDIS_URL, decode_responses=True
)
return self._redis_client
async def store_transfer_context(self, context: TransferContext, ttl: int = 300) -> None:
async def store_transfer_context(
self, context: TransferContext, ttl: int = 300
) -> None:
"""Store transfer context in Redis with TTL.
Args:
context: Transfer context data
ttl: Time to live in seconds (default 5 minutes)
"""
try:
redis = await self._get_redis()
key = TransferRedisChannels.transfer_context_key(context.tool_call_id)
key = TransferRedisChannels.transfer_context_key(context.transfer_id)
await redis.setex(key, ttl, context.to_json())
logger.debug(f"Stored transfer context for {context.tool_call_id}")
logger.debug(f"Stored transfer context for {context.transfer_id}")
except Exception as e:
logger.error(f"Failed to store transfer context: {e}")
async def get_transfer_context(self, tool_call_id: str) -> Optional[TransferContext]:
async def get_transfer_context(self, transfer_id: str) -> Optional[TransferContext]:
"""Retrieve transfer context from Redis.
Args:
tool_call_id: Tool call identifier
transfer_id: Transfer identifier
Returns:
Transfer context if found, None otherwise
"""
try:
redis = await self._get_redis()
key = TransferRedisChannels.transfer_context_key(tool_call_id)
key = TransferRedisChannels.transfer_context_key(transfer_id)
data = await redis.get(key)
if data:
return TransferContext.from_json(data)
@ -67,24 +70,24 @@ class CallTransferManager:
except Exception as e:
logger.error(f"Failed to get transfer context: {e}")
return None
async def remove_transfer_context(self, tool_call_id: str) -> None:
async def remove_transfer_context(self, transfer_id: str) -> None:
"""Remove transfer context from Redis.
Args:
tool_call_id: Tool call identifier
transfer_id: Transfer identifier
"""
try:
redis = await self._get_redis()
key = TransferRedisChannels.transfer_context_key(tool_call_id)
key = TransferRedisChannels.transfer_context_key(transfer_id)
await redis.delete(key)
logger.debug(f"Removed transfer context for {tool_call_id}")
logger.debug(f"Removed transfer context for {transfer_id}")
except Exception as e:
logger.error(f"Failed to remove transfer context: {e}")
async def publish_transfer_event(self, event: TransferEvent) -> None:
"""Publish transfer event to Redis channel.
Args:
event: Transfer event to publish
"""
@ -92,74 +95,69 @@ class CallTransferManager:
# Add timestamp if not present
if event.timestamp is None:
event.timestamp = time.time()
redis = await self._get_redis()
channel = TransferRedisChannels.transfer_events(event.tool_call_id)
channel = TransferRedisChannels.transfer_events(event.transfer_id)
await redis.publish(channel, event.to_json())
logger.info(f"Published {event.type} event for {event.tool_call_id}")
logger.info(f"Published {event.type} event for {event.transfer_id}")
except Exception as e:
logger.error(f"Failed to publish transfer event: {e}")
async def wait_for_transfer_completion(
self,
tool_call_id: str,
timeout_seconds: float = 30.0
self, transfer_id: str, timeout_seconds: float = 30.0
) -> Optional[TransferEvent]:
"""Wait for transfer completion event using Redis pub/sub.
Args:
tool_call_id: Tool call identifier to wait for
transfer_id: Transfer identifier to wait for
timeout_seconds: Maximum time to wait
Returns:
Transfer completion event if received, None on timeout
"""
channel = TransferRedisChannels.transfer_events(tool_call_id)
channel = TransferRedisChannels.transfer_events(transfer_id)
redis = await self._get_redis()
pubsub = redis.pubsub()
try:
await pubsub.subscribe(channel)
logger.info(f"Waiting for transfer completion on {channel} (timeout: {timeout_seconds}s)")
logger.info(
f"Waiting for transfer completion on {channel} (timeout: {timeout_seconds}s)"
)
# Wait for completion event with timeout
async def wait_for_message():
async for message in pubsub.listen():
if message["type"] == "message":
try:
event = TransferEvent.from_json(message["data"])
logger.info(f"Received {event.type} event for {tool_call_id}")
logger.info(
f"Received {event.type} event for {transfer_id}"
)
# Check if this is a completion event
if event.type in [
TransferEventType.TRANSFER_ANSWERED, # Call answered = transfer successful
TransferEventType.TRANSFER_COMPLETED,
TransferEventType.TRANSFER_FAILED,
TransferEventType.TRANSFER_CANCELLED,
TransferEventType.TRANSFER_TIMEOUT
]:
if (
event.type
in [
TransferEventType.TRANSFER_ANSWERED, # Call answered = transfer successful
TransferEventType.TRANSFER_COMPLETED,
TransferEventType.TRANSFER_FAILED,
TransferEventType.TRANSFER_CANCELLED,
TransferEventType.TRANSFER_TIMEOUT,
]
):
return event
except Exception as e:
logger.error(f"Failed to parse transfer event: {e}")
continue
return None
# Wait with timeout
result = await asyncio.wait_for(wait_for_message(), timeout=timeout_seconds)
return result
except asyncio.TimeoutError:
logger.error(f"Transfer completion wait timed out for {tool_call_id}")
# Publish timeout event for other instances
timeout_event = TransferEvent(
type=TransferEventType.TRANSFER_TIMEOUT,
tool_call_id=tool_call_id,
original_call_sid="",
status="failed",
reason="timeout",
end_call=True
)
await self.publish_transfer_event(timeout_event)
logger.debug(f"Transfer completion wait timed out for {transfer_id}")
return None
except Exception as e:
logger.error(f"Error waiting for transfer completion: {e}")
@ -170,7 +168,7 @@ class CallTransferManager:
await pubsub.close()
except Exception as e:
logger.error(f"Error closing pubsub connection: {e}")
async def cleanup(self):
"""Clean up Redis connections."""
try:
@ -181,7 +179,7 @@ class CallTransferManager:
except:
pass
self._pubsub_connections.clear()
# Close main Redis connection
if self._redis_client:
await self._redis_client.close()
@ -199,4 +197,4 @@ async def get_call_transfer_manager() -> CallTransferManager:
global _call_transfer_manager
if not _call_transfer_manager:
_call_transfer_manager = CallTransferManager()
return _call_transfer_manager
return _call_transfer_manager

View file

@ -128,29 +128,3 @@ async def get_all_telephony_providers() -> List[Type[TelephonyProvider]]:
List of provider classes that can be used for webhook detection
"""
return [CloudonixProvider, TwilioProvider, VobizProvider, VonageProvider]
async def get_transfer_provider(organization_id: int) -> TelephonyProvider:
"""
Get telephony provider that supports call transfers.
Args:
organization_id: Organization ID for provider lookup
Returns:
Configured telephony provider that supports transfers
Raises:
ValueError: If provider doesn't support transfers or org not configured
"""
provider = await get_telephony_provider(organization_id)
if not provider.supports_transfers():
config = await load_telephony_config(organization_id)
provider_type = config.get("provider", "unknown")
raise ValueError(
f"Organization telephony provider '{provider_type}' does not support call transfers. "
f"Only Twilio provider supports transfers."
)
return provider

View file

@ -686,9 +686,10 @@ class CloudonixProvider(TelephonyProvider):
async def transfer_call(
self,
destination: str,
tool_call_id: str,
transfer_id: str,
conference_name: str,
timeout: int = 30,
**kwargs: Any
**kwargs: Any,
) -> Dict[str, Any]:
"""
Cloudonix provider does not support call transfers.

View file

@ -10,7 +10,6 @@ import aiohttp
from fastapi import HTTPException
from loguru import logger
from twilio.request_validator import RequestValidator
from pipecat.utils.run_context import set_current_call_sid
from api.enums import WorkflowRunMode
from api.services.telephony.base import (
@ -283,11 +282,6 @@ class TwilioProvider(TelephonyProvider):
try:
stream_sid = start_msg["start"]["streamSid"]
call_sid = start_msg["start"]["callSid"]
# Set call SID in Pipecat context for use throughout the pipeline
set_current_call_sid(call_sid)
logger.info(f"Set call SID context: {call_sid}")
except KeyError:
logger.error("Missing streamSid or callSid in start message")
await websocket.close(code=4400, reason="Missing stream identifiers")
@ -473,16 +467,21 @@ class TwilioProvider(TelephonyProvider):
async def transfer_call(
self,
destination: str,
tool_call_id: str,
transfer_id: str,
conference_name: str,
timeout: int = 30,
**kwargs: Any
**kwargs: Any,
) -> Dict[str, Any]:
"""
Initiate a call transfer via Twilio.
Uses inline TwiML to put the destination into a conference when they answer,
and a status callback to track the transfer outcome.
Args:
destination: The destination phone number (E.164 format)
tool_call_id: Unique identifier for tracking this transfer
transfer_id: Unique identifier for tracking this transfer
conference_name: Name of the conference to join the destination into
timeout: Transfer timeout in seconds
**kwargs: Additional Twilio-specific parameters
@ -502,11 +501,18 @@ class TwilioProvider(TelephonyProvider):
backend_endpoint, _ = await get_backend_endpoints()
# Generate webhook URLs for the transfer call
call_url = f"{backend_endpoint}/api/v1/telephony/transfer-call-handler/{tool_call_id}"
status_callback_url = f"{backend_endpoint}/api/v1/telephony/transfer-result/{tool_call_id}"
status_callback_url = (
f"{backend_endpoint}/api/v1/telephony/transfer-result/{transfer_id}"
)
logger.debug(f"Transfer webhook URLs - Answer: {call_url}, Status: {status_callback_url}")
# Inline TwiML: when the destination answers, put them into the conference
twiml = f"""<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Say>You have answered a transfer call. Connecting you now.</Say>
<Dial>
<Conference endConferenceOnExit="true">{conference_name}</Conference>
</Dial>
</Response>"""
# Prepare Twilio API call data
endpoint = f"{self.base_url}/Calls.json"
@ -514,49 +520,55 @@ class TwilioProvider(TelephonyProvider):
"To": destination,
"From": from_number,
"Timeout": timeout,
"Url": call_url,
"Twiml": twiml,
"StatusCallback": status_callback_url,
"StatusCallbackEvent": ["answered", "no-answer", "busy", "failed", "completed"],
"StatusCallbackMethod": "POST"
"StatusCallbackEvent": [
"answered",
"no-answer",
"busy",
"failed",
"completed",
],
"StatusCallbackMethod": "POST",
}
# Add any additional kwargs
data.update(kwargs)
try:
# Make Twilio API call
logger.info(f"Making Twilio transfer API call to: {endpoint}")
logger.info(f"Transfer call data: {data}")
logger.debug(f"Transfer call data: {data}")
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:
response_status = response.status
response_text = await response.text()
logger.info(f"Twilio transfer API response status: {response_status}")
logger.info(f"Twilio transfer API response body: {response_text}")
logger.info(
f"Twilio transfer API response status: {response_status}"
)
logger.debug(f"Twilio transfer API response body: {response_text}")
if response_status in [200, 201]:
try:
response_data = await response.json()
call_sid = response_data.get("sid")
logger.info(f"Transfer call initiated successfully: {call_sid}")
logger.info(
f"Transfer call initiated successfully: {call_sid}"
)
return {
"call_sid": call_sid,
"status": response_data.get("status", "queued"),
"provider": self.PROVIDER_NAME,
"webhook_urls": {
"answer": call_url,
"status": status_callback_url
},
"from_number": from_number,
"to_number": destination,
"raw_response": response_data
"raw_response": response_data,
}
except Exception as e:
logger.error(f"Failed to parse Twilio transfer response JSON: {e}")
logger.error(
f"Failed to parse Twilio transfer response JSON: {e}"
)
raise Exception(f"Failed to parse transfer response: {e}")
else:
error_msg = f"Twilio API call failed with status {response_status}: {response_text}"

View file

@ -539,9 +539,10 @@ class VobizProvider(TelephonyProvider):
async def transfer_call(
self,
destination: str,
tool_call_id: str,
transfer_id: str,
conference_name: str,
timeout: int = 30,
**kwargs: Any
**kwargs: Any,
) -> Dict[str, Any]:
"""
Vobiz provider does not support call transfers.

View file

@ -490,9 +490,10 @@ class VonageProvider(TelephonyProvider):
async def transfer_call(
self,
destination: str,
tool_call_id: str,
transfer_id: str,
conference_name: str,
timeout: int = 30,
**kwargs: Any
**kwargs: Any,
) -> Dict[str, Any]:
"""
Vonage provider does not support call transfers.

View file

@ -12,7 +12,7 @@ from typing import Any, Dict, Optional
class TransferEventType(str, Enum):
"""Types of transfer events sent between instances."""
TRANSFER_INITIATED = "transfer_initiated"
TRANSFER_ANSWERED = "transfer_answered"
TRANSFER_COMPLETED = "transfer_completed"
@ -24,9 +24,9 @@ class TransferEventType(str, Enum):
@dataclass
class TransferEvent:
"""Event data structure for transfer coordination."""
type: TransferEventType
tool_call_id: str
transfer_id: str
original_call_sid: str
transfer_call_sid: Optional[str] = None
target_number: Optional[str] = None
@ -37,16 +37,16 @@ class TransferEvent:
reason: Optional[str] = None
end_call: bool = False
timestamp: Optional[float] = None
def to_json(self) -> str:
"""Convert event to JSON string."""
return json.dumps(asdict(self))
@classmethod
@classmethod
def from_json(cls, data: str) -> "TransferEvent":
"""Create event from JSON string."""
return cls(**json.loads(data))
def to_result_dict(self) -> Dict[str, Any]:
"""Convert to function call result format."""
result = {
@ -56,9 +56,8 @@ class TransferEvent:
"conference_id": self.conference_name,
"transfer_call_sid": self.transfer_call_sid,
"original_call_sid": self.original_call_sid,
"caller_number": None, # Will be populated by webhook handler
"end_call": self.end_call,
"reason": self.reason
"reason": self.reason,
}
return result
@ -66,24 +65,24 @@ class TransferEvent:
@dataclass
class TransferContext:
"""Transfer context data stored in Redis."""
tool_call_id: str
transfer_id: str
call_sid: Optional[str]
target_number: str
tool_uuid: str
original_call_sid: str
caller_number: Optional[str]
conference_name: str
initiated_at: float
def to_json(self) -> str:
"""Convert context to JSON string."""
return json.dumps(asdict(self))
@classmethod
def from_json(cls, data: str) -> "TransferContext":
"""Create context from JSON string."""
"""Create context from JSON string."""
return cls(**json.loads(data))
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return asdict(self)
@ -91,13 +90,13 @@ class TransferContext:
class TransferRedisChannels:
"""Redis channel naming conventions for transfer events."""
@staticmethod
def transfer_events(tool_call_id: str) -> str:
"""Channel for transfer events for a specific tool call."""
return f"transfer:events:{tool_call_id}"
def transfer_events(transfer_id: str) -> str:
"""Channel for transfer events for a specific transfer."""
return f"transfer:events:{transfer_id}"
@staticmethod
def transfer_context_key(tool_call_id: str) -> str:
def transfer_context_key(transfer_id: str) -> str:
"""Redis key for transfer context storage."""
return f"transfer:context:{tool_call_id}"
return f"transfer:context:{transfer_id}"