feat: make vici configurable from UI

This commit is contained in:
Abhishek Kumar 2026-07-20 19:40:28 +05:30
parent cf80b20be1
commit 8628e576ca
46 changed files with 2277 additions and 618 deletions

View file

@ -0,0 +1,206 @@
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"])

View file

@ -0,0 +1,117 @@
from unittest.mock import AsyncMock
import pytest
from fastapi import HTTPException
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_allows_unchanged_telephony_configuration(monkeypatch):
monkeypatch.setattr(
organization,
"external_pbx_integrations_enabled",
AsyncMock(return_value=False),
)
existing = _credentials()
await organization._enforce_external_pbx_feature(
7,
"ari",
_credentials(),
existing_credentials=existing,
)
@pytest.mark.asyncio
async def test_disabled_feature_rejects_removing_telephony_configuration(monkeypatch):
monkeypatch.setattr(
organization,
"external_pbx_integrations_enabled",
AsyncMock(return_value=False),
)
with pytest.raises(HTTPException) as exc_info:
await organization._enforce_external_pbx_feature(
7,
"ari",
{"external_pbx": None},
existing_credentials=_credentials(),
)
assert exc_info.value.status_code == 403
@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

@ -42,3 +42,39 @@ 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_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

@ -59,3 +59,22 @@ 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].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"}
]
)