feat: add Plivo telephony provider support (#245)

* Add Plivo telephony provider support

* add Plivo telephony UI, fix audio config, and improve inbound call handling

---------

Co-authored-by: Dilip Tiwari <digitalapache20@gmail.com>
Co-authored-by: Sabiha Khan <sabihak89@gmail.com>
Co-authored-by: Abhishek <abhishek@a6k.me>
This commit is contained in:
dilipevents2007-cpu 2026-04-25 20:41:46 +05:30 committed by GitHub
parent 3e3773f400
commit 2218ba8ad9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1123 additions and 13 deletions

View file

@ -12,6 +12,8 @@ from api.schemas.telephony_config import (
ARIConfigurationResponse,
CloudonixConfigurationRequest,
CloudonixConfigurationResponse,
PlivoConfigurationRequest,
PlivoConfigurationResponse,
TelephonyConfigurationResponse,
TelnyxConfigurationRequest,
TelnyxConfigurationResponse,
@ -33,6 +35,7 @@ router = APIRouter(prefix="/organizations", tags=["organizations"])
# Provider configuration constants
PROVIDER_MASKED_FIELDS = {
"twilio": ["account_sid", "auth_token"],
"plivo": ["auth_id", "auth_token"],
"vonage": ["private_key", "api_key", "api_secret"],
"vobiz": ["auth_id", "auth_token"],
"cloudonix": ["bearer_token"],
@ -72,6 +75,26 @@ async def get_telephony_configuration(user: UserModel = Depends(get_user)):
auth_token=mask_key(auth_token) if auth_token else "",
from_numbers=from_numbers,
),
plivo=None,
vonage=None,
vobiz=None,
cloudonix=None,
)
elif stored_provider == "plivo":
auth_id = config.value.get("auth_id", "")
auth_token = config.value.get("auth_token", "")
from_numbers = (
config.value.get("from_numbers", []) if auth_id and auth_token else []
)
return TelephonyConfigurationResponse(
twilio=None,
plivo=PlivoConfigurationResponse(
provider="plivo",
auth_id=mask_key(auth_id) if auth_id else "",
auth_token=mask_key(auth_token) if auth_token else "",
from_numbers=from_numbers,
),
vonage=None,
vobiz=None,
cloudonix=None,
@ -89,6 +112,7 @@ async def get_telephony_configuration(user: UserModel = Depends(get_user)):
return TelephonyConfigurationResponse(
twilio=None,
plivo=None,
vonage=VonageConfigurationResponse(
provider="vonage",
application_id=application_id,
@ -109,6 +133,7 @@ async def get_telephony_configuration(user: UserModel = Depends(get_user)):
return TelephonyConfigurationResponse(
twilio=None,
plivo=None,
vonage=None,
vobiz=VobizConfigurationResponse(
provider="vobiz",
@ -125,6 +150,7 @@ async def get_telephony_configuration(user: UserModel = Depends(get_user)):
return TelephonyConfigurationResponse(
twilio=None,
plivo=None,
vonage=None,
cloudonix=CloudonixConfigurationResponse(
provider="cloudonix",
@ -175,6 +201,7 @@ async def get_telephony_configuration(user: UserModel = Depends(get_user)):
async def save_telephony_configuration(
request: Union[
TwilioConfigurationRequest,
PlivoConfigurationRequest,
VonageConfigurationRequest,
VobizConfigurationRequest,
CloudonixConfigurationRequest,
@ -201,6 +228,13 @@ async def save_telephony_configuration(
"auth_token": request.auth_token,
"from_numbers": request.from_numbers,
}
elif request.provider == "plivo":
config_value = {
"provider": "plivo",
"auth_id": request.auth_id,
"auth_token": request.auth_token,
"from_numbers": request.from_numbers,
}
elif request.provider == "vonage":
config_value = {
"provider": "vonage",

View file

@ -89,6 +89,33 @@ class StatusCallbackRequest(BaseModel):
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"""
@ -340,6 +367,9 @@ async def _validate_inbound_request(
webhook_data: dict,
webhook_body: str = "",
x_twilio_signature: str = None,
x_plivo_signature: str = None,
x_plivo_signature_ma: str = None,
x_plivo_signature_nonce: str = None,
x_vobiz_signature: str = None,
x_vobiz_timestamp: str = None,
x_cx_apikey: str = None,
@ -377,7 +407,14 @@ async def _validate_inbound_request(
# Verify webhook signature/API key if provided
provider_instance = None
if x_twilio_signature or x_vobiz_signature or x_cx_apikey or telnyx_signature:
if (
x_twilio_signature
or x_plivo_signature
or x_plivo_signature_ma
or x_vobiz_signature
or x_cx_apikey
or telnyx_signature
):
backend_endpoint, _ = await get_backend_endpoints()
webhook_url = f"{backend_endpoint}/api/v1/telephony/inbound/{workflow_id}"
@ -389,6 +426,16 @@ async def _validate_inbound_request(
signature_valid = await provider_instance.verify_inbound_signature(
webhook_url, webhook_data, x_twilio_signature
)
elif provider_class.PROVIDER_NAME == "plivo" and (
x_plivo_signature or x_plivo_signature_ma
):
logger.info(f"Verifying Plivo signature for URL: {webhook_url}")
signature_valid = await provider_instance.verify_inbound_signature(
webhook_url,
webhook_data,
x_plivo_signature or x_plivo_signature_ma,
x_plivo_signature_nonce,
)
elif provider_class.PROVIDER_NAME == "vobiz" and x_vobiz_signature:
logger.info(f"Verifying Vobiz signature for URL: {webhook_url}")
signature_valid = await provider_instance.verify_inbound_signature(
@ -478,12 +525,6 @@ async def _validate_organization_provider_config(
organization_id: int, provider_class, account_id: str
) -> TelephonyError:
"""Validate provider and account_id, returning specific error type"""
if not account_id:
logger.warning(
f"No account_id provided for provider {provider_class.PROVIDER_NAME}"
)
return TelephonyError.ACCOUNT_VALIDATION_FAILED
try:
config = await db_client.get_configuration(
organization_id,
@ -1015,6 +1056,160 @@ async def handle_vobiz_xml_webhook(
return HTMLResponse(content=response_content, media_type="application/xml")
@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,
x_plivo_signature_v3: Optional[str] = Header(None),
x_plivo_signature_ma_v3: Optional[str] = Header(None),
x_plivo_signature_v3_nonce: Optional[str] = Header(None),
):
"""
Handle initial webhook from Plivo when an outbound call is answered.
Returns Plivo XML response with Stream element.
"""
set_current_run_id(workflow_run_id)
provider = await get_telephony_provider(organization_id)
form_data = await request.form()
callback_data = dict(form_data)
signature = x_plivo_signature_v3 or x_plivo_signature_ma_v3
if signature:
backend_endpoint, _ = await get_backend_endpoints()
full_url = (
f"{backend_endpoint}/api/v1/telephony/plivo-xml"
f"?workflow_id={workflow_id}"
f"&user_id={user_id}"
f"&workflow_run_id={workflow_run_id}"
f"&organization_id={organization_id}"
)
is_valid = await provider.verify_inbound_signature(
full_url, callback_data, signature, x_plivo_signature_v3_nonce
)
if not is_valid:
logger.warning(
f"[run {workflow_run_id}] Invalid Plivo signature on answer webhook"
)
return provider.generate_error_response(
"invalid_signature", "Invalid webhook signature."
)
call_id = callback_data.get("CallUUID") or callback_data.get("RequestUUID")
if call_id:
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
gathered_context = dict(workflow_run.gathered_context or {})
gathered_context["call_id"] = call_id
await db_client.update_workflow_run(
run_id=workflow_run_id, gathered_context=gathered_context
)
response_content = await provider.get_webhook_response(
workflow_id, user_id, workflow_run_id
)
return HTMLResponse(content=response_content, media_type="application/xml")
async def _handle_plivo_status_callback(
workflow_run_id: int,
request: Request,
x_plivo_signature_v3: Optional[str],
x_plivo_signature_ma_v3: Optional[str],
x_plivo_signature_v3_nonce: Optional[str],
):
set_current_run_id(workflow_run_id)
form_data = await request.form()
callback_data = dict(form_data)
logger.info(
f"[run {workflow_run_id}] Received Plivo callback: {json.dumps(callback_data)}"
)
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 Plivo callback")
return {"status": "ignored", "reason": "workflow_run_not_found"}
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(workflow.organization_id)
signature = x_plivo_signature_v3 or x_plivo_signature_ma_v3
if signature:
backend_endpoint, _ = await get_backend_endpoints()
callback_kind = request.url.path.split("/")[-2]
full_url = (
f"{backend_endpoint}/api/v1/telephony/plivo/{callback_kind}/{workflow_run_id}"
)
is_valid = await provider.verify_inbound_signature(
full_url,
callback_data,
signature,
x_plivo_signature_v3_nonce,
)
if not is_valid:
logger.warning(
f"[run {workflow_run_id}] Invalid Plivo webhook signature"
)
return {"status": "error", "reason": "invalid_signature"}
parsed_data = provider.parse_status_callback(callback_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": "success"}
@router.post("/plivo/hangup-callback/{workflow_run_id}")
async def handle_plivo_hangup_callback(
workflow_run_id: int,
request: Request,
x_plivo_signature_v3: Optional[str] = Header(None),
x_plivo_signature_ma_v3: Optional[str] = Header(None),
x_plivo_signature_v3_nonce: Optional[str] = Header(None),
):
"""Handle Plivo hangup callbacks."""
return await _handle_plivo_status_callback(
workflow_run_id,
request,
x_plivo_signature_v3,
x_plivo_signature_ma_v3,
x_plivo_signature_v3_nonce,
)
@router.post("/plivo/ring-callback/{workflow_run_id}")
async def handle_plivo_ring_callback(
workflow_run_id: int,
request: Request,
x_plivo_signature_v3: Optional[str] = Header(None),
x_plivo_signature_ma_v3: Optional[str] = Header(None),
x_plivo_signature_v3_nonce: Optional[str] = Header(None),
):
"""Handle Plivo ring callbacks."""
return await _handle_plivo_status_callback(
workflow_run_id,
request,
x_plivo_signature_v3,
x_plivo_signature_ma_v3,
x_plivo_signature_v3_nonce,
)
@router.post("/vobiz/hangup-callback/{workflow_run_id}")
async def handle_vobiz_hangup_callback(
workflow_run_id: int,
@ -1440,6 +1635,9 @@ async def handle_inbound_telephony(
workflow_id: int,
request: Request,
x_twilio_signature: Optional[str] = Header(None),
x_plivo_signature_v3: Optional[str] = Header(None),
x_plivo_signature_ma_v3: Optional[str] = Header(None),
x_plivo_signature_v3_nonce: Optional[str] = Header(None),
x_vobiz_signature: Optional[str] = Header(None),
x_vobiz_timestamp: Optional[str] = Header(None),
x_cx_apikey: Optional[str] = Header(None),
@ -1495,6 +1693,9 @@ async def handle_inbound_telephony(
webhook_data,
webhook_body,
x_twilio_signature,
x_plivo_signature_v3,
x_plivo_signature_ma_v3,
x_plivo_signature_v3_nonce,
x_vobiz_signature,
x_vobiz_timestamp,
x_cx_apikey,