mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-07-25 12:01:04 +02:00
Merge branch 'main' into feat/vici-dial
This commit is contained in:
commit
d6996be920
455 changed files with 33133 additions and 11135 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 "
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue