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

@ -0,0 +1,218 @@
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
import redis.asyncio as aioredis
from api.db import db_client
from api.services.telephony.external_pbx import resolve_external_pbx_field_mappings
from api.services.telephony.providers.ari.external_pbx import (
ExternalPBXResult,
create_adapter,
)
from api.services.telephony.providers.ari.strategies import ARIHangupStrategy
from api.services.workflow.tools import transfer_resolver
def _vicidial_config() -> dict:
return {
"type": "vicidial",
"agent_api": {
"url": "https://vici.example.com/agc/api.php",
"username": "agent-api-user",
"password": "secret",
"source": "dograh",
},
"non_agent_api": {
"url": "https://vici.example.com/vicidial/non_agent_api.php",
"username": "lead-api-user",
"password": "secret",
"source": "dograh",
},
}
@pytest.mark.asyncio
async def test_vicidial_adapter_captures_call_identity_from_headers():
adapter = create_adapter(_vicidial_config())
headers = {
"X-VICIDIAL-callerid": "M123",
"X-VICIDIAL-user": "remote-agent",
"X-VICIDIAL-lead_id": "42",
"X-VICIDIAL-campaign_id": "campaign",
"X-VICIDIAL-ingroup_id": "source-group",
}
async def read_header(name: str) -> str:
return headers.get(name, "")
identity = await adapter.capture_call_identity(read_header)
assert identity == {
"type": "vicidial",
"callerid": "M123",
"agent_user": "remote-agent",
"lead_id": "42",
"campaign_id": "campaign",
"ingroup_id": "source-group",
}
@pytest.mark.asyncio
async def test_vicidial_adapter_resolves_source_ingroup(monkeypatch):
adapter = create_adapter(_vicidial_config())
call_control = AsyncMock(
return_value=ExternalPBXResult(True, "ingrouptransfer", "ok")
)
monkeypatch.setattr(adapter, "_agent_call_control", call_control)
result = await adapter.transfer(
{"callerid": "M123", "agent_user": "agent", "ingroup_id": "support"},
"source",
)
assert result.ok is True
call_control.assert_awaited_once_with(
{"callerid": "M123", "agent_user": "agent", "ingroup_id": "support"},
"INGROUPTRANSFER",
ingroup_choices="support",
)
def test_field_mapping_reads_extracted_variables_and_skips_empty_values():
fields = resolve_external_pbx_field_mappings(
{
"extracted_variables": {"qualified": "yes", "empty": " "},
"call_disposition": "completed",
},
[
{"context_path": "qualified", "destination_field": "address3"},
{"context_path": "empty", "destination_field": "comments"},
{
"context_path": "call_disposition",
"destination_field": "status_notes",
},
],
)
assert fields == {"address3": "yes", "status_notes": "completed"}
@pytest.mark.asyncio
async def test_context_mapping_resolves_ingroup_destination(monkeypatch):
monkeypatch.setattr(
transfer_resolver,
"external_pbx_integrations_enabled",
AsyncMock(return_value=True),
)
resolved = await transfer_resolver.resolve_transfer_config(
tool=SimpleNamespace(tool_uuid="tool-1"),
config={
"destination_source": "context_mapping",
"context_mapping": {
"context_path": "qualified",
"routes": [
{"context_value": "YES", "destination": "sales"},
],
},
},
arguments={},
call_context_vars={},
gathered_context_vars={"extracted_variables": {"qualified": " yes "}},
organization_id=7,
workflow_run_id=11,
)
assert resolved.destination == "sales"
assert resolved.source == "context_mapping"
@pytest.mark.asyncio
async def test_context_mapping_is_disabled_at_runtime(monkeypatch):
monkeypatch.setattr(
transfer_resolver,
"external_pbx_integrations_enabled",
AsyncMock(return_value=False),
)
with pytest.raises(
transfer_resolver.TransferResolutionError,
match="External PBX integrations are disabled",
):
await transfer_resolver.resolve_transfer_config(
tool=SimpleNamespace(tool_uuid="tool-1"),
config={
"destination_source": "context_mapping",
"context_mapping": {
"context_path": "qualified",
"routes": [
{"context_value": "yes", "destination": "sales"},
],
},
},
arguments={},
call_context_vars={},
gathered_context_vars={"qualified": "yes"},
organization_id=7,
workflow_run_id=11,
)
@pytest.mark.asyncio
async def test_hangup_strategy_updates_lead_before_customer_leg(monkeypatch):
redis = AsyncMock()
redis.get.return_value = "11"
monkeypatch.setattr(aioredis, "from_url", lambda *args, **kwargs: redis)
run = SimpleNamespace(
initial_context={
"external_pbx_call": {
"type": "vicidial",
"callerid": "M123",
"agent_user": "agent",
"lead_id": "42",
}
},
gathered_context={"extracted_variables": {"qualified": "yes"}},
workflow=SimpleNamespace(organization_id=7),
)
monkeypatch.setattr(
db_client, "get_workflow_run_by_id", AsyncMock(return_value=run)
)
monkeypatch.setattr(
db_client,
"get_workflow_run_configurations",
AsyncMock(
return_value={
"external_pbx_field_mappings": [
{"context_path": "qualified", "destination_field": "address3"}
]
}
),
)
adapter = SimpleNamespace(
type="vicidial",
update_fields=AsyncMock(
return_value=ExternalPBXResult(True, "update_lead", "ok")
),
hangup=AsyncMock(return_value=ExternalPBXResult(True, "hangup", "ok")),
)
await ARIHangupStrategy(adapter)._terminate_external_pbx_if_any("channel-1")
adapter.update_fields.assert_awaited_once_with(
run.initial_context["external_pbx_call"], {"address3": "yes"}
)
adapter.hangup.assert_awaited_once_with(run.initial_context["external_pbx_call"])
redis.aclose.assert_awaited_once_with()
@pytest.mark.asyncio
async def test_hangup_strategy_closes_redis_when_channel_has_no_run(monkeypatch):
redis = AsyncMock()
redis.get.return_value = None
monkeypatch.setattr(aioredis, "from_url", lambda *args, **kwargs: redis)
await ARIHangupStrategy()._terminate_external_pbx_if_any("missing-channel")
redis.aclose.assert_awaited_once_with()

