mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-28 08:49:42 +02:00
feat: integrate Telnyx telephony for outbound and inbound calling (#206)
* feat: integrate Telnyx telephony for outbound and inbound calling * chore: remove redundant code --------- Co-authored-by: Abhishek <abhishek@a6k.me>
This commit is contained in:
parent
dc800bdd63
commit
5b820cb0ba
15 changed files with 1050 additions and 12 deletions
|
|
@ -13,6 +13,8 @@ from api.schemas.telephony_config import (
|
|||
CloudonixConfigurationRequest,
|
||||
CloudonixConfigurationResponse,
|
||||
TelephonyConfigurationResponse,
|
||||
TelnyxConfigurationRequest,
|
||||
TelnyxConfigurationResponse,
|
||||
TwilioConfigurationRequest,
|
||||
TwilioConfigurationResponse,
|
||||
VobizConfigurationRequest,
|
||||
|
|
@ -33,6 +35,7 @@ PROVIDER_MASKED_FIELDS = {
|
|||
"vobiz": ["auth_id", "auth_token"],
|
||||
"cloudonix": ["bearer_token"],
|
||||
"ari": ["app_password"],
|
||||
"telnyx": ["api_key"],
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -149,6 +152,19 @@ async def get_telephony_configuration(user: UserModel = Depends(get_user)):
|
|||
from_numbers=from_numbers,
|
||||
),
|
||||
)
|
||||
elif stored_provider == "telnyx":
|
||||
api_key = config.value.get("api_key", "")
|
||||
connection_id = config.value.get("connection_id", "")
|
||||
from_numbers = config.value.get("from_numbers", [])
|
||||
|
||||
return TelephonyConfigurationResponse(
|
||||
telnyx=TelnyxConfigurationResponse(
|
||||
provider="telnyx",
|
||||
api_key=mask_key(api_key) if api_key else "",
|
||||
connection_id=connection_id,
|
||||
from_numbers=from_numbers,
|
||||
),
|
||||
)
|
||||
else:
|
||||
return TelephonyConfigurationResponse()
|
||||
|
||||
|
|
@ -161,6 +177,7 @@ async def save_telephony_configuration(
|
|||
VobizConfigurationRequest,
|
||||
CloudonixConfigurationRequest,
|
||||
ARIConfigurationRequest,
|
||||
TelnyxConfigurationRequest,
|
||||
],
|
||||
user: UserModel = Depends(get_user),
|
||||
):
|
||||
|
|
@ -205,6 +222,13 @@ async def save_telephony_configuration(
|
|||
"domain_id": request.domain_id,
|
||||
"from_numbers": request.from_numbers,
|
||||
}
|
||||
elif request.provider == "telnyx":
|
||||
config_value = {
|
||||
"provider": "telnyx",
|
||||
"api_key": request.api_key,
|
||||
"connection_id": request.connection_id,
|
||||
"from_numbers": request.from_numbers,
|
||||
}
|
||||
elif request.provider == "ari":
|
||||
config_value = {
|
||||
"provider": "ari",
|
||||
|
|
|
|||
|
|
@ -318,6 +318,7 @@ async def _validate_inbound_request(
|
|||
x_vobiz_signature: str = None,
|
||||
x_vobiz_timestamp: str = None,
|
||||
x_cx_apikey: str = None,
|
||||
telnyx_signature: str = None,
|
||||
) -> tuple[bool, TelephonyError, dict, object]:
|
||||
"""
|
||||
Validate all aspects of inbound request.
|
||||
|
|
@ -351,7 +352,7 @@ 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:
|
||||
if x_twilio_signature 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}"
|
||||
|
||||
|
|
@ -377,6 +378,11 @@ async def _validate_inbound_request(
|
|||
signature_valid = await provider_instance.verify_inbound_signature(
|
||||
webhook_url, webhook_data, x_cx_apikey
|
||||
)
|
||||
elif provider_class.PROVIDER_NAME == "telnyx" and telnyx_signature:
|
||||
logger.info(f"Verifying Telnyx signature for URL: {webhook_url}")
|
||||
signature_valid = await provider_instance.verify_inbound_signature(
|
||||
webhook_url, webhook_data, telnyx_signature
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"No signature/API key validation for provider {provider_class.PROVIDER_NAME}"
|
||||
|
|
@ -818,6 +824,63 @@ async def _process_status_update(workflow_run_id: int, status: StatusCallbackReq
|
|||
)
|
||||
|
||||
|
||||
@router.post("/telnyx/events/{workflow_run_id}")
|
||||
async def handle_telnyx_events(
|
||||
request: Request,
|
||||
workflow_run_id: int,
|
||||
):
|
||||
"""Handle Telnyx Call Control webhook events.
|
||||
|
||||
Telnyx sends all call lifecycle events (call.initiated, call.answered,
|
||||
call.hangup, streaming.started, streaming.stopped) as JSON POST requests.
|
||||
"""
|
||||
set_current_run_id(workflow_run_id)
|
||||
|
||||
event_data = await request.json()
|
||||
logger.info(
|
||||
f"[run {workflow_run_id}] Received Telnyx event: {json.dumps(event_data)}"
|
||||
)
|
||||
|
||||
# Extract event type from Telnyx envelope
|
||||
data = event_data.get("data", {})
|
||||
event_type = data.get("event_type", "")
|
||||
|
||||
# Skip streaming events — they're informational only
|
||||
if event_type in ("streaming.started", "streaming.stopped"):
|
||||
logger.debug(f"[run {workflow_run_id}] Telnyx streaming event: {event_type}")
|
||||
return {"status": "success"}
|
||||
|
||||
# Get workflow run and provider
|
||||
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 Telnyx event")
|
||||
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)
|
||||
|
||||
# Parse the callback data into generic format
|
||||
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": "success"}
|
||||
|
||||
|
||||
@router.post("/vonage/events/{workflow_run_id}")
|
||||
async def handle_vonage_events(
|
||||
request: Request,
|
||||
|
|
@ -1355,6 +1418,7 @@ async def handle_inbound_telephony(
|
|||
x_vobiz_signature: Optional[str] = Header(None),
|
||||
x_vobiz_timestamp: Optional[str] = Header(None),
|
||||
x_cx_apikey: Optional[str] = Header(None),
|
||||
telnyx_signature: Optional[str] = Header(None, alias="telnyx-signature-ed25519"),
|
||||
):
|
||||
"""Handle inbound telephony calls from any supported provider with common processing"""
|
||||
logger.info(f"Inbound call received for workflow_id: {workflow_id}")
|
||||
|
|
@ -1409,6 +1473,7 @@ async def handle_inbound_telephony(
|
|||
x_vobiz_signature,
|
||||
x_vobiz_timestamp,
|
||||
x_cx_apikey,
|
||||
telnyx_signature,
|
||||
)
|
||||
|
||||
if not is_valid:
|
||||
|
|
@ -1436,8 +1501,38 @@ async def handle_inbound_telephony(
|
|||
)
|
||||
|
||||
# Generate response URLs
|
||||
_, wss_backend_endpoint = await get_backend_endpoints()
|
||||
backend_endpoint, wss_backend_endpoint = await get_backend_endpoints()
|
||||
websocket_url = f"{wss_backend_endpoint}/api/v1/telephony/ws/{workflow_id}/{workflow_context['user_id']}/{workflow_run_id}"
|
||||
|
||||
# Telnyx requires answering the call via REST API (not via webhook response)
|
||||
if provider_class.PROVIDER_NAME == "telnyx":
|
||||
# Get provider instance with credentials if not already loaded
|
||||
if not provider_instance:
|
||||
provider_instance = await get_telephony_provider(
|
||||
workflow_context["organization_id"]
|
||||
)
|
||||
|
||||
events_url = (
|
||||
f"{backend_endpoint}/api/v1/telephony/telnyx/events/{workflow_run_id}"
|
||||
)
|
||||
|
||||
try:
|
||||
await provider_instance.answer_and_stream(
|
||||
call_control_id=normalized_data.call_id,
|
||||
stream_url=websocket_url,
|
||||
webhook_url=events_url,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to answer Telnyx inbound call: {e}")
|
||||
return provider_class.generate_error_response(
|
||||
"ANSWER_FAILED", "Failed to answer call"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Answered Telnyx inbound call {normalized_data.call_id} for workflow_run {workflow_run_id}"
|
||||
)
|
||||
return {"status": "ok"}
|
||||
|
||||
response = await provider_class.generate_inbound_response(
|
||||
websocket_url, workflow_run_id
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue