feat: added vobiz telephony (#65)

* feat: added vobiz telephony

* chore: run formatter

* chore: add migration

* Add tsclient

---------

Co-authored-by: Abhishek Kumar <abhishek@a6k.me>
This commit is contained in:
Piyush Sahoo 2025-11-28 09:36:04 +05:30 committed by GitHub
parent 749a0c557f
commit 09897cb5d8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 994 additions and 19 deletions

View file

@ -234,7 +234,10 @@ async def websocket_endpoint(
provider_type = workflow_run.gathered_context.get("provider")
if not provider_type:
logger.error(f"No provider type found in workflow run {workflow_run_id}")
logger.error(
f"No provider type found in workflow run {workflow_run_id}. "
f"gathered_context: {workflow_run.gathered_context}, mode: {workflow_run.mode}"
)
await websocket.close(code=4400, reason="Provider type not found")
return
@ -483,3 +486,160 @@ async def handle_vonage_events(
# Return 204 No Content as expected by Vonage
return {"status": "ok"}
@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
):
"""
Handle initial webhook from Vobiz when call is answered.
Returns Vobiz XML response with Stream element.
Vobiz uses Plivo-compatible XML format similar to Twilio's TwiML.
"""
logger.info(
f"[run {workflow_run_id}] Vobiz XML webhook called - "
f"workflow_id={workflow_id}, user_id={user_id}, org_id={organization_id}"
)
provider = await get_telephony_provider(organization_id)
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
)
logger.debug(
f"[run {workflow_run_id}] Vobiz XML response generated:\n{response_content}"
)
return HTMLResponse(content=response_content, media_type="application/xml")
@router.post("/vobiz/hangup-callback/{workflow_run_id}")
async def handle_vobiz_hangup_callback(
workflow_run_id: int,
request: Request,
):
"""Handle Vobiz hangup callback (sent when call ends).
Vobiz sends callbacks to hangup_url when the call terminates.
This includes call duration, status, and billing information.
"""
# Parse the callback data (Vobiz sends form data or JSON)
try:
callback_data = await request.json()
logger.info(
f"[run {workflow_run_id}] Received Vobiz hangup callback (JSON): "
f"{json.dumps(callback_data)}"
)
except Exception:
# Fallback to form data if JSON fails
form_data = await request.form()
callback_data = dict(form_data)
logger.info(
f"[run {workflow_run_id}] Received Vobiz hangup callback (form): "
f"{json.dumps(callback_data)}"
)
# Get workflow run for processing
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
if not workflow_run:
logger.warning(
f"[run {workflow_run_id}] Workflow run not found for Vobiz hangup 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"[run {workflow_run_id}] Workflow not found")
return {"status": "ignored", "reason": "workflow_not_found"}
provider = await get_telephony_provider(workflow.organization_id)
logger.debug(
f"[run {workflow_run_id}] Processing Vobiz hangup with provider: {provider.PROVIDER_NAME}"
)
# Parse the callback data into generic format
parsed_data = provider.parse_status_callback(callback_data)
logger.debug(
f"[run {workflow_run_id}] Parsed Vobiz callback data: {json.dumps(parsed_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, workflow_run)
logger.info(f"[run {workflow_run_id}] Vobiz hangup callback processed successfully")
return {"status": "success"}
@router.post("/vobiz/ring-callback/{workflow_run_id}")
async def handle_vobiz_ring_callback(
workflow_run_id: int,
request: Request,
):
"""Handle Vobiz ring callback (sent when call starts ringing).
Vobiz can send callbacks to ring_url when the call starts ringing.
This is optional and used for tracking ringing status.
"""
# Parse the callback data
try:
callback_data = await request.json()
logger.info(
f"[run {workflow_run_id}] Received Vobiz ring callback (JSON): "
f"{json.dumps(callback_data)}"
)
except Exception:
form_data = await request.form()
callback_data = dict(form_data)
logger.info(
f"[run {workflow_run_id}] Received Vobiz ring callback (form): "
f"{json.dumps(callback_data)}"
)
# Get workflow run for processing
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
if not workflow_run:
logger.warning(
f"[run {workflow_run_id}] Workflow run not found for Vobiz ring callback"
)
return {"status": "ignored", "reason": "workflow_run_not_found"}
# Log the ringing event
telephony_callback_logs = workflow_run.logs.get("telephony_status_callbacks", [])
ring_log = {
"status": "ringing",
"timestamp": datetime.now(UTC).isoformat(),
"call_id": callback_data.get("call_uuid", callback_data.get("CallUUID", "")),
"event_type": "ring",
"raw_data": callback_data,
}
telephony_callback_logs.append(ring_log)
# Update workflow run logs
await db_client.update_workflow_run(
run_id=workflow_run_id,
logs={"telephony_status_callbacks": telephony_callback_logs},
)
logger.info(f"[run {workflow_run_id}] Vobiz ring callback logged")
return {"status": "success"}