View file

@ -0,0 +1,80 @@
from unittest.mock import AsyncMock
import pytest
from api.routes import organization
from api.services import tool_management
def _credentials(password: str = "agent-secret") -> dict:
return {
"ari_endpoint": "https://asterisk.example.com",
"app_name": "dograh",
"app_password": "ari-secret",
"external_pbx": {
"type": "vicidial",
"agent_api": {
"url": "https://vici.example.com/agc/api.php",
"username": "agent-user",
"password": password,
},
},
}
def test_nested_external_pbx_secrets_are_masked_without_mutating_source():
credentials = _credentials()
masked = organization._mask_sensitive("ari", credentials)
assert masked["app_password"] != "ari-secret"
assert masked["external_pbx"]["agent_api"]["password"] != "agent-secret"
assert credentials["external_pbx"]["agent_api"]["password"] == "agent-secret"
def test_nested_masked_external_pbx_secrets_are_restored_on_update():
existing = _credentials()
request = organization._mask_sensitive("ari", existing)
organization.preserve_masked_fields("ari", request, existing)
assert request["app_password"] == "ari-secret"
assert request["external_pbx"]["agent_api"]["password"] == "agent-secret"
@pytest.mark.asyncio
async def test_disabled_feature_preserves_existing_tool_mapping(monkeypatch):
monkeypatch.setattr(
tool_management,
"external_pbx_integrations_enabled",
AsyncMock(return_value=False),
)
definition = {
"type": "transfer_call",
"config": {
"destination_source": "context_mapping",
"context_mapping": {
"context_path": "qualified",
"routes": [{"context_value": "yes", "destination": "sales"}],
},
},
}
await tool_management.validate_external_pbx_tool_definition(
definition,
organization_id=7,
existing_definition=definition,
)
changed = {
"type": "transfer_call",
"config": {"destination_source": "static", "destination": "+15555550100"},
}
with pytest.raises(tool_management.ToolManagementError) as exc_info:
await tool_management.validate_external_pbx_tool_definition(
changed,
organization_id=7,
existing_definition=definition,
)
assert exc_info.value.status_code == 403

