feat: add asterisk ARI websocket interface (#159)

* chore: remove old files

* feat: ari outbound dialing

* feat: add websocket configuration for ARI

* feat: handling inbound calls

* delete ext channel from redis on stasis end

* fix: add lock in workflow run update, refactor _handle_stasis_start

* chore: update submodule

---------

Co-authored-by: Sabiha Khan <sabihak89@gmail.com>
This commit is contained in:
Abhishek 2026-02-17 19:32:03 +05:30 committed by GitHub
parent ee4a874e54
commit 7552b6c819
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 2076 additions and 4172 deletions

View file

@ -8,6 +8,8 @@ from api.db import db_client
from api.db.models import UserModel
from api.enums import OrganizationConfigurationKey
from api.schemas.telephony_config import (
ARIConfigurationRequest,
ARIConfigurationResponse,
CloudonixConfigurationRequest,
CloudonixConfigurationResponse,
TelephonyConfigurationResponse,
@ -29,6 +31,7 @@ PROVIDER_MASKED_FIELDS = {
"vonage": ["private_key", "api_key", "api_secret"],
"vobiz": ["auth_id", "auth_token"],
"cloudonix": ["bearer_token"],
"ari": ["app_password"],
}
@ -125,6 +128,26 @@ async def get_telephony_configuration(user: UserModel = Depends(get_user)):
),
vobiz=None,
)
elif stored_provider == "ari":
ari_endpoint = config.value.get("ari_endpoint", "")
app_name = config.value.get("app_name", "")
app_password = config.value.get("app_password", "")
ws_client_name = config.value.get("ws_client_name", "")
from_numbers = config.value.get("from_numbers", [])
inbound_workflow_id = config.value.get("inbound_workflow_id")
return TelephonyConfigurationResponse(
ari=ARIConfigurationResponse(
provider="ari",
ari_endpoint=ari_endpoint,
app_name=app_name,
app_password=mask_key(app_password) if app_password else "",
ws_client_name=ws_client_name,
inbound_workflow_id=inbound_workflow_id,
from_numbers=from_numbers,
),
)
else:
return TelephonyConfigurationResponse()
@ -136,6 +159,7 @@ async def save_telephony_configuration(
VonageConfigurationRequest,
VobizConfigurationRequest,
CloudonixConfigurationRequest,
ARIConfigurationRequest,
],
user: UserModel = Depends(get_user),
):
@ -180,6 +204,16 @@ async def save_telephony_configuration(
"domain_id": request.domain_id,
"from_numbers": request.from_numbers,
}
elif request.provider == "ari":
config_value = {
"provider": "ari",
"ari_endpoint": request.ari_endpoint,
"app_name": request.app_name,
"app_password": request.app_password,
"ws_client_name": request.ws_client_name,
"inbound_workflow_id": request.inbound_workflow_id,
"from_numbers": request.from_numbers,
}
else:
raise HTTPException(
status_code=400, detail=f"Unsupported provider: {request.provider}"

View file

@ -1,45 +0,0 @@
import random
from loguru import logger
from api.db import db_client
from api.enums import WorkflowRunMode
from api.services.pipecat.run_pipeline import run_pipeline_ari_stasis
from api.services.telephony.stasis_rtp_connection import StasisRTPConnection
from pipecat.utils.run_context import set_current_run_id
async def on_stasis_call(call: StasisRTPConnection, call_context_vars: dict):
workflow_id = call_context_vars.get("workflow_id") or call_context_vars.get(
"campaign_id"
)
user_id = call_context_vars.get("user_id")
assert workflow_id is not None
assert user_id is not None
try:
workflow_id = int(workflow_id)
user_id = int(user_id)
except ValueError:
logger.error(f"Invalid workflow ID or user ID: {workflow_id} or {user_id}")
return
workflow_run_name = f"WR-ARI-{random.randint(1000, 9999)}"
workflow_run = await db_client.create_workflow_run(
workflow_run_name, workflow_id, WorkflowRunMode.STASIS.value, user_id
)
set_current_run_id(workflow_run.id)
# Store the workflow_run_id in the connection for later use
call.workflow_run_id = workflow_run.id
# Connect channelID with Workflow run ID in logs
logger.info(
f"channelID: {call.caller_channel_id} run_id: {workflow_run.id} "
f"Received call for workflow ID {workflow_id}, user ID {user_id}"
)
await run_pipeline_ari_stasis(
call, workflow_id, workflow_run.id, user_id, call_context_vars
)

View file

@ -523,13 +523,47 @@ async def handle_ncco_webhook(
return json.loads(response_content)
@router.websocket("/ws/ari")
async def websocket_ari_endpoint(websocket: WebSocket):
"""WebSocket endpoint for ARI chan_websocket external media.
Asterisk connects here via chan_websocket. Routing params are passed as
query params (appended by the v() dial string option in externalMedia).
"""
workflow_id = websocket.query_params.get("workflow_id")
user_id = websocket.query_params.get("user_id")
workflow_run_id = websocket.query_params.get("workflow_run_id")
if not workflow_id or not user_id or not workflow_run_id:
logger.error(
f"ARI WebSocket missing query params: "
f"workflow_id={workflow_id}, user_id={user_id}, workflow_run_id={workflow_run_id}"
)
await websocket.close(code=4400, reason="Missing required query params")
return
# Accept with "media" subprotocol — chan_websocket sends
# Sec-WebSocket-Protocol: media and requires it echoed back.
await websocket.accept(subprotocol="media")
await _handle_telephony_websocket(
websocket, int(workflow_id), int(user_id), int(workflow_run_id)
)
@router.websocket("/ws/{workflow_id}/{user_id}/{workflow_run_id}")
async def websocket_endpoint(
websocket: WebSocket, workflow_id: int, user_id: int, workflow_run_id: int
):
"""WebSocket endpoint for real-time call handling - routes to provider-specific handlers."""
await websocket.accept()
await _handle_telephony_websocket(websocket, workflow_id, user_id, workflow_run_id)
async def _handle_telephony_websocket(
websocket: WebSocket, workflow_id: int, user_id: int, workflow_run_id: int
):
"""Shared WebSocket handler logic (connection already accepted)."""
try:
# Set the run context
set_current_run_id(workflow_run_id)