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

@ -257,6 +257,26 @@ class WorkflowRunClient(BaseDBClient):
)
return result.scalars().first()
async def get_workflow_run_configurations(
self, run_id: int, organization_id: int
) -> dict:
"""Load the immutable workflow configuration snapshot for one run."""
async with self.async_session() as session:
result = await session.execute(
select(WorkflowDefinitionModel.workflow_configurations)
.join(
WorkflowRunModel,
WorkflowRunModel.definition_id == WorkflowDefinitionModel.id,
)
.join(WorkflowModel, WorkflowRunModel.workflow_id == WorkflowModel.id)
.where(
WorkflowRunModel.id == run_id,
WorkflowModel.organization_id == organization_id,
)
)
return result.scalar_one_or_none() or {}
async def get_organization_id_by_workflow_run_id(
self, run_id: int | None
) -> int | None:

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
):

View file

@ -4,3 +4,4 @@ from pydantic import BaseModel
class OrganizationPreferences(BaseModel):
test_phone_number: str | None = None
timezone: str | None = None
external_pbx_integrations_enabled: bool = False

View file

@ -223,12 +223,70 @@ class HttpTransferResolverConfig(BaseModel):
return v
class ContextDestinationRoute(BaseModel):
"""Map one gathered-context value to an external-PBX destination."""
context_value: str = Field(min_length=1, max_length=255)
destination: str = Field(min_length=1, max_length=255)
@field_validator("context_value", "destination")
@classmethod
def strip_non_empty(cls, value: str) -> str:
stripped = value.strip()
if not stripped:
raise ValueError("mapping values cannot be blank")
return stripped
class ContextDestinationMappingConfig(BaseModel):
"""Resolve an external-PBX destination from gathered context."""
context_path: str = Field(
min_length=1,
max_length=255,
description=(
"Gathered-context path or extracted-variable name used for routing."
),
)
routes: List[ContextDestinationRoute] = Field(min_length=1, max_length=100)
fallback_destination: Optional[str] = Field(
default=None,
max_length=255,
description="Optional provider-native fallback destination.",
)
@field_validator("context_path")
@classmethod
def strip_context_path(cls, value: str) -> str:
stripped = value.strip()
if not stripped:
raise ValueError("context path cannot be blank")
return stripped
@field_validator("fallback_destination")
@classmethod
def normalize_fallback(cls, value: Optional[str]) -> Optional[str]:
if value is None:
return None
return value.strip() or None
@model_validator(mode="after")
def validate_unique_values(self):
values = [route.context_value.casefold() for route in self.routes]
if len(values) != len(set(values)):
raise ValueError("context mapping values must be unique")
return self
class TransferCallConfig(BaseModel):
"""Configuration for Transfer Call tools."""
destination_source: Literal["static", "dynamic"] = Field(
destination_source: Literal["static", "dynamic", "context_mapping"] = Field(
default="static",
description="Whether transfer destination is static/template or resolved by HTTP.",
description=(
"Whether the destination is static/template, resolved by HTTP, or "
"mapped from gathered context to an external-PBX destination."
),
)
destination: str = Field(
default="",
@ -263,6 +321,10 @@ class TransferCallConfig(BaseModel):
default=None,
description="Optional resolver that determines transfer routing at call time.",
)
context_mapping: Optional[ContextDestinationMappingConfig] = Field(
default=None,
description="Optional gathered-context to external-PBX destination mapping.",
)
@model_validator(mode="after")
def validate_destination_source_config(self):
@ -270,6 +332,14 @@ class TransferCallConfig(BaseModel):
raise ValueError(
"config.resolver is required when destination_source is dynamic"
)
if (
self.destination_source == "context_mapping"
and self.context_mapping is None
):
raise ValueError(
"config.context_mapping is required when destination_source is "
"context_mapping"
)
return self

View file

@ -1,6 +1,6 @@
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
DEFAULT_MAX_CALL_DURATION_SECONDS = 300
# Hard ceiling on configurable call duration. Must stay <= the concurrency
@ -16,6 +16,23 @@ DEFAULT_TURN_STOP_STRATEGY = "transcription"
DEFAULT_CONTEXT_COMPACTION_ENABLED = False
class ExternalPBXFieldMapping(BaseModel):
"""Map one gathered-context value to a provider-native field."""
context_path: str = Field(min_length=1, max_length=255)
destination_field: str = Field(pattern=r"^[A-Za-z][A-Za-z0-9_]{0,63}$")
@field_validator("context_path", mode="before")
@classmethod
def strip_context_path(cls, value: object) -> object:
return value.strip() if isinstance(value, str) else value
@field_validator("destination_field", mode="before")
@classmethod
def strip_destination_field(cls, value: object) -> object:
return value.strip() if isinstance(value, str) else value
class AmbientNoiseConfigurationDefaults(BaseModel):
model_config = ConfigDict(extra="allow")
@ -56,6 +73,10 @@ class WorkflowConfigurationDefaults(BaseModel):
)
dictionary: str = ""
context_compaction_enabled: bool = DEFAULT_CONTEXT_COMPACTION_ENABLED
external_pbx_field_mappings: list[ExternalPBXFieldMapping] = Field(
default_factory=list,
max_length=100,
)
def get_default_workflow_configurations() -> WorkflowConfigurationDefaults:

View file

@ -42,6 +42,16 @@ async def upsert_organization_preferences(
return preferences
async def external_pbx_integrations_enabled(
organization_id: int | None,
db=None,
) -> bool:
"""Return whether the organization opted into external-PBX integrations."""
preferences = await get_organization_preferences(organization_id, db=db)
return preferences.external_pbx_integrations_enabled
async def _get_configuration(db, organization_id: int, key: str):
row = db.get_configuration(organization_id, key)
if isawaitable(row):

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,
}
)

View file

@ -446,3 +446,18 @@ class TelephonyProvider(ABC):
True if provider supports call transfers, False otherwise
"""
pass
async def transfer_external_pbx_call(
self,
*,
identity: Dict[str, Any],
destination: str,
field_updates: Optional[Dict[str, str]] = None,
) -> Optional[Dict[str, Any]]:
"""Handle an external-PBX-owned customer leg when one is present.
Providers without an external PBX return ``None`` so the ordinary
telephony transfer path continues unchanged.
"""
return None

View file

@ -0,0 +1,43 @@
"""Provider-neutral helpers for external-PBX workflow mappings."""
from __future__ import annotations
from typing import Any, Iterable, Mapping
def _read_path(context: Mapping[str, Any], path: str) -> Any:
normalized = path.strip()
if normalized.startswith("gathered_context."):
normalized = normalized.removeprefix("gathered_context.")
current: Any = context
for part in normalized.split("."):
if not isinstance(current, Mapping):
return None
current = current.get(part)
if current is None and "." not in normalized:
extracted = context.get("extracted_variables")
if isinstance(extracted, Mapping):
current = extracted.get(normalized)
return current
def resolve_external_pbx_field_mappings(
gathered_context: Mapping[str, Any] | None,
mappings: Iterable[Mapping[str, Any]] | None,
) -> dict[str, str]:
"""Return provider field -> non-empty gathered-context value."""
context = gathered_context or {}
resolved: dict[str, str] = {}
for mapping in mappings or []:
context_path = str(mapping.get("context_path", "")).strip()
destination_field = str(mapping.get("destination_field", "")).strip()
if not context_path or not destination_field:
continue
value = _read_path(context, context_path)
if value is None:
continue
text = str(value).strip()
if text:
resolved[destination_field] = text
return resolved

View file

@ -4,8 +4,10 @@ from typing import Any, Dict
from api.services.telephony.registry import (
ProviderSpec,
ProviderUICondition,
ProviderUIField,
ProviderUIMetadata,
ProviderUIOption,
register,
)
@ -20,6 +22,7 @@ def _config_loader(value: Dict[str, Any]) -> Dict[str, Any]:
"ari_endpoint": value.get("ari_endpoint"),
"app_name": value.get("app_name"),
"app_password": value.get("app_password"),
"external_pbx": value.get("external_pbx"),
"from_numbers": value.get("from_numbers", []),
}
@ -58,6 +61,92 @@ _UI_METADATA = ProviderUIMetadata(
type="string-array",
description="SIP extensions/numbers for outbound calls",
),
ProviderUIField(
name="external_pbx.type",
label="External PBX Type",
type="select",
required=False,
description=(
"Enable PBX-specific call control for calls patched into Dograh "
"through this Asterisk configuration."
),
options=[ProviderUIOption(value="vicidial", label="VICIdial")],
section="External PBX",
feature_gate="external_pbx_integrations",
),
ProviderUIField(
name="external_pbx.agent_api.url",
label="Agent API URL",
type="text",
description="Full VICIdial remote-agent API URL, ending in agc/api.php",
placeholder="https://vici.example.com/agc/api.php",
visible_when=ProviderUICondition(
field="external_pbx.type", equals="vicidial"
),
section="External PBX",
feature_gate="external_pbx_integrations",
),
ProviderUIField(
name="external_pbx.agent_api.username",
label="Agent API User",
type="text",
sensitive=True,
visible_when=ProviderUICondition(
field="external_pbx.type", equals="vicidial"
),
section="External PBX",
feature_gate="external_pbx_integrations",
),
ProviderUIField(
name="external_pbx.agent_api.password",
label="Agent API Password",
type="password",
sensitive=True,
visible_when=ProviderUICondition(
field="external_pbx.type", equals="vicidial"
),
section="External PBX",
feature_gate="external_pbx_integrations",
),
ProviderUIField(
name="external_pbx.non_agent_api.url",
label="Non-Agent API URL",
type="text",
required=False,
description=(
"Optional. Required only when a workflow updates VICIdial lead fields."
),
placeholder="https://vici.example.com/vicidial/non_agent_api.php",
visible_when=ProviderUICondition(
field="external_pbx.type", equals="vicidial"
),
section="External PBX",
feature_gate="external_pbx_integrations",
),
ProviderUIField(
name="external_pbx.non_agent_api.username",
label="Non-Agent API User",
type="text",
required=False,
sensitive=True,
visible_when=ProviderUICondition(
field="external_pbx.type", equals="vicidial"
),
section="External PBX",
feature_gate="external_pbx_integrations",
),
ProviderUIField(
name="external_pbx.non_agent_api.password",
label="Non-Agent API Password",
type="password",
required=False,
sensitive=True,
visible_when=ProviderUICondition(
field="external_pbx.type", equals="vicidial"
),
section="External PBX",
feature_gate="external_pbx_integrations",
),
],
)

View file

@ -1,8 +1,69 @@
"""ARI (Asterisk REST Interface) telephony configuration schemas."""
from typing import List, Literal
from typing import List, Literal, Optional
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator, model_validator
class VicidialAgentAPIConfiguration(BaseModel):
"""VICIdial remote-agent call-control API configuration."""
url: str = Field(..., min_length=1, description="Full URL to agc/api.php")
username: str = Field(..., min_length=1, description="VICIdial agent API user")
password: str = Field(..., min_length=1, description="VICIdial agent API password")
source: str = Field(default="dograh", description="VICIdial API source tag")
@field_validator("url")
@classmethod
def validate_http_url(cls, value: str) -> str:
stripped = value.strip()
if not stripped.startswith(("http://", "https://")):
raise ValueError("VICIdial agent API URL must use http:// or https://")
return stripped
class VicidialNonAgentAPIConfiguration(BaseModel):
"""Optional VICIdial non-agent API configuration for lead updates."""
url: Optional[str] = Field(default=None, description="Full non_agent_api.php URL")
username: Optional[str] = Field(default=None, description="Non-agent API user")
password: Optional[str] = Field(default=None, description="Non-agent API password")
source: str = Field(default="dograh", description="Non-agent API source tag")
@field_validator("url")
@classmethod
def validate_http_url(cls, value: Optional[str]) -> Optional[str]:
if value is None:
return None
stripped = value.strip()
if stripped and not stripped.startswith(("http://", "https://")):
raise ValueError("VICIdial non-agent API URL must use http:// or https://")
return stripped or None
@model_validator(mode="after")
def validate_complete_credentials(self):
supplied = [self.url, self.username, self.password]
if any(supplied) and not all(supplied):
raise ValueError(
"VICIdial non-agent API URL, username, and password must be "
"configured together"
)
return self
class VicidialExternalPBXConfiguration(BaseModel):
"""External-PBX configuration used by the VICIdial strategy adapter."""
type: Literal["vicidial"] = Field(default="vicidial")
agent_api: VicidialAgentAPIConfiguration
non_agent_api: Optional[VicidialNonAgentAPIConfiguration] = None
timeout_seconds: int = Field(default=8, ge=1, le=30)
@model_validator(mode="after")
def drop_empty_non_agent_configuration(self):
if self.non_agent_api is not None and not self.non_agent_api.url:
self.non_agent_api = None
return self
class ARIConfigurationRequest(BaseModel):
@ -20,6 +81,10 @@ class ARIConfigurationRequest(BaseModel):
default="",
description="websocket_client.conf connection name for externalMedia (e.g., dograh_staging)",
)
external_pbx: Optional[VicidialExternalPBXConfiguration] = Field(
default=None,
description="Optional external PBX connected through this Asterisk instance",
)
from_numbers: List[str] = Field(
default_factory=list,
description="List of SIP extensions/numbers for outbound calls (optional)",
@ -34,4 +99,5 @@ class ARIConfigurationResponse(BaseModel):
app_name: str
app_password: str # Masked
ws_client_name: str = ""
external_pbx: Optional[VicidialExternalPBXConfiguration] = None
from_numbers: List[str]

View file

@ -0,0 +1,14 @@
"""External-PBX adapter entrypoint for the ARI provider."""
from .base import ExternalPBXAdapter, ExternalPBXResult
from .registry import create_adapter, register_adapter, registered_adapter_types
from .vicidial import VicidialAdapter
register_adapter("vicidial", VicidialAdapter)
__all__ = [
"ExternalPBXAdapter",
"ExternalPBXResult",
"create_adapter",
"registered_adapter_types",
]

View file

@ -0,0 +1,44 @@
"""Contracts for PBXs that hand a customer leg to Dograh through Asterisk."""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Awaitable, Callable, Mapping
HeaderReader = Callable[[str], Awaitable[str]]
@dataclass(frozen=True)
class ExternalPBXResult:
ok: bool
action: str
message: str
class ExternalPBXAdapter(ABC):
"""PBX-specific operations; ARI continues to own only Dograh's local leg."""
type: str
@abstractmethod
async def capture_call_identity(
self, read_header: HeaderReader
) -> dict[str, str] | None:
"""Read a stable upstream-call identity from inbound SIP headers."""
@abstractmethod
async def hangup(self, identity: Mapping[str, str]) -> ExternalPBXResult:
"""Hang up the customer leg owned by the external PBX."""
@abstractmethod
async def transfer(
self, identity: Mapping[str, str], destination: str
) -> ExternalPBXResult:
"""Transfer the customer leg to a PBX-native destination."""
@abstractmethod
async def update_fields(
self, identity: Mapping[str, str], fields: Mapping[str, str]
) -> ExternalPBXResult:
"""Update provider-native fields associated with the call."""