View file

@ -1818,7 +1818,6 @@ class TestCustomToolManagerUnit:
assert [
call.args[0] for call in mock_engine.set_mute_pipeline.call_args_list
] == [
True,
True,
False,
]
@ -1830,8 +1829,8 @@ class TestCustomToolManagerUnit:
assert "I will connect you with our Texas partner now." in spoken_texts
@pytest.mark.asyncio
async def test_transfer_call_resolver_failure_resets_queued_speech_mute(self):
"""Resolver failure after a wait message should not leave user input muted."""
async def test_transfer_call_resolver_failure_does_not_toggle_pipeline_mute(self):
"""Function-call muting covers resolver execution without pipeline state."""
from api.services.workflow.pipecat_engine_custom_tools import CustomToolManager
mock_engine = Mock()
@ -1843,7 +1842,6 @@ class TestCustomToolManagerUnit:
mock_engine.task = SimpleNamespace(queue_frame=AsyncMock())
mock_engine.set_mute_pipeline = Mock()
mock_engine.end_call_with_reason = AsyncMock()
mock_engine._queued_speech_mute_state = "idle"
manager = CustomToolManager(mock_engine)
tool = MockToolModel(
@ -1906,13 +1904,7 @@ class TestCustomToolManagerUnit:
assert result_received["status"] == "transfer_failed"
assert result_received["reason"] == "no_destination"
assert mock_engine._queued_speech_mute_state == "idle"
assert [
call.args[0] for call in mock_engine.set_mute_pipeline.call_args_list
] == [
True,
False,
]
mock_engine.set_mute_pipeline.assert_not_called()
@pytest.mark.asyncio
async def test_transfer_call_propagates_provider_destination_error(self):

View file

@ -42,3 +42,50 @@ def test_transfer_call_dynamic_accepts_resolver_without_destination():
assert config.destination_source == "dynamic"
assert config.destination == ""
assert config.resolver is not None
def test_transfer_call_context_mapping_requires_mapping():
with pytest.raises(ValueError, match="context_mapping is required"):
TransferCallConfig(destination_source="context_mapping")
def test_transfer_call_context_mapping_accepts_unique_routes():
config = TransferCallConfig(
destination_source="context_mapping",
context_mapping={
"context_path": "qualified",
"routes": [
{"context_value": "yes", "destination": "sales"},
{"context_value": "no", "destination": "support"},
],
"fallback_destination": "source",
},
)
assert config.context_mapping is not None
assert config.context_mapping.routes[0].destination == "sales"
def test_transfer_call_context_mapping_rejects_blank_context_path():
with pytest.raises(ValueError, match="context path cannot be blank"):
TransferCallConfig(
destination_source="context_mapping",
context_mapping={
"context_path": " ",
"routes": [{"context_value": "yes", "destination": "sales"}],
},
)
def test_transfer_call_context_mapping_rejects_duplicate_values_case_insensitively():
with pytest.raises(ValueError, match="must be unique"):
TransferCallConfig(
destination_source="context_mapping",
context_mapping={
"context_path": "qualified",
"routes": [
{"context_value": "Yes", "destination": "sales"},
{"context_value": "yes", "destination": "support"},
],
},
)

View file

@ -0,0 +1,95 @@
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from api.services.workflow import configuration_policy
def _stored_workflow(mappings: list[dict]):
return SimpleNamespace(
released_definition=SimpleNamespace(
workflow_configurations={"external_pbx_field_mappings": mappings}
)
)
@pytest.mark.asyncio
async def test_disabled_external_pbx_policy_preserves_hidden_mappings(monkeypatch):
mappings = [{"context_path": "qualified", "destination_field": "address3"}]
get_workflow = AsyncMock(return_value=_stored_workflow(mappings))
get_draft = AsyncMock(return_value=None)
monkeypatch.setattr(
configuration_policy,
"external_pbx_integrations_enabled",
AsyncMock(return_value=False),
)
monkeypatch.setattr(configuration_policy.db_client, "get_workflow", get_workflow)
monkeypatch.setattr(configuration_policy.db_client, "get_draft_version", get_draft)
incoming = {"max_call_duration": 600}
prepared = await configuration_policy.apply_external_pbx_mapping_policy(
incoming,
workflow_id=12,
organization_id=7,
)
assert prepared == {
"max_call_duration": 600,
"external_pbx_field_mappings": mappings,
}
assert "external_pbx_field_mappings" not in incoming
get_workflow.assert_awaited_once_with(12, organization_id=7)
get_draft.assert_awaited_once_with(12)
@pytest.mark.asyncio
async def test_disabled_external_pbx_policy_rejects_mapping_changes(monkeypatch):
stored = [{"context_path": "qualified", "destination_field": "address3"}]
monkeypatch.setattr(
configuration_policy,
"external_pbx_integrations_enabled",
AsyncMock(return_value=False),
)
monkeypatch.setattr(
configuration_policy.db_client,
"get_workflow",
AsyncMock(return_value=_stored_workflow(stored)),
)
monkeypatch.setattr(
configuration_policy.db_client,
"get_draft_version",
AsyncMock(return_value=None),
)
with pytest.raises(configuration_policy.ExternalPBXConfigurationDisabledError):
await configuration_policy.apply_external_pbx_mapping_policy(
{
"external_pbx_field_mappings": [
{"context_path": "qualified", "destination_field": "comments"}
]
},
workflow_id=12,
organization_id=7,
)
@pytest.mark.asyncio
async def test_enabled_external_pbx_policy_does_not_load_workflow(monkeypatch):
get_workflow = AsyncMock()
monkeypatch.setattr(
configuration_policy,
"external_pbx_integrations_enabled",
AsyncMock(return_value=True),
)
monkeypatch.setattr(configuration_policy.db_client, "get_workflow", get_workflow)
incoming = {"external_pbx_field_mappings": []}
prepared = await configuration_policy.apply_external_pbx_mapping_policy(
incoming,
workflow_id=12,
organization_id=7,
)
assert prepared is incoming
get_workflow.assert_not_awaited()

View file

@ -59,3 +59,32 @@ def test_cap_stays_within_concurrency_stale_timeout():
from api.services.campaign.rate_limiter import rate_limiter
assert MAX_CALL_DURATION_SECONDS <= rate_limiter.stale_call_timeout
def test_external_pbx_field_mapping_is_validated():
config = WorkflowConfigurationDefaults(
external_pbx_field_mappings=[
{"context_path": " qualified ", "destination_field": " address3 "}
]
)
assert config.external_pbx_field_mappings[0].context_path == "qualified"
assert config.external_pbx_field_mappings[0].destination_field == "address3"
def test_external_pbx_field_mapping_rejects_blank_context_paths():
with pytest.raises(ValidationError, match="context_path"):
WorkflowConfigurationDefaults(
external_pbx_field_mappings=[
{"context_path": " ", "destination_field": "address3"}
]
)
def test_external_pbx_field_mapping_rejects_invalid_field_names():
with pytest.raises(ValidationError, match="destination_field"):
WorkflowConfigurationDefaults(
external_pbx_field_mappings=[
{"context_path": "qualified", "destination_field": "invalid-field"}
]
)