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

@ -1,4 +1,5 @@
from typing import List, Optional
from copy import deepcopy
from typing import Any, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from loguru import logger
@ -74,6 +75,7 @@ from api.services.organization_context import (
get_organization_context,
)
from api.services.organization_preferences import (
external_pbx_integrations_enabled,
get_organization_preferences,
upsert_organization_preferences,
)
@ -101,14 +103,24 @@ def _sensitive_fields(provider_name: str) -> List[str]:
def _mask_sensitive(provider_name: str, value: dict) -> dict:
"""Return a copy of ``value`` with sensitive fields masked for display."""
out = dict(value)
out = deepcopy(value)
for field_name in _sensitive_fields(provider_name):
v = out.get(field_name)
v = _get_nested_field(out, field_name)
if v:
out[field_name] = mask_key(v)
_set_nested_field(out, field_name, mask_key(str(v)))
return out
class TelephonyProviderUIOption(BaseModel):
value: str
label: str
class TelephonyProviderUICondition(BaseModel):
field: str
equals: Any
class TelephonyProviderUIField(BaseModel):
"""One form field on a telephony provider's configuration UI."""
@ -119,6 +131,9 @@ class TelephonyProviderUIField(BaseModel):
sensitive: bool
description: Optional[str] = None
placeholder: Optional[str] = None
options: Optional[List[TelephonyProviderUIOption]] = None
visible_when: Optional[TelephonyProviderUICondition] = None
section: Optional[str] = None
class TelephonyProviderMetadata(BaseModel):
@ -184,6 +199,9 @@ async def get_telephony_providers_metadata(user: UserModel = Depends(get_user)):
if not user.selected_organization_id:
raise HTTPException(status_code=400, detail="No organization selected")
external_pbx_enabled = await external_pbx_integrations_enabled(
user.selected_organization_id
)
providers = []
for spec in telephony_registry.all_specs():
if spec.ui_metadata is None:
@ -201,8 +219,30 @@ async def get_telephony_providers_metadata(user: UserModel = Depends(get_user)):
sensitive=f.sensitive,
description=f.description,
placeholder=f.placeholder,
options=(
[
{"value": option.value, "label": option.label}
for option in f.options
]
if f.options
else None
),
visible_when=(
{
"field": f.visible_when.field,
"equals": f.visible_when.equals,
}
if f.visible_when
else None
),
section=f.section,
)
for f in spec.ui_metadata.fields
if not f.feature_gate
or (
f.feature_gate == "external_pbx_integrations"
and external_pbx_enabled
)
],
docs_url=spec.ui_metadata.docs_url,
)
@ -496,24 +536,34 @@ async def get_model_configuration_preferences_legacy(
return await get_preferences(user=user)
@router.put(
"/model-configurations/preferences",
response_model=OrganizationPreferences,
include_in_schema=False,
)
async def save_model_configuration_preferences_legacy(
request: OrganizationPreferences,
user: UserModel = Depends(get_user_with_selected_organization),
):
return await save_preferences(request=request, user=user)
def preserve_masked_fields(provider: str, request_dict: dict, existing: dict):
"""If the client re-submitted a masked sensitive field, restore the original."""
for field_name in _sensitive_fields(provider):
v = request_dict.get(field_name)
if v and is_mask_of(v, existing.get(field_name, "")):
request_dict[field_name] = existing[field_name]
v = _get_nested_field(request_dict, field_name)
existing_value = _get_nested_field(existing, field_name)
if v and is_mask_of(v, existing_value or ""):
_set_nested_field(request_dict, field_name, existing_value)
def _get_nested_field(value: dict, dotted_path: str):
current = value
for part in dotted_path.split("."):
if not isinstance(current, dict):
return None
current = current.get(part)
return current
def _set_nested_field(value: dict, dotted_path: str, field_value) -> None:
current = value
parts = dotted_path.split(".")
for part in parts[:-1]:
child = current.get(part)
if not isinstance(child, dict):
child = {}
current[part] = child
current = child
current[parts[-1]] = field_value
def _credentials_from_payload(config: TelephonyConfigRequest) -> dict:

View file

@ -36,6 +36,7 @@ from api.services.tool_management import (
build_tool_response,
create_tool_for_user,
refresh_mcp_tool_for_user,
validate_external_pbx_tool_definition,
validate_tool_credential_references,
)
from api.services.tool_management import (
@ -375,6 +376,18 @@ async def update_tool(
if request.definition:
definition = request.definition.model_dump()
try:
existing_tool = await db_client.get_tool_by_uuid(
tool_uuid,
user.selected_organization_id,
include_archived=True,
)
await validate_external_pbx_tool_definition(
definition,
organization_id=user.selected_organization_id,
existing_definition=(
existing_tool.definition if existing_tool else None
),
)
await validate_tool_credential_references(
definition,
organization_id=user.selected_organization_id,

View file

@ -44,6 +44,11 @@ from api.services.mps_service_key_client import mps_service_key_client
from api.services.posthog_client import capture_event
from api.services.reports import generate_workflow_report_csv
from api.services.storage import storage_fs
from api.services.workflow.configuration_policy import (
ExternalPBXConfigurationDisabledError,
WorkflowConfigurationNotFoundError,
apply_external_pbx_mapping_policy,
)
from api.services.workflow.dto import ReactFlowDTO, sanitize_workflow_definition
from api.services.workflow.duplicate import duplicate_workflow
from api.services.workflow.errors import ItemKind, WorkflowError
@ -1050,6 +1055,16 @@ async def update_workflow(
if request.workflow_configurations is not None
else None
)
try:
workflow_configurations = await apply_external_pbx_mapping_policy(
workflow_configurations,
workflow_id=workflow_id,
organization_id=user.selected_organization_id,
)
except WorkflowConfigurationNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except ExternalPBXConfigurationDisabledError as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
if workflow_configurations and workflow_configurations.get(
WORKFLOW_MODEL_CONFIGURATION_V2_OVERRIDE_KEY
):