Merge branch 'main' into feat/vici-dial

This commit is contained in:
Abhishek Kumar 2026-07-20 14:24:49 +05:30
commit d6996be920
455 changed files with 33133 additions and 11135 deletions

View file

@ -77,7 +77,7 @@ class TelephonyProvider(ABC):
async def verify_webhook_signature(self, url: str, params: Dict[str, Any], signature: str) -> bool
@abstractmethod
async def get_webhook_response(self, workflow_id: int, user_id: int, workflow_run_id: int) -> str
async def get_webhook_response(self, workflow_id: int, organization_id: int, workflow_run_id: int) -> str
```
## Configuration Loading

View file

@ -26,6 +26,10 @@ from loguru import logger
from api.constants import REDIS_URL
from api.db import db_client
from api.enums import CallType, WorkflowRunMode
from api.services.call_concurrency import (
CallConcurrencyLimitError,
call_concurrency,
)
from api.services.quota_service import authorize_workflow_run_start
from api.services.telephony.call_transfer_manager import get_call_transfer_manager
from api.services.telephony.transfer_event_protocol import (
@ -119,11 +123,20 @@ class ARIConnection:
r = await self._get_redis()
return await r.exists(f"{_EXT_CHANNEL_KEY_PREFIX}{channel_id}") > 0
async def _delete_ext_channel(self, channel_id: str):
async def _delete_ext_channel(self, channel_id: Optional[str]):
"""Remove the external media channel marker."""
if not channel_id:
return
r = await self._get_redis()
await r.delete(f"{_EXT_CHANNEL_KEY_PREFIX}{channel_id}")
async def _delete_transfer_channel_mapping(self, channel_id: Optional[str]):
"""Remove transfer destination channel correlation marker."""
if not channel_id:
return
r = await self._get_redis()
await r.delete(f"ari:transfer_channel:{channel_id}")
async def _set_pending_bridge(
self,
ext_channel_id: str,
@ -340,19 +353,18 @@ class ARIConnection:
workflow_run_id = args_dict.get("workflow_run_id")
workflow_id = args_dict.get("workflow_id")
user_id = args_dict.get("user_id")
if not workflow_run_id or not workflow_id or not user_id:
if not workflow_run_id or not workflow_id:
logger.warning(
f"[ARI org={self.organization_id}] StasisStart missing required args: "
f"workflow_run_id={workflow_run_id}, workflow_id={workflow_id}, user_id={user_id}"
f"workflow_run_id={workflow_run_id}, workflow_id={workflow_id}"
)
return
# Start pipeline connection in background task
asyncio.create_task(
self._handle_stasis_start(
channel_id, channel_state, workflow_run_id, workflow_id, user_id
channel_id, channel_state, workflow_run_id, workflow_id
)
)
@ -521,7 +533,6 @@ class ARIConnection:
async def _create_external_media(
self,
workflow_id: str,
user_id: str,
workflow_run_id: str,
channel_id: Optional[str] = None,
) -> str:
@ -537,10 +548,10 @@ class ARIConnection:
the POST and avoid racing against the StasisStart event.
"""
# v() appends URI query params to the websocket_client.conf URL
# e.g. wss://api.dograh.com/ws/ari?workflow_id=1&user_id=2&workflow_run_id=3
# e.g. wss://api.dograh.com/ws/ari?workflow_id=1&organization_id=2&workflow_run_id=3
transport_data = (
f"v(workflow_id={workflow_id},"
f"user_id={user_id},"
f"organization_id={self.organization_id},"
f"workflow_run_id={workflow_run_id})"
)
@ -604,6 +615,8 @@ class ARIConnection:
channel = event.get("channel", {})
caller_number = channel.get("caller", {}).get("number", "unknown")
called_number = channel.get("dialplan", {}).get("exten", "unknown")
concurrency_slot = None
workflow_run = None
try:
# 1. Resolve the workflow from the called extension via the
@ -650,6 +663,20 @@ class ARIConnection:
user_id = workflow.user_id
try:
concurrency_slot = await call_concurrency.acquire_org_slot(
self.organization_id,
source="ari_inbound",
timeout=0,
)
except CallConcurrencyLimitError:
logger.warning(
f"[ARI org={self.organization_id}] Concurrent call limit "
f"reached; hanging up inbound channel {channel_id}"
)
await self._delete_channel(channel_id)
return
# 3. Create workflow run
call_id = channel_id
# Capture the upstream-PBX (VICIdial) identity off the SIP headers so
@ -675,7 +702,9 @@ class ARIConnection:
gathered_context={
"call_id": call_id,
},
organization_id=self.organization_id,
)
await call_concurrency.bind_workflow_run(concurrency_slot, workflow_run.id)
logger.info(
f"[ARI org={self.organization_id}] Created inbound workflow run "
@ -687,6 +716,7 @@ class ARIConnection:
# store the MPS correlation id before the pipeline starts.
quota_result = await authorize_workflow_run_start(
workflow_id=inbound_workflow_id,
organization_id=self.organization_id,
workflow_run_id=workflow_run.id,
)
if not quota_result.has_quota:
@ -694,6 +724,7 @@ class ARIConnection:
f"[ARI org={self.organization_id}] Quota exceeded for user {user_id} "
f"— hanging up inbound call {channel_id}"
)
await call_concurrency.release_workflow_run_slot(workflow_run.id)
await self._delete_channel(channel_id)
return
@ -706,9 +737,12 @@ class ARIConnection:
channel_state,
str(workflow_run.id),
str(inbound_workflow_id),
str(user_id),
)
except Exception as e:
if workflow_run:
await call_concurrency.release_workflow_run_slot(workflow_run.id)
elif concurrency_slot:
await call_concurrency.release_slot(concurrency_slot)
logger.error(
f"[ARI org={self.organization_id}] Error handling inbound StasisStart "
f"for channel {channel_id}: {e}"
@ -724,7 +758,6 @@ class ARIConnection:
channel_state: str,
workflow_run_id: str,
workflow_id: str,
user_id: str,
):
"""Set up external media for a caller channel that has entered Stasis.
@ -768,7 +801,6 @@ class ARIConnection:
# 3. Create the ext media channel with the id we just registered.
created_id = await self._create_external_media(
workflow_id,
user_id,
workflow_run_id,
channel_id=ext_channel_id,
)
@ -844,6 +876,14 @@ class ARIConnection:
the bridge and both channels like endConferenceOnExit.
"""
try:
# Release the org concurrency slot. Normally the pipeline's own
# teardown does this when the ext media websocket closes, but if
# the pipeline never started (caller hung up before external
# media connected, ext media creation failed, ...) this is the
# only cleanup that runs before the Redis stale timeout. No-op
# when the slot was already released.
await call_concurrency.unregister_active_call(int(workflow_run_id))
workflow_run = await db_client.get_workflow_run_by_id(int(workflow_run_id))
if not workflow_run or not workflow_run.gathered_context:
logger.warning(
@ -859,6 +899,11 @@ class ARIConnection:
ext_channel_id = ctx.get("ext_channel_id")
bridge_id = ctx.get("bridge_id")
transfer_state = ctx.get("transfer_state")
transfer_bridge_id = ctx.get("transfer_bridge_id") or bridge_id
transfer_caller_channel_id = (
ctx.get("transfer_caller_channel_id") or call_id
)
transfer_destination_channel_id = ctx.get("transfer_destination_channel_id")
# Check if this is a call transfer scenario external channel. Skip full teardown if
# transfer is in progress and this is the external media channel
@ -889,6 +934,64 @@ class ARIConnection:
)
return
if (
transfer_state == "complete"
and transfer_bridge_id
and transfer_caller_channel_id
and transfer_destination_channel_id
and channel_id
in (
transfer_caller_channel_id,
transfer_destination_channel_id,
)
):
peer_channel_id = (
transfer_destination_channel_id
if channel_id == transfer_caller_channel_id
else transfer_caller_channel_id
)
logger.info(
f"[ARI org={self.organization_id}] Completed transfer participant "
f"{channel_id} left Stasis; tearing down peer {peer_channel_id} "
f"and bridge {transfer_bridge_id}"
)
# Mark terminal state before issuing ARI deletes so duplicate
# StasisEnd events run through the idempotent normal cleanup path.
ctx["transfer_state"] = "terminated"
await db_client.update_workflow_run(
run_id=int(workflow_run_id), gathered_context=ctx
)
await self._delete_bridge(transfer_bridge_id)
if peer_channel_id and peer_channel_id != channel_id:
await self._delete_channel(peer_channel_id)
keys_to_delete = [
cid
for cid in (
transfer_caller_channel_id,
transfer_destination_channel_id,
ext_channel_id,
channel_id,
)
if cid
]
if keys_to_delete:
await self._delete_channel_run(*keys_to_delete)
await self._delete_ext_channel(ext_channel_id)
await self._delete_transfer_channel_mapping(
transfer_destination_channel_id
)
logger.info(
f"[ARI org={self.organization_id}] Completed transfer teardown "
f"finished for channel={channel_id}, peer={peer_channel_id}, "
f"bridge={transfer_bridge_id}"
)
return
# Normal full teardown for non-transfer scenarios (transfer_state is None or not in-progress)
# Delete the bridge first (removes channels from it)
if bridge_id:
@ -908,6 +1011,7 @@ class ARIConnection:
# Clean up the Redis marker for external channel
await self._delete_ext_channel(ext_channel_id)
await self._delete_transfer_channel_mapping(transfer_destination_channel_id)
logger.info(
f"[ARI org={self.organization_id}] StasisEnd full teardown for "

View file

@ -56,6 +56,15 @@ class NormalizedInboundData:
raw_data: Dict[str, Any] = field(default_factory=dict) # Original webhook data
@dataclass
class AnsweringMachineDetectionResult:
"""Standardized answering-machine detection result across providers."""
call_id: str
answered_by: str
raw_data: Dict[str, Any] = field(default_factory=dict)
class TelephonyProvider(ABC):
"""
Abstract base class for telephony providers.
@ -141,14 +150,15 @@ class TelephonyProvider(ABC):
@abstractmethod
async def get_webhook_response(
self, workflow_id: int, user_id: int, workflow_run_id: int
self, workflow_id: int, organization_id: int, workflow_run_id: int
) -> str:
"""
Generate the initial webhook response for starting a call session.
Args:
workflow_id: The workflow ID
user_id: The user ID
organization_id: The organization owning the workflow; providers
embed it in the media websocket URL they hand back
workflow_run_id: The workflow run ID
Returns:
@ -192,12 +202,29 @@ class TelephonyProvider(ABC):
"""
pass
def supports_answering_machine_detection(self) -> bool:
"""Return whether this provider can request answering-machine detection."""
return False
def apply_answering_machine_detection_call_params(
self,
data: Dict[str, Any],
) -> Dict[str, Any]:
"""Add provider-specific AMD parameters to an outbound call request."""
return data
def parse_answering_machine_detection_result(
self, data: Dict[str, Any]
) -> Optional[AnsweringMachineDetectionResult]:
"""Parse provider-specific callback data into a normalized AMD result."""
return None
@abstractmethod
async def handle_websocket(
self,
websocket: "WebSocket",
workflow_id: int,
user_id: int,
organization_id: int,
workflow_run_id: int,
) -> None:
"""
@ -206,10 +233,14 @@ class TelephonyProvider(ABC):
This method encapsulates all provider-specific WebSocket handshake and
message routing logic, keeping the main websocket endpoint clean.
``organization_id`` is the tenant that every workflow/run lookup must be
scoped by. The workflow owner is deliberately not passed in derive it
from the workflow row where it's needed for attribution.
Args:
websocket: The WebSocket connection
workflow_id: The workflow ID
user_id: The user ID
organization_id: The organization owning the workflow and run
workflow_run_id: The workflow run ID
"""
pass
@ -329,17 +360,19 @@ class TelephonyProvider(ABC):
*,
organization_id: int,
workflow_id: int,
user_id: int,
workflow_run_id: int,
params: Dict[str, str],
) -> None:
"""Handle the agent-stream WebSocket where credentials are passed inline.
"""Handle the provider-specific agent-stream WebSocket.
Used by ``/api/v1/agent-stream/{workflow_uuid}`` when the caller carries
provider credentials in the query string (no stored
``TelephonyConfigurationModel`` row required). ``organization_id`` is
passed so providers can scope any config lookups to the workflow's
org. Default raises so providers that haven't opted in fail loudly.
Used by ``/api/v1/agent-stream/{provider_name}/{workflow_uuid}`` when
the caller carries a provider stream protocol. ``organization_id`` is
passed so providers can scope any config lookups to the workflow's org.
Default raises so providers that haven't opted in fail loudly.
The route holds an org concurrency slot while this runs, so
implementations must bound their pre-pipeline handshake reads with a
timeout an idle socket must not hold the slot indefinitely.
"""
raise NotImplementedError(
f"Agent-stream not supported for provider {self.PROVIDER_NAME}"

View file

@ -14,7 +14,7 @@ from fastapi import HTTPException
from loguru import logger
from api.db import db_client
from api.enums import WorkflowRunMode
from api.enums import TelephonyCallStatus, WorkflowRunMode
from api.services.telephony.base import (
CallInitiationResult,
NormalizedInboundData,
@ -99,7 +99,6 @@ class ARIProvider(TelephonyProvider):
[
f"workflow_run_id={workflow_run_id}",
f"workflow_id={kwargs.get('workflow_id', '')}",
f"user_id={kwargs.get('user_id', '')}",
],
)
),
@ -179,7 +178,7 @@ class ARIProvider(TelephonyProvider):
return True
async def get_webhook_response(
self, workflow_id: int, user_id: int, workflow_run_id: int
self, workflow_id: int, organization_id: int, workflow_run_id: int
) -> str:
"""ARI does not use webhook responses - call control is via REST API."""
logger.warning(
@ -205,12 +204,12 @@ class ARIProvider(TelephonyProvider):
"""
# Map ARI channel states to common status format
state_map = {
"Up": "answered",
"Down": "completed",
"Ringing": "ringing",
"Ring": "ringing",
"Busy": "busy",
"Unavailable": "failed",
"Up": TelephonyCallStatus.ANSWERED,
"Down": TelephonyCallStatus.COMPLETED,
"Ringing": TelephonyCallStatus.RINGING,
"Ring": TelephonyCallStatus.RINGING,
"Busy": TelephonyCallStatus.BUSY,
"Unavailable": TelephonyCallStatus.FAILED,
}
channel_state = data.get("channel", {}).get("state", "")
@ -218,11 +217,11 @@ class ARIProvider(TelephonyProvider):
# Determine status from event type
if event_type == "StasisStart":
status = "answered"
status = TelephonyCallStatus.ANSWERED
elif event_type == "StasisEnd":
status = "completed"
status = TelephonyCallStatus.COMPLETED
elif event_type == "ChannelDestroyed":
status = "completed"
status = TelephonyCallStatus.COMPLETED
else:
status = state_map.get(channel_state, channel_state.lower())
@ -241,7 +240,7 @@ class ARIProvider(TelephonyProvider):
self,
websocket: "WebSocket",
workflow_id: int,
user_id: int,
organization_id: int,
workflow_run_id: int,
) -> None:
"""
@ -253,7 +252,9 @@ class ARIProvider(TelephonyProvider):
from api.services.pipecat.run_pipeline import run_pipeline_telephony
# Get channel_id from workflow run context
workflow_run = await db_client.get_workflow_run(workflow_run_id, user_id)
workflow_run = await db_client.get_workflow_run(
workflow_run_id, organization_id=organization_id
)
channel_id = ""
if workflow_run and workflow_run.gathered_context:
channel_id = workflow_run.gathered_context.get("call_id", "")
@ -267,7 +268,7 @@ class ARIProvider(TelephonyProvider):
provider_name=self.PROVIDER_NAME,
workflow_id=workflow_id,
workflow_run_id=workflow_run_id,
user_id=user_id,
organization_id=organization_id,
call_id=channel_id,
transport_kwargs={"channel_id": channel_id},
)

View file

@ -103,8 +103,16 @@ class ARIBridgeSwapStrategy(TransferStrategy):
f"destination={destination_channel_id}, ext_media={ext_channel_id}"
)
# 3. Set transfer state to prevent StasisEnd auto-teardown
workflow_run.gathered_context["transfer_state"] = "in-progress"
# 3. Set transfer state to prevent StasisEnd auto-teardown and
# persist the transferred pair for post-handoff participant cleanup.
workflow_run.gathered_context.update(
{
"transfer_state": "in-progress",
"transfer_bridge_id": bridge_id,
"transfer_caller_channel_id": channel_id,
"transfer_destination_channel_id": destination_channel_id,
}
)
await db_client.update_workflow_run(
run_id=int(workflow_run_id),
gathered_context=workflow_run.gathered_context,
@ -124,6 +132,13 @@ class ARIBridgeSwapStrategy(TransferStrategy):
logger.info(
f"[ARI Transfer] Added destination {destination_channel_id} to bridge {bridge_id}"
)
# Let ari_manager route StasisEnd for the destination leg
# back to this workflow after transfer context cleanup.
await redis.setex(
f"ari:channel:{destination_channel_id}",
3600,
workflow_run_id,
)
else:
error_text = await response.text()
logger.error(
@ -169,6 +184,7 @@ class ARIBridgeSwapStrategy(TransferStrategy):
)
# 5. Clean up transfer context after successful completion
await redis.delete(f"ari:transfer_channel:{destination_channel_id}")
call_transfer_manager = await get_call_transfer_manager()
await call_transfer_manager.remove_transfer_context(

View file

@ -2,6 +2,7 @@
Cloudonix implementation of the TelephonyProvider interface.
"""
import asyncio
import json
import random
from typing import TYPE_CHECKING, Any, Dict, List, Optional
@ -11,7 +12,7 @@ from fastapi import HTTPException
from loguru import logger
from api.db import db_client
from api.enums import WorkflowRunMode
from api.enums import TelephonyCallStatus, WorkflowRunMode
from api.services.telephony.base import (
CallInitiationResult,
NormalizedInboundData,
@ -25,6 +26,11 @@ if TYPE_CHECKING:
CLOUDONIX_API_BASE_URL = "https://api.cloudonix.io"
# Cloudonix sends the connected/start handshake immediately after the media
# stream opens. The agent-stream route holds an org concurrency slot while we
# wait, so an idle socket must not be able to hold it indefinitely.
AGENT_STREAM_HANDSHAKE_TIMEOUT_S = 10
class CloudonixProvider(TelephonyProvider):
"""
@ -114,7 +120,10 @@ class CloudonixProvider(TelephonyProvider):
logger.info(
f"Selected phone number {from_number} for outbound call to {to_number}"
)
workflow_id, user_id = kwargs["workflow_id"], kwargs["user_id"]
workflow_id, organization_id = (
kwargs["workflow_id"],
kwargs["organization_id"],
)
# Prepare call data using Cloudonix callObject schema
# Note: 'caller-id' is REQUIRED by Cloudonix API
@ -124,7 +133,7 @@ class CloudonixProvider(TelephonyProvider):
"cxml": f"""<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Connect>
<Stream url="{wss_backend_endpoint}/api/v1/telephony/ws/{workflow_id}/{user_id}/{workflow_run_id}"></Stream>
<Stream url="{wss_backend_endpoint}/api/v1/telephony/ws/{workflow_id}/{organization_id}/{workflow_run_id}"></Stream>
</Connect>
<Pause length="40"/>
</Response>""",
@ -348,15 +357,15 @@ class CloudonixProvider(TelephonyProvider):
# Map Cloudonix status values to common format
# These mappings may need adjustment based on actual Cloudonix callback format
status_map = {
"initiated": "initiated",
"ringing": "ringing",
"answered": "answered",
"completed": "completed",
"failed": "failed",
"busy": "busy",
"no-answer": "no-answer",
"canceled": "canceled",
"error": "error",
"initiated": TelephonyCallStatus.INITIATED,
"ringing": TelephonyCallStatus.RINGING,
"answered": TelephonyCallStatus.ANSWERED,
"completed": TelephonyCallStatus.COMPLETED,
"failed": TelephonyCallStatus.FAILED,
"busy": TelephonyCallStatus.BUSY,
"no-answer": TelephonyCallStatus.NO_ANSWER,
"canceled": TelephonyCallStatus.CANCELED,
"error": TelephonyCallStatus.ERROR,
}
call_status = data.get("status", "")
@ -374,8 +383,35 @@ class CloudonixProvider(TelephonyProvider):
"extra": data, # Include all original data
}
@staticmethod
def parse_cdr_status_callback(data: Dict[str, Any]) -> Dict[str, Any]:
"""Parse Cloudonix CDR data into generic status callback format."""
disposition_map = {
"ANSWER": TelephonyCallStatus.COMPLETED,
"BUSY": TelephonyCallStatus.BUSY,
"CANCEL": TelephonyCallStatus.CANCELED,
"FAILED": TelephonyCallStatus.FAILED,
"CONGESTION": TelephonyCallStatus.FAILED,
"NOANSWER": TelephonyCallStatus.NO_ANSWER,
}
disposition = data.get("disposition") or ""
session = data.get("session")
billsec = data.get("billsec")
return {
"call_id": session.get("token") if isinstance(session, dict) else "",
"status": disposition_map.get(disposition.upper(), disposition.lower()),
"from_number": data.get("from"),
"to_number": data.get("to"),
"duration": str(
billsec if billsec is not None else (data.get("duration") or 0)
),
"extra": data,
}
async def get_webhook_response(
self, workflow_id: int, user_id: int, workflow_run_id: int
self, workflow_id: int, organization_id: int, workflow_run_id: int
) -> str:
"""
Dummy implementation - Cloudonix doesn't use webhook responses.
@ -397,7 +433,7 @@ class CloudonixProvider(TelephonyProvider):
self,
websocket: "WebSocket",
workflow_id: int,
user_id: int,
organization_id: int,
workflow_run_id: int,
) -> None:
"""
@ -432,10 +468,15 @@ class CloudonixProvider(TelephonyProvider):
await websocket.close(code=4400, reason="Expected start event")
return
# Extract Twilio-compatible identifiers
start = start_msg.get("start")
if not isinstance(start, dict):
logger.error("Cloudonix start message missing start object")
await websocket.close(code=4400, reason="Missing start metadata")
return
try:
stream_sid = start_msg["start"]["streamSid"]
call_sid = start_msg["start"]["callSid"]
stream_sid = start["streamSid"]
call_sid = start["callSid"]
except KeyError:
logger.error("Missing streamSid or callSid in start message")
await websocket.close(code=4400, reason="Missing stream identifiers")
@ -464,7 +505,7 @@ class CloudonixProvider(TelephonyProvider):
provider_name=self.PROVIDER_NAME,
workflow_id=workflow_id,
workflow_run_id=workflow_run_id,
user_id=user_id,
organization_id=organization_id,
call_id=call_id,
transport_kwargs={"call_id": call_id, "stream_sid": stream_sid},
)
@ -479,86 +520,166 @@ class CloudonixProvider(TelephonyProvider):
*,
organization_id: int,
workflow_id: int,
user_id: int,
workflow_run_id: int,
params: Dict[str, str],
) -> None:
"""Agent-stream entry point.
``Domain`` (domain id) is read from the query string. The bearer
token comes from the stored Cloudonix telephony configuration
matched by ``domain_id`` within the workflow's organization — never
from the URL or stream payload. The websocket handshake (connected
/ start) is identical to the standard inbound flow.
The Cloudonix domain is read from the ``start.accountSid`` field
in the start message. The bearer token comes from the stored
Cloudonix telephony configuration matched by ``domain_id`` within
the workflow's organization — never from the URL or stream payload.
The websocket handshake (connected / start) is identical to the
standard inbound flow.
Before starting the pipeline we (a) require an existing Cloudonix
telephony configuration for the supplied ``domain_id`` and (b)
telephony configuration for the stream's ``domain_id`` and (b)
validate the call session with Cloudonix using the bearer token
from that configuration. Either failure closes the socket with
4400.
"""
from api.services.pipecat.run_pipeline import run_pipeline_telephony
domain_id = self._normalize_domain(params.get("Domain"))
if not domain_id:
logger.error("Cloudonix agent-stream missing required param: Domain")
await websocket.close(code=4400, reason="Missing Domain query param")
return
config = await self._find_config_by_domain(organization_id, domain_id)
if not config:
logger.error(
f"Cloudonix agent-stream: no telephony configuration found "
f"for domain_id={domain_id}"
)
await websocket.close(
code=4400, reason=f"Unknown Cloudonix domain: {domain_id}"
)
return
bearer_token = (config.credentials or {}).get("bearer_token")
if not bearer_token:
logger.error(
f"Cloudonix agent-stream: telephony configuration {config.id} "
f"is missing bearer_token in credentials"
)
await websocket.close(
code=4400, reason="Cloudonix configuration missing bearer_token"
)
return
try:
first_msg = await websocket.receive_text()
msg = json.loads(first_msg)
if msg.get("event") != "connected":
logger.error(f"Expected 'connected' event, got: {msg.get('event')}")
await websocket.close(code=4400, reason="Expected connected event")
try:
first_msg = await asyncio.wait_for(
websocket.receive_text(),
timeout=AGENT_STREAM_HANDSHAKE_TIMEOUT_S,
)
msg = json.loads(first_msg)
if msg.get("event") != "connected":
logger.error(f"Expected 'connected' event, got: {msg.get('event')}")
await websocket.close(code=4400, reason="Expected connected event")
return
start_msg = json.loads(
await asyncio.wait_for(
websocket.receive_text(),
timeout=AGENT_STREAM_HANDSHAKE_TIMEOUT_S,
)
)
except asyncio.TimeoutError:
logger.warning(
f"Cloudonix agent-stream handshake timed out for workflow_run "
f"{workflow_run_id}"
)
await websocket.close(code=4408, reason="Handshake timeout")
return
start_msg = json.loads(await websocket.receive_text())
if start_msg.get("event") != "start":
logger.error("Expected 'start' event second")
await websocket.close(code=4400, reason="Expected start event")
return
start = start_msg.get("start")
if not isinstance(start, dict):
logger.error(
"Cloudonix agent-stream start message missing start object"
)
await websocket.close(code=4400, reason="Missing start metadata")
return
try:
stream_sid = start_msg["start"]["streamSid"]
call_sid = start_msg["start"]["callSid"]
call_session = start_msg["start"]["session"]
stream_sid = start["streamSid"]
call_sid = start["callSid"]
call_session = start["session"]
domain_id = self._normalize_domain(start["accountSid"])
except KeyError:
logger.error("Missing streamSid or callSid or session in start message")
logger.error(
"Missing streamSid, callSid, session, or accountSid in start message"
)
await websocket.close(code=4400, reason="Missing stream identifiers")
return
if not domain_id:
logger.error("Cloudonix agent-stream start message missing accountSid")
await websocket.close(
code=4400, reason="Missing Cloudonix domain in start message"
)
return
config = await self._find_config_by_domain(organization_id, domain_id)
if not config:
logger.error(
f"Cloudonix agent-stream: no telephony configuration found "
f"for domain_id={domain_id}"
)
await websocket.close(
code=4400, reason=f"Unknown Cloudonix domain: {domain_id}"
)
return
bearer_token = (config.credentials or {}).get("bearer_token")
if not bearer_token:
logger.error(
f"Cloudonix agent-stream: telephony configuration {config.id} "
f"is missing bearer_token in credentials"
)
await websocket.close(
code=4400, reason="Cloudonix configuration missing bearer_token"
)
return
if not await self._validate_session(domain_id, call_session, bearer_token):
await websocket.close(
code=4400, reason="Cloudonix session validation failed"
)
return
start_context = start.get("context")
custom_parameters = start.get("customParameters")
builtin_context = {
"caller_number": start.get("from"),
"called_number": start.get("to"),
"direction": (
"outbound" if start_context == "outbound-api" else "inbound"
),
"cloudonix_context": start_context,
}
await db_client.update_workflow_run(
run_id=workflow_run_id,
initial_context={
# Flatten customParameters, but never let them overwrite a
# built-in key even when the built-in's value is None.
key: value
for key, value in {
**{
k: v
for k, v in (
custom_parameters
if isinstance(custom_parameters, dict)
else {}
).items()
if k not in builtin_context
},
**builtin_context,
}.items()
if value is not None
},
gathered_context={
"call_id": call_session,
"cloudonix_call_sid": call_sid,
"cloudonix_stream_sid": stream_sid,
},
logs={
"inbound_webhook": {
"domain": domain_id,
"session": call_session,
"callSid": call_sid,
"streamSid": stream_sid,
"from": start.get("from"),
"to": start.get("to"),
"context": start_context,
"tracks": start.get("tracks"),
"mediaFormat": start.get("mediaFormat"),
},
},
)
logger.info(
f"Cloudonix agent-stream connected for workflow_run "
f"{workflow_run_id} stream_sid={stream_sid} call_sid={call_sid} session={call_session}"
f"{workflow_run_id} stream_sid={stream_sid} call_sid={call_sid} "
f"session={call_session} "
f"telephony_configuration_id={config.id}"
)
@ -567,7 +688,7 @@ class CloudonixProvider(TelephonyProvider):
provider_name=self.PROVIDER_NAME,
workflow_id=workflow_id,
workflow_run_id=workflow_run_id,
user_id=user_id,
organization_id=organization_id,
call_id=call_session,
transport_kwargs={
"call_id": call_session,
@ -601,7 +722,7 @@ class CloudonixProvider(TelephonyProvider):
if response.status == 200:
return True
body = await response.text()
logger.error(
logger.warning(
f"Cloudonix session validation failed: "
f"HTTP {response.status} domain_id={domain_id} "
f"call_id={call_session} body={body}"
@ -945,6 +1066,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">{conference_name}</Conference>'
"</Dial>"
"</Response>"
)
async def transfer_call(
self,
@ -954,33 +1087,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,14 +11,107 @@ 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 {}
conferenceStatus = str(data.get("StatusCallbackEvent", "")).lower()
outboundCallStatus = str(data.get("status", "")).lower()
destination_token = data.get("Session", "")
logger.info(
f"[Cloudonix Transfer] transfer_id={transfer_id} status={outboundCallStatus} conferenceStatus={conferenceStatus}"
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 conferenceStatus == "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 outboundCallStatus 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=outboundCallStatus,
)
else:
logger.info(
f"[Cloudonix Transfer] Intermediate status {outboundCallStatus} 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(
@ -120,8 +213,15 @@ async def handle_cloudonix_cdr(request: Request):
set_current_run_id(workflow_run_id)
logger.info(f"[run {workflow_run_id}] Processing Cloudonix CDR for call {call_id}")
# Convert CDR to status update using StatusCallbackRequest
status_update = StatusCallbackRequest.from_cloudonix_cdr(cdr_data)
parsed_data = CloudonixProvider.parse_cdr_status_callback(cdr_data)
status_update = StatusCallbackRequest(
call_id=parsed_data["call_id"],
status=parsed_data["status"],
from_number=parsed_data.get("from_number"),
to_number=parsed_data.get("to_number"),
duration=parsed_data.get("duration"),
extra=parsed_data.get("extra", {}),
)
# Process the status update
await _process_status_update(workflow_run_id, status_update)

View file

@ -3,11 +3,128 @@
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(

View file

@ -15,7 +15,7 @@ from fastapi import HTTPException
from loguru import logger
from api.db import db_client
from api.enums import WorkflowRunMode
from api.enums import TelephonyCallStatus, WorkflowRunMode
from api.services.telephony.base import (
CallInitiationResult,
NormalizedInboundData,
@ -238,13 +238,13 @@ class PlivoProvider(TelephonyProvider):
return any(hmac.compare_digest(computed, candidate) for candidate in candidates)
async def get_webhook_response(
self, workflow_id: int, user_id: int, workflow_run_id: int
self, workflow_id: int, organization_id: int, workflow_run_id: int
) -> str:
_, wss_backend_endpoint = await get_backend_endpoints()
return f"""<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Stream bidirectional="true" keepCallAlive="true" contentType="audio/x-mulaw;rate=8000">{wss_backend_endpoint}/api/v1/telephony/ws/{workflow_id}/{user_id}/{workflow_run_id}</Stream>
<Stream bidirectional="true" keepCallAlive="true" contentType="audio/x-mulaw;rate=8000">{wss_backend_endpoint}/api/v1/telephony/ws/{workflow_id}/{organization_id}/{workflow_run_id}</Stream>
</Response>"""
async def get_call_cost(self, call_id: str) -> Dict[str, Any]:
@ -281,17 +281,17 @@ class PlivoProvider(TelephonyProvider):
def parse_status_callback(self, data: Dict[str, Any]) -> Dict[str, Any]:
status_map = {
"in-progress": "answered",
"ringing": "ringing",
"ring": "ringing",
"completed": "completed",
"hangup": "completed",
"stopstream": "completed",
"busy": "busy",
"no-answer": "no-answer",
"cancel": "canceled",
"cancelled": "canceled",
"timeout": "no-answer",
"in-progress": TelephonyCallStatus.ANSWERED,
"ringing": TelephonyCallStatus.RINGING,
"ring": TelephonyCallStatus.RINGING,
"completed": TelephonyCallStatus.COMPLETED,
"hangup": TelephonyCallStatus.COMPLETED,
"stopstream": TelephonyCallStatus.COMPLETED,
"busy": TelephonyCallStatus.BUSY,
"no-answer": TelephonyCallStatus.NO_ANSWER,
"cancel": TelephonyCallStatus.CANCELED,
"cancelled": TelephonyCallStatus.CANCELED,
"timeout": TelephonyCallStatus.NO_ANSWER,
}
call_status = (data.get("CallStatus") or data.get("Event") or "").lower()
@ -309,7 +309,7 @@ class PlivoProvider(TelephonyProvider):
self,
websocket: "WebSocket",
workflow_id: int,
user_id: int,
organization_id: int,
workflow_run_id: int,
) -> None:
from api.services.pipecat.run_pipeline import run_pipeline_telephony
@ -348,7 +348,7 @@ class PlivoProvider(TelephonyProvider):
provider_name=self.PROVIDER_NAME,
workflow_id=workflow_id,
workflow_run_id=workflow_run_id,
user_id=user_id,
organization_id=organization_id,
call_id=call_id,
transport_kwargs={"stream_id": stream_id, "call_id": call_id},
)

View file

@ -74,7 +74,6 @@ async def _handle_plivo_status_callback(
@router.post("/plivo-xml", include_in_schema=False)
async def handle_plivo_xml_webhook(
workflow_id: int,
user_id: int,
workflow_run_id: int,
organization_id: int,
request: Request,
@ -110,7 +109,7 @@ async def handle_plivo_xml_webhook(
)
response_content = await provider.get_webhook_response(
workflow_id, user_id, workflow_run_id
workflow_id, organization_id, workflow_run_id
)
return HTMLResponse(content=response_content, media_type="application/xml")

View file

@ -25,7 +25,7 @@ TELNYX_TIMESTAMP_TOLERANCE_SECONDS = 300
TELNYX_PUBLIC_KEY_BYTES = 32
TELNYX_SIGNATURE_BYTES = 64
from api.enums import WorkflowRunMode
from api.enums import TelephonyCallStatus, WorkflowRunMode
from api.services.telephony.base import (
CallInitiationResult,
NormalizedInboundData,
@ -101,10 +101,10 @@ class TelnyxProvider(TelephonyProvider):
# Build the WebSocket stream URL for inline audio streaming
workflow_id = kwargs.get("workflow_id")
user_id = kwargs.get("user_id")
organization_id = kwargs.get("organization_id")
stream_url = (
f"{wss_backend_endpoint}/api/v1/telephony/ws"
f"/{workflow_id}/{user_id}/{workflow_run_id}"
f"/{workflow_id}/{organization_id}/{workflow_run_id}"
)
# Build the webhook URL for status callbacks
@ -267,7 +267,7 @@ class TelnyxProvider(TelephonyProvider):
return False
async def get_webhook_response(
self, workflow_id: int, user_id: int, workflow_run_id: int
self, workflow_id: int, organization_id: int, workflow_run_id: int
) -> str:
"""Not used for Telnyx — streaming is inline with the dial request."""
return ""
@ -305,23 +305,25 @@ class TelnyxProvider(TelephonyProvider):
}
@staticmethod
def _resolve_status(event_type: str, payload: Dict[str, Any]) -> str:
def _resolve_status(
event_type: str, payload: Dict[str, Any]
) -> TelephonyCallStatus | str:
"""Map a Telnyx event type (and hangup cause) to a normalized status."""
EVENT_STATUS = {
"call.initiated": "initiated",
"call.answered": "in-progress",
"call.hangup": "completed",
"call.initiated": TelephonyCallStatus.INITIATED,
"call.answered": TelephonyCallStatus.IN_PROGRESS,
"call.hangup": TelephonyCallStatus.COMPLETED,
"call.machine.detection.ended": "machine-detected",
"streaming.started": "streaming-started",
"streaming.stopped": "streaming-stopped",
}
HANGUP_STATUS = {
"busy": "busy",
"no_answer": "no-answer",
"timeout": "no-answer",
"call_rejected": "failed",
"unallocated_number": "failed",
"busy": TelephonyCallStatus.BUSY,
"no_answer": TelephonyCallStatus.NO_ANSWER,
"timeout": TelephonyCallStatus.NO_ANSWER,
"call_rejected": TelephonyCallStatus.FAILED,
"unallocated_number": TelephonyCallStatus.FAILED,
}
status = EVENT_STATUS.get(event_type, event_type)
@ -336,7 +338,7 @@ class TelnyxProvider(TelephonyProvider):
self,
websocket: "WebSocket",
workflow_id: int,
user_id: int,
organization_id: int,
workflow_run_id: int,
) -> None:
"""Handle Telnyx WebSocket connection for real-time audio.
@ -404,7 +406,7 @@ class TelnyxProvider(TelephonyProvider):
provider_name=self.PROVIDER_NAME,
workflow_id=workflow_id,
workflow_run_id=workflow_run_id,
user_id=user_id,
organization_id=organization_id,
call_id=call_control_id,
transport_kwargs={
"stream_id": stream_id,

View file

@ -20,6 +20,7 @@ def _config_loader(value: Dict[str, Any]) -> Dict[str, Any]:
"account_sid": value.get("account_sid"),
"auth_token": value.get("auth_token"),
"from_numbers": value.get("from_numbers", []),
"amd_enabled": value.get("amd_enabled", False),
}
@ -47,6 +48,15 @@ _UI_METADATA = ProviderUIMetadata(
type="string-array",
description="E.164-formatted Twilio phone numbers used for outbound calls",
),
ProviderUIField(
name="amd_enabled",
label="Answering Machine Detection",
type="boolean",
description=(
"Detect whether outbound calls are answered by a person or "
"machine. Twilio may bill AMD as an additional per-call feature."
),
),
],
)

View file

@ -16,6 +16,13 @@ class TwilioConfigurationRequest(BaseModel):
from_numbers: List[str] = Field(
default_factory=list, description="List of Twilio phone numbers"
)
amd_enabled: bool = Field(
default=False,
description=(
"Detect whether outbound calls are answered by a person or machine. "
"Twilio may bill AMD as an additional per-call feature."
),
)
class TwilioConfigurationResponse(BaseModel):
@ -25,3 +32,4 @@ class TwilioConfigurationResponse(BaseModel):
account_sid: str # Masked (e.g., "****************def0")
auth_token: str # Masked (e.g., "****************abc1")
from_numbers: List[str]
amd_enabled: bool = False

View file

@ -11,8 +11,9 @@ from fastapi import HTTPException
from loguru import logger
from twilio.request_validator import RequestValidator
from api.enums import WorkflowRunMode
from api.enums import TelephonyCallStatus, WorkflowRunMode
from api.services.telephony.base import (
AnsweringMachineDetectionResult,
CallInitiationResult,
NormalizedInboundData,
ProviderSyncResult,
@ -47,6 +48,7 @@ class TwilioProvider(TelephonyProvider):
self.account_sid = config.get("account_sid")
self.auth_token = config.get("auth_token")
self.from_numbers = config.get("from_numbers", [])
self.amd_enabled: bool = bool(config.get("amd_enabled", False))
# Handle both single number (string) and multiple numbers (list)
if isinstance(self.from_numbers, str):
@ -96,6 +98,8 @@ class TwilioProvider(TelephonyProvider):
}
)
data = self.apply_answering_machine_detection_call_params(data)
data.update(kwargs)
# Make the API request
@ -162,7 +166,7 @@ class TwilioProvider(TelephonyProvider):
return validator.validate(url, params, signature)
async def get_webhook_response(
self, workflow_id: int, user_id: int, workflow_run_id: int
self, workflow_id: int, organization_id: int, workflow_run_id: int
) -> str:
"""
Generate TwiML response for starting a call session.
@ -172,7 +176,7 @@ class TwilioProvider(TelephonyProvider):
twiml_content = f"""<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Connect>
<Stream url="{wss_backend_endpoint}/api/v1/telephony/ws/{workflow_id}/{user_id}/{workflow_run_id}"></Stream>
<Stream url="{wss_backend_endpoint}/api/v1/telephony/ws/{workflow_id}/{organization_id}/{workflow_run_id}"></Stream>
</Connect>
<Pause length="40"/>
</Response>"""
@ -230,9 +234,10 @@ class TwilioProvider(TelephonyProvider):
"""
Parse Twilio status callback data into generic format.
"""
call_status = data.get("CallStatus", "")
return {
"call_id": data.get("CallSid", ""),
"status": data.get("CallStatus", ""),
"status": TelephonyCallStatus.from_raw(call_status) or call_status,
"from_number": data.get("From"),
"to_number": data.get("To"),
"direction": data.get("Direction"),
@ -240,11 +245,36 @@ class TwilioProvider(TelephonyProvider):
"extra": data, # Include all original data
}
def supports_answering_machine_detection(self) -> bool:
"""Twilio supports AMD through the Voice Calls API."""
return True
def apply_answering_machine_detection_call_params(
self,
data: Dict[str, Any],
) -> Dict[str, Any]:
if self.amd_enabled:
data["MachineDetection"] = "Enable"
return data
def parse_answering_machine_detection_result(
self, data: Dict[str, Any]
) -> Optional[AnsweringMachineDetectionResult]:
answered_by = data.get("AnsweredBy")
if not answered_by:
return None
return AnsweringMachineDetectionResult(
call_id=data.get("CallSid", ""),
answered_by=answered_by,
raw_data=data,
)
async def handle_websocket(
self,
websocket: "WebSocket",
workflow_id: int,
user_id: int,
organization_id: int,
workflow_run_id: int,
) -> None:
"""
@ -295,7 +325,7 @@ class TwilioProvider(TelephonyProvider):
provider_name=self.PROVIDER_NAME,
workflow_id=workflow_id,
workflow_run_id=workflow_run_id,
user_id=user_id,
organization_id=organization_id,
call_id=call_sid,
transport_kwargs={"stream_sid": stream_sid, "call_sid": call_sid},
)

View file

@ -12,6 +12,7 @@ from pipecat.utils.run_context import set_current_run_id
from starlette.responses import HTMLResponse
from api.db import db_client
from api.services.telephony.base import TelephonyProvider
from api.services.telephony.factory import get_telephony_provider_for_run
from api.services.telephony.status_processor import (
StatusCallbackRequest,
@ -21,10 +22,31 @@ from api.services.telephony.status_processor import (
router = APIRouter()
async def _persist_amd_result_if_present(
*,
provider: TelephonyProvider,
workflow_run_id: int,
callback_data: dict,
) -> None:
amd_result = provider.parse_answering_machine_detection_result(callback_data)
if not amd_result:
return
try:
logger.info(
f"[run {workflow_run_id}] AMD result: AnsweredBy={amd_result.answered_by}"
)
await db_client.update_workflow_run(
run_id=workflow_run_id,
gathered_context={"answered_by": amd_result.answered_by},
)
except Exception as exc:
logger.warning(f"[run {workflow_run_id}] Failed to persist AMD result: {exc}")
@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,
request: Request,
@ -49,8 +71,14 @@ async def handle_twiml_webhook(
)
raise HTTPException(status_code=401, detail="Invalid webhook signature")
await _persist_amd_result_if_present(
provider=provider,
workflow_run_id=workflow_run_id,
callback_data=callback_data,
)
response_content = await provider.get_webhook_response(
workflow_id, user_id, workflow_run_id
workflow_id, organization_id, workflow_run_id
)
return HTMLResponse(content=response_content, media_type="application/xml")
@ -111,6 +139,12 @@ async def handle_twilio_status_callback(
extra=parsed_data.get("extra", {}),
)
await _persist_amd_result_if_present(
provider=provider,
workflow_run_id=workflow_run_id,
callback_data=callback_data,
)
# Process the status update
await _process_status_update(workflow_run_id, status_update)

View file

@ -14,7 +14,7 @@ import aiohttp
from fastapi import HTTPException
from loguru import logger
from api.enums import WorkflowRunMode
from api.enums import TelephonyCallStatus, WorkflowRunMode
from api.services.telephony.base import (
CallInitiationResult,
NormalizedInboundData,
@ -255,7 +255,7 @@ class VobizProvider(TelephonyProvider):
return is_valid
async def get_webhook_response(
self, workflow_id: int, user_id: int, workflow_run_id: int
self, workflow_id: int, organization_id: int, workflow_run_id: int
) -> str:
"""
Generate Vobiz XML response for starting a call session.
@ -269,7 +269,7 @@ class VobizProvider(TelephonyProvider):
vobiz_xml = f"""<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Stream bidirectional="true" keepCallAlive="true" contentType="audio/x-mulaw;rate=8000">{wss_backend_endpoint}/api/v1/telephony/ws/{workflow_id}/{user_id}/{workflow_run_id}</Stream>
<Stream bidirectional="true" keepCallAlive="true" contentType="audio/x-mulaw;rate=8000">{wss_backend_endpoint}/api/v1/telephony/ws/{workflow_id}/{organization_id}/{workflow_run_id}</Stream>
</Response>"""
return vobiz_xml
@ -335,9 +335,10 @@ class VobizProvider(TelephonyProvider):
- call_uuid (instead of CallSid)
- status, from, to, duration, etc.
"""
call_status = data.get("CallStatus", "")
return {
"call_id": data.get("CallUUID", ""),
"status": data.get("CallStatus", ""),
"status": TelephonyCallStatus.from_raw(call_status) or call_status,
"from_number": data.get("From"),
"to_number": data.get("To"),
"direction": data.get("Direction"),
@ -349,7 +350,7 @@ class VobizProvider(TelephonyProvider):
self,
websocket: "WebSocket",
workflow_id: int,
user_id: int,
organization_id: int,
workflow_run_id: int,
) -> None:
"""
@ -393,7 +394,7 @@ class VobizProvider(TelephonyProvider):
provider_name=self.PROVIDER_NAME,
workflow_id=workflow_id,
workflow_run_id=workflow_run_id,
user_id=user_id,
organization_id=organization_id,
call_id=call_id,
transport_kwargs={"stream_id": stream_id, "call_id": call_id},
)

View file

@ -54,7 +54,7 @@ async def _verify_vobiz_callback(
@router.post("/vobiz-xml", include_in_schema=False)
async def handle_vobiz_xml_webhook(
workflow_id: int, user_id: int, workflow_run_id: int, organization_id: int
workflow_id: int, workflow_run_id: int, organization_id: int
):
"""
Handle initial webhook from Vobiz when call is answered.
@ -65,7 +65,7 @@ async def handle_vobiz_xml_webhook(
set_current_run_id(workflow_run_id)
logger.info(
f"[run {workflow_run_id}] Vobiz XML webhook called - "
f"workflow_id={workflow_id}, user_id={user_id}, org_id={organization_id}"
f"workflow_id={workflow_id}, org_id={organization_id}"
)
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
@ -74,7 +74,7 @@ async def handle_vobiz_xml_webhook(
logger.debug(f"[run {workflow_run_id}] Using provider: {provider.PROVIDER_NAME}")
response_content = await provider.get_webhook_response(
workflow_id, user_id, workflow_run_id
workflow_id, organization_id, workflow_run_id
)
logger.debug(

View file

@ -21,6 +21,7 @@ def _config_loader(value: Dict[str, Any]) -> Dict[str, Any]:
"private_key": value.get("private_key"),
"api_key": value.get("api_key"),
"api_secret": value.get("api_secret"),
"signature_secret": value.get("signature_secret"),
"from_numbers": value.get("from_numbers", []),
}
@ -49,6 +50,13 @@ _UI_METADATA = ProviderUIMetadata(
type="password",
sensitive=True,
),
ProviderUIField(
name="signature_secret",
label="Signature Secret",
type="password",
sensitive=True,
description="Vonage signature secret for signed webhook verification",
),
ProviderUIField(
name="from_numbers",
label="Phone Numbers",

View file

@ -1,6 +1,6 @@
"""Vonage telephony configuration schemas."""
from typing import List, Literal
from typing import List, Literal, Optional
from pydantic import BaseModel, Field
@ -13,6 +13,10 @@ class VonageConfigurationRequest(BaseModel):
api_secret: str = Field(..., description="Vonage API Secret")
application_id: str = Field(..., description="Vonage Application ID")
private_key: str = Field(..., description="Private key for JWT generation")
signature_secret: Optional[str] = Field(
None,
description="Vonage signature secret used to verify signed webhooks",
)
from_numbers: List[str] = Field(
default_factory=list,
description="List of Vonage phone numbers (without + prefix)",
@ -27,4 +31,5 @@ class VonageConfigurationResponse(BaseModel):
api_key: str # Masked
api_secret: str # Masked
private_key: str # Masked
signature_secret: Optional[str] = None # Masked
from_numbers: List[str]

View file

@ -2,6 +2,7 @@
Vonage (Nexmo) implementation of the TelephonyProvider interface.
"""
import hashlib
import json
import random
import time
@ -12,7 +13,7 @@ import jwt
from fastapi import HTTPException, Response
from loguru import logger
from api.enums import WorkflowRunMode
from api.enums import TelephonyCallStatus, WorkflowRunMode
from api.services.telephony.base import (
CallInitiationResult,
NormalizedInboundData,
@ -44,12 +45,14 @@ class VonageProvider(TelephonyProvider):
- api_secret: Vonage API Secret
- application_id: Vonage Application ID
- private_key: Private key for JWT generation
- signature_secret: Signature secret for signed webhooks
- from_numbers: List of phone numbers to use
"""
self.api_key = config.get("api_key")
self.api_secret = config.get("api_secret")
self.application_id = config.get("application_id")
self.private_key = config.get("private_key")
self.signature_secret = config.get("signature_secret")
self.from_numbers = config.get("from_numbers", [])
# Handle both single number (string) and multiple numbers (list)
@ -186,24 +189,25 @@ class VonageProvider(TelephonyProvider):
Verify Vonage webhook signature for security.
Vonage uses JWT for webhook signatures.
"""
if not self.api_secret:
logger.error("No API secret available for webhook signature verification")
if not self.signature_secret:
logger.error(
"No signature secret available for Vonage webhook verification"
)
return False
try:
# Vonage sends JWT in Authorization header. Verify the JWT signature
decoded = jwt.decode(
jwt.decode(
signature,
self.api_secret,
self.signature_secret,
algorithms=["HS256"],
options={"verify_signature": True},
options={"verify_signature": True, "verify_aud": False},
)
return True
except jwt.InvalidTokenError:
return False
async def get_webhook_response(
self, workflow_id: int, user_id: int, workflow_run_id: int
self, workflow_id: int, organization_id: int, workflow_run_id: int
) -> str:
"""
Generate NCCO response for starting a call session.
@ -218,7 +222,7 @@ class VonageProvider(TelephonyProvider):
"endpoint": [
{
"type": "websocket",
"uri": f"{wss_backend_endpoint}/api/v1/telephony/ws/{workflow_id}/{user_id}/{workflow_run_id}",
"uri": f"{wss_backend_endpoint}/api/v1/telephony/ws/{workflow_id}/{organization_id}/{workflow_run_id}",
"content-type": "audio/l16;rate=16000", # 16kHz Linear PCM
"headers": {},
}
@ -291,14 +295,18 @@ class VonageProvider(TelephonyProvider):
"""
# Map Vonage status to common format
status_map = {
"started": "initiated",
"ringing": "ringing",
"answered": "answered",
"complete": "completed",
"failed": "failed",
"busy": "busy",
"timeout": "no-answer",
"rejected": "busy",
"started": TelephonyCallStatus.INITIATED,
"ringing": TelephonyCallStatus.RINGING,
"answered": TelephonyCallStatus.ANSWERED,
"complete": TelephonyCallStatus.COMPLETED,
"completed": TelephonyCallStatus.COMPLETED,
"disconnected": TelephonyCallStatus.COMPLETED,
"failed": TelephonyCallStatus.FAILED,
"busy": TelephonyCallStatus.BUSY,
"timeout": TelephonyCallStatus.NO_ANSWER,
"unanswered": TelephonyCallStatus.NO_ANSWER,
"cancelled": TelephonyCallStatus.NO_ANSWER,
"rejected": TelephonyCallStatus.BUSY,
}
return {
@ -315,7 +323,7 @@ class VonageProvider(TelephonyProvider):
self,
websocket: "WebSocket",
workflow_id: int,
user_id: int,
organization_id: int,
workflow_run_id: int,
) -> None:
"""
@ -330,14 +338,17 @@ class VonageProvider(TelephonyProvider):
try:
# Get workflow run to extract call UUID
workflow_run = await db_client.get_workflow_run(workflow_run_id)
workflow_run = await db_client.get_workflow_run(
workflow_run_id, organization_id=organization_id
)
if not workflow_run:
logger.error(f"Workflow run {workflow_run_id} not found")
await websocket.close(code=4404, reason="Workflow run not found")
return
# Get workflow for organization info
workflow = await db_client.get_workflow(workflow_id, user_id)
workflow = await db_client.get_workflow(
workflow_id, organization_id=organization_id
)
if not workflow:
logger.error(f"Workflow {workflow_id} not found")
await websocket.close(code=4404, reason="Workflow not found")
@ -349,6 +360,8 @@ class VonageProvider(TelephonyProvider):
if workflow_run.gathered_context
else None
)
if not call_uuid and workflow_run.gathered_context:
call_uuid = workflow_run.gathered_context.get("call_id")
if not call_uuid:
logger.error(
@ -382,7 +395,7 @@ class VonageProvider(TelephonyProvider):
provider_name=self.PROVIDER_NAME,
workflow_id=workflow_id,
workflow_run_id=workflow_run_id,
user_id=user_id,
organization_id=organization_id,
call_id=call_uuid,
transport_kwargs={"call_uuid": call_uuid},
)
@ -400,26 +413,126 @@ class VonageProvider(TelephonyProvider):
"""
Determine if this provider can handle the incoming webhook.
"""
return False
claims = cls._decode_unverified_signed_claims(headers)
if claims.get("api_key") or claims.get("application_id"):
return True
return bool(
webhook_data.get("uuid")
and webhook_data.get("conversation_uuid")
and webhook_data.get("from")
and webhook_data.get("to")
)
@staticmethod
def parse_inbound_webhook(webhook_data: Dict[str, Any]) -> NormalizedInboundData:
def parse_inbound_webhook(
webhook_data: Dict[str, Any], headers: Optional[Dict[str, str]] = None
) -> NormalizedInboundData:
"""
Parse Vonage-specific inbound webhook data into normalized format.
"""
claims = VonageProvider._decode_unverified_signed_claims(headers or {})
direction = webhook_data.get("direction") or "inbound"
status = webhook_data.get("status") or "started"
return NormalizedInboundData(
provider=VonageProvider.PROVIDER_NAME,
call_id=webhook_data.get("uuid", ""),
from_number=webhook_data.get("from", ""),
to_number=webhook_data.get("to", ""),
direction=webhook_data.get("direction", ""),
call_status=webhook_data.get("status", ""),
account_id=webhook_data.get("account_id"),
direction=direction,
call_status=status,
account_id=claims.get("api_key") or webhook_data.get("account_id"),
from_country=None,
to_country=None,
raw_data=webhook_data,
)
@staticmethod
def _header(headers: Dict[str, str], name: str) -> Optional[str]:
for key, value in headers.items():
if key.lower() == name.lower():
return value
return None
@classmethod
def _bearer_token(cls, headers: Dict[str, str]) -> Optional[str]:
auth_header = cls._header(headers, "authorization")
if not auth_header:
return None
parts = auth_header.split(None, 1)
if len(parts) != 2 or parts[0].lower() != "bearer":
return None
return parts[1].strip()
@classmethod
def _decode_unverified_signed_claims(
cls, headers: Dict[str, str]
) -> Dict[str, Any]:
token = cls._bearer_token(headers)
if not token:
return {}
try:
claims = jwt.decode(
token,
options={
"verify_signature": False,
"verify_aud": False,
"verify_exp": False,
},
)
except jwt.InvalidTokenError:
return {}
return claims if isinstance(claims, dict) else {}
def _verify_signed_claims(
self, headers: Dict[str, str], body: str = ""
) -> Optional[Dict[str, Any]]:
token = self._bearer_token(headers)
if not token:
logger.warning("Missing Vonage Authorization bearer token")
return None
if not self.signature_secret:
logger.error("Missing Vonage signature_secret for signed webhook")
return None
try:
claims = jwt.decode(
token,
self.signature_secret,
algorithms=["HS256"],
options={"verify_signature": True, "verify_aud": False},
)
except jwt.InvalidTokenError as exc:
logger.warning(f"Invalid Vonage signed webhook JWT: {exc}")
return None
if claims.get("iss") != "Vonage":
logger.warning("Vonage signed webhook JWT has unexpected issuer")
return None
if self.api_key and claims.get("api_key") != self.api_key:
logger.warning("Vonage signed webhook api_key does not match config")
return None
claim_application_id = claims.get("application_id")
if (
self.application_id
and claim_application_id
and claim_application_id != self.application_id
):
logger.warning("Vonage signed webhook application_id does not match config")
return None
payload_hash = claims.get("payload_hash")
if payload_hash:
actual_hash = hashlib.sha256(body.encode("utf-8")).hexdigest()
if actual_hash != payload_hash:
logger.warning("Vonage signed webhook payload hash mismatch")
return None
return claims
@staticmethod
def validate_account_id(config_data: dict, webhook_account_id: str) -> bool:
"""Validate Vonage account_id from webhook matches configuration"""
@ -437,9 +550,10 @@ class VonageProvider(TelephonyProvider):
body: str = "",
) -> bool:
"""
Vonage inbound signature verification - minimalist implementation.
Verify Vonage signed webhook JWT and optional payload hash.
"""
return True
claims = self._verify_signed_claims(headers, body)
return claims is not None
async def configure_inbound(
self, address: str, webhook_url: Optional[str]
@ -486,6 +600,15 @@ class VonageProvider(TelephonyProvider):
),
)
if not self.signature_secret:
return ProviderSyncResult(
ok=False,
message=(
"Vonage signature_secret is required because inbound calls "
"use signed webhook verification"
),
)
app_endpoint = f"{self.base_url}/v2/applications/{self.application_id}"
auth = aiohttp.BasicAuth(self.api_key, self.api_secret)
@ -510,12 +633,18 @@ class VonageProvider(TelephonyProvider):
capabilities = app_data.get("capabilities") or {}
voice = capabilities.get("voice") or {}
webhooks = voice.get("webhooks") or {}
backend_endpoint, _ = await get_backend_endpoints()
webhooks["answer_url"] = {
"address": webhook_url,
"http_method": "POST",
}
webhooks["event_url"] = {
"address": f"{backend_endpoint}/api/v1/telephony/vonage/events",
"http_method": "POST",
}
voice["webhooks"] = webhooks
voice["signed_callbacks"] = True
capabilities["voice"] = voice
update_body = {
@ -561,13 +690,24 @@ class VonageProvider(TelephonyProvider):
"""
Generate NCCO response for inbound Vonage webhook.
"""
# Minimalist NCCO response for interface compliance
ncco_response = [
{
"action": "talk",
"text": "Vonage inbound calls are not currently supported.",
},
{"action": "hangup"},
"action": "connect",
"eventUrl": [
f"{backend_endpoint}/api/v1/telephony/vonage/events/{workflow_run_id}"
],
"endpoint": [
{
"type": "websocket",
"uri": websocket_url,
"content-type": "audio/l16;rate=16000",
"headers": {
"workflow_run_id": str(workflow_run_id),
"call_uuid": normalized_data.call_id,
},
}
],
}
]
return Response(

View file

@ -5,18 +5,13 @@ provider registry — see ProviderSpec.router.
"""
import json
from typing import Optional
from fastapi import APIRouter, Request
from fastapi import APIRouter, HTTPException, Request
from loguru import logger
from pipecat.utils.run_context import set_current_run_id
from api.db import db_client
from api.services.telephony.factory import get_telephony_provider_for_run
from api.services.telephony.status_processor import (
StatusCallbackRequest,
_process_status_update,
)
router = APIRouter()
@ -24,9 +19,8 @@ router = APIRouter()
@router.get("/ncco", include_in_schema=False)
async def handle_ncco_webhook(
workflow_id: int,
user_id: int,
workflow_run_id: int,
organization_id: Optional[int] = None,
organization_id: int,
):
"""Handle NCCO (Nexmo Call Control Objects) webhook for Vonage.
@ -34,17 +28,76 @@ async def handle_ncco_webhook(
"""
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
provider = await get_telephony_provider_for_run(
workflow_run, organization_id or user_id
)
provider = await get_telephony_provider_for_run(workflow_run, organization_id)
response_content = await provider.get_webhook_response(
workflow_id, user_id, workflow_run_id
workflow_id, organization_id, workflow_run_id
)
return json.loads(response_content)
async def _read_json_body(request: Request) -> tuple[dict, str]:
body_bytes = await request.body()
try:
raw_body = body_bytes.decode("utf-8")
except UnicodeDecodeError as exc:
raise HTTPException(
status_code=400, detail="Webhook body is not valid UTF-8"
) from exc
try:
return json.loads(raw_body or "{}"), raw_body
except json.JSONDecodeError as exc:
raise HTTPException(status_code=400, detail="Webhook body is not JSON") from exc
async def _handle_vonage_event_request(request: Request, workflow_run_id: int):
set_current_run_id(workflow_run_id)
event_data, raw_body = await _read_json_body(request)
logger.info(
f"[run {workflow_run_id}] Received Vonage event "
f"uuid={event_data.get('uuid')} status={event_data.get('status')}"
)
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
if not workflow_run:
logger.error(f"[run {workflow_run_id}] Workflow run not found")
return {"status": "error", "message": "Workflow run not found"}
workflow = await db_client.get_workflow_by_id(workflow_run.workflow_id)
if not workflow:
logger.error(f"[run {workflow_run_id}] Workflow not found")
return {"status": "error", "message": "Workflow not found"}
provider = await get_telephony_provider_for_run(
workflow_run, workflow.organization_id
)
signature_valid = await provider.verify_inbound_signature(
str(request.url), event_data, dict(request.headers), raw_body
)
if not signature_valid:
raise HTTPException(status_code=401, detail="Invalid webhook signature")
from api.services.telephony.status_processor import (
StatusCallbackRequest,
_process_status_update,
)
parsed_data = provider.parse_status_callback(event_data)
status_update = StatusCallbackRequest(
call_id=parsed_data["call_id"],
status=parsed_data["status"],
from_number=parsed_data.get("from_number"),
to_number=parsed_data.get("to_number"),
direction=parsed_data.get("direction"),
duration=parsed_data.get("duration"),
extra=parsed_data.get("extra", {}),
)
await _process_status_update(workflow_run_id, status_update)
return {"status": "ok"}
@router.post("/vonage/events/{workflow_run_id}")
async def handle_vonage_events(
request: Request,
@ -55,43 +108,21 @@ async def handle_vonage_events(
Vonage sends all call events to a single endpoint.
Events include: started, ringing, answered, complete, failed, etc.
"""
set_current_run_id(workflow_run_id)
# Parse the event data
event_data = await request.json()
logger.info(f"[run {workflow_run_id}] Received Vonage event: {event_data}")
return await _handle_vonage_event_request(request, workflow_run_id)
# Get workflow run for processing
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
if not workflow_run:
logger.error(f"[run {workflow_run_id}] Workflow run not found")
return {"status": "error", "message": "Workflow run not found"}
# Get workflow and provider
workflow = await db_client.get_workflow_by_id(workflow_run.workflow_id)
if not workflow:
logger.error(f"[run {workflow_run_id}] Workflow not found")
return {"status": "error", "message": "Workflow not found"}
@router.post("/vonage/events")
async def handle_vonage_events_without_run(request: Request):
"""Handle application-level events by resolving the run from call UUID."""
event_data, _ = await _read_json_body(request)
call_id = event_data.get("uuid")
if call_id:
workflow_run = await db_client.get_workflow_run_by_call_id(call_id)
if workflow_run:
return await _handle_vonage_event_request(request, workflow_run.id)
provider = await get_telephony_provider_for_run(
workflow_run, workflow.organization_id
logger.info(
"Received unmatched Vonage application event "
f"uuid={event_data.get('uuid')} status={event_data.get('status')}"
)
# Parse the event data into generic format
parsed_data = provider.parse_status_callback(event_data)
# Create StatusCallbackRequest from parsed data
status_update = StatusCallbackRequest(
call_id=parsed_data["call_id"],
status=parsed_data["status"],
from_number=parsed_data.get("from_number"),
to_number=parsed_data.get("to_number"),
direction=parsed_data.get("direction"),
duration=parsed_data.get("duration"),
extra=parsed_data.get("extra", {}),
)
# Process the status update
await _process_status_update(workflow_run_id, status_update)
# Return 204 No Content as expected by Vonage
return {"status": "ok"}

View file

@ -40,7 +40,8 @@ class ProviderUIField:
name: str # Must match the Pydantic field name on config_request_cls
label: str
type: str # "text" | "password" | "textarea" | "string-array" | "number"
# "text" | "password" | "textarea" | "string-array" | "number" | "boolean"
type: str
required: bool = True
sensitive: bool = False # If true, mask when displaying stored value
description: Optional[str] = None

View file

@ -12,25 +12,96 @@ from loguru import logger
from pydantic import BaseModel
from api.db import db_client
from api.enums import WorkflowRunState
from api.enums import TelephonyCallStatus, WorkflowRunState
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.campaign.circuit_breaker import circuit_breaker
from api.tasks.arq import enqueue_job
from api.tasks.function_names import FunctionNames
TERMINAL_NOT_CONNECTED_STATUSES = frozenset(
{
TelephonyCallStatus.FAILED,
TelephonyCallStatus.BUSY,
TelephonyCallStatus.NO_ANSWER,
TelephonyCallStatus.CANCELED,
TelephonyCallStatus.ERROR,
}
)
IN_FLIGHT_STATUSES = frozenset(
{
TelephonyCallStatus.INITIATED,
TelephonyCallStatus.RINGING,
TelephonyCallStatus.IN_PROGRESS,
TelephonyCallStatus.ANSWERED,
}
)
RETRYABLE_NOT_CONNECTED_STATUSES = frozenset(
{TelephonyCallStatus.BUSY, TelephonyCallStatus.NO_ANSWER}
)
FAILURE_NOT_CONNECTED_STATUSES = frozenset(
{TelephonyCallStatus.ERROR, TelephonyCallStatus.FAILED}
)
def _status_value(value: object) -> str:
status = TelephonyCallStatus.from_raw(value)
if status is not None:
return status.value
return str(value or "").lower()
def _duration_seconds(duration: str | None) -> int | float:
if duration in (None, ""):
return 0
try:
parsed = float(duration)
except (TypeError, ValueError):
return 0
return int(parsed) if parsed.is_integer() else parsed
def _append_unique_tags(existing_tags: object, new_tags: list[str]) -> list[str]:
tags = existing_tags if isinstance(existing_tags, list) else []
merged = list(tags)
for tag in new_tags:
if tag not in merged:
merged.append(tag)
return merged
async def _enqueue_integrations_for_unconnected_run(
workflow_run_id: int,
status: str,
) -> None:
"""Fire post-call integrations (e.g. webhooks) when a call ends before the
Pipecat pipeline ever starts.
Enqueues integrations only -- deliberately *not*
``PROCESS_WORKFLOW_COMPLETION`` -- so an unconnected call still triggers the
configured webhooks without incurring platform-usage billing.
"""
await enqueue_job(FunctionNames.RUN_INTEGRATIONS_POST_WORKFLOW_RUN, workflow_run_id)
logger.info(
f"[run {workflow_run_id}] Enqueued post-call integrations after terminal "
f"telephony status: {status}"
)
class StatusCallbackRequest(BaseModel):
"""Normalized status callback shape used across all telephony providers.
Per-provider converters live as classmethods (``from_twilio``, ``from_plivo``,
``from_vonage``, ``from_cloudonix_cdr``) so the route handler for each
provider can map raw webhook payloads into this shape and hand off to
:func:`_process_status_update`.
Provider-specific route handlers map raw webhook payloads into this shape,
then hand it off to :func:`_process_status_update`.
"""
call_id: str
status: str
status: TelephonyCallStatus | str
from_number: Optional[str] = None
to_number: Optional[str] = None
direction: Optional[str] = None
@ -38,102 +109,14 @@ class StatusCallbackRequest(BaseModel):
extra: dict = {}
@classmethod
def from_twilio(cls, data: dict):
"""Convert Twilio callback to generic format."""
return cls(
call_id=data.get("CallSid", ""),
status=data.get("CallStatus", ""),
from_number=data.get("From"),
to_number=data.get("To"),
direction=data.get("Direction"),
duration=data.get("CallDuration") or data.get("Duration"),
extra=data,
)
@classmethod
def from_plivo(cls, data: dict):
"""Convert Plivo callback to generic format."""
status_map = {
"in-progress": "answered",
"ringing": "ringing",
"ring": "ringing",
"completed": "completed",
"hangup": "completed",
"stopstream": "completed",
"busy": "busy",
"no-answer": "no-answer",
"cancel": "canceled",
"cancelled": "canceled",
"timeout": "no-answer",
}
call_status = (data.get("CallStatus") or data.get("Event") or "").lower()
return cls(
call_id=data.get("CallUUID", "") or data.get("RequestUUID", ""),
status=status_map.get(call_status, call_status),
from_number=data.get("From"),
to_number=data.get("To"),
direction=data.get("Direction"),
duration=data.get("Duration"),
extra=data,
)
@classmethod
def from_vonage(cls, data: dict):
"""Convert Vonage event to generic format."""
status_map = {
"started": "initiated",
"ringing": "ringing",
"answered": "answered",
"complete": "completed",
"failed": "failed",
"busy": "busy",
"timeout": "no-answer",
"rejected": "busy",
}
return cls(
call_id=data.get("uuid", ""),
status=status_map.get(data.get("status", ""), data.get("status", "")),
from_number=data.get("from"),
to_number=data.get("to"),
direction=data.get("direction"),
duration=data.get("duration"),
extra=data,
)
@classmethod
def from_cloudonix_cdr(cls, data: dict):
"""Convert Cloudonix CDR to generic format."""
disposition_map = {
"ANSWER": "completed",
"BUSY": "busy",
"CANCEL": "canceled",
"FAILED": "failed",
"CONGESTION": "failed",
"NOANSWER": "no-answer",
}
disposition = data.get("disposition") or ""
status = disposition_map.get(disposition.upper(), disposition.lower())
session = data.get("session")
call_id = session.get("token") if isinstance(session, dict) else ""
return cls(
call_id=call_id or "",
status=status,
from_number=data.get("from"),
to_number=data.get("to"),
duration=str(data.get("billsec") or data.get("duration") or 0),
extra=data,
)
async def _process_status_update(workflow_run_id: int, status: StatusCallbackRequest):
"""Process status updates from telephony providers.
Idempotent: handles repeated callbacks (e.g. from both webhook and CDR).
"""
normalized_status = TelephonyCallStatus.from_raw(status.status)
status_value = _status_value(status.status)
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
if not workflow_run:
logger.warning(
@ -143,7 +126,7 @@ async def _process_status_update(workflow_run_id: int, status: StatusCallbackReq
telephony_callback_logs = workflow_run.logs.get("telephony_status_callbacks", [])
telephony_callback_log = {
"status": status.status,
"status": status_value,
"timestamp": datetime.now(UTC).isoformat(),
"call_id": status.call_id,
"duration": status.duration,
@ -156,13 +139,14 @@ async def _process_status_update(workflow_run_id: int, status: StatusCallbackReq
logs={"telephony_status_callbacks": telephony_callback_logs},
)
if status.status == "completed":
if normalized_status == TelephonyCallStatus.COMPLETED:
logger.info(
f"[run {workflow_run_id}] Call completed with duration: {status.duration}s"
)
await campaign_call_dispatcher.release_call_slot(workflow_run_id)
if workflow_run.campaign_id:
await campaign_call_dispatcher.release_call_slot(workflow_run_id)
await circuit_breaker.record_and_evaluate(
workflow_run.campaign_id, is_failure=False
)
@ -174,26 +158,30 @@ async def _process_status_update(workflow_run_id: int, status: StatusCallbackReq
state=WorkflowRunState.COMPLETED.value,
)
elif status.status in ["failed", "busy", "no-answer", "canceled", "error"]:
elif normalized_status in TERMINAL_NOT_CONNECTED_STATUSES:
logger.warning(
f"[run {workflow_run_id}] Call failed with status: {status.status}"
f"[run {workflow_run_id}] Call failed with status: {normalized_status.value}"
)
await campaign_call_dispatcher.release_call_slot(workflow_run_id)
if workflow_run.campaign_id:
await campaign_call_dispatcher.release_call_slot(workflow_run_id)
is_failure = status.status in ("error", "failed")
is_failure = normalized_status in FAILURE_NOT_CONNECTED_STATUSES
await circuit_breaker.record_and_evaluate(
workflow_run.campaign_id,
is_failure=is_failure,
workflow_run_id=workflow_run_id if is_failure else None,
reason=status.status if is_failure else None,
reason=normalized_status.value if is_failure else None,
)
if status.status in ["busy", "no-answer"] and workflow_run.campaign_id:
if (
normalized_status in RETRYABLE_NOT_CONNECTED_STATUSES
and workflow_run.campaign_id
):
publisher = await get_campaign_event_publisher()
await publisher.publish_retry_needed(
workflow_run_id=workflow_run_id,
reason=status.status.replace("-", "_"),
reason=normalized_status.value.replace("-", "_"),
campaign_id=workflow_run.campaign_id,
queued_run_id=workflow_run.queued_run_id,
)
@ -203,15 +191,42 @@ async def _process_status_update(workflow_run_id: int, status: StatusCallbackReq
if workflow_run.gathered_context
else []
)
call_tags.extend(["not_connected", f"telephony_{status.status.lower()}"])
await db_client.update_workflow_run(
run_id=workflow_run_id,
is_completed=True,
state=WorkflowRunState.COMPLETED.value,
gathered_context={"call_tags": call_tags},
call_tags = _append_unique_tags(
call_tags,
["not_connected", f"telephony_{normalized_status.value}"],
)
elif status.status in ["in-progress", "initiated", "ringing"]:
gathered_context = {
"call_tags": call_tags,
"call_disposition": normalized_status.value,
"mapped_call_disposition": normalized_status.value,
}
if status.call_id:
gathered_context["call_id"] = status.call_id
should_run_post_call_integrations = (
workflow_run.state == WorkflowRunState.INITIALIZED.value
and not workflow_run.is_completed
)
update_kwargs = {
"run_id": workflow_run_id,
"is_completed": True,
"state": WorkflowRunState.COMPLETED.value,
"gathered_context": gathered_context,
}
if should_run_post_call_integrations:
update_kwargs["usage_info"] = {
"call_duration_seconds": _duration_seconds(status.duration)
}
await db_client.update_workflow_run(**update_kwargs)
if should_run_post_call_integrations:
await _enqueue_integrations_for_unconnected_run(
workflow_run_id, normalized_status.value
)
elif normalized_status in IN_FLIGHT_STATUSES:
# No-op while the call is in flight.
pass
else: