Cloudonix Transfer Feature (#542)

* Add support for foreground debugging

* Add support for Cloudonix call transfers

* Improve the customer/agent conference experience with less annoying sounds.

* Update remote_up.sh

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* Resolve a small redundant code segment from cubic

* Resolve an issue with callbacks not providing the correct experience for failed
originated calls

* Yet a small fix

* Remove stale code

* Remove the beeps on transfer

* Remove unrelated remote_up.sh changes

* Update pipecat submodule to main

---------

Co-authored-by: Nir Simionovich <nirs@cloudonix.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
Co-authored-by: Abhishek Kumar <abhishek@a6k.me>
This commit is contained in:
Nir Simionovich 2026-07-15 12:21:40 +03:00 committed by GitHub
parent eeb027dd73
commit ba312d0dfa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 298 additions and 26 deletions

View file

@ -1051,6 +1051,18 @@ class CloudonixProvider(TelephonyProvider):
return Response(content=twiml, media_type="application/xml"), "application/xml"
# ======== CALL TRANSFER METHODS ========
@staticmethod
def _conference_join_cxml(conference_name: str, callback_url: str) -> str:
"""CXML the destination leg runs once it answers: join the conference."""
return (
'<?xml version="1.0" encoding="UTF-8"?>'
"<Response>"
"<Say>You have answered a transfer call. Connecting you now.</Say>"
"<Dial>"
f'<Conference endConferenceOnExit="true" statusCallback="{callback_url}" statusCallbackEvent="join" holdMusic="false" beep="false">{conference_name}</Conference>'
"</Dial>"
"</Response>"
)
async def transfer_call(
self,
@ -1060,33 +1072,86 @@ class CloudonixProvider(TelephonyProvider):
timeout: int = 30,
**kwargs: Any,
) -> Dict[str, Any]:
"""Dial the transfer destination into a conference via Cloudonix.
Places an outbound call whose inline CXML joins ``conference_name`` when
the destination answers, and sets the call object's ``callback`` to the
Cloudonix transfer-result route so the destination's session-status
transitions (``connected`` / terminal) drive transfer completion. The
original caller leg is later forked into the same conference by
``CloudonixConferenceStrategy``.
Supports both PSTN numbers and SIP URIs as ``destination``.
Returns a dict with the destination leg's ``call_sid`` (session token).
"""
Initiate a call transfer via Cloudonix.
if not self.validate_config():
raise ValueError("Cloudonix provider not properly configured")
Uses inline CXML to put the destination into a conference when they answer,
and a status callback to track the transfer outcome.
if not self.from_numbers:
raise ValueError(
"No phone numbers configured for Cloudonix provider; a caller-id "
"is required to place the transfer call."
)
from_number = random.choice(self.from_numbers)
Args:
destination: The destination phone number (E.164 format)
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
backend_endpoint, _ = await get_backend_endpoints()
callback_url = (
f"{backend_endpoint}/api/v1/telephony/cloudonix/transfer-result/{transfer_id}"
)
Returns:
Dict containing transfer result information
endpoint = f"{self.base_url}/calls/{self.domain_id}/application"
data: Dict[str, Any] = {
"destination": destination,
"caller-id": from_number,
"cxml": self._conference_join_cxml(conference_name, callback_url),
"callback": callback_url,
"timeout": timeout,
}
Raises:
ValueError: If provider configuration is invalid
Exception: If Twilio API call fails
"""
raise NotImplementedError("Cloudonix provider does not support call transfers")
data.update(kwargs)
headers = self._get_auth_headers()
masked_destination = (
f"***{destination[-4:]}" if len(destination) > 4 else "***"
)
logger.info(
f"[Cloudonix Transfer] Dialing {masked_destination} into conference "
f"{conference_name} (transfer_id={transfer_id})"
)
async with aiohttp.ClientSession() as session:
async with session.post(endpoint, json=data, headers=headers) as response:
response_text = await response.text()
if response.status != 200:
logger.error(
f"[Cloudonix Transfer] Dial failed: HTTP {response.status}, "
f"body: {response_text}"
)
raise Exception(
f"Cloudonix transfer dial failed (HTTP {response.status}): "
f"{response_text}"
)
response_data = await response.json()
session_token = response_data.get("token")
if not session_token:
raise Exception(
"No session token returned from Cloudonix transfer dial"
)
logger.info(
f"[Cloudonix Transfer] Destination leg initiated "
f"(token={session_token}, transfer_id={transfer_id})"
)
return {
"call_sid": session_token,
"status": response_data.get("status", "initiated"),
"provider": self.PROVIDER_NAME,
"from_number": from_number,
"to_number": destination,
"raw_response": response_data,
}
def supports_transfers(self) -> bool:
"""
Cloudonix does not support call transfers.
Returns:
False - Cloudonix provider does not support call transfers
"""
return False
"""Cloudonix supports conference-based call transfers."""
return True

View file

@ -11,15 +11,106 @@ from loguru import logger
from pipecat.utils.run_context import set_current_run_id
from api.db import db_client
from api.services.telephony.call_transfer_manager import get_call_transfer_manager
from api.services.telephony.factory import get_telephony_provider_for_run
from api.services.telephony.providers.cloudonix.provider import CloudonixProvider
from api.services.telephony.status_processor import (
StatusCallbackRequest,
_process_status_update,
)
from api.services.telephony.transfer_event_protocol import (
TransferEvent,
TransferEventType,
)
router = APIRouter()
# Cloudonix session statuses that terminate a transfer without an answer.
_CLOUDONIX_TRANSFER_FAILURE_STATUSES = {
"busy",
"noanswer",
"cancel",
"nocredit",
"error",
"congestion",
"failed",
}
@router.post("/cloudonix/transfer-result/{transfer_id}")
async def handle_cloudonix_transfer_result(transfer_id: str, request: Request):
"""Drive transfer completion from the destination leg's session status.
``CloudonixProvider.transfer_call`` sets this URL as the outbound call
object's ``callback``. Cloudonix POSTs session-status notifications here;
a ``connected`` status means the destination answered (publish
DESTINATION_ANSWERED so the shared handler forks the caller into the
conference), while terminal non-answer statuses publish TRANSFER_FAILED.
Intermediate statuses (ringing/processing) are acked without publishing.
"""
content_type = request.headers.get("content-type", "")
if "application/json" in content_type:
data = await request.json()
else:
data = dict(await request.form())
# Cloudonix session notifications may arrive as a single object or a
# one-element list (see session-update webhook payloads).
if isinstance(data, list):
data = data[0] if data else {}
status = str(data.get("StatusCallbackEvent", "")).lower()
destination_token = data.get("Session", "")
logger.info(
f"[Cloudonix Transfer] transfer_id={transfer_id} status={status} "
f"token={destination_token}"
)
call_transfer_manager = await get_call_transfer_manager()
transfer_context = await call_transfer_manager.get_transfer_context(transfer_id)
if not transfer_context:
logger.warning(
f"[Cloudonix Transfer] No transfer context for {transfer_id}; ignoring"
)
return {"status": "ignored", "reason": "unknown_transfer"}
original_call_sid = transfer_context.original_call_sid
conference_name = transfer_context.conference_name
if (status == "participant-join"):
event = TransferEvent(
type=TransferEventType.DESTINATION_ANSWERED,
transfer_id=transfer_id,
original_call_sid=original_call_sid or "",
transfer_call_sid=destination_token,
conference_name=conference_name,
message="Great! The destination answered. Connecting you now.",
status="success",
action="destination_answered",
)
elif status in _CLOUDONIX_TRANSFER_FAILURE_STATUSES:
event = TransferEvent(
type=TransferEventType.TRANSFER_FAILED,
transfer_id=transfer_id,
original_call_sid=original_call_sid or "",
transfer_call_sid=destination_token,
conference_name=conference_name,
message="The transfer call could not be completed.",
status="transfer_failed",
action="transfer_failed",
reason=status,
)
else:
logger.info(
f"[Cloudonix Transfer] Intermediate status {status} for {transfer_id}, "
"waiting"
)
return {"status": "pending"}
await call_transfer_manager.publish_transfer_event(event)
return {"status": "completed"}
@router.post("/cloudonix/status-callback/{workflow_run_id}")
async def handle_cloudonix_status_callback(

View file

@ -3,11 +3,126 @@
from typing import Any, Dict
from loguru import logger
from pipecat.serializers.call_strategies import HangupStrategy
from pipecat.serializers.call_strategies import HangupStrategy, TransferStrategy
from api.services.telephony.providers.cloudonix.provider import CLOUDONIX_API_BASE_URL
class CloudonixConferenceStrategy(TransferStrategy):
"""Conference-based call transfer for Cloudonix.
Moves the original caller leg into the transfer conference by forking its
live session onto new CXML. Cloudonix has no live-CXML push equivalent to
Twilio's call-update; ``POST /calls/{domain}/sessions/{token}/fork`` is the
primitive that re-runs CXML on a connected session. The destination leg was
already dialed into the conference by ``CloudonixProvider.transfer_call``.
The fork MUST target the Cloudonix session token, which is carried on
``TransferContext.original_call_sid`` (the media ``callSid`` will not
resolve the session).
"""
async def execute_transfer(self, context: Dict[str, Any]) -> bool:
import aiohttp
transfer_context = None
try:
# call_sid here is the serializer's session token (remapped from
# Cloudonix call_id); use it only to locate the transfer context.
call_sid = context.get("call_sid") or context.get("call_id")
domain_id = context.get("account_sid") or context.get("domain_id")
bearer_token = context.get("auth_token") or context.get("bearer_token")
transfer_context = await self._find_transfer_context_for_call(call_sid)
if not transfer_context:
logger.error(
f"[Cloudonix Transfer] No active transfer context for call {call_sid}"
)
return False
if not domain_id or not bearer_token:
logger.error(
"[Cloudonix Transfer] Missing domain_id or bearer_token in context"
)
await self._cleanup_transfer_context(transfer_context.transfer_id)
return False
# Always fork the session token, never the media callSid.
session_token = transfer_context.original_call_sid
conference_name = transfer_context.conference_name
endpoint = (
f"{CLOUDONIX_API_BASE_URL}/calls/{domain_id}/sessions/"
f"{session_token}/application"
)
caller_cxml = (
'<?xml version="1.0" encoding="UTF-8"?>'
"<Response><Dial>"
f'<Conference endConferenceOnExit="true" beep="false" holdMusic="false">{conference_name}</Conference>'
"</Dial><Hangup/></Response>"
)
payload = {"cxml": caller_cxml}
headers = {
"Authorization": f"Bearer {bearer_token}",
"Content-Type": "application/json",
}
logger.info(
f"[Cloudonix Transfer] Switching session {session_token} into "
f"conference {conference_name}"
)
async with aiohttp.ClientSession() as session:
async with session.post(
endpoint, json=payload, headers=headers
) as response:
body = await response.text()
if response.status in (200, 202):
logger.info(
f"[Cloudonix Transfer] Session {session_token} joined "
f"conference {conference_name} (HTTP {response.status})"
)
await self._cleanup_transfer_context(
transfer_context.transfer_id
)
return True
logger.error(
f"[Cloudonix Transfer] Switch Voice Application failed for session "
f"{session_token}: HTTP {response.status}, body: {body}"
)
await self._cleanup_transfer_context(transfer_context.transfer_id)
return False
except Exception as e:
logger.error(f"[Cloudonix Transfer] Failed to transfer call: {e}")
if transfer_context:
await self._cleanup_transfer_context(transfer_context.transfer_id)
return False
async def _find_transfer_context_for_call(self, call_sid: str):
try:
from api.services.telephony.call_transfer_manager import (
get_call_transfer_manager,
)
manager = await get_call_transfer_manager()
return await manager.find_transfer_context_for_call(call_sid)
except Exception as e:
logger.error(f"[Cloudonix Transfer] Error finding transfer context: {e}")
return None
async def _cleanup_transfer_context(self, transfer_id: str):
try:
from api.services.telephony.call_transfer_manager import (
get_call_transfer_manager,
)
manager = await get_call_transfer_manager()
await manager.remove_transfer_context(transfer_id)
except Exception as e:
logger.error(f"[Cloudonix Transfer] Error cleaning up transfer context: {e}")
class CloudonixHangupStrategy(HangupStrategy):
"""Implements hangup for Cloudonix calls."""

View file

@ -12,7 +12,7 @@ from api.services.pipecat.transport_params import realtime_param_overrides
from api.services.telephony.factory import load_credentials_for_transport
from .serializers import CloudonixFrameSerializer
from .strategies import CloudonixHangupStrategy
from .strategies import CloudonixConferenceStrategy, CloudonixHangupStrategy
async def create_transport(
@ -55,6 +55,7 @@ async def create_transport(
domain_id=domain_id,
bearer_token=bearer_token,
hangup_strategy=CloudonixHangupStrategy(),
transfer_strategy=CloudonixConferenceStrategy(),
)
mixer = await build_audio_out_mixer(

@ -1 +1 @@
Subproject commit 695f9322f870484d8507a63a7343d23725f2fb3a
Subproject commit f4feab0c59129702b3a6e6f818f62229cc663f1a