mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-07-01 08:59:46 +02:00
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:
parent
3e3773f400
commit
2218ba8ad9
14 changed files with 1123 additions and 13 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue