feat: telephony call transfer (#155)

* transfer call

* fix: ignore completed call status

* chore: refactor telephony

* chore: refactor pipecat engine custom tools and other telephony services

* chore: code refactor

* chore: put back office ambient sound files

* chore: remove transport from engine

* fix: fix alembic revision

* chore: remove set_transferring_call from engine

* fix: send OutputAudio frame and let transport chunk it

* fix: reinstate docker compose

* chore: remove unused transfer-twmil route for caller

* chore: update pipecat submodule

---------

Co-authored-by: Abhishek Kumar <abhishek@a6k.me>
This commit is contained in:
Sabiha Khan 2026-02-16 14:33:33 +05:30 committed by GitHub
parent 5d14d17ceb
commit c711920165
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 1965 additions and 128 deletions

View file

@ -8,9 +8,16 @@ import uuid
from datetime import UTC, datetime
from typing import Optional
from fastapi import APIRouter, Depends, Header, HTTPException, Request, WebSocket
from fastapi import (
APIRouter,
Depends,
Header,
HTTPException,
Request,
WebSocket,
)
from loguru import logger
from pydantic import BaseModel
from pydantic import BaseModel, field_validator
from sqlalchemy import text
from sqlalchemy.future import select
from starlette.responses import HTMLResponse
@ -26,10 +33,15 @@ 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.call_transfer_manager import get_call_transfer_manager
from api.services.telephony.factory import (
get_all_telephony_providers,
get_telephony_provider,
)
from api.services.telephony.transfer_event_protocol import (
TransferEvent,
TransferEventType,
)
from api.utils.common import get_backend_endpoints
from api.utils.telephony_helper import (
generic_hangup_response,
@ -157,7 +169,8 @@ async def initiate_call(
if not phone_number:
raise HTTPException(
status_code=400,
detail="Phone number must be provided in request or set in user configuration",
detail="Phone number must be provided in request or set in user "
"configuration",
)
workflow_run_id = request.workflow_run_id
@ -1480,3 +1493,210 @@ async def handle_cloudonix_cdr(request: Request):
)
return {"status": "success"}
class TransferCallRequest(BaseModel):
"""Request model for initiating a call transfer."""
destination: str # E.164 format phone number (required)
organization_id: int # Organization ID for provider configuration
transfer_id: str # Unique identifier for tracking this transfer
conference_name: str # Conference name for the transfer
timeout: Optional[int] = 20 # seconds to wait for answer
@field_validator("destination")
@classmethod
def validate_destination(cls, destination: str) -> str:
"""Validate destination is in E.164 format."""
import re
if not destination or not destination.strip():
raise ValueError("Destination phone number is required")
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()
@router.post("/call-transfer")
async def initiate_call_transfer(request: TransferCallRequest):
"""Initiate a call transfer via the telephony provider.
This endpoint only initiates the outbound call. Transfer context
(original_call_sid, etc.) is stored by the caller
before invoking this endpoint.
"""
logger.info(
f"Starting call transfer to {request.destination} with transfer_id: {request.transfer_id}"
)
try:
try:
provider = await get_telephony_provider(request.organization_id)
except ValueError as e:
logger.error(f"Transfer provider validation failed: {e}")
raise HTTPException(
status_code=400, detail=f"Call transfer not supported: {str(e)}"
)
if not provider.supports_transfers():
raise HTTPException(
status_code=400,
detail=f"Provider '{provider.PROVIDER_NAME}' does not support call transfers",
)
if not provider.validate_config():
logger.error(f"Provider {provider.PROVIDER_NAME} configuration is invalid")
raise HTTPException(
status_code=400,
detail=f"Telephony provider '{provider.PROVIDER_NAME}' is not properly configured for transfers",
)
logger.info(f"Initiating transfer call via {provider.PROVIDER_NAME} provider")
try:
transfer_result = await provider.transfer_call(
destination=request.destination,
transfer_id=request.transfer_id,
conference_name=request.conference_name,
timeout=request.timeout,
)
except NotImplementedError as e:
logger.error(
f"Provider {provider.PROVIDER_NAME} doesn't support transfers: {e}"
)
raise HTTPException(
status_code=400,
detail=f"Provider '{provider.PROVIDER_NAME}' does not support call transfers",
)
except Exception as e:
logger.error(f"Provider transfer call failed: {e}")
raise HTTPException(
status_code=500, detail=f"Transfer call failed: {str(e)}"
)
call_sid = transfer_result.get("call_sid")
logger.info(f"Transfer call initiated successfully: {call_sid}")
logger.debug(f"Transfer result: {transfer_result}")
return {
"status": "transfer_initiated",
"call_id": call_sid,
"message": f"Calling {request.destination}...",
"transfer_id": request.transfer_id,
"provider": provider.PROVIDER_NAME,
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Unexpected error during transfer call: {e}")
raise HTTPException(
status_code=500, detail=f"Internal error during transfer: {str(e)}"
)
@router.post("/transfer-result/{transfer_id}")
async def complete_transfer_function_call(transfer_id: str, request: Request):
"""Webhook endpoint to complete the function call with transfer result.
Called by Twilio's StatusCallback when the transfer call status changes.
"""
form_data = await request.form()
data = dict(form_data)
call_status = data.get("CallStatus", "")
call_sid = data.get("CallSid", "")
logger.info(
f"Transfer result(call status) webhook: {transfer_id} status={call_status}"
)
# Get transfer context from Redis for additional information
call_transfer_manager = await get_call_transfer_manager()
transfer_context = await call_transfer_manager.get_transfer_context(transfer_id)
original_call_sid = transfer_context.original_call_sid if transfer_context else None
conference_name = transfer_context.conference_name if transfer_context else None
# Determine the result based on call status with user-friendly messaging
if call_status in ("in-progress", "answered"):
result = {
"status": "success",
"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
"original_call_sid": original_call_sid, # The original caller's SID
"end_call": False, # Continue with transfer
}
elif call_status == "no-answer":
result = {
"status": "transfer_failed",
"reason": "no_answer",
"message": "The transfer call was not answered. The person may be busy or unavailable right now.",
"action": "transfer_failed",
"call_sid": call_sid,
"end_call": True,
}
elif call_status == "busy":
result = {
"status": "transfer_failed",
"reason": "busy",
"message": "The transfer call encountered a busy signal. The person is likely on another call.",
"action": "transfer_failed",
"call_sid": call_sid,
"end_call": True,
}
elif call_status == "failed":
result = {
"status": "transfer_failed",
"reason": "call_failed",
"message": "The transfer call failed to connect. There may be a network issue or the number is unavailable.",
"action": "transfer_failed",
"call_sid": call_sid,
"end_call": True,
}
else:
# Intermediate status (ringing, in-progress, etc.), don't complete yet
logger.info(
f"Received intermediate status {call_status}, waiting for final status"
)
return {"status": "pending"}
# Complete the function call with Redis event publishing
try:
# Determine event type based on result status
if result["status"] == "success":
event_type = TransferEventType.TRANSFER_COMPLETED
elif result.get("reason") == "timeout":
event_type = TransferEventType.TRANSFER_TIMEOUT
else:
event_type = TransferEventType.TRANSFER_FAILED
transfer_event = TransferEvent(
type=event_type,
transfer_id=transfer_id,
original_call_sid=original_call_sid or "",
transfer_call_sid=call_sid,
conference_name=conference_name,
message=result.get("message", ""),
status=result["status"],
action=result.get("action", ""),
reason=result.get("reason"),
end_call=result.get("end_call", False),
)
# Publish the event via Redis
await call_transfer_manager.publish_transfer_event(transfer_event)
logger.info(
f"Published {event_type} event for {transfer_id} with result: {result['status']}"
)
except Exception as e:
logger.error(f"Error completing transfer {transfer_id}: {e}")
return {"status": "completed", "result": result}

View file

@ -1,10 +1,11 @@
"""API routes for managing tools."""
import re
from datetime import datetime
from typing import Annotated, Any, Dict, List, Literal, Optional, Union
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator
from api.db import db_client
from api.db.models import UserModel
@ -56,6 +57,42 @@ class EndCallConfig(BaseModel):
)
class TransferCallConfig(BaseModel):
"""Configuration for Transfer Call tools."""
destination: str = Field(
description="Phone number to transfer the call to (E.164 format, e.g., +1234567890)"
)
messageType: Literal["none", "custom"] = Field(
default="none", description="Type of message to play before transfer"
)
customMessage: Optional[str] = Field(
default=None, description="Custom message to play before transferring the call"
)
timeout: int = Field(
default=30,
ge=5,
le=120,
description="Maximum time in seconds to wait for destination to answer (5-120 seconds)",
)
@field_validator("destination")
@classmethod
def validate_destination(cls, v: str) -> str:
"""Validate that destination is a valid E.164 phone number."""
# Allow empty string for initial creation (like HTTP API tools with empty URL)
if not v.strip():
return v
# E.164 format: +[1-9]\d{1,14}
e164_pattern = r"^\+[1-9]\d{1,14}$"
if not re.match(e164_pattern, v):
raise ValueError(
"Destination must be a valid E.164 phone number (e.g., +1234567890)"
)
return v
class HttpApiToolDefinition(BaseModel):
"""Tool definition for HTTP API tools."""
@ -72,9 +109,17 @@ class EndCallToolDefinition(BaseModel):
config: EndCallConfig = Field(description="End Call configuration")
class TransferCallToolDefinition(BaseModel):
"""Tool definition for Transfer Call tools."""
schema_version: int = Field(default=1, description="Schema version")
type: Literal["transfer_call"] = Field(description="Tool type")
config: TransferCallConfig = Field(description="Transfer Call configuration")
# Union type for tool definitions - Pydantic will discriminate based on 'type' field
ToolDefinition = Annotated[
Union[HttpApiToolDefinition, EndCallToolDefinition],
Union[HttpApiToolDefinition, EndCallToolDefinition, TransferCallToolDefinition],
Field(discriminator="type"),
]
@ -89,6 +134,17 @@ class CreateToolRequest(BaseModel):
icon_color: Optional[str] = Field(default="#3B82F6", max_length=7)
definition: ToolDefinition
@field_validator("category")
@classmethod
def validate_category(cls, v: str) -> str:
"""Validate that category is a valid ToolCategory value."""
valid_categories = [c.value for c in ToolCategory]
if v not in valid_categories:
raise ValueError(
f"Invalid category '{v}'. Must be one of: {', '.join(valid_categories)}"
)
return v
class UpdateToolRequest(BaseModel):
"""Request schema for updating a tool."""