View file

@ -0,0 +1,34 @@
"""Registry for drop-in external-PBX adapters."""
from __future__ import annotations
from collections.abc import Callable
from typing import Any
from .base import ExternalPBXAdapter
AdapterFactory = Callable[[dict[str, Any]], ExternalPBXAdapter]
_FACTORIES: dict[str, AdapterFactory] = {}
def register_adapter(pbx_type: str, factory: AdapterFactory) -> None:
normalized = pbx_type.strip().lower()
if not normalized:
raise ValueError("External PBX type cannot be empty")
if normalized in _FACTORIES:
raise ValueError(f"External PBX adapter already registered: {normalized}")
_FACTORIES[normalized] = factory
def create_adapter(config: dict[str, Any] | None) -> ExternalPBXAdapter | None:
if not config:
return None
pbx_type = str(config.get("type", "")).strip().lower()
factory = _FACTORIES.get(pbx_type)
if factory is None:
raise ValueError(f"Unsupported external PBX type: {pbx_type or '<empty>'}")
return factory(config)
def registered_adapter_types() -> tuple[str, ...]:
return tuple(sorted(_FACTORIES))

View file

@ -0,0 +1,170 @@
"""VICIdial implementation of external-PBX call and lead operations."""
from __future__ import annotations
import re
from typing import Any, Mapping
import aiohttp
from loguru import logger
from .base import ExternalPBXAdapter, ExternalPBXResult, HeaderReader
_LEAD_FIELD_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_]{0,63}$")
_RESERVED_LEAD_FIELDS = frozenset({"source", "user", "pass", "function", "lead_id"})
class VicidialAdapter(ExternalPBXAdapter):
type = "vicidial"
def __init__(self, config: dict[str, Any]):
agent_api = config.get("agent_api") or {}
non_agent_api = config.get("non_agent_api") or {}
self._agent_url = str(agent_api.get("url", "")).strip()
self._agent_user = str(agent_api.get("username", "")).strip()
self._agent_password = str(agent_api.get("password", ""))
self._agent_source = str(agent_api.get("source", "dograh")).strip()
self._non_agent_url = str(non_agent_api.get("url", "")).strip()
self._non_agent_user = str(non_agent_api.get("username", "")).strip()
self._non_agent_password = str(non_agent_api.get("password", ""))
self._non_agent_source = str(non_agent_api.get("source", "dograh")).strip()
self._timeout = aiohttp.ClientTimeout(
total=min(max(int(config.get("timeout_seconds", 8)), 1), 30)
)
async def capture_call_identity(
self, read_header: HeaderReader
) -> dict[str, str] | None:
callerid = (await read_header("X-VICIDIAL-callerid")).strip()
if not callerid:
return None
return {
"type": self.type,
"callerid": callerid,
"agent_user": (await read_header("X-VICIDIAL-user")).strip(),
"lead_id": (await read_header("X-VICIDIAL-lead_id")).strip(),
"campaign_id": (await read_header("X-VICIDIAL-campaign_id")).strip(),
"ingroup_id": (await read_header("X-VICIDIAL-ingroup_id")).strip(),
}
async def _agent_call_control(
self, identity: Mapping[str, str], stage: str, **extra: str
) -> ExternalPBXResult:
if not all([self._agent_url, self._agent_user, self._agent_password]):
return ExternalPBXResult(
False, stage.lower(), "Agent API is not configured"
)
if not identity.get("callerid") or not identity.get("agent_user"):
return ExternalPBXResult(
False, stage.lower(), "VICIdial call identity is incomplete"
)
params = {
"source": self._agent_source,
"user": self._agent_user,
"pass": self._agent_password,
"agent_user": identity["agent_user"],
"function": "ra_call_control",
"stage": stage,
"value": identity["callerid"],
**extra,
}
try:
async with aiohttp.ClientSession(timeout=self._timeout) as session:
async with session.get(self._agent_url, params=params) as response:
response_text = (await response.text()).strip()
ok = response.status == 200 and response_text.startswith("SUCCESS")
logger.info(
"[VICIdial] ra_call_control completed "
f"stage={stage} status={response.status} ok={ok}"
)
return ExternalPBXResult(
ok,
stage.lower(),
"VICIdial accepted the operation"
if ok
else "VICIdial rejected the operation",
)
except Exception as exc:
logger.error(f"[VICIdial] ra_call_control failed stage={stage}: {exc}")
return ExternalPBXResult(
False, stage.lower(), "VICIdial API request failed"
)
async def hangup(self, identity: Mapping[str, str]) -> ExternalPBXResult:
return await self._agent_call_control(identity, "HANGUP")
async def transfer(
self, identity: Mapping[str, str], destination: str
) -> ExternalPBXResult:
choice = destination.strip()
if choice.lower() == "source":
choice = str(identity.get("ingroup_id", "")).strip()
if not choice:
return ExternalPBXResult(
False, "ingrouptransfer", "No VICIdial in-group was resolved"
)
return await self._agent_call_control(
identity, "INGROUPTRANSFER", ingroup_choices=choice
)
async def update_fields(
self, identity: Mapping[str, str], fields: Mapping[str, str]
) -> ExternalPBXResult:
if not fields:
return ExternalPBXResult(True, "update_lead", "No lead fields configured")
if not all(
[self._non_agent_url, self._non_agent_user, self._non_agent_password]
):
return ExternalPBXResult(
False, "update_lead", "Non-agent API is not configured"
)
lead_id = str(identity.get("lead_id", "")).strip()
if not lead_id:
return ExternalPBXResult(
False, "update_lead", "No VICIdial lead ID captured"
)
safe_fields: dict[str, str] = {}
for key, value in fields.items():
normalized = str(key).strip()
if (
not _LEAD_FIELD_RE.fullmatch(normalized)
or normalized.lower() in _RESERVED_LEAD_FIELDS
):
logger.warning(
f"[VICIdial] Ignoring invalid lead field name: {normalized!r}"
)
continue
safe_fields[normalized] = str(value)
if not safe_fields:
return ExternalPBXResult(
False, "update_lead", "No valid lead fields resolved"
)
params = {
**safe_fields,
"source": self._non_agent_source,
"user": self._non_agent_user,
"pass": self._non_agent_password,
"function": "update_lead",
"lead_id": lead_id,
}
try:
async with aiohttp.ClientSession(timeout=self._timeout) as session:
async with session.get(self._non_agent_url, params=params) as response:
response_text = (await response.text()).strip()
ok = response.status == 200 and response_text.startswith("SUCCESS")
logger.info(
"[VICIdial] update_lead completed "
f"status={response.status} ok={ok} field_count={len(safe_fields)}"
)
return ExternalPBXResult(
ok,
"update_lead",
"VICIdial lead updated" if ok else "VICIdial rejected the lead update",
)
except Exception as exc:
logger.error(f"[VICIdial] update_lead failed: {exc}")
return ExternalPBXResult(
False, "update_lead", "VICIdial API request failed"
)

View file

@ -20,6 +20,7 @@ from api.services.telephony.base import (
NormalizedInboundData,
TelephonyProvider,
)
from api.services.telephony.providers.ari.external_pbx import create_adapter
if TYPE_CHECKING:
from fastapi import WebSocket
@ -51,6 +52,7 @@ class ARIProvider(TelephonyProvider):
self.app_name = config.get("app_name", "")
self.app_password = config.get("app_password", "")
self.from_numbers = config.get("from_numbers", [])
self.external_pbx_adapter = create_adapter(config.get("external_pbx"))
if isinstance(self.from_numbers, str):
self.from_numbers = [self.from_numbers]
@ -363,6 +365,53 @@ class ARIProvider(TelephonyProvider):
"""ARI supports call transfers via bridge manipulation."""
return True
async def transfer_external_pbx_call(
self,
*,
identity: Dict[str, Any],
destination: str,
field_updates: Optional[Dict[str, str]] = None,
) -> Optional[Dict[str, Any]]:
"""Delegate a PBX-owned customer leg to the configured adapter."""
adapter = self.external_pbx_adapter
if adapter is None or not identity:
return None
identity_type = identity.get("type") or identity.get("provider")
if identity_type != adapter.type:
logger.warning(
"[ARI External PBX] Captured identity does not match configured "
f"adapter: identity={identity_type!r} adapter={adapter.type!r}"
)
return {
"status": "failed",
"action": "external_pbx_transfer",
"message": "The external PBX call identity is invalid.",
"reason": "external_pbx_identity_mismatch",
}
update_result = None
if field_updates:
update_result = await adapter.update_fields(identity, field_updates)
if not update_result.ok:
logger.warning(
"[ARI External PBX] Field update failed; continuing transfer "
f"adapter={adapter.type} message={update_result.message}"
)
transfer_result = await adapter.transfer(identity, destination)
return {
"status": "success" if transfer_result.ok else "failed",
"action": "external_pbx_transfer",
"message": (
"Transferring your call now."
if transfer_result.ok
else "I'm sorry, I couldn't complete the transfer."
),
"reason": None if transfer_result.ok else "external_pbx_transfer_failed",
"field_update_ok": update_result.ok if update_result else None,
}
async def transfer_call(
self,
destination: str,

View file

@ -3,11 +3,14 @@
This module contains the business logic for Asterisk ARI call operations.
"""
from typing import Any, Dict
from typing import TYPE_CHECKING, Any, Dict
from loguru import logger
from pipecat.serializers.call_strategies import HangupStrategy, TransferStrategy
if TYPE_CHECKING:
from .external_pbx import ExternalPBXAdapter
class ARIBridgeSwapStrategy(TransferStrategy):
"""Implements bridge swap transfer for Asterisk ARI.
@ -200,6 +203,9 @@ class ARIBridgeSwapStrategy(TransferStrategy):
class ARIHangupStrategy(HangupStrategy):
"""Implements hangup for Asterisk ARI channels."""
def __init__(self, external_pbx_adapter: "ExternalPBXAdapter | None" = None):
self._external_pbx_adapter = external_pbx_adapter
async def execute_hangup(self, context: Dict[str, Any]) -> bool:
"""Hang up the Asterisk channel via ARI REST API."""
try:
@ -217,6 +223,10 @@ class ARIHangupStrategy(HangupStrategy):
)
return False
# The external PBX owns the real customer leg. End it before the
# local ARI leg so its conference teardown cannot race our SIP BYE.
await self._terminate_external_pbx_if_any(channel_id)
endpoint = f"{ari_endpoint}/ari/channels/{channel_id}"
auth = BasicAuth(app_name, app_password)
@ -243,3 +253,76 @@ class ARIHangupStrategy(HangupStrategy):
except Exception as e:
logger.exception(f"Failed to hang up Asterisk channel: {e}")
return False
async def _terminate_external_pbx_if_any(self, channel_id: str) -> None:
"""If configured, hang up the external PBX's customer leg first.
Reuses the same channel->run lookup the transfer strategy uses
(Redis ``ari:channel:{id}`` -> run_id -> ``initial_context``). Best-effort:
never blocks dograh's own hangup if the upstream call fails.
"""
redis = None
try:
import redis.asyncio as aioredis
from api.constants import REDIS_URL
from api.db import db_client
redis = aioredis.from_url(REDIS_URL, decode_responses=True)
run_id = await redis.get(f"ari:channel:{channel_id}")
if not run_id:
return
run = await db_client.get_workflow_run_by_id(int(run_id))
if not run:
return
identity = (run.initial_context or {}).get("external_pbx_call")
# Read the legacy key for calls created before this refactor.
identity = identity or (run.initial_context or {}).get("upstream_pbx")
# If the call was already transferred to the external PBX, the customer
# leg has moved on -- do NOT hang it up (that would drop the transferred
# customer); just let dograh's own legs tear down below.
transferred = (run.gathered_context or {}).get("external_pbx_transferred")
transferred = transferred or (run.gathered_context or {}).get(
"upstream_transferred"
)
if identity and not transferred and self._external_pbx_adapter:
from api.services.telephony.external_pbx import (
resolve_external_pbx_field_mappings,
)
workflow_configurations = (
await db_client.get_workflow_run_configurations(
int(run_id), run.workflow.organization_id
)
)
field_updates = resolve_external_pbx_field_mappings(
run.gathered_context,
workflow_configurations.get("external_pbx_field_mappings", []),
)
if field_updates:
update_result = await self._external_pbx_adapter.update_fields(
identity, field_updates
)
if not update_result.ok:
logger.warning(
"[ARI Hangup] External PBX field update failed; "
f"continuing hangup: {update_result.message}"
)
logger.info(
"[ARI Hangup] External PBX call "
f"({self._external_pbx_adapter.type}); "
f"terminating customer leg via API before dropping dograh leg"
)
result = await self._external_pbx_adapter.hangup(identity)
if not result.ok:
logger.warning(
f"[ARI Hangup] External PBX rejected hangup: {result.message}"
)
except Exception as e:
logger.error(f"[ARI Hangup] external PBX terminate check failed: {e}")
finally:
if redis is not None:
try:
await redis.aclose()
except Exception as e:
logger.warning(f"[ARI Hangup] failed to close Redis client: {e}")

