mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-07-16 11:31:04 +02:00
* fix: fix transition logic for realtime providers * chore: run formatter * chore: generate SDK and fix other realtime providers * fix: fix ultravox node transitions
234 lines
8.7 KiB
Python
234 lines
8.7 KiB
Python
"""Cloudonix telephony routes (webhooks, status callbacks, answer URLs).
|
|
|
|
Mounted under ``/api/v1/telephony`` by ``api.routes.telephony`` via the
|
|
provider registry — see ProviderSpec.router.
|
|
"""
|
|
|
|
import json
|
|
|
|
from fastapi import APIRouter, 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.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(
|
|
workflow_run_id: int,
|
|
request: Request,
|
|
):
|
|
"""Handle Cloudonix-specific status callbacks.
|
|
|
|
Cloudonix sends call status updates to the callback URL specified during call initiation.
|
|
"""
|
|
set_current_run_id(workflow_run_id)
|
|
# Parse callback data - determine if JSON or form data
|
|
content_type = request.headers.get("content-type", "")
|
|
|
|
if "application/json" in content_type:
|
|
callback_data = await request.json()
|
|
else:
|
|
# Assume form data (like Twilio)
|
|
form_data = await request.form()
|
|
callback_data = dict(form_data)
|
|
|
|
logger.info(
|
|
f"[run {workflow_run_id}] Received Cloudonix status callback: {json.dumps(callback_data)}"
|
|
)
|
|
|
|
# Get workflow run to find organization
|
|
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
|
|
if not workflow_run:
|
|
logger.warning(f"Workflow run {workflow_run_id} not found for status callback")
|
|
return {"status": "ignored", "reason": "workflow_run_not_found"}
|
|
|
|
# Get workflow and provider
|
|
workflow = await db_client.get_workflow_by_id(workflow_run.workflow_id)
|
|
if not workflow:
|
|
logger.warning(f"Workflow {workflow_run.workflow_id} not found")
|
|
return {"status": "ignored", "reason": "workflow_not_found"}
|
|
|
|
provider = await get_telephony_provider_for_run(
|
|
workflow_run, workflow.organization_id
|
|
)
|
|
|
|
# Parse the callback data into generic format
|
|
parsed_data = provider.parse_status_callback(callback_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 {"status": "success"}
|
|
|
|
|
|
@router.post("/cloudonix/cdr")
|
|
async def handle_cloudonix_cdr(request: Request):
|
|
"""Handle Cloudonix CDR (Call Detail Record) webhooks.
|
|
|
|
Cloudonix sends CDR records when calls complete. The CDR contains:
|
|
- domain: Used to identify the organization
|
|
- call_id: Used to find the workflow run
|
|
- disposition: Call termination status (ANSWER, BUSY, CANCEL, FAILED, CONGESTION, NOANSWER)
|
|
- duration/billsec: Call duration information
|
|
"""
|
|
try:
|
|
cdr_data = await request.json()
|
|
except Exception as e:
|
|
logger.error(f"Failed to parse Cloudonix CDR JSON: {e}")
|
|
return {"status": "error", "message": "Invalid JSON payload"}
|
|
|
|
# Extract domain to find organization
|
|
domain = cdr_data.get("domain")
|
|
if not domain:
|
|
logger.warning("Cloudonix CDR missing domain field")
|
|
return {"status": "error", "message": "Missing domain field"}
|
|
|
|
# Extract call_id to find workflow run
|
|
session = cdr_data.get("session")
|
|
call_id = session.get("token") if isinstance(session, dict) else None
|
|
logger.info(f"Cloudonix CDR data for call id {call_id} - {cdr_data}")
|
|
if not call_id:
|
|
logger.warning("Cloudonix CDR missing call_id field")
|
|
return {"status": "error", "message": "Missing call_id field"}
|
|
|
|
# Find workflow run by call_id in gathered_context
|
|
workflow_run = await db_client.get_workflow_run_by_call_id(call_id)
|
|
if not workflow_run:
|
|
logger.warning(f"No workflow run found for Cloudonix call_id: {call_id}")
|
|
return {"status": "ignored", "reason": "workflow_run_not_found"}
|
|
|
|
workflow_run_id = workflow_run.id
|
|
set_current_run_id(workflow_run_id)
|
|
logger.info(f"[run {workflow_run_id}] Processing Cloudonix CDR for call {call_id}")
|
|
|
|
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)
|
|
|
|
logger.info(
|
|
f"[run {workflow_run_id}] Cloudonix CDR processed successfully - "
|
|
f"disposition: {cdr_data.get('disposition')}, status: {status_update.status}"
|
|
)
|
|
|
|
return {"status": "success"}
|