feat: add vici dial controls from UI (#563)

* ViciDial Working

* feat(telephony): add FreeSWITCH provider to upstream-PBX seam

Generalize the upstream-PBX capture and control paths to dispatch by
provider. FreeSWITCH bridges in with X-PBX-* headers and is driven over
the Event Socket Library (uuid_kill / uuid_transfer) by the channel UUID,
alongside the existing VICIdial ra_call_control path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* add vici specific configs

* feat(telephony): env-based VICIdial config + address3 post-call routing

Move VICIdial agent/non-agent API and FreeSWITCH ESL connection settings
out of hardcoded POC values into environment variables so the same image
works against a PBX on another server.

Add VICIdial update_lead forwarding: X-VICI-UPDATE-LEAD_* extracted
variables are mapped to lead columns and pushed via the non-agent API
before a transfer, with reserved API-control params dropped.

Add hardcoded address3 disposition routing (Y/N -> in-group transfer,
else hang up) and a synchronous final variable extraction so the
transfer path sees the freshest conversation state. Skip upstream_pbx
capture on non-PJSIP channels to avoid Asterisk 500s.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: run formatter

* feat: make vici configurable from UI

* chore: clean up PR

* fix: incorporate review comments

* chore: incorporate review comments

* chore: generate client

* chore: incporporate review comments

* chore: incorporate review comments

---------

Co-authored-by: Dograh POC <payment@dograh.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Abhishek 2026-07-20 21:57:34 +05:30 committed by GitHub
parent 2f7b47a1b4
commit d8cd34e8d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
51 changed files with 2886 additions and 102 deletions

View file

@ -30,8 +30,10 @@ from api.services.call_concurrency import (
CallConcurrencyLimitError,
call_concurrency,
)
from api.services.organization_preferences import external_pbx_integrations_enabled
from api.services.quota_service import authorize_workflow_run_start
from api.services.telephony.call_transfer_manager import get_call_transfer_manager
from api.services.telephony.providers.ari.external_pbx import create_adapter
from api.services.telephony.transfer_event_protocol import (
TransferEvent,
TransferEventType,
@ -56,6 +58,7 @@ class ARIConnection:
app_name: str,
app_password: str,
ws_client_name: str = "",
external_pbx_config: Optional[dict] = None,
):
self.organization_id = organization_id
self.telephony_configuration_id = telephony_configuration_id
@ -63,6 +66,8 @@ class ARIConnection:
self.app_name = app_name
self.app_password = app_password
self.ws_client_name = ws_client_name
self.external_pbx_config = external_pbx_config
self.external_pbx_adapter = create_adapter(external_pbx_config)
self._ws: Optional[websockets.ClientConnection] = None
self._task: Optional[asyncio.Task] = None
@ -287,7 +292,7 @@ class ARIConnection:
channel_state = channel.get("state", "unknown")
# Log all events for each channel
logger.debug(
logger.trace(
f"[ARI EVENT org={self.organization_id}] {event_type}: channel={channel_id}, state={channel_state}"
)
@ -413,7 +418,7 @@ class ARIConnection:
)
else:
logger.debug(
logger.trace(
f"[ARI org={self.organization_id}] Event: {event_type} "
f"channel={channel_id}"
)
@ -428,9 +433,9 @@ class ARIConnection:
async with session.request(method, url, auth=auth, **kwargs) as response:
response_text = await response.text()
if response.status not in (200, 201, 204):
logger.error(
logger.warning(
f"[ARI org={self.organization_id}] REST API error: "
f"{method} {path} -> {response.status}: {response_text}"
f"{method} {path} {kwargs} -> {response.status}: {response_text}"
)
return {}
if response_text:
@ -444,6 +449,41 @@ class ARIConnection:
logger.info(f"[ARI org={self.organization_id}] Answered channel {channel_id}")
return True
async def _get_channel_var(self, channel_id: str, variable: str) -> str:
"""Read a channel variable/function via ARI. Returns '' if unset."""
result = await self._ari_request(
"GET", f"/channels/{channel_id}/variable", params={"variable": variable}
)
return (result or {}).get("value", "") or ""
async def _capture_external_pbx_call(
self, channel_id: str, channel_name: str = ""
) -> Optional[dict]:
"""Capture adapter-defined identity from inbound SIP headers."""
if self.external_pbx_adapter is None:
return None
# PJSIP_HEADER() only works on a PJSIP channel; on any other technology
# (Local, WebSocket, etc.) Asterisk returns a 500 ("This function
# requires a PJSIP channel"). Non-PJSIP legs carry no SIP headers to
# capture anyway, so skip the reads quietly instead of spamming errors.
if not channel_name.startswith("PJSIP/"):
logger.debug(
f"[ARI org={self.organization_id}] Skipping external PBX capture "
f"for non-PJSIP channel {channel_id} ({channel_name or 'unknown'})"
)
return None
async def read_header(name: str) -> str:
return await self._get_channel_var(channel_id, f"PJSIP_HEADER(read,{name})")
identity = await self.external_pbx_adapter.capture_call_identity(read_header)
if identity:
logger.info(
f"[ARI org={self.organization_id}] Captured "
f"{self.external_pbx_adapter.type} call identity for channel {channel_id} identity: {identity}"
)
return identity
async def _create_external_media(
self,
workflow_id: str,
@ -593,6 +633,10 @@ class ARIConnection:
# 3. Create workflow run
call_id = channel_id
# Capture the configured external PBX identity from SIP headers.
external_pbx_call = await self._capture_external_pbx_call(
channel_id, channel.get("name", "")
)
workflow_run = await db_client.create_workflow_run(
name=f"ARI Inbound {caller_number}",
workflow_id=inbound_workflow_id,
@ -605,6 +649,7 @@ class ARIConnection:
"direction": "inbound",
"provider": "ari",
"telephony_configuration_id": self.telephony_configuration_id,
"external_pbx_call": external_pbx_call,
},
gathered_context={
"call_id": call_id,
@ -1151,6 +1196,7 @@ class ARIManager:
app_name = config["app_name"]
app_password = config["app_password"]
ws_client_name = config["ws_client_name"]
external_pbx_config = config.get("external_pbx")
conn = ARIConnection(
org_id,
@ -1159,6 +1205,7 @@ class ARIManager:
app_name,
app_password,
ws_client_name,
external_pbx_config,
)
key = conn.connection_key
@ -1183,6 +1230,7 @@ class ARIManager:
or existing.app_name != app_name
or existing.app_password != app_password
or existing.ws_client_name != ws_client_name
or existing.external_pbx_config != external_pbx_config
):
logger.info(
f"[ARI Manager] Config {telephony_configuration_id} "
@ -1220,6 +1268,11 @@ class ARIManager:
app_name = credentials.get("app_name")
app_password = credentials.get("app_password")
ws_client_name = credentials.get("ws_client_name", "")
external_pbx = credentials.get("external_pbx")
if external_pbx and not await external_pbx_integrations_enabled(
row.organization_id
):
external_pbx = None
if not all([ari_endpoint, app_name, app_password]):
logger.warning(
@ -1242,6 +1295,7 @@ class ARIManager:
"app_name": app_name,
"app_password": app_password,
"ws_client_name": ws_client_name,
"external_pbx": external_pbx,
}
)