View file

@ -11,6 +11,7 @@ from api.services.pipecat.audio_mixer import build_audio_out_mixer
from api.services.pipecat.transport_params import realtime_param_overrides
from api.services.telephony.factory import load_credentials_for_transport
from .external_pbx import create_adapter
from .serializers import AsteriskFrameSerializer
from .strategies import ARIBridgeSwapStrategy, ARIHangupStrategy
@ -47,7 +48,9 @@ async def create_transport(
app_name=app_name,
app_password=app_password,
transfer_strategy=ARIBridgeSwapStrategy(),
hangup_strategy=ARIHangupStrategy(),
hangup_strategy=ARIHangupStrategy(
external_pbx_adapter=create_adapter(config.get("external_pbx"))
),
params=AsteriskFrameSerializer.InputParams(
asterisk_sample_rate=audio_config.transport_in_sample_rate,
sample_rate=audio_config.pipeline_sample_rate,

View file

@ -30,6 +30,22 @@ if TYPE_CHECKING:
from api.services.telephony.base import TelephonyProvider
@dataclass(frozen=True)
class ProviderUIOption:
"""One selectable value for a provider configuration field."""
value: str
label: str
@dataclass(frozen=True)
class ProviderUICondition:
"""Display a field only when another form value matches ``equals``."""
field: str
equals: Any
@dataclass(frozen=True)
class ProviderUIField:
"""One form field for the telephony configuration UI.
@ -46,6 +62,10 @@ class ProviderUIField:
sensitive: bool = False # If true, mask when displaying stored value
description: Optional[str] = None
placeholder: Optional[str] = None
options: Optional[List[ProviderUIOption]] = None
visible_when: Optional[ProviderUICondition] = None
section: Optional[str] = None
feature_gate: Optional[str] = None
@dataclass(frozen=True)

View file

@ -20,6 +20,7 @@ from api.schemas.tool import (
McpRefreshResponse,
ToolResponse,
)
from api.services.organization_preferences import external_pbx_integrations_enabled
from api.services.posthog_client import capture_event
from api.services.workflow.mcp_tool_session import discover_mcp_tools
from api.services.workflow.tools.mcp_tool import (
@ -119,6 +120,41 @@ async def validate_tool_credential_references(
)
async def validate_external_pbx_tool_definition(
definition: dict[str, Any],
*,
organization_id: int,
existing_definition: Optional[dict[str, Any]] = None,
) -> None:
"""Enforce the org feature gate for context-to-in-group routing."""
config = definition.get("config")
existing_config = (existing_definition or {}).get("config")
uses_external_pbx = (
isinstance(config, dict)
and config.get("destination_source") == "context_mapping"
)
existing_uses_external_pbx = (
isinstance(existing_config, dict)
and existing_config.get("destination_source") == "context_mapping"
)
if not uses_external_pbx and not existing_uses_external_pbx:
return
if await external_pbx_integrations_enabled(organization_id):
return
if isinstance(existing_config, dict) and existing_config == config:
# Preserve a hidden existing mapping while the feature is disabled.
return
raise ToolManagementError(
"external_pbx_feature_disabled",
(
"External PBX integrations are disabled for this organization. "
"Enable them in Platform Settings before configuring in-group routing."
),
status_code=403,
)
async def populate_discovered_tools(
definition: dict[str, Any], *, organization_id: int
) -> dict[str, Any]:
@ -168,6 +204,9 @@ async def create_tool_for_user(
)
definition = request.definition.model_dump()
await validate_external_pbx_tool_definition(
definition, organization_id=user.selected_organization_id
)
await validate_tool_credential_references(
definition, organization_id=user.selected_organization_id
)

View file

@ -0,0 +1,70 @@
"""Workflow-configuration policies shared by workflow update surfaces."""
from __future__ import annotations
from copy import deepcopy
from typing import Any
from api.db import db_client
from api.services.organization_preferences import external_pbx_integrations_enabled
class WorkflowConfigurationNotFoundError(LookupError):
"""Raised when configuration policy cannot resolve the target workflow."""
class ExternalPBXConfigurationDisabledError(PermissionError):
"""Raised when a disabled organization changes external-PBX mappings."""
async def apply_external_pbx_mapping_policy(
workflow_configurations: dict[str, Any] | None,
*,
workflow_id: int,
organization_id: int,
) -> dict[str, Any] | None:
"""Preserve hidden mappings and reject edits while external PBX is disabled.
Workflow configuration updates replace the stored configuration document. When
the External PBX UI is hidden, its field mappings are absent from the request,
so they must be copied from the active draft or published definition before the
update is persisted.
"""
if workflow_configurations is None or await external_pbx_integrations_enabled(
organization_id
):
return workflow_configurations
workflow = await db_client.get_workflow(
workflow_id, organization_id=organization_id
)
if workflow is None:
raise WorkflowConfigurationNotFoundError(
f"Workflow with id {workflow_id} not found"
)
draft = await db_client.get_draft_version(workflow_id)
stored_configurations = (
draft.workflow_configurations
if draft
else workflow.released_definition.workflow_configurations
)
stored_mappings = (stored_configurations or {}).get(
"external_pbx_field_mappings", []
)
incoming_mappings = workflow_configurations.get(
"external_pbx_field_mappings", stored_mappings
)
if incoming_mappings != stored_mappings:
raise ExternalPBXConfigurationDisabledError(
"External PBX integrations are disabled for this organization. "
"Enable them in Platform Settings before changing field mappings."
)
if not stored_mappings:
return workflow_configurations
prepared_configurations = dict(workflow_configurations)
prepared_configurations["external_pbx_field_mappings"] = deepcopy(stored_mappings)
return prepared_configurations

View file

@ -98,6 +98,10 @@ class PipecatEngine:
self._gathered_context: dict = {}
self._user_response_timeout_task: Optional[asyncio.Task] = None
self._pending_extraction_tasks: set[asyncio.Task] = set()
# True once a final (synchronous) extraction has run, so the end-of-call
# and upstream-transfer paths don't redundantly re-extract the same
# terminal state.
self._final_extraction_done: bool = False
# Will be set later in initialize() when we have
# access to _context
@ -507,6 +511,29 @@ class PipecatEngine:
f"Incomplete: {incomplete}"
)
async def perform_final_variable_extraction(self) -> None:
"""Flush in-flight + current-node variable extraction synchronously.
Awaits any background extractions still running from previous nodes,
then runs the current node's extraction inline so callers that need the
freshest extracted variables before acting can rely on them -- e.g.
end_call_with_reason before disposing the call, or an external-PBX
transfer that maps extracted variables into a provider lead update
call before handing the customer off.
Idempotent: only the first call does work. The external-PBX transfer
runs this just before forwarding update_lead, so the subsequent
end_call_with_reason would otherwise re-extract the same terminal state.
"""
if self._final_extraction_done:
logger.debug("Final variable extraction already performed; skipping")
return
self._final_extraction_done = True
await self._await_pending_extractions()
await self._perform_variable_extraction_if_needed(
self._current_node, run_in_background=False
)
async def _setup_llm_context(self, node: Node) -> None:
"""Common method to set up LLM context"""
# Set OTel span name for tracing
@ -742,13 +769,8 @@ class PipecatEngine:
EndTaskReason.PIPELINE_ERROR.value,
EndTaskReason.VOICEMAIL_DETECTED.value,
):
# Await any in-flight background extractions from previous nodes
await self._await_pending_extractions()
# Perform final variable extraction synchronously before ending
await self._perform_variable_extraction_if_needed(
self._current_node, run_in_background=False
)
# Flush in-flight + current-node extractions synchronously before ending
await self.perform_final_variable_extraction()
frame_to_push = (
CancelFrame(reason=reason) if abort_immediately else EndFrame(reason=reason)
@ -766,6 +788,20 @@ class PipecatEngine:
call_tags.append(call_disposition)
self._gathered_context["call_tags"] = call_tags
# Hangup strategies run while serializing the terminal frame. Persist
# the final extracted values first so external-PBX adapters can apply
# workflow lead-field mappings before terminating the customer leg.
try:
await db_client.update_workflow_run(
run_id=self._workflow_run_id,
gathered_context=self._gathered_context,
)
except Exception as exc:
# Call teardown must never be held hostage by an enrichment write.
logger.warning(
f"Could not persist final gathered context before hangup: {exc}"
)
logger.debug(
f"Finishing run with reason: {reason}, disposition: {call_disposition} "
f"queueing frame {frame_to_push}"

View file

@ -24,6 +24,7 @@ from api.db import db_client
from api.enums import ToolCategory, WorkflowRunMode
from api.services.pipecat.audio_playback import play_audio, play_audio_loop
from api.services.telephony.call_transfer_manager import get_call_transfer_manager
from api.services.telephony.external_pbx import resolve_external_pbx_field_mappings
from api.services.telephony.factory import get_telephony_provider_for_run
from api.services.telephony.transfer_event_protocol import TransferContext
from api.services.workflow.tools.calculator import get_calculator_tools, safe_calculator
@ -609,22 +610,22 @@ class CustomToolManager:
)
return
external_pbx_call = (
getattr(workflow_run, "initial_context", None) or {}
).get("external_pbx_call")
# Compatibility for calls that started before the migration.
external_pbx_call = external_pbx_call or (
getattr(workflow_run, "initial_context", None) or {}
).get("upstream_pbx")
if external_pbx_call:
# Context-to-in-group and lead-field mappings must see the
# final conversation-derived values.
await self._engine.perform_final_variable_extraction()
resolver = config.get("resolver") if isinstance(config, dict) else None
is_dynamic_transfer = config.get(
"destination_source", "static"
) == "dynamic" and isinstance(resolver, dict)
resolver_phase_muted = False
def clear_transfer_setup_mute_state() -> None:
nonlocal resolver_phase_muted
if resolver_phase_muted:
self._engine.set_mute_pipeline(False)
resolver_phase_muted = False
self._engine._queued_speech_mute_state = "idle"
if is_dynamic_transfer:
self._engine.set_mute_pipeline(True)
resolver_phase_muted = True
if is_dynamic_transfer and resolver.get("wait_message"):
await self._engine.task.queue_frame(
@ -634,7 +635,6 @@ class CustomToolManager:
persist_to_logs=True,
)
)
self._engine._queued_speech_mute_state = "waiting"
try:
resolved_transfer = await resolve_transfer_config(
@ -649,7 +649,6 @@ class CustomToolManager:
destination = resolved_transfer.destination
timeout_seconds = resolved_transfer.timeout_seconds
except TransferResolutionError as e:
clear_transfer_setup_mute_state()
validation_error_result = {
"status": "failed",
"message": "I'm sorry, but I couldn't find a valid destination for this transfer.",
@ -661,6 +660,25 @@ class CustomToolManager:
)
return
if (
resolved_transfer.source == "context_mapping"
and not external_pbx_call
):
await self._handle_transfer_result(
{
"status": "failed",
"message": (
"This call did not arrive through the configured "
"external PBX."
),
"action": "transfer_failed",
"reason": "external_pbx_call_required",
},
function_call_params,
properties,
)
return
# Validate destination phone number
if not destination or not destination.strip():
validation_error_result = {
@ -669,12 +687,15 @@ class CustomToolManager:
"action": "transfer_failed",
"reason": "no_destination",
}
clear_transfer_setup_mute_state()
await self._handle_transfer_result(
validation_error_result, function_call_params, properties
)
return
provider = await get_telephony_provider_for_run(
workflow_run, organization_id
)
if resolved_transfer.message:
await self._engine.task.queue_frame(
TTSSpeakFrame(
@ -683,15 +704,54 @@ class CustomToolManager:
persist_to_logs=True,
)
)
self._engine._queued_speech_mute_state = "waiting"
else:
played = await self._play_config_message(config)
if played:
self._engine._queued_speech_mute_state = "waiting"
await self._play_config_message(config)
if external_pbx_call:
workflow_configurations = (
await db_client.get_workflow_run_configurations(
self._engine._workflow_run_id, organization_id
)
)
field_updates = resolve_external_pbx_field_mappings(
self._engine._gathered_context,
workflow_configurations.get("external_pbx_field_mappings", []),
)
external_result = await provider.transfer_external_pbx_call(
identity=external_pbx_call,
destination=destination,
field_updates=field_updates,
)
if external_result is not None:
if external_result.get("status") == "success":
self._engine._gathered_context[
"external_pbx_transferred"
] = True
await db_client.update_workflow_run(
run_id=self._engine._workflow_run_id,
gathered_context={"external_pbx_transferred": True},
)
await function_call_params.result_callback(
external_result, properties=properties
)
# Let VICIdial redirect the customer out of its
# conference before Dograh tears down the local leg.
await asyncio.sleep(4)
await self._engine.end_call_with_reason(
EndTaskReason.END_CALL_TOOL_REASON.value,
abort_immediately=True,
)
else:
await self._handle_transfer_result(
{
**external_result,
"action": "transfer_failed",
},
function_call_params,
properties,
)
return
provider = await get_telephony_provider_for_run(
workflow_run, organization_id
)
if not provider.supports_transfers() or not provider.validate_config():
validation_error_result = {
"status": "failed",
@ -699,7 +759,6 @@ class CustomToolManager:
"action": "transfer_failed",
"reason": "provider_does_not_support_transfer",
}
clear_transfer_setup_mute_state()
await self._handle_transfer_result(
validation_error_result, function_call_params, properties
)
@ -750,7 +809,6 @@ class CustomToolManager:
except Exception as e:
logger.error(f"Transfer provider failed: {e}")
self._engine.set_mute_pipeline(False)
self._engine._queued_speech_mute_state = "idle"
await call_transfer_manager.remove_transfer_context(transfer_id)
provider_error_result = {
"status": "failed",
@ -850,7 +908,6 @@ class CustomToolManager:
f"Transfer call tool '{function_name}' execution failed: {e}"
)
self._engine.set_mute_pipeline(False)
self._engine._queued_speech_mute_state = "idle"
# Handle generic exception with user-friendly message
exception_result = {

View file

@ -11,6 +11,7 @@ import httpx
from loguru import logger
from api.db import db_client
from api.services.organization_preferences import external_pbx_integrations_enabled
from api.services.workflow.tools.custom_tool import _resolve_preset_parameters
from api.utils.credential_auth import build_auth_header
from api.utils.template_renderer import render_template
@ -123,6 +124,70 @@ def _resolve_static_transfer(
)
def _context_value(
path: str,
call_context_vars: Optional[Dict[str, Any]],
gathered_context_vars: Optional[Dict[str, Any]],
) -> Any:
initial = call_context_vars or {}
gathered = gathered_context_vars or {}
normalized = path.strip()
if normalized.startswith("initial_context."):
current: Any = initial
parts = normalized.removeprefix("initial_context.").split(".")
elif normalized.startswith("gathered_context."):
current = gathered
parts = normalized.removeprefix("gathered_context.").split(".")
else:
current = gathered
parts = normalized.split(".")
for part in parts:
if not isinstance(current, dict):
return None
current = current.get(part)
if current is None and "." not in normalized:
extracted = gathered.get("extracted_variables")
if isinstance(extracted, dict):
current = extracted.get(normalized)
return current
def _resolve_context_mapping_transfer(
config: dict[str, Any],
call_context_vars: Optional[Dict[str, Any]],
gathered_context_vars: Optional[Dict[str, Any]],
) -> ResolvedTransferConfig:
mapping = config.get("context_mapping")
if not isinstance(mapping, dict):
raise TransferResolutionError(
"invalid_context_mapping", "Transfer context mapping is missing"
)
path = str(mapping.get("context_path", "")).strip()
raw_value = _context_value(path, call_context_vars, gathered_context_vars)
match_value = "" if raw_value is None else str(raw_value).strip().casefold()
destination = ""
for route in mapping.get("routes") or []:
if not isinstance(route, dict):
continue
if str(route.get("context_value", "")).strip().casefold() == match_value:
destination = str(route.get("destination", "")).strip()
break
if not destination:
destination = str(mapping.get("fallback_destination") or "").strip()
if not destination:
raise TransferResolutionError(
"no_context_mapping_match",
f"No destination mapping matched gathered context path '{path}'",
)
return ResolvedTransferConfig(
destination=destination,
timeout_seconds=_base_timeout(config),
source="context_mapping",
metadata={"context_path": path, "matched": bool(match_value)},
)
def _resolver_arguments(
*,
resolver: dict[str, Any],
@ -276,6 +341,25 @@ async def resolve_transfer_config(
) -> ResolvedTransferConfig:
"""Resolve transfer destination and options for a transfer tool call."""
destination_source = config.get("destination_source", "static")
if destination_source == "context_mapping":
if not organization_id or not await external_pbx_integrations_enabled(
organization_id
):
raise TransferResolutionError(
"external_pbx_feature_disabled",
"External PBX integrations are disabled for this organization",
)
resolved = _resolve_context_mapping_transfer(
config, call_context_vars, gathered_context_vars
)
logger.info(
"Transfer destination resolved from context mapping "
f"context_path={resolved.metadata.get('context_path')} "
f"source={resolved.source} destination={resolved.destination}"
)
return resolved
resolver = config.get("resolver")
if config.get("destination_source", "static") != "dynamic" or not isinstance(
resolver, dict

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"}
]
)

File diff suppressed because one or more lines are too long

View file

@ -116,6 +116,7 @@
"integrations/telephony/cloudonix",
"integrations/telephony/vobiz",
"integrations/telephony/asterisk-ari",
"integrations/telephony/vicidial",
"integrations/telephony/webhooks",
"integrations/telephony/agent-stream",
"integrations/telephony/custom"

View file

@ -0,0 +1,138 @@
---
title: "VICIdial Integration"
description: "Connect Dograh to VICIdial through Asterisk ARI for call control, in-group transfers, and lead updates"
---
## Overview
Use this integration when VICIdial owns the customer call and connects Dograh
to it through Asterisk. Dograh uses Asterisk ARI for audio and VICIdial APIs for
operations that must affect the original customer leg:
- Hang up the customer call when the agent ends the conversation
- Transfer the customer to a VICIdial in-group
- Update selected VICIdial lead fields from gathered workflow context
VICIdial controls are an advanced organization feature. Users who do not enable
the feature do not see VICIdial configuration, transfer mappings, or lead field
mappings in the UI.
## Prerequisites
Before configuring VICIdial, ensure that:
- Asterisk ARI is connected to Dograh as described in the
[Asterisk ARI integration](./asterisk-ari)
- VICIdial sends the customer call to the Asterisk dialplan that enters the
Dograh Stasis application
- You have VICIdial agent API credentials with permission to hang up and
perform in-group transfers
- If workflows will update leads, you also have non-agent API credentials with
permission to call `update_lead`
## Enable the organization feature
1. Open **Platform Settings** by going to https://app.dograh.com/settings
2. Enable **External PBX integrations**.
This switch reveals the advanced settings in Asterisk telephony
configurations, Transfer Call tools, and workflow configurations. Disabling it
hides and disables those controls without deleting their saved values.
## Configure VICIdial credentials
1. Open **Telephony Configurations**.
2. Create or edit an **Asterisk ARI** configuration.
3. In **External PBX**, set **External PBX Type** to **VICIdial**.
4. Configure the agent API:
- **Agent API URL**: normally ends in `/agc/api.php`
- **Agent API User**
- **Agent API Password**
5. To update leads, configure the non-agent API:
- **Non-Agent API URL**: normally ends in `/vicidial/non_agent_api.php`
- **Non-Agent API User**
- **Non-Agent API Password**
6. Save the configuration.
The non-agent API is optional when no lead updates are required. If it is used,
its URL, user, and password must all be present. Credentials are stored with the
telephony configuration and are returned masked by the Dograh API.
## Pass VICIdial call identity to Asterisk
Dograh needs VICIdial's call identity to control the original customer leg.
Configure VICIdial or the connecting dialplan to preserve these SIP headers on
the call that enters the Dograh Stasis application:
| Header | Purpose |
| --- | --- |
| `X-VICIDIAL-callerid` | Call-control identifier used by the agent API |
| `X-VICIDIAL-user` | Remote-agent user used by the agent API |
| `X-VICIDIAL-lead_id` | Lead identifier used by optional lead updates |
| `X-VICIDIAL-campaign_id` | **(Optional)** Campaign context retained with the call |
| `X-VICIDIAL-ingroup_id` | **(Optional)** Original in-group available to transfer fallback routing |
Hangup and transfer require the call-control identifier and remote-agent user.
Lead updates additionally require the lead ID. The source in-group header is
only required when a transfer mapping uses the `source` fallback.
## Configure in-group transfers
In-group mappings select a VICIdial destination from information gathered by
the agent during a call.
1. Open or create a **Transfer Call** tool.
2. Select **Context Mapping** as the destination source.
3. Enter a context path, such as `qualified` or
`extracted_variables.qualified`.
4. Add one route for each expected value and its destination in-group ID.
5. Optionally configure a fallback in-group. Enter `source` to return the
caller to the original in-group captured from the SIP headers.
6. Save the tool and attach it to the workflow.
The match is case-insensitive. Dograh performs final variable extraction before
resolving the mapping. If no route or fallback matches, the transfer fails and
the agent can recover instead of silently hanging up the caller.
## Configure lead field mappings
Lead field mappings copy selected values from gathered context into the
VICIdial lead before transfer or hangup.
1. Open the workflow's **Configurations** dialog.
2. In **External PBX Field Updates**, add a mapping.
3. Enter the gathered-context path and the destination VICIdial lead field.
4. Add any additional mappings and save the workflow configuration.
For example, map `extracted_variables.customer_state` to `state`. A context
path can address a direct gathered-context value or a nested value. Mappings
whose source value is absent are skipped.
The VICIdial adapter ignores mappings that target the API control parameters
`source`, `user`, `pass`, `function`, and `lead_id`. The non-agent API
configuration is required for mappings to be applied. A lead-update failure
does not prevent Dograh from attempting the requested transfer or hangup.
## Runtime behavior
When the workflow transfers the call, Dograh resolves the configured in-group,
applies available lead updates, asks VICIdial to transfer the customer leg, and
then closes only the local Asterisk media leg.
When the conversation ends without a transfer, Dograh persists final gathered
context, applies available lead updates, asks VICIdial to hang up the customer
leg, and then closes the local Asterisk leg.
## Troubleshooting
- **VICIdial settings are not visible:** enable **External PBX integrations**
in Platform Settings.
- **The local media leg ends but the customer remains connected:** confirm that
`X-VICIDIAL-callerid` and `X-VICIDIAL-user` reach Asterisk and that the agent
API credentials can control the call.
- **A transfer cannot resolve a destination:** inspect the workflow's final
gathered context, the configured context path, and the mapping values.
- **The `source` fallback fails:** ensure `X-VICIDIAL-ingroup_id` is present on
the inbound call.
- **Lead fields are unchanged:** confirm `X-VICIDIAL-lead_id`, the non-agent API
credentials, and the destination field names.

View file

@ -1,6 +1,6 @@
# generated by datamodel-codegen:
# filename: dograh-openapi-XXXXXX.json.di0tn7Gw6b
# timestamp: 2026-07-15T13:20:43+00:00
# filename: dograh-openapi-XXXXXX.json.XxKaWUL4jA
# timestamp: 2026-07-20T15:51:54+00:00
from __future__ import annotations
@ -39,6 +39,26 @@ class CallDispositionCodes(BaseModel):
)
class FallbackDestination(RootModel[str]):
root: Annotated[str, Field(max_length=255, title='Fallback Destination')]
"""
Optional provider-native fallback destination.
"""
class ContextDestinationRoute(BaseModel):
"""
Map one gathered-context value to an external-PBX destination.
"""
context_value: Annotated[
str, Field(max_length=255, min_length=1, title='Context Value')
]
destination: Annotated[
str, Field(max_length=255, min_length=1, title='Destination')
]
class Category(Enum):
"""
Tool category. Must match definition.type.
@ -199,6 +219,19 @@ class EndCallToolDefinition(BaseModel):
"""
class ExternalPBXFieldMapping(BaseModel):
"""
Map one gathered-context value to a provider-native field.
"""
context_path: Annotated[
str, Field(max_length=255, min_length=1, title='Context Path')
]
destination_field: Annotated[
str, Field(pattern='^[A-Za-z][A-Za-z0-9_]{0,63}$', title='Destination Field')
]
class GraphConstraints(BaseModel):
"""
Per-node-type graph rules. WorkflowGraph enforces these at validation.
@ -536,11 +569,12 @@ class ToolResponse(BaseModel):
class DestinationSource(Enum):
"""
Whether transfer destination is static/template or resolved by HTTP.
Whether the destination is static/template, resolved by HTTP, or mapped from gathered context to an external-PBX destination.
"""
static = 'static'
dynamic = 'dynamic'
context_mapping = 'context_mapping'
class MessageType1(Enum):
@ -600,6 +634,10 @@ class WorkflowConfigurationDefaults(BaseModel):
context_compaction_enabled: Annotated[
bool | None, Field(title='Context Compaction Enabled')
] = False
external_pbx_field_mappings: Annotated[
list[ExternalPBXFieldMapping] | None,
Field(max_length=100, title='External Pbx Field Mappings'),
] = None
class WorkflowListResponse(BaseModel):
@ -636,6 +674,29 @@ class WorkflowResponse(BaseModel):
workflow_uuid: Annotated[str | None, Field(title='Workflow Uuid')] = None
class ContextDestinationMappingConfig(BaseModel):
"""
Resolve an external-PBX destination from gathered context.
"""
context_path: Annotated[
str, Field(max_length=255, min_length=1, title='Context Path')
]
"""
Gathered-context path or extracted-variable name used for routing.
"""
routes: Annotated[
list[ContextDestinationRoute],
Field(max_length=100, min_length=1, title='Routes'),
]
fallback_destination: Annotated[
FallbackDestination | None, Field(title='Fallback Destination')
] = None
"""
Optional provider-native fallback destination.
"""
class DocumentListResponseSchema(BaseModel):
"""
Response schema for list of documents.
@ -827,7 +888,7 @@ class TransferCallConfig(BaseModel):
DestinationSource | None, Field(title='Destination Source')
] = 'static'
"""
Whether transfer destination is static/template or resolved by HTTP.
Whether the destination is static/template, resolved by HTTP, or mapped from gathered context to an external-PBX destination.
"""
destination: Annotated[str | None, Field(title='Destination')] = ''
"""
@ -857,6 +918,10 @@ class TransferCallConfig(BaseModel):
"""
Optional resolver that determines transfer routing at call time.
"""
context_mapping: ContextDestinationMappingConfig | None = None
"""
Optional gathered-context to external-PBX destination mapping.
"""
class TransferCallToolDefinition(BaseModel):

View file

@ -312,6 +312,34 @@ export interface components {
*/
disposition_codes: string[];
};
/**
* ContextDestinationMappingConfig
* @description Resolve an external-PBX destination from gathered context.
*/
ContextDestinationMappingConfig: {
/**
* Context Path
* @description Gathered-context path or extracted-variable name used for routing.
*/
context_path: string;
/** Routes */
routes: components["schemas"]["ContextDestinationRoute"][];
/**
* Fallback Destination
* @description Optional provider-native fallback destination.
*/
fallback_destination?: string | null;
};
/**
* ContextDestinationRoute
* @description Map one gathered-context value to an external-PBX destination.
*/
ContextDestinationRoute: {
/** Context Value */
context_value: string;
/** Destination */
destination: string;
};
/**
* CreateToolRequest
* @description Request schema for creating a reusable tool.
@ -538,6 +566,16 @@ export interface components {
/** @description End Call configuration. */
config: components["schemas"]["EndCallConfig"];
};
/**
* ExternalPBXFieldMapping
* @description Map one gathered-context value to a provider-native field.
*/
ExternalPBXFieldMapping: {
/** Context Path */
context_path: string;
/** Destination Field */
destination_field: string;
};
/**
* GraphConstraints
* @description Per-node-type graph rules. WorkflowGraph enforces these at validation.
@ -1092,11 +1130,11 @@ export interface components {
TransferCallConfig: {
/**
* Destination Source
* @description Whether transfer destination is static/template or resolved by HTTP.
* @description Whether the destination is static/template, resolved by HTTP, or mapped from gathered context to an external-PBX destination.
* @default static
* @enum {string}
*/
destination_source: "static" | "dynamic";
destination_source: "static" | "dynamic" | "context_mapping";
/**
* Destination
* @description Phone number, SIP endpoint, or template to transfer the call to, e.g. +1234567890, PJSIP/1234, or {{initial_context.transfer_destination}}.
@ -1133,6 +1171,8 @@ export interface components {
parameters?: components["schemas"]["ToolParameter"][] | null;
/** @description Optional resolver that determines transfer routing at call time. */
resolver?: components["schemas"]["HttpTransferResolverConfig"] | null;
/** @description Optional gathered-context to external-PBX destination mapping. */
context_mapping?: components["schemas"]["ContextDestinationMappingConfig"] | null;
};
/**
* TransferCallToolDefinition
@ -1230,6 +1270,8 @@ export interface components {
* @default false
*/
context_compaction_enabled: boolean;
/** External Pbx Field Mappings */
external_pbx_field_mappings?: components["schemas"]["ExternalPBXFieldMapping"][];
} & {
[key: string]: unknown;
};
@ -1303,6 +1345,8 @@ export interface components {
export type AmbientNoiseConfigurationDefaults = components['schemas']['AmbientNoiseConfigurationDefaults'];
export type CalculatorToolDefinition = components['schemas']['CalculatorToolDefinition'];
export type CallDispositionCodes = components['schemas']['CallDispositionCodes'];
export type ContextDestinationMappingConfig = components['schemas']['ContextDestinationMappingConfig'];
export type ContextDestinationRoute = components['schemas']['ContextDestinationRoute'];
export type CreateToolRequest = components['schemas']['CreateToolRequest'];
export type CreateWorkflowRequest = components['schemas']['CreateWorkflowRequest'];
export type CreatedByResponse = components['schemas']['CreatedByResponse'];
@ -1312,6 +1356,7 @@ export type DocumentListResponseSchema = components['schemas']['DocumentListResp
export type DocumentResponseSchema = components['schemas']['DocumentResponseSchema'];
export type EndCallConfig = components['schemas']['EndCallConfig'];
export type EndCallToolDefinition = components['schemas']['EndCallToolDefinition'];
export type ExternalPbxFieldMapping = components['schemas']['ExternalPBXFieldMapping'];
export type GraphConstraints = components['schemas']['GraphConstraints'];
export type HttpValidationError = components['schemas']['HTTPValidationError'];
export type HttpApiConfig = components['schemas']['HttpApiConfig'];

View file

@ -56,6 +56,7 @@ import {
TableRow,
} from "@/components/ui/table";
import { useAppConfig } from "@/context/AppConfigContext";
import { useOrgConfig } from "@/context/OrgConfigContext";
import { detailFromError } from "@/lib/apiError";
import { useAuth } from "@/lib/auth";
import { resolveWebhookBaseUrl } from "@/lib/webhookUrl";
@ -69,6 +70,7 @@ export default function TelephonyConfigurationDetailPage() {
const { user, getAccessToken, loading: authLoading } = useAuth();
const { config: appConfig } = useAppConfig();
const { externalPbxIntegrationsEnabled } = useOrgConfig();
const inboundWebhookUrl = `${resolveWebhookBaseUrl(appConfig?.tunnelUrl)}${INBOUND_WEBHOOK_PATH}`;
const [config, setConfig] = useState<TelephonyConfigurationDetail | null>(null);
const [phoneNumbers, setPhoneNumbers] = useState<PhoneNumberResponse[]>([]);
@ -248,11 +250,13 @@ export default function TelephonyConfigurationDetailPage() {
</CardHeader>
<CardContent className="space-y-4">
<dl className="grid grid-cols-2 gap-x-6 gap-y-2 text-sm">
{Object.entries(config.credentials ?? {}).map(([k, v]) => (
{Object.entries(config.credentials ?? {})
.filter(([key]) => key !== "external_pbx" || externalPbxIntegrationsEnabled)
.map(([k, v]) => (
<div key={k} className="flex justify-between gap-3">
<dt className="text-muted-foreground">{k}</dt>
<dd className="font-mono text-right truncate max-w-[60%]">
{String(v ?? "")}
{v && typeof v === "object" ? "Configured" : String(v ?? "")}
</dd>
</div>
))}

View file

@ -0,0 +1,77 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { useState } from "react";
import { describe, expect, it, vi } from "vitest";
import type { ContextDestinationRouteRow } from "../../config";
import { TransferCallToolConfig } from "./TransferCallToolConfig";
const noop = vi.fn();
function ContextMappingHarness() {
const [routes, setRoutes] = useState<ContextDestinationRouteRow[]>([
{
id: "existing-route",
context_value: "support",
destination: "PJSIP/support",
},
]);
return (
<TransferCallToolConfig
name="Transfer call"
onNameChange={noop}
description=""
onDescriptionChange={noop}
destinationSource="context_mapping"
onDestinationSourceChange={noop}
destination=""
onDestinationChange={noop}
messageType="none"
onMessageTypeChange={noop}
customMessage=""
onCustomMessageChange={noop}
audioRecordingId=""
onAudioRecordingIdChange={noop}
timeout={30}
onTimeoutChange={noop}
resolverUrl=""
onResolverUrlChange={noop}
resolverCredentialUuid=""
onResolverCredentialUuidChange={noop}
resolverHeaders={[]}
onResolverHeadersChange={noop}
resolverTimeoutMs={3000}
onResolverTimeoutMsChange={noop}
resolverWaitMessage=""
onResolverWaitMessageChange={noop}
parameters={[]}
onParametersChange={noop}
presetParameters={[]}
onPresetParametersChange={noop}
externalPbxRoutingEnabled
contextMappingPath="department"
onContextMappingPathChange={noop}
contextDestinationRoutes={routes}
onContextDestinationRoutesChange={setRoutes}
fallbackDestination=""
onFallbackDestinationChange={noop}
/>
);
}
describe("TransferCallToolConfig context mappings", () => {
it("preserves the focused row when an earlier mapping is removed", () => {
render(<ContextMappingHarness />);
fireEvent.click(screen.getByRole("button", { name: "Add mapping" }));
const addedDestination = screen.getByLabelText("PBX destination 2");
addedDestination.focus();
expect(document.activeElement).toBe(addedDestination);
fireEvent.click(screen.getByRole("button", { name: "Remove mapping 1" }));
expect(screen.getByLabelText("PBX destination 1")).toBe(addedDestination);
expect(document.activeElement).toBe(addedDestination);
});
});

View file

@ -1,5 +1,7 @@
"use client";
import { Plus, Trash2 } from "lucide-react";
import type { RecordingResponseSchema } from "@/client/types.gen";
import { RecordingSelect, StaticTextWarning } from "@/components/flow/TextOrAudioInput";
import {
@ -12,6 +14,7 @@ import {
type ToolParameter,
UrlInput,
} from "@/components/http";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@ -20,6 +23,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import {
type ContextDestinationRouteRow,
type EndCallMessageType,
type TransferDestinationSource,
} from "../../config";
@ -56,6 +60,13 @@ export interface TransferCallToolConfigProps {
onParametersChange: (parameters: ToolParameter[]) => void;
presetParameters: PresetToolParameter[];
onPresetParametersChange: (parameters: PresetToolParameter[]) => void;
externalPbxRoutingEnabled: boolean;
contextMappingPath: string;
onContextMappingPathChange: (path: string) => void;
contextDestinationRoutes: ContextDestinationRouteRow[];
onContextDestinationRoutesChange: (routes: ContextDestinationRouteRow[]) => void;
fallbackDestination: string;
onFallbackDestinationChange: (destination: string) => void;
}
export function TransferCallToolConfig({
@ -90,6 +101,13 @@ export function TransferCallToolConfig({
onParametersChange,
presetParameters,
onPresetParametersChange,
externalPbxRoutingEnabled,
contextMappingPath,
onContextMappingPathChange,
contextDestinationRoutes,
onContextDestinationRoutesChange,
fallbackDestination,
onFallbackDestinationChange,
}: TransferCallToolConfigProps) {
return (
<Card>
@ -217,14 +235,22 @@ export function TransferCallToolConfig({
Choose whether the transfer uses a configured destination or resolves one from an HTTP endpoint.
</p>
</div>
<Tabs
{!externalPbxRoutingEnabled && destinationSource === "context_mapping" ? (
<div className="rounded-md border bg-muted/30 p-3 text-sm text-muted-foreground">
This tool has advanced external-PBX routing configured. Enable
External PBX integrations in Platform Settings to view or change it.
</div>
) : <Tabs
value={destinationSource}
onValueChange={(v) => onDestinationSourceChange(v as TransferDestinationSource)}
className="w-full"
>
<TabsList className="grid w-full grid-cols-2">
<TabsList className={`grid w-full ${externalPbxRoutingEnabled ? "grid-cols-3" : "grid-cols-2"}`}>
<TabsTrigger value="static">Static / Template</TabsTrigger>
<TabsTrigger value="dynamic">Dynamic HTTP Resolver</TabsTrigger>
{externalPbxRoutingEnabled && (
<TabsTrigger value="context_mapping">Context Mapping</TabsTrigger>
)}
</TabsList>
<TabsContent value="static" className="space-y-4 mt-4">
@ -343,7 +369,100 @@ export function TransferCallToolConfig({
/>
</div>
</TabsContent>
</Tabs>
{externalPbxRoutingEnabled && (
<TabsContent value="context_mapping" className="space-y-5 mt-4">
<div>
<Label>External PBX Context Routing</Label>
<p className="text-xs text-muted-foreground">
Resolve a gathered-context value to a provider-native destination.
Matching ignores case and surrounding whitespace.
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="pbx-context-path">Gathered Context Field</Label>
<Input
id="pbx-context-path"
value={contextMappingPath}
onChange={(event) => onContextMappingPathChange(event.target.value)}
placeholder="qualified or extracted_variables.qualified"
/>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label>Value to Destination Mappings</Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => onContextDestinationRoutesChange([
...contextDestinationRoutes,
{
id: crypto.randomUUID(),
context_value: "",
destination: "",
},
])}
>
<Plus className="mr-1 h-4 w-4" /> Add mapping
</Button>
</div>
{contextDestinationRoutes.map((route, index) => (
<div key={route.id} className="grid grid-cols-[1fr_1fr_auto] gap-2">
<Input
aria-label={`Context value ${index + 1}`}
value={route.context_value}
onChange={(event) => onContextDestinationRoutesChange(
contextDestinationRoutes.map((item) =>
item.id === route.id
? { ...item, context_value: event.target.value }
: item
)
)}
placeholder="Context value"
/>
<Input
aria-label={`PBX destination ${index + 1}`}
value={route.destination}
onChange={(event) => onContextDestinationRoutesChange(
contextDestinationRoutes.map((item) =>
item.id === route.id
? { ...item, destination: event.target.value }
: item
)
)}
placeholder="Provider destination"
/>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={`Remove mapping ${index + 1}`}
onClick={() => onContextDestinationRoutesChange(
contextDestinationRoutes.filter((item) => item.id !== route.id)
)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
{contextDestinationRoutes.length === 0 && (
<p className="text-xs text-muted-foreground">
Add at least one mapping.
</p>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="pbx-fallback-destination">Fallback Destination (Optional)</Label>
<Input
id="pbx-fallback-destination"
value={fallbackDestination}
onChange={(event) => onFallbackDestinationChange(event.target.value)}
placeholder="Provider-native fallback destination"
/>
</div>
</TabsContent>
)}
</Tabs>}
</div>
</CardContent>
</Card>

View file

@ -40,10 +40,12 @@ import { Skeleton } from "@/components/ui/skeleton";
import { Textarea } from "@/components/ui/textarea";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { TOOL_DOCUMENTATION_URLS } from "@/constants/documentation";
import { useOrgConfig } from "@/context/OrgConfigContext";
import { detailFromError } from "@/lib/apiError";
import { useAuth } from "@/lib/auth";
import {
type ContextDestinationRouteRow,
createMcpDefinition,
DEFAULT_END_CALL_REASON_DESCRIPTION,
type EndCallMessageType,
@ -84,6 +86,7 @@ function headersToRows(headers: Record<string, string> | undefined | null): KeyV
export default function ToolDetailPage() {
const { toolUuid } = useParams<{ toolUuid: string }>();
const { user, getAccessToken, redirectToLogin, loading } = useAuth();
const { externalPbxIntegrationsEnabled } = useOrgConfig();
const router = useRouter();
const [tool, setTool] = useState<ToolResponse | null>(null);
@ -138,6 +141,10 @@ export default function ToolDetailPage() {
const [transferResolverWaitMessage, setTransferResolverWaitMessage] = useState("");
const [transferParameters, setTransferParameters] = useState<ToolParameter[]>([]);
const [transferPresetParameters, setTransferPresetParameters] = useState<PresetToolParameter[]>([]);
const [transferContextMappingPath, setTransferContextMappingPath] = useState("");
const [transferContextDestinationRoutes, setTransferContextDestinationRoutes] =
useState<ContextDestinationRouteRow[]>([]);
const [transferFallbackDestination, setTransferFallbackDestination] = useState("");
// HTTP API form state - custom message type
const [customMessageType, setCustomMessageType] = useState<'text' | 'audio'>('text');
@ -237,6 +244,16 @@ export default function ToolDetailPage() {
required: p.required ?? true,
})),
);
setTransferContextMappingPath(config.context_mapping?.context_path || "");
setTransferContextDestinationRoutes(
(config.context_mapping?.routes || []).map((route) => ({
...route,
id: crypto.randomUUID(),
}))
);
setTransferFallbackDestination(
config.context_mapping?.fallback_destination || ""
);
} else {
setTransferDestinationSource("static");
setTransferDestination("");
@ -251,6 +268,9 @@ export default function ToolDetailPage() {
setTransferResolverWaitMessage("");
setTransferParameters([]);
setTransferPresetParameters([]);
setTransferContextMappingPath("");
setTransferContextDestinationRoutes([]);
setTransferFallbackDestination("");
}
} else if (tool.category === "mcp") {
// Populate MCP specific fields
@ -413,6 +433,28 @@ export default function ToolDetailPage() {
return;
}
}
if (transferDestinationSource === "context_mapping") {
if (!transferContextMappingPath.trim()) {
setError("Please enter a gathered-context field for PBX routing");
return;
}
if (
transferContextDestinationRoutes.length === 0 ||
transferContextDestinationRoutes.some(
(route) => !route.context_value.trim() || !route.destination.trim()
)
) {
setError("Add at least one complete context value to destination mapping");
return;
}
const routeValues = transferContextDestinationRoutes.map((route) =>
route.context_value.trim().toLocaleLowerCase()
);
if (new Set(routeValues).size !== routeValues.length) {
setError("Destination mapping context values must be unique");
return;
}
}
} else if (tool.category === "mcp") {
// Validate MCP server URL (must be http(s))
if (!mcpUrl.trim()) {
@ -536,6 +578,17 @@ export default function ToolDetailPage() {
: undefined,
}
: undefined,
context_mapping: transferDestinationSource === "context_mapping"
? {
context_path: transferContextMappingPath.trim(),
routes: transferContextDestinationRoutes.map((route) => ({
context_value: route.context_value.trim(),
destination: route.destination.trim(),
})),
fallback_destination:
transferFallbackDestination.trim() || undefined,
}
: undefined,
};
// Build transfer call request body
requestBody = {
@ -871,6 +924,13 @@ const data = await response.json();`;
onParametersChange={setTransferParameters}
presetParameters={transferPresetParameters}
onPresetParametersChange={setTransferPresetParameters}
externalPbxRoutingEnabled={externalPbxIntegrationsEnabled}
contextMappingPath={transferContextMappingPath}
onContextMappingPathChange={setTransferContextMappingPath}
contextDestinationRoutes={transferContextDestinationRoutes}
onContextDestinationRoutesChange={setTransferContextDestinationRoutes}
fallbackDestination={transferFallbackDestination}
onFallbackDestinationChange={setTransferFallbackDestination}
/>
) : isMcpTool ? (
<Card>

View file

@ -18,7 +18,22 @@ import type {
export type ToolCategory = "http_api" | "end_call" | "transfer_call" | "calculator" | "native" | "integration" | "mcp";
export type EndCallMessageType = "none" | "custom" | "audio";
export type TransferDestinationSource = "static" | "dynamic";
export type TransferDestinationSource = "static" | "dynamic" | "context_mapping";
export interface ContextDestinationRoute {
context_value: string;
destination: string;
}
export interface ContextDestinationRouteRow extends ContextDestinationRoute {
id: string;
}
export interface ContextDestinationMappingConfig {
context_path: string;
routes: ContextDestinationRoute[];
fallback_destination?: string | null;
}
export interface TransferResolverConfig {
type: "http";
@ -34,6 +49,7 @@ export interface TransferResolverConfig {
export interface ExtendedTransferCallConfig extends TransferCallConfig {
destination_source?: TransferDestinationSource;
resolver?: TransferResolverConfig | null;
context_mapping?: ContextDestinationMappingConfig | null;
}
export interface ToolCategoryConfig {

View file

@ -1,3 +1,4 @@
import { Plus, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
@ -6,10 +7,12 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { useOrgConfig } from "@/context/OrgConfigContext";
import {
AmbientNoiseConfiguration,
DEFAULT_PROVISIONAL_VAD_PAUSE_SECS,
DEFAULT_TURN_START_MIN_WORDS,
ExternalPBXFieldMapping,
resolveWorkflowConfigurations,
TURN_START_STRATEGY_OPTIONS,
TurnStartStrategy,
@ -32,6 +35,7 @@ export const ConfigurationsDialog = ({
workflowName,
onSave
}: ConfigurationsDialogProps) => {
const { externalPbxIntegrationsEnabled } = useOrgConfig();
const resolvedWorkflowConfigurations = resolveWorkflowConfigurations(workflowConfigurations);
const [name, setName] = useState<string>(workflowName);
const [ambientNoiseConfig, setAmbientNoiseConfig] = useState<AmbientNoiseConfiguration>(
@ -61,10 +65,18 @@ export const ConfigurationsDialog = ({
const [contextCompactionEnabled, setContextCompactionEnabled] = useState<boolean>(
resolvedWorkflowConfigurations.context_compaction_enabled
);
const [externalPbxFieldMappings, setExternalPbxFieldMappings] = useState<ExternalPBXFieldMapping[]>(
resolvedWorkflowConfigurations.external_pbx_field_mappings
);
const [isSaving, setIsSaving] = useState(false);
const selectedTurnStartStrategy = TURN_START_STRATEGY_OPTIONS.find(
(option) => option.value === turnStartStrategy
);
const externalPbxFieldMappingsValid = externalPbxFieldMappings.every(
(mapping) =>
Boolean(mapping.context_path.trim()) &&
/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(mapping.destination_field.trim())
);
const handleSave = async () => {
setIsSaving(true);
@ -80,6 +92,7 @@ export const ConfigurationsDialog = ({
turn_stop_strategy: turnStopStrategy,
transcript_configuration: resolvedWorkflowConfigurations.transcript_configuration,
context_compaction_enabled: contextCompactionEnabled,
external_pbx_field_mappings: externalPbxFieldMappings,
}, name);
onOpenChange(false);
} catch (error) {
@ -103,12 +116,13 @@ export const ConfigurationsDialog = ({
setProvisionalVadPauseSecs(nextWorkflowConfigurations.provisional_vad_pause_secs);
setTurnStopStrategy(nextWorkflowConfigurations.turn_stop_strategy);
setContextCompactionEnabled(nextWorkflowConfigurations.context_compaction_enabled);
setExternalPbxFieldMappings(nextWorkflowConfigurations.external_pbx_field_mappings);
}
}, [open, workflowName, workflowConfigurations]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Configurations</DialogTitle>
</DialogHeader>
@ -401,13 +415,92 @@ export const ConfigurationsDialog = ({
</div>
</div>
</div>
{externalPbxIntegrationsEnabled && (
<div className="space-y-4 border-t pt-4">
<div>
<h3 className="text-sm font-semibold mb-1">External PBX Field Updates</h3>
<p className="text-xs text-muted-foreground">
Optionally copy final gathered-context values into provider-native fields before transfer or hangup.
</p>
</div>
<div className="flex items-center justify-between">
<Label className="text-sm">Field Mappings</Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setExternalPbxFieldMappings((current) => [
...current,
{ context_path: "", destination_field: "" },
])}
>
<Plus className="mr-1 h-4 w-4" /> Add mapping
</Button>
</div>
<div className="space-y-2">
{externalPbxFieldMappings.map((mapping, index) => (
<div key={index} className="grid grid-cols-[1fr_1fr_auto] gap-2">
<Input
aria-label={`Gathered context field ${index + 1}`}
value={mapping.context_path}
onChange={(event) => setExternalPbxFieldMappings((current) =>
current.map((item, itemIndex) =>
itemIndex === index
? { ...item, context_path: event.target.value }
: item
)
)}
placeholder="qualified"
/>
<Input
aria-label={`External PBX destination field ${index + 1}`}
value={mapping.destination_field}
onChange={(event) => setExternalPbxFieldMappings((current) =>
current.map((item, itemIndex) =>
itemIndex === index
? { ...item, destination_field: event.target.value }
: item
)
)}
placeholder="address3"
/>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={`Remove external PBX field mapping ${index + 1}`}
onClick={() => setExternalPbxFieldMappings((current) =>
current.filter((_, itemIndex) => itemIndex !== index)
)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
{externalPbxFieldMappings.length === 0 && (
<p className="text-xs text-muted-foreground">
No external fields will be updated. Context names may be direct extracted-variable names or paths such as extracted_variables.qualified.
</p>
)}
{!externalPbxFieldMappingsValid && (
<p className="text-xs text-destructive">
Each mapping needs a context field and a destination field containing only letters, numbers, and underscores.
</p>
)}
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSave} disabled={isSaving}>
<Button
onClick={handleSave}
disabled={isSaving || (externalPbxIntegrationsEnabled && !externalPbxFieldMappingsValid)}
>
{isSaving ? "Saving..." : "Save"}
</Button>
</DialogFooter>

View file

@ -1,7 +1,7 @@
"use client";
import { format } from "date-fns";
import { ArrowLeft, BookA, Brain, CalendarIcon, Clipboard, Download, ExternalLink, FileDown, Fingerprint, Loader2, Mic, Pause, PhoneOff, Play, Rocket, Settings, Trash2Icon, Upload, Variable, X } from "lucide-react";
import { ArrowLeft, BookA, Brain, CalendarIcon, Clipboard, Download, ExternalLink, FileDown, Fingerprint, Loader2, Mic, Pause, PhoneOff, Play, Plus, Rocket, Settings, Trash2Icon, Upload, Variable, X } from "lucide-react";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useMemo, useRef, useState } from "react";
@ -38,6 +38,7 @@ import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { SETTINGS_DOCUMENTATION_URLS } from "@/constants/documentation";
import { useOrgConfig } from "@/context/OrgConfigContext";
import { UnsavedChangesProvider, useUnsavedChanges, useUnsavedChangesContext } from "@/context/UnsavedChangesContext";
import { useAudioPlayback } from "@/hooks/useAudioPlayback";
import { detailFromError } from "@/lib/apiError";
@ -49,6 +50,7 @@ import {
DEFAULT_PROVISIONAL_VAD_PAUSE_SECS,
DEFAULT_TURN_START_MIN_WORDS,
DEFAULT_VOICEMAIL_DETECTION_CONFIGURATION,
type ExternalPBXFieldMapping,
resolveWorkflowConfigurations,
TURN_START_STRATEGY_OPTIONS,
type TurnStartStrategy,
@ -273,6 +275,7 @@ function GeneralSection({
workflowId: number;
onSave: (configurations: WorkflowConfigurations, workflowName: string) => Promise<void>;
}) {
const { externalPbxIntegrationsEnabled } = useOrgConfig();
const [name, setName] = useState(workflowName);
const [ambientNoiseConfig, setAmbientNoiseConfig] = useState<AmbientNoiseConfiguration>(
workflowConfigurations.ambient_noise_configuration,
@ -298,6 +301,9 @@ function GeneralSection({
const [includeTranscriptEndTimestamps, setIncludeTranscriptEndTimestamps] = useState(
workflowConfigurations.transcript_configuration?.include_end_timestamps ?? false,
);
const [externalPbxFieldMappings, setExternalPbxFieldMappings] = useState<ExternalPBXFieldMapping[]>(
workflowConfigurations.external_pbx_field_mappings,
);
const [isSaving, setIsSaving] = useState(false);
const [isUploadingAudio, setIsUploadingAudio] = useState(false);
const [audioUploadError, setAudioUploadError] = useState<string | null>(null);
@ -306,6 +312,11 @@ function GeneralSection({
const selectedTurnStartStrategy = TURN_START_STRATEGY_OPTIONS.find(
(option) => option.value === turnStartStrategy,
);
const externalPbxFieldMappingsValid = externalPbxFieldMappings.every(
(mapping) =>
Boolean(mapping.context_path.trim()) &&
/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(mapping.destination_field.trim()),
);
const isDirty = useMemo(() => {
const initAmbient = workflowConfigurations.ambient_noise_configuration;
@ -321,9 +332,11 @@ function GeneralSection({
turnStopStrategy !== workflowConfigurations.turn_stop_strategy ||
contextCompactionEnabled !== workflowConfigurations.context_compaction_enabled ||
includeTranscriptEndTimestamps !==
(workflowConfigurations.transcript_configuration?.include_end_timestamps ?? false)
(workflowConfigurations.transcript_configuration?.include_end_timestamps ?? false) ||
JSON.stringify(externalPbxFieldMappings) !==
JSON.stringify(workflowConfigurations.external_pbx_field_mappings)
);
}, [name, workflowName, ambientNoiseConfig, maxCallDuration, maxUserIdleTimeout, smartTurnStopSecs, turnStartStrategy, turnStartMinWords, provisionalVadPauseSecs, turnStopStrategy, contextCompactionEnabled, includeTranscriptEndTimestamps, workflowConfigurations]);
}, [name, workflowName, ambientNoiseConfig, maxCallDuration, maxUserIdleTimeout, smartTurnStopSecs, turnStartStrategy, turnStartMinWords, provisionalVadPauseSecs, turnStopStrategy, contextCompactionEnabled, includeTranscriptEndTimestamps, externalPbxFieldMappings, workflowConfigurations]);
useUnsavedChanges("general", isDirty);
@ -404,6 +417,7 @@ function GeneralSection({
...(workflowConfigurations.transcript_configuration ?? {}),
include_end_timestamps: includeTranscriptEndTimestamps,
},
external_pbx_field_mappings: externalPbxFieldMappings,
},
name,
);
@ -793,10 +807,94 @@ function GeneralSection({
</div>
</div>
</div>
{externalPbxIntegrationsEnabled && (
<>
<Separator />
{/* External PBX Field Updates */}
<div className="space-y-4">
<div>
<h3 className="text-sm font-medium">External PBX Field Updates</h3>
<p className="text-xs text-muted-foreground mt-0.5">
Optionally copy final gathered-context values into provider-native fields before transfer or hangup.
</p>
</div>
<div className="flex items-center justify-between">
<Label className="text-sm">Field Mappings</Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setExternalPbxFieldMappings((current) => [
...current,
{ context_path: "", destination_field: "" },
])}
>
<Plus className="mr-1 h-4 w-4" /> Add mapping
</Button>
</div>
<div className="space-y-2">
{externalPbxFieldMappings.map((mapping, index) => (
<div key={index} className="grid grid-cols-[1fr_1fr_auto] gap-2">
<Input
aria-label={`Gathered context field ${index + 1}`}
value={mapping.context_path}
onChange={(event) => setExternalPbxFieldMappings((current) =>
current.map((item, itemIndex) =>
itemIndex === index
? { ...item, context_path: event.target.value }
: item,
)
)}
placeholder="qualified"
/>
<Input
aria-label={`External PBX destination field ${index + 1}`}
value={mapping.destination_field}
onChange={(event) => setExternalPbxFieldMappings((current) =>
current.map((item, itemIndex) =>
itemIndex === index
? { ...item, destination_field: event.target.value }
: item,
)
)}
placeholder="address3"
/>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={`Remove external PBX field mapping ${index + 1}`}
onClick={() => setExternalPbxFieldMappings((current) =>
current.filter((_, itemIndex) => itemIndex !== index)
)}
>
<Trash2Icon className="h-4 w-4" />
</Button>
</div>
))}
{externalPbxFieldMappings.length === 0 && (
<p className="text-xs text-muted-foreground">
No external fields will be updated. Context names may be direct extracted-variable names or paths such as extracted_variables.qualified.
</p>
)}
{!externalPbxFieldMappingsValid && (
<p className="text-xs text-destructive">
Each mapping needs a context field and a destination field containing only letters, numbers, and underscores.
</p>
)}
</div>
</div>
</>
)}
</CardContent>
<CardFooter className="justify-end gap-3 border-t pt-6">
{isDirty && <span className="text-xs text-muted-foreground">Unsaved changes</span>}
<Button onClick={handleSave} disabled={isSaving || !isDirty}>
<Button
onClick={handleSave}
disabled={isSaving || !isDirty || (externalPbxIntegrationsEnabled && !externalPbxFieldMappingsValid)}
>
{isSaving ? "Saving..." : "Save General Settings"}
</Button>
</CardFooter>

File diff suppressed because one or more lines are too long

View file

@ -96,6 +96,10 @@ export type AriConfigurationRequest = {
* websocket_client.conf connection name for externalMedia (e.g., dograh_staging)
*/
ws_client_name?: string;
/**
* Optional external PBX connected through this Asterisk instance
*/
external_pbx?: VicidialExternalPbxConfiguration | null;
/**
* From Numbers
*
@ -130,6 +134,7 @@ export type AriConfigurationResponse = {
* Ws Client Name
*/
ws_client_name?: string;
external_pbx?: VicidialExternalPbxConfiguration | null;
/**
* From Numbers
*/
@ -1329,6 +1334,46 @@ export type CloudonixConfigurationResponse = {
from_numbers: Array<string>;
};
/**
* ContextDestinationMappingConfig
*
* Resolve an external-PBX destination from gathered context.
*/
export type ContextDestinationMappingConfig = {
/**
* Context Path
*
* Gathered-context path or extracted-variable name used for routing.
*/
context_path: string;
/**
* Routes
*/
routes: Array<ContextDestinationRoute>;
/**
* Fallback Destination
*
* Optional provider-native fallback destination.
*/
fallback_destination?: string | null;
};
/**
* ContextDestinationRoute
*
* Map one gathered-context value to an external-PBX destination.
*/
export type ContextDestinationRoute = {
/**
* Context Value
*/
context_value: string;
/**
* Destination
*/
destination: string;
};
/**
* CreateAPIKeyRequest
*/
@ -2472,6 +2517,22 @@ export type EndCallToolDefinition = {
config: EndCallConfig;
};
/**
* ExternalPBXFieldMapping
*
* Map one gathered-context value to a provider-native field.
*/
export type ExternalPbxFieldMapping = {
/**
* Context Path
*/
context_path: string;
/**
* Destination Field
*/
destination_field: string;
};
/**
* FileDescriptor
*
@ -3993,6 +4054,12 @@ export type OpenAiRealtimeLlmConfiguration = {
* Voice the model speaks in.
*/
voice?: string;
/**
* Language
*
* ISO 639-1 language code for input audio transcription (e.g. 'pt', 'es'). Improves transcription accuracy and latency. Leave unset to auto-detect.
*/
language?: string | null;
};
/**
@ -4192,6 +4259,10 @@ export type OrganizationPreferences = {
* Timezone
*/
timezone?: string | null;
/**
* External Pbx Integrations Enabled
*/
external_pbx_integrations_enabled?: boolean;
};
/**
@ -5703,6 +5774,20 @@ export type TelephonyProviderMetadata = {
docs_url?: string | null;
};
/**
* TelephonyProviderUICondition
*/
export type TelephonyProviderUiCondition = {
/**
* Field
*/
field: string;
/**
* Equals
*/
equals: unknown;
};
/**
* TelephonyProviderUIField
*
@ -5737,6 +5822,29 @@ export type TelephonyProviderUiField = {
* Placeholder
*/
placeholder?: string | null;
/**
* Options
*/
options?: Array<TelephonyProviderUiOption> | null;
visible_when?: TelephonyProviderUiCondition | null;
/**
* Section
*/
section?: string | null;
};
/**
* TelephonyProviderUIOption
*/
export type TelephonyProviderUiOption = {
/**
* Value
*/
value: string;
/**
* Label
*/
label: string;
};
/**
@ -6029,9 +6137,9 @@ export type TransferCallConfig = {
/**
* Destination Source
*
* Whether transfer destination is static/template or resolved by HTTP.
* Whether the destination is static/template, resolved by HTTP, or mapped from gathered context to an external-PBX destination.
*/
destination_source?: 'static' | 'dynamic';
destination_source?: 'static' | 'dynamic' | 'context_mapping';
/**
* Destination
*
@ -6072,6 +6180,10 @@ export type TransferCallConfig = {
* Optional resolver that determines transfer routing at call time.
*/
resolver?: HttpTransferResolverConfig | null;
/**
* Optional gathered-context to external-PBX destination mapping.
*/
context_mapping?: ContextDestinationMappingConfig | null;
};
/**
@ -6535,6 +6647,88 @@ export type ValidationError = {
};
};
/**
* VicidialAgentAPIConfiguration
*
* VICIdial remote-agent call-control API configuration.
*/
export type VicidialAgentApiConfiguration = {
/**
* Url
*
* Full URL to agc/api.php
*/
url: string;
/**
* Username
*
* VICIdial agent API user
*/
username: string;
/**
* Password
*
* VICIdial agent API password
*/
password: string;
/**
* Source
*
* VICIdial API source tag
*/
source?: string;
};
/**
* VicidialExternalPBXConfiguration
*
* External-PBX configuration used by the VICIdial strategy adapter.
*/
export type VicidialExternalPbxConfiguration = {
/**
* Type
*/
type?: 'vicidial';
agent_api: VicidialAgentApiConfiguration;
non_agent_api?: VicidialNonAgentApiConfiguration | null;
/**
* Timeout Seconds
*/
timeout_seconds?: number;
};
/**
* VicidialNonAgentAPIConfiguration
*
* Optional VICIdial non-agent API configuration for lead updates.
*/
export type VicidialNonAgentApiConfiguration = {
/**
* Url
*
* Full non_agent_api.php URL
*/
url?: string | null;
/**
* Username
*
* Non-agent API user
*/
username?: string | null;
/**
* Password
*
* Non-agent API password
*/
password?: string | null;
/**
* Source
*
* Non-agent API source tag
*/
source?: string;
};
/**
* VobizConfigurationRequest
*
@ -6800,6 +6994,10 @@ export type WorkflowConfigurationDefaults = {
* Context Compaction Enabled
*/
context_compaction_enabled?: boolean;
/**
* External Pbx Field Mappings
*/
external_pbx_field_mappings?: Array<ExternalPbxFieldMapping>;
[key: string]: unknown;
};
@ -8709,10 +8907,14 @@ export type GetWorkflowRunsApiV1WorkflowWorkflowIdRunsGetData = {
query?: {
/**
* Page
*
* Page number (starts from 1)
*/
page?: number;
/**
* Limit
*
* Number of items per page
*/
limit?: number;
/**
@ -9980,10 +10182,14 @@ export type GetCampaignRunsApiV1CampaignCampaignIdRunsGetData = {
query?: {
/**
* Page
*
* Page number (starts from 1)
*/
page?: number;
/**
* Limit
*
* Number of items per page
*/
limit?: number;
/**

View file

@ -13,6 +13,7 @@ import type { OrganizationPreferences } from "@/client/types.gen";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { useUserConfig } from "@/context/UserConfigContext";
import { detailFromError } from "@/lib/apiError";
import { useAuth } from "@/lib/auth";
@ -20,6 +21,7 @@ import { useAuth } from "@/lib/auth";
const emptyPreferences: OrganizationPreferences = {
test_phone_number: "",
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
external_pbx_integrations_enabled: false,
};
const timezoneSelectStyles = {
@ -130,6 +132,8 @@ export function OrganizationPreferencesSection() {
setPreferences({
test_phone_number: nextPreferences.test_phone_number || "",
timezone: nextPreferences.timezone || emptyPreferences.timezone,
external_pbx_integrations_enabled:
nextPreferences.external_pbx_integrations_enabled ?? false,
});
setTimezone(
nextPreferences.timezone || emptyPreferences.timezone || "UTC",
@ -151,6 +155,8 @@ export function OrganizationPreferencesSection() {
body: {
test_phone_number: preferences.test_phone_number || null,
timezone: getTimezoneValue(timezone),
external_pbx_integrations_enabled:
preferences.external_pbx_integrations_enabled ?? false,
},
},
);
@ -167,6 +173,8 @@ export function OrganizationPreferencesSection() {
setPreferences({
test_phone_number: result.data.test_phone_number || "",
timezone: result.data.timezone || emptyPreferences.timezone,
external_pbx_integrations_enabled:
result.data.external_pbx_integrations_enabled ?? false,
});
setTimezone(result.data.timezone || emptyPreferences.timezone || "UTC");
await refreshConfig();
@ -212,6 +220,28 @@ export function OrganizationPreferencesSection() {
/>
</div>
</div>
<div className="flex items-start justify-between gap-4 rounded-lg border p-4">
<div className="space-y-1">
<Label htmlFor="settings-external-pbx-integrations">
External PBX integrations
</Label>
<p className="text-xs text-muted-foreground">
Show and enable advanced external-PBX configuration for Asterisk,
transfer tools, and workflows. Existing configuration is preserved
when this is disabled.
</p>
</div>
<Switch
id="settings-external-pbx-integrations"
checked={preferences.external_pbx_integrations_enabled ?? false}
onCheckedChange={(checked) =>
setPreferences({
...preferences,
external_pbx_integrations_enabled: checked,
})
}
/>
</div>
<Button type="submit" disabled={saving}>
<Save className="mr-2 h-4 w-4" />
{saving ? "Saving..." : "Save"}

View file

@ -50,6 +50,45 @@ interface ConfigFormDialogProps {
type FieldValue = string | number | boolean | undefined;
type FieldValues = Record<string, FieldValue>;
function flattenValues(
value: Record<string, unknown>,
prefix = "",
): FieldValues {
const flattened: FieldValues = {};
for (const [key, child] of Object.entries(value)) {
const path = prefix ? `${prefix}.${key}` : key;
if (child && typeof child === "object" && !Array.isArray(child)) {
Object.assign(flattened, flattenValues(child as Record<string, unknown>, path));
} else if (
child === undefined ||
typeof child === "string" ||
typeof child === "number" ||
typeof child === "boolean"
) {
flattened[path] = child;
}
}
return flattened;
}
function nestValues(values: FieldValues): Record<string, unknown> {
const nested: Record<string, unknown> = {};
for (const [path, value] of Object.entries(values)) {
if (value === undefined || value === "") continue;
const parts = path.split(".");
let current = nested;
for (const part of parts.slice(0, -1)) {
const child = current[part];
if (!child || typeof child !== "object" || Array.isArray(child)) {
current[part] = {};
}
current = current[part] as Record<string, unknown>;
}
current[parts[parts.length - 1]] = value;
}
return nested;
}
export function ConfigFormDialog({
open,
onOpenChange,
@ -71,6 +110,15 @@ export function ConfigFormDialog({
() => providers.find((p) => p.provider === providerName),
[providers, providerName],
);
const visibleFields = useMemo(
() =>
currentProvider?.fields.filter(
(field) =>
!field.visible_when ||
values[field.visible_when.field] === field.visible_when.equals,
) ?? [],
[currentProvider, values],
);
// Fetch provider metadata once when the dialog opens.
useEffect(() => {
@ -88,7 +136,7 @@ export function ConfigFormDialog({
setProviderName(existing.provider);
setName(existing.name);
setIsDefault(existing.is_default_outbound);
setValues((existing.credentials ?? {}) as FieldValues);
setValues(flattenValues(existing.credentials ?? {}));
} else if (list.length > 0 && !providerName) {
setProviderName(list[0].provider);
setValues({});
@ -106,7 +154,15 @@ export function ConfigFormDialog({
}, [providerName, isEdit]);
const updateField = (fieldName: string, value: FieldValue) => {
setValues((prev) => ({ ...prev, [fieldName]: value }));
setValues((prev) => {
const next = { ...prev, [fieldName]: value };
if (value === undefined) {
for (const field of currentProvider?.fields ?? []) {
if (field.visible_when?.field === fieldName) delete next[field.name];
}
}
return next;
});
};
const handleSubmit = async () => {
@ -123,7 +179,7 @@ export function ConfigFormDialog({
// Build the provider-discriminated config payload from collected values.
const configPayload = {
provider: providerName,
...values,
...nestValues(values),
} as unknown as TelephonyConfigPayload;
if (isEdit && existing) {
@ -253,8 +309,13 @@ export function ConfigFormDialog({
{currentProvider && (
<div className="space-y-3 border-t pt-3">
{currentProvider.fields.map((field) => (
{visibleFields.map((field, index) => (
<div className="space-y-1" key={field.name}>
{field.section && field.section !== visibleFields[index - 1]?.section && (
<div className="pb-2 pt-3">
<h3 className="text-sm font-semibold">{field.section}</h3>
</div>
)}
<Label htmlFor={`cfg-field-${field.name}`}>
{field.label}
{!field.required && (
@ -345,6 +406,26 @@ function FieldInput({ field, value, onChange, isEdit }: FieldInputProps) {
/>
);
}
if (field.type === "select") {
return (
<Select
value={value === undefined ? "__none__" : String(value)}
onValueChange={(next) => onChange(next === "__none__" ? undefined : next)}
>
<SelectTrigger id={`cfg-field-${field.name}`}>
<SelectValue placeholder={placeholder || "Select an option"} />
</SelectTrigger>
<SelectContent>
{!field.required && <SelectItem value="__none__">Not configured</SelectItem>}
{(field.options ?? []).map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
return (
<Input
id={`cfg-field-${field.name}`}

View file

@ -0,0 +1,92 @@
import { render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { OrgConfigProvider, useOrgConfig } from './OrgConfigContext';
const {
getCurrentOrganizationContextMock,
getPreferencesMock,
getUserConfigurationsMock,
useAuthMock,
} = vi.hoisted(() => ({
getCurrentOrganizationContextMock: vi.fn(),
getPreferencesMock: vi.fn(),
getUserConfigurationsMock: vi.fn(),
useAuthMock: vi.fn(),
}));
vi.mock('@/client/sdk.gen', () => ({
getCurrentOrganizationContextApiV1OrganizationsContextGet: getCurrentOrganizationContextMock,
getPreferencesApiV1OrganizationsPreferencesGet: getPreferencesMock,
getUserConfigurationsApiV1UserConfigurationsUserGet: getUserConfigurationsMock,
}));
vi.mock('@/lib/apiClient', () => ({
createClientConfig: (config: unknown) => config,
setupAuthInterceptor: vi.fn(),
}));
vi.mock('@/lib/auth', () => ({
useAuth: useAuthMock,
}));
function ContextState() {
const { error, loading } = useOrgConfig();
return (
<div>
<span data-testid="loading">{String(loading)}</span>
<span data-testid="error">{error?.message ?? ''}</span>
</div>
);
}
describe('OrgConfigProvider', () => {
beforeEach(() => {
vi.clearAllMocks();
useAuthMock.mockReturnValue({
user: { id: 'user-1', provider: 'local' },
isAuthenticated: true,
loading: false,
getAccessToken: vi.fn(async () => 'token'),
redirectToLogin: vi.fn(),
logout: vi.fn(async () => undefined),
provider: 'local',
});
getCurrentOrganizationContextMock.mockResolvedValue({
data: {
organization_id: 1,
organization_provider_id: null,
model_services: {
config_source: 'empty',
has_model_configuration_v2: false,
managed_service_version: null,
uses_managed_service_v2: false,
},
},
error: undefined,
});
getUserConfigurationsMock.mockResolvedValue({
data: {},
error: undefined,
});
});
it('surfaces an HTTP error returned while loading organization preferences', async () => {
getPreferencesMock.mockResolvedValue({
data: undefined,
error: { detail: 'Preferences unavailable' },
});
render(
<OrgConfigProvider>
<ContextState />
</OrgConfigProvider>,
);
await waitFor(() => {
expect(screen.getByTestId('loading').textContent).toBe('false');
});
expect(screen.getByTestId('error').textContent).toBe('Preferences unavailable');
});
});

View file

@ -3,9 +3,10 @@
import { createContext, ReactNode, useCallback, useContext, useEffect, useRef, useState } from 'react';
import { client } from '@/client/client.gen';
import { getCurrentOrganizationContextApiV1OrganizationsContextGet, getUserConfigurationsApiV1UserConfigurationsUserGet } from '@/client/sdk.gen';
import type { OrganizationContextResponse, UserConfigurationRequestResponseSchema } from '@/client/types.gen';
import { getCurrentOrganizationContextApiV1OrganizationsContextGet, getPreferencesApiV1OrganizationsPreferencesGet, getUserConfigurationsApiV1UserConfigurationsUserGet } from '@/client/sdk.gen';
import type { OrganizationContextResponse, OrganizationPreferences, UserConfigurationRequestResponseSchema } from '@/client/types.gen';
import { setupAuthInterceptor } from '@/lib/apiClient';
import { detailFromError } from '@/lib/apiError';
import type { AuthUser } from '@/lib/auth';
import { useAuth } from '@/lib/auth';
@ -28,6 +29,8 @@ interface OrgConfigContextType {
permissions: TeamPermission[];
user: AuthUser | null;
organizationPricing: OrganizationPricing | null;
organizationPreferences: OrganizationPreferences | null;
externalPbxIntegrationsEnabled: boolean;
}
const OrgConfigContext = createContext<OrgConfigContextType | null>(null);
@ -52,6 +55,7 @@ export function OrgConfigProvider({ children }: { children: ReactNode }) {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const [organizationPricing, setOrganizationPricing] = useState<OrganizationPricing | null>(null);
const [organizationPreferences, setOrganizationPreferences] = useState<OrganizationPreferences | null>(null);
const [permissions, setPermissions] = useState<TeamPermission[]>([]);
const auth = useAuth();
@ -102,11 +106,16 @@ export function OrgConfigProvider({ children }: { children: ReactNode }) {
setLoading(true);
try {
const [orgContextResponse, userConfigResponse] = await Promise.all([
const [orgContextResponse, userConfigResponse, preferencesResponse] = await Promise.all([
getCurrentOrganizationContextApiV1OrganizationsContextGet(),
getUserConfigurationsApiV1UserConfigurationsUserGet(),
getPreferencesApiV1OrganizationsPreferencesGet(),
]);
if (preferencesResponse.error) {
throw new Error(detailFromError(preferencesResponse.error, 'Failed to load organization preferences'));
}
if (orgContextResponse.data) {
setOrgContext(orgContextResponse.data);
}
@ -116,6 +125,10 @@ export function OrgConfigProvider({ children }: { children: ReactNode }) {
setOrganizationPricing(pricingFromUserConfig(userConfigResponse.data));
}
if (preferencesResponse.data) {
setOrganizationPreferences(preferencesResponse.data);
}
setError(null);
} catch (err) {
setError(err instanceof Error ? err : new Error('Failed to fetch organization configuration'));
@ -147,6 +160,9 @@ export function OrgConfigProvider({ children }: { children: ReactNode }) {
permissions,
user: auth.user,
organizationPricing,
organizationPreferences,
externalPbxIntegrationsEnabled:
organizationPreferences?.external_pbx_integrations_enabled ?? false,
}}
>
{children}

View file

@ -64,6 +64,11 @@ export interface TranscriptConfiguration {
include_end_timestamps: boolean;
}
export interface ExternalPBXFieldMapping {
context_path: string;
destination_field: string;
}
export const DEFAULT_TRANSCRIPT_CONFIGURATION: TranscriptConfiguration = {
include_end_timestamps: false,
};
@ -110,6 +115,7 @@ type WorkflowConfigurationBase = Omit<
| "turn_stop_strategy"
| "dictionary"
| "context_compaction_enabled"
| "external_pbx_field_mappings"
>;
export type WorkflowConfigurations = WorkflowConfigurationBase & {
@ -125,6 +131,7 @@ export type WorkflowConfigurations = WorkflowConfigurationBase & {
voicemail_detection?: VoicemailDetectionConfiguration;
transcript_configuration: TranscriptConfiguration;
context_compaction_enabled: boolean; // Summarize context on node transitions to remove stale tool calls
external_pbx_field_mappings: ExternalPBXFieldMapping[];
model_overrides?: ModelOverrides; // Per-workflow model configuration overrides
model_configuration_v2_override?: OrganizationAiModelConfigurationV2; // Full v2 model configuration override
[key: string]: unknown; // Allow additional properties for future configurations
@ -145,6 +152,7 @@ const FALLBACK_WORKFLOW_CONFIGURATIONS: WorkflowConfigurations = {
dictionary: '',
transcript_configuration: DEFAULT_TRANSCRIPT_CONFIGURATION,
context_compaction_enabled: false,
external_pbx_field_mappings: [],
};
export function resolveWorkflowConfigurations(
@ -196,6 +204,10 @@ export function resolveWorkflowConfigurations(
configurations?.context_compaction_enabled
?? defaults?.context_compaction_enabled
?? FALLBACK_WORKFLOW_CONFIGURATIONS.context_compaction_enabled,
external_pbx_field_mappings:
configurations?.external_pbx_field_mappings
?? defaults?.external_pbx_field_mappings
?? FALLBACK_WORKFLOW_CONFIGURATIONS.external_pbx_field_mappings,
transcript_configuration: {
...DEFAULT_TRANSCRIPT_CONFIGURATION,
...(defaults?.transcript_configuration as Partial<TranscriptConfiguration> | undefined),