Merge remote-tracking branch 'origin/main' into review-560

# Conflicts:
#	api/services/telephony/ari_manager.py
This commit is contained in:
Abhishek Kumar 2026-07-21 13:18:21 +05:30
commit 045a012c40
75 changed files with 5418 additions and 368 deletions

View file

@ -229,6 +229,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

@ -696,8 +696,8 @@ async def update_campaign(
@router.get("/{campaign_id}/runs")
async def get_campaign_runs(
campaign_id: int,
page: int = 1,
limit: int = 50,
page: int = Query(1, ge=1, description="Page number (starts from 1)"),
limit: int = Query(50, ge=1, le=100, description="Number of items per page"),
filters: Optional[str] = Query(None, description="JSON-encoded filter criteria"),
sort_by: Optional[str] = Query(
None, description="Field to sort by (e.g., 'duration', 'created_at')"

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

@ -1,5 +1,6 @@
"""API routes for managing tools."""
import time
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException
@ -22,6 +23,8 @@ from api.schemas.tool import (
ToolDefinition,
ToolParameter,
ToolResponse,
ToolTestRequest,
ToolTestResponse,
TransferCallConfig,
TransferCallToolDefinition,
UpdateToolRequest,
@ -33,11 +36,16 @@ 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 (
populate_discovered_tools as _populate_discovered_tools,
)
from api.services.workflow.tools.custom_tool import (
execute_http_tool,
serialize_query_params,
)
router = APIRouter(prefix="/tools")
@ -56,6 +64,8 @@ __all__ = [
"ToolDefinition",
"ToolParameter",
"ToolResponse",
"ToolTestRequest",
"ToolTestResponse",
"TransferCallConfig",
"TransferCallToolDefinition",
"UpdateToolRequest",
@ -195,6 +205,149 @@ async def refresh_mcp_tools(
raise HTTPException(status_code=e.status_code, detail=e.message) from e
@router.post("/{tool_uuid}/test")
async def test_tool(
tool_uuid: str,
request: ToolTestRequest,
user: UserModel = Depends(get_user),
) -> ToolTestResponse:
"""Execute an HTTP API tool with sample LLM and preset parameters."""
if not user.selected_organization_id:
raise HTTPException(
status_code=400, detail="No organization selected for the user"
)
tool = await db_client.get_tool_by_uuid(
tool_uuid, user.selected_organization_id, include_archived=True
)
if not tool:
raise HTTPException(status_code=404, detail="Tool not found")
if tool.category != ToolCategory.HTTP_API.value:
raise HTTPException(status_code=400, detail="Only HTTP API tools can be tested")
tool_config = (
tool.definition.get("config", {}) if isinstance(tool.definition, dict) else {}
)
configured_method = tool_config.get("method", "?")
configured_url = tool_config.get("url", "?")
started_at = time.perf_counter()
result = await execute_http_tool(
tool,
request.llm_params,
preset_params=request.preset_params,
organization_id=user.selected_organization_id,
include_request_headers=True,
)
duration_ms = max(0, round((time.perf_counter() - started_at) * 1000))
status = result.get("status", "error")
status_code = result.get("status_code")
if status_code is not None and status_code >= 400:
status = "error"
hint = _hint_for_status_code(status_code, configured_method)
# Preset values take precedence over model-supplied values, matching live
# execution after configured preset templates have been resolved.
resolved_arguments = {**request.llm_params, **request.preset_params}
# Mirror execute_http_tool's own branch: POST/PUT/PATCH send the
# resolved arguments as a JSON body; GET/DELETE send them as query
# params. Never both.
request_body = None
request_params = None
if configured_method in ("POST", "PUT", "PATCH"):
request_body = resolved_arguments # keep {} so preview matches wire request
elif resolved_arguments:
request_params = serialize_query_params(resolved_arguments)
return ToolTestResponse(
status=status,
status_code=status_code,
data=result.get("data"),
error=result.get("error"),
duration_ms=duration_ms,
hint=hint,
request_method=configured_method,
request_url=configured_url,
request_headers=result.get("request_headers", {}),
request_body=request_body,
request_params=request_params,
)
def _hint_for_status_code(
status_code: Optional[int], configured_method: str
) -> Optional[str]:
"""Human-readable explanation for a status code a misconfigured tool
is likely to hit. Returns None for 2xx and any code not covered."""
if status_code == 400:
return (
"HTTP 400 Bad Request — the server rejected the request payload. "
"Verify the arguments/body match what this endpoint expects."
)
if status_code == 401:
return (
"HTTP 401 Unauthorized — the request wasn't authenticated. Check "
"the credential configured on the Authentication tab is present "
"and valid."
)
if status_code == 403:
return (
"HTTP 403 Forbidden — authenticated, but the configured "
"credential doesn't have permission for this endpoint/action."
)
if status_code == 404:
return (
f"HTTP 404 Not Found — verify the endpoint URL is correct and "
f"that {configured_method} is a valid method for it."
)
if status_code == 405:
return (
f"HTTP 405 Method Not Allowed — the endpoint rejected the "
f"configured method ({configured_method}). Verify the API expects "
f"{configured_method} for this URL."
)
if status_code == 408:
return (
"HTTP 408 Request Timeout — the endpoint didn't respond in time. "
"Check the endpoint is reachable, or increase Timeout (ms) if it's "
"just slow."
)
if status_code == 409:
return (
"HTTP 409 Conflict — the endpoint rejected the request due to a "
"conflicting resource state (e.g. duplicate create). Not "
"necessarily a configuration problem."
)
if status_code == 415:
return (
"HTTP 415 Unsupported Media Type — check the Content-Type header "
"matches the format this endpoint expects for the body."
)
if status_code == 422:
return (
"HTTP 422 Unprocessable Entity — the request was well-formed but "
"the payload's structure or field types don't match what this "
"endpoint expects. Compare your arguments against the API's "
"documented schema."
)
if status_code == 429:
return (
"HTTP 429 Too Many Requests — the endpoint is rate-limiting. Wait "
"and retry; not a configuration problem."
)
if status_code is not None and 500 <= status_code < 600:
return (
f"HTTP {status_code} — the endpoint itself errored. This is "
"likely an issue on the API's side, not your tool configuration."
)
return None
@router.put("/{tool_uuid}")
async def update_tool(
tool_uuid: str,
@ -223,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
@ -1051,6 +1056,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
):
@ -1407,8 +1422,8 @@ class WorkflowRunsResponse(BaseModel):
@router.get("/{workflow_id}/runs")
async def get_workflow_runs(
workflow_id: int,
page: int = 1,
limit: int = 50,
page: int = Query(1, ge=1, description="Page number (starts from 1)"),
limit: int = Query(50, ge=1, le=100, description="Number of items per page"),
filters: Optional[str] = Query(None, description="JSON-encoded filter criteria"),
sort_by: Optional[str] = Query(
None, description="Field to sort by (e.g., 'duration', 'created_at')"

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
@ -491,3 +561,32 @@ class McpRefreshResponse(BaseModel):
tool_uuid: str
discovered_tools: list = Field(default_factory=list)
error: Optional[str] = None
class ToolTestRequest(BaseModel):
"""Request body for testing an HTTP API tool outside a live call."""
llm_params: Dict[str, Any] = Field(
default_factory=dict,
description="Values for parameters normally supplied by the model.",
)
preset_params: Dict[str, Any] = Field(
default_factory=dict,
description="Resolved values for parameters normally supplied from presets.",
)
class ToolTestResponse(BaseModel):
"""Result of testing an HTTP API tool."""
status: str
status_code: Optional[int] = None
data: Optional[Any] = None
error: Optional[str] = None
hint: Optional[str] = None
request_method: str
request_url: str
request_headers: Dict[str, str] = Field(default_factory=dict)
request_body: Optional[Dict[str, Any]] = None
request_params: Optional[Dict[str, Any]] = None
duration_ms: int

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

@ -584,6 +584,20 @@ class SarvamLLMConfiguration(BaseLLMConfiguration):
OPENAI_REALTIME_MODELS = ["gpt-realtime-2"]
# ISO 639-1 codes accepted by the Realtime API's input_audio_transcription.
# Not exhaustive — the field allows custom input.
OPENAI_REALTIME_LANGUAGES = [
"en",
"es",
"pt",
"fr",
"de",
"it",
"hi",
"ja",
"ko",
"zh",
]
OPENAI_REALTIME_VOICES = [
"alloy",
"ash",
@ -618,6 +632,17 @@ class OpenAIRealtimeLLMConfiguration(BaseLLMConfiguration):
"allow_custom_input": True,
},
)
language: str | None = Field(
default=None,
description=(
"ISO 639-1 language code for input audio transcription (e.g. 'pt', 'es'). "
"Improves transcription accuracy and latency. Leave unset to auto-detect."
),
json_schema_extra={
"examples": OPENAI_REALTIME_LANGUAGES,
"allow_custom_input": True,
},
)
GROK_REALTIME_MODELS = ["grok-voice-think-fast-1.0"]
@ -1307,7 +1332,6 @@ class XAITTSConfiguration(BaseServiceConfiguration):
description="BCP-47 language code for synthesis (e.g. 'en', 'fr', 'de'), or 'auto' for automatic language detection.",
json_schema_extra={"allow_custom_input": True},
)
@computed_field
@property
def model(self) -> str:

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

@ -52,16 +52,6 @@ class AudioConfig:
)
self.pipeline_sample_rate = 16000
# Log configuration for auditing
logger.info(
f"AudioConfig initialized: "
f"transport_in={self.transport_in_sample_rate}Hz, "
f"transport_out={self.transport_out_sample_rate}Hz, "
f"vad={self.vad_sample_rate}Hz, "
f"pipeline={self.pipeline_sample_rate}Hz, "
f"buffer={self.buffer_size_seconds}s"
)
@property
def buffer_size_bytes(self) -> int:
"""Calculate buffer size in bytes based on pipeline sample rate."""

View file

@ -95,6 +95,9 @@ from pipecat.turns.user_start import (
MinWordsUserTurnStartStrategy,
ProvisionalVADUserTurnStartStrategy,
)
from pipecat.turns.user_start.transcription_user_turn_start_strategy import (
TranscriptionUserTurnStartStrategy,
)
from pipecat.turns.user_start.vad_user_turn_start_strategy import (
VADUserTurnStartStrategy,
)
@ -162,7 +165,7 @@ def _create_non_realtime_user_turn_start_strategies(
return [
ProvisionalVADUserTurnStartStrategy(
pause_secs=_resolve_provisional_vad_pause_secs(run_configs)
)
),
]
if uses_external_turns:
@ -172,7 +175,7 @@ def _create_non_realtime_user_turn_start_strategies(
# confirms a real turn.
return [ExternalUserTurnStartStrategy(enable_interruptions=True)]
return [VADUserTurnStartStrategy()]
return [TranscriptionUserTurnStartStrategy(), VADUserTurnStartStrategy()]
def _create_non_realtime_user_turn_stop_strategies(

View file

@ -86,7 +86,7 @@ from pipecat.services.speechmatics.stt import (
SpeechmaticsSTTService,
SpeechmaticsSTTSettings,
)
from pipecat.services.xai.tts import XAIHttpTTSService, XAITTSSettings
from pipecat.services.xai.tts import XAITTSService, XAIWebsocketTTSSettings
from pipecat.transcriptions.language import Language
from pipecat.utils.text.xml_function_tag_filter import XMLFunctionTagFilter
@ -227,7 +227,6 @@ def create_stt_service(
# Other models than flux
# Use language from user config, defaulting to "multi" for multilingual support
language = getattr(user_config.stt, "language", None) or "multi"
logger.debug(f"Using DeepGram Model - {user_config.stt.model}")
return DeepgramSTTService(
api_key=user_config.stt.api_key,
settings=DeepgramSTTSettings(
@ -818,11 +817,9 @@ def create_tts_service(
pipecat_language = Language(language_code)
except ValueError:
pipecat_language = Language.EN
return XAIHttpTTSService(
return XAITTSService(
api_key=user_config.tts.api_key,
sample_rate=audio_config.transport_out_sample_rate,
encoding="pcm",
settings=XAITTSSettings(
settings=XAIWebsocketTTSSettings(
voice=voice,
language=pipecat_language,
),
@ -1009,6 +1006,13 @@ def create_realtime_llm_service(user_config, audio_config: "AudioConfig"):
SessionProperties,
)
# Pin the transcription language when configured. Without it the model
# auto-detects per utterance, which misfires on short/noisy telephony
# audio (e.g. Portuguese transcribed as English or Chinese).
transcription_kwargs = {}
if language:
transcription_kwargs["language"] = language
return DograhOpenAIRealtimeLLMService(
api_key=api_key,
settings=DograhOpenAIRealtimeLLMService.Settings(
@ -1016,7 +1020,9 @@ def create_realtime_llm_service(user_config, audio_config: "AudioConfig"):
session_properties=SessionProperties(
audio=AudioConfiguration(
input=AudioInput(
transcription=InputAudioTranscription(),
transcription=InputAudioTranscription(
**transcription_kwargs
),
),
output=AudioOutput(
voice=voice or "alloy",

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,
@ -57,6 +59,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
@ -64,6 +67,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
@ -288,7 +293,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}"
)
@ -414,7 +419,7 @@ class ARIConnection:
)
else:
logger.debug(
logger.trace(
f"[ARI org={self.organization_id}] Event: {event_type} "
f"channel={channel_id}"
)
@ -429,9 +434,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:
@ -445,6 +450,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,
@ -594,18 +634,11 @@ class ARIConnection:
# 3. Create workflow run
call_id = channel_id
workflow = await db_client.get_workflow(
inbound_workflow_id, organization_id=self.organization_id
)
if not workflow:
logger.error(
f"[ARI org={self.organization_id}] Workflow "
f"{inbound_workflow_id} not found"
)
await call_concurrency.release_slot(concurrency_slot)
await self._delete_channel(channel_id)
return
run_inputs = await prepare_workflow_run_inputs(db_client, workflow)
# 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,
@ -618,6 +651,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,
@ -1165,6 +1199,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,
@ -1173,6 +1208,7 @@ class ARIManager:
app_name,
app_password,
ws_client_name,
external_pbx_config,
)
key = conn.connection_key
@ -1197,6 +1233,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} "
@ -1234,6 +1271,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(
@ -1256,6 +1298,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

@ -8,6 +8,7 @@ import httpx
from loguru import logger
from api.db import db_client
from api.services.configuration.masking import mask_key
from api.utils.credential_auth import build_auth_header
from api.utils.template_renderer import render_template
@ -21,6 +22,19 @@ TYPE_MAP = {
}
def serialize_query_params(arguments: Dict[str, Any]) -> Dict[str, Any]:
"""JSON-stringify dict/list values so they're safe to pass as query params.
httpx (and query strings in general) only support primitive param values.
Object/array-typed tool arguments must be serialized before going out as
GET/DELETE query params, otherwise httpx raises a TypeError.
"""
return {
k: json.dumps(v) if isinstance(v, (dict, list)) else v
for k, v in arguments.items()
}
def tool_to_function_schema(tool: Any) -> Dict[str, Any]:
"""Convert a ToolModel to an LLM function schema.
@ -224,7 +238,9 @@ async def execute_http_tool(
arguments: Dict[str, Any],
call_context_vars: Optional[Dict[str, Any]] = None,
gathered_context_vars: Optional[Dict[str, Any]] = None,
preset_params: Optional[Dict[str, Any]] = None,
organization_id: Optional[int] = None,
include_request_headers: bool = False,
) -> Dict[str, Any]:
"""Execute an HTTP API tool.
@ -233,7 +249,11 @@ async def execute_http_tool(
arguments: Arguments passed by the LLM (parameter name -> value)
call_context_vars: Initial context variables available at runtime
gathered_context_vars: Variables extracted during the conversation
preset_params: Pre-resolved preset parameter values. Used by the test
endpoint; live calls omit this so configured templates are resolved.
organization_id: Organization ID for credential lookup
include_request_headers: Include a client-safe header preview in the result.
Headers supplied by a stored credential are masked.
Returns:
Result dict with response data or error
@ -248,7 +268,9 @@ async def execute_http_tool(
# Get headers from config
headers = dict(config.get("headers", {}) or {})
# Add auth header if credential is configured
# Add auth header if credential is configured. Keep track of which headers
# came from the credential so only those values are masked in test previews.
credential_headers: Dict[str, str] = {}
credential_uuid = config.get("credential_uuid")
if credential_uuid and organization_id:
try:
@ -256,8 +278,8 @@ async def execute_http_tool(
credential_uuid, organization_id
)
if credential:
auth_header = build_auth_header(credential)
headers.update(auth_header)
credential_headers = build_auth_header(credential)
headers.update(credential_headers)
logger.debug(f"Applied credential '{credential.name}' to tool request")
else:
logger.warning(
@ -266,17 +288,31 @@ async def execute_http_tool(
except Exception as e:
logger.error(f"Failed to fetch credential for tool '{tool.name}': {e}")
request_headers: Dict[str, str] = {}
if include_request_headers:
request_headers = {str(name): str(value) for name, value in headers.items()}
for header_name, header_value in credential_headers.items():
request_headers[header_name] = mask_key(str(header_value))
def build_result(result: Dict[str, Any]) -> Dict[str, Any]:
if include_request_headers:
return {**result, "request_headers": request_headers}
return result
# Get timeout
timeout_ms = config.get("timeout_ms", 5000)
timeout_seconds = timeout_ms / 1000
try:
preset_arguments = _resolve_preset_parameters(
config, call_context_vars, gathered_context_vars
)
except ValueError as e:
logger.error(f"Custom tool '{tool.name}' preset parameter error: {e}")
return {"status": "error", "error": str(e)}
if preset_params is None:
try:
preset_arguments = _resolve_preset_parameters(
config, call_context_vars, gathered_context_vars
)
except ValueError as e:
logger.error(f"Custom tool '{tool.name}' preset parameter error: {e}")
return build_result({"status": "error", "error": str(e)})
else:
preset_arguments = dict(preset_params)
resolved_arguments = {**(arguments or {}), **preset_arguments}
@ -286,7 +322,7 @@ async def execute_http_tool(
if method in ("POST", "PUT", "PATCH"):
body = resolved_arguments
elif method in ("GET", "DELETE") and resolved_arguments:
params = resolved_arguments
params = serialize_query_params(resolved_arguments)
logger.info(
f"Executing custom tool '{tool.name}' ({tool.tool_uuid}): {method} {url}"
@ -322,23 +358,29 @@ async def execute_http_tool(
logger.debug(
f"Custom tool '{tool.name}' completed with status {response.status_code}"
)
return result
return build_result(result)
except httpx.TimeoutException:
logger.error(f"Custom tool '{tool.name}' timed out after {timeout_seconds}s")
return {
"status": "error",
"error": f"Request timed out after {timeout_seconds} seconds",
}
return build_result(
{
"status": "error",
"error": f"Request timed out after {timeout_seconds} seconds",
}
)
except httpx.RequestError as e:
logger.error(f"Custom tool '{tool.name}' request failed: {e}")
return {
"status": "error",
"error": f"Request failed: {str(e)}",
}
return build_result(
{
"status": "error",
"error": f"Request failed: {str(e)}",
}
)
except Exception as e:
logger.error(f"Custom tool '{tool.name}' execution failed: {e}")
return {
"status": "error",
"error": f"Tool execution failed: {str(e)}",
}
return build_result(
{
"status": "error",
"error": f"Tool execution failed: {str(e)}",
}
)

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

@ -30,6 +30,7 @@ from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.services.llm_service import FunctionCallParams
from api.enums import WorkflowRunMode
from api.services.configuration.masking import mask_key
from api.services.workflow.pipecat_engine_custom_tools import get_function_schema
from api.services.workflow.tools.custom_tool import (
_coerce_parameter_value,
@ -430,6 +431,7 @@ class TestExecuteHttpTool:
assert result["status"] == "success"
assert result["status_code"] == 201
assert result["data"]["id"] == 123
assert "request_headers" not in result
@pytest.mark.asyncio
async def test_post_request_sends_nested_json_body(self):
@ -546,6 +548,55 @@ class TestExecuteHttpTool:
}
assert result["status"] == "success"
@pytest.mark.asyncio
async def test_post_request_accepts_pre_resolved_preset_params(self):
"""The test endpoint can supply preset values without call context."""
tool = MockToolModel(
tool_uuid="test-uuid-preset-override",
name="Create Lead",
description="Create a lead with caller context",
category="http_api",
definition={
"schema_version": 1,
"type": "http_api",
"config": {
"method": "POST",
"url": "https://api.example.com/leads",
"timeout_ms": 5000,
"preset_parameters": [
{
"name": "phone_number",
"type": "string",
"value_template": "{{initial_context.phone_number}}",
"required": True,
}
],
},
},
)
with patch(
"api.services.workflow.tools.custom_tool.httpx.AsyncClient"
) as mock_client_class:
mock_client = AsyncMock()
mock_response = Mock()
mock_response.status_code = 201
mock_response.json.return_value = {"id": 123}
mock_client.request.return_value = mock_response
mock_client_class.return_value.__aenter__.return_value = mock_client
result = await execute_http_tool(
tool,
{"name": "John"},
preset_params={"phone_number": "+14155550123"},
)
assert mock_client.request.call_args.kwargs["json"] == {
"name": "John",
"phone_number": "+14155550123",
}
assert result["status"] == "success"
@pytest.mark.asyncio
async def test_missing_required_preset_parameter_returns_error(self):
"""Test that required preset parameters fail before the HTTP request."""
@ -619,6 +670,52 @@ class TestExecuteHttpTool:
assert result["status"] == "success"
@pytest.mark.asyncio
async def test_get_request_serializes_object_and_array_query_params(self):
"""Object/array-typed arguments must be JSON-stringified for GET query
params httpx raises a TypeError if a dict/list is passed as-is."""
tool = MockToolModel(
tool_uuid="test-uuid",
name="Search Users",
description="Search for users",
category="http_api",
definition={
"schema_version": 1,
"type": "http_api",
"config": {
"method": "GET",
"url": "https://api.example.com/users/search",
"timeout_ms": 5000,
},
},
)
arguments = {
"source": "voice",
"metadata": {"campaign": "spring"},
"tags": ["a", "b"],
}
with patch(
"api.services.workflow.tools.custom_tool.httpx.AsyncClient"
) as mock_client_class:
mock_client = AsyncMock()
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {"users": []}
mock_client.request.return_value = mock_response
mock_client_class.return_value.__aenter__.return_value = mock_client
result = await execute_http_tool(tool, arguments)
call_kwargs = mock_client.request.call_args.kwargs
assert call_kwargs["params"] == {
"source": "voice",
"metadata": '{"campaign": "spring"}',
"tags": '["a", "b"]',
}
assert result["status"] == "success"
@pytest.mark.asyncio
async def test_get_request_without_arguments_preserves_url_query_params(self):
"""Empty runtime args should not override query params already in the URL."""
@ -762,11 +859,17 @@ class TestExecuteHttpTool:
mock_client.request.return_value = mock_response
mock_client_class.return_value.__aenter__.return_value = mock_client
await execute_http_tool(tool, {"data": "test"})
result = await execute_http_tool(
tool, {"data": "test"}, include_request_headers=True
)
call_kwargs = mock_client.request.call_args.kwargs
assert call_kwargs["headers"]["X-API-Key"] == "secret-key"
assert call_kwargs["headers"]["X-Custom-Header"] == "custom-value"
assert result["request_headers"] == {
"X-API-Key": "secret-key",
"X-Custom-Header": "custom-value",
}
@pytest.mark.asyncio
async def test_request_includes_auth_header_from_credential(self):
@ -807,7 +910,12 @@ class TestExecuteHttpTool:
with patch("api.services.workflow.tools.custom_tool.db_client") as mock_db:
mock_db.get_credential_by_uuid = AsyncMock(return_value=mock_credential)
await execute_http_tool(tool, {"data": "test"}, organization_id=1)
result = await execute_http_tool(
tool,
{"data": "test"},
organization_id=1,
include_request_headers=True,
)
# Verify credential was fetched
mock_db.get_credential_by_uuid.assert_called_once_with(
@ -819,6 +927,12 @@ class TestExecuteHttpTool:
assert (
call_kwargs["headers"]["Authorization"] == "Bearer my-secret-token"
)
assert result["request_headers"]["Authorization"] == mask_key(
"Bearer my-secret-token"
)
assert (
"my-secret-token" not in result["request_headers"]["Authorization"]
)
@pytest.mark.asyncio
async def test_no_credential_lookup_without_organization_id(self):
@ -1704,7 +1818,6 @@ class TestCustomToolManagerUnit:
assert [
call.args[0] for call in mock_engine.set_mute_pipeline.call_args_list
] == [
True,
True,
False,
]
@ -1716,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()
@ -1729,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(
@ -1792,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

@ -26,9 +26,15 @@ from api.routes.tool import (
CreateToolRequest,
McpToolConfig,
McpToolDefinition,
ToolTestRequest,
ToolTestResponse,
UpdateToolRequest,
_populate_discovered_tools,
refresh_mcp_tools,
router,
)
from api.routes.tool import (
test_tool as call_test_tool_route,
)
from api.services.workflow.tools.mcp_tool import (
validate_mcp_definition,
@ -420,6 +426,314 @@ def _mcp_tool_model(org_id=1):
return t
def _http_tool_model(method="GET"):
t = MagicMock()
t.tool_uuid = "tu-http"
t.name = "Mock HTTP"
t.category = "http_api"
t.definition = {
"schema_version": 1,
"type": "http_api",
"config": {"method": method, "url": "https://example.com/search"},
}
return t
@pytest.mark.asyncio
async def test_tool_executes_http_api_tool_with_llm_and_preset_params(monkeypatch):
import api.routes.tool as tool_route
tool = _http_tool_model()
monkeypatch.setattr(
tool_route.db_client, "get_tool_by_uuid", AsyncMock(return_value=tool)
)
executor = AsyncMock(
return_value={
"status": "success",
"status_code": 200,
"data": {"ok": True},
}
)
monkeypatch.setattr(tool_route, "execute_http_tool", executor)
resp = await call_test_tool_route(
"tu-http",
request=ToolTestRequest(
llm_params={"query": "cart"},
preset_params={
"customer_id": "c_123",
"sentiment": "cooperative",
},
),
user=_fake_user(),
)
assert resp.status == "success"
assert resp.status_code == 200
assert resp.data == {"ok": True}
assert resp.error is None
executor.assert_awaited_once_with(
tool,
{"query": "cart"},
preset_params={
"customer_id": "c_123",
"sentiment": "cooperative",
},
organization_id=1,
include_request_headers=True,
)
assert resp.hint is None
assert resp.request_method == "GET"
assert resp.request_url == "https://example.com/search"
assert resp.request_headers == {}
assert resp.request_body is None
assert resp.request_params == {
"query": "cart",
"customer_id": "c_123",
"sentiment": "cooperative",
}
@pytest.mark.asyncio
async def test_tool_test_sets_request_body_for_post_method(monkeypatch):
import api.routes.tool as tool_route
tool = _http_tool_model(method="POST")
monkeypatch.setattr(
tool_route.db_client, "get_tool_by_uuid", AsyncMock(return_value=tool)
)
monkeypatch.setattr(
tool_route,
"execute_http_tool",
AsyncMock(
return_value={"status": "success", "status_code": 200, "data": {"id": 1}}
),
)
resp = await call_test_tool_route(
"tu-http",
request=ToolTestRequest(llm_params={"name": "Ada"}),
user=_fake_user(),
)
assert resp.request_method == "POST"
assert resp.request_body == {"name": "Ada"}
assert resp.request_params is None
@pytest.mark.asyncio
async def test_tool_test_returns_masked_effective_request_headers(monkeypatch):
import api.routes.tool as tool_route
tool = _http_tool_model(method="POST")
monkeypatch.setattr(
tool_route.db_client, "get_tool_by_uuid", AsyncMock(return_value=tool)
)
monkeypatch.setattr(
tool_route,
"execute_http_tool",
AsyncMock(
return_value={
"status": "success",
"status_code": 200,
"data": {"ok": True},
"request_headers": {
"X-Tenant": "acme",
"Authorization": "****************oken",
},
}
),
)
resp = await call_test_tool_route(
"tu-http", request=ToolTestRequest(), user=_fake_user()
)
assert resp.request_headers == {
"X-Tenant": "acme",
"Authorization": "****************oken",
}
@pytest.mark.asyncio
async def test_tool_test_request_body_includes_resolved_preset_parameters(
monkeypatch,
):
"""The Request preview includes direct preset values alongside LLM values."""
import api.routes.tool as tool_route
tool = _http_tool_model(method="POST")
tool.definition["config"]["preset_parameters"] = [
{
"name": "source",
"type": "string",
"value_template": "{{initial_context.metadata.channel}}",
"required": True,
}
]
monkeypatch.setattr(
tool_route.db_client, "get_tool_by_uuid", AsyncMock(return_value=tool)
)
monkeypatch.setattr(
tool_route,
"execute_http_tool",
AsyncMock(
return_value={"status": "success", "status_code": 200, "data": {"ok": True}}
),
)
resp = await call_test_tool_route(
"tu-http",
request=ToolTestRequest(
llm_params={"name": "Ada"},
preset_params={"source": "web_widget"},
),
user=_fake_user(),
)
assert resp.request_body == {"name": "Ada", "source": "web_widget"}
@pytest.mark.asyncio
async def test_tool_test_no_arguments_post_shows_empty_body(monkeypatch):
import api.routes.tool as tool_route
tool = _http_tool_model(method="POST")
monkeypatch.setattr(
tool_route.db_client, "get_tool_by_uuid", AsyncMock(return_value=tool)
)
monkeypatch.setattr(
tool_route,
"execute_http_tool",
AsyncMock(return_value={"status": "success", "status_code": 200, "data": None}),
)
resp = await call_test_tool_route(
"tu-http", request=ToolTestRequest(), user=_fake_user()
)
# POST with no arguments sends json={} over the wire; preview must show {}
# so callers can distinguish an absent body from an empty one.
assert resp.request_body == {}
assert resp.request_params is None
@pytest.mark.asyncio
@pytest.mark.parametrize(
"status_code,expected_snippet",
[
(400, "HTTP 400 Bad Request"),
(401, "HTTP 401 Unauthorized"),
(403, "HTTP 403 Forbidden"),
(404, "HTTP 404 Not Found"),
(405, "HTTP 405 Method Not Allowed"),
(408, "HTTP 408 Request Timeout"),
(409, "HTTP 409 Conflict"),
(415, "HTTP 415 Unsupported Media Type"),
(422, "HTTP 422 Unprocessable Entity"),
(429, "HTTP 429 Too Many Requests"),
(500, "HTTP 500"),
(503, "HTTP 503"),
],
)
async def test_tool_test_hint_for_status_code(
monkeypatch, status_code, expected_snippet
):
import api.routes.tool as tool_route
tool = _http_tool_model(method="POST")
monkeypatch.setattr(
tool_route.db_client, "get_tool_by_uuid", AsyncMock(return_value=tool)
)
monkeypatch.setattr(
tool_route,
"execute_http_tool",
AsyncMock(
return_value={
"status": "error",
"status_code": status_code,
"error": "boom",
}
),
)
resp = await call_test_tool_route(
"tu-http", request=ToolTestRequest(llm_params={"a": 1}), user=_fake_user()
)
assert resp.hint is not None
assert resp.hint.startswith(expected_snippet)
@pytest.mark.asyncio
async def test_tool_test_no_hint_on_success(monkeypatch):
import api.routes.tool as tool_route
tool = _http_tool_model(method="GET")
monkeypatch.setattr(
tool_route.db_client, "get_tool_by_uuid", AsyncMock(return_value=tool)
)
monkeypatch.setattr(
tool_route,
"execute_http_tool",
AsyncMock(return_value={"status": "success", "status_code": 200, "data": {}}),
)
resp = await call_test_tool_route(
"tu-http", request=ToolTestRequest(), user=_fake_user()
)
assert resp.hint is None
@pytest.mark.asyncio
async def test_tool_test_no_hint_for_uncovered_status_code(monkeypatch):
import api.routes.tool as tool_route
tool = _http_tool_model(method="GET")
monkeypatch.setattr(
tool_route.db_client, "get_tool_by_uuid", AsyncMock(return_value=tool)
)
monkeypatch.setattr(
tool_route,
"execute_http_tool",
AsyncMock(
return_value={"status": "error", "status_code": 418, "error": "teapot"}
),
)
resp = await call_test_tool_route(
"tu-http", request=ToolTestRequest(), user=_fake_user()
)
assert resp.hint is None
def test_tool_test_route_is_registered():
assert any(
route.path == "/tools/{tool_uuid}/test" and "POST" in route.methods
for route in router.routes
)
@pytest.mark.asyncio
async def test_tool_rejects_non_http_api_tool(monkeypatch):
import api.routes.tool as tool_route
monkeypatch.setattr(
tool_route.db_client,
"get_tool_by_uuid",
AsyncMock(return_value=_mcp_tool_model()),
)
with pytest.raises(HTTPException) as ei:
await call_test_tool_route(
"tu-mcp", request=ToolTestRequest(), user=_fake_user()
)
assert ei.value.status_code == 400
@pytest.mark.asyncio
async def test_refresh_success(monkeypatch):
import api.services.tool_management as tool_svc
@ -484,3 +798,41 @@ async def test_refresh_not_found_is_404(monkeypatch):
with pytest.raises(HTTPException) as ei:
await refresh_mcp_tools("nope", user=_fake_user())
assert ei.value.status_code == 404
def test_tool_test_response_has_hint_and_request_fields():
"""ToolTestResponse must carry hint + request_method/url/body/params
so the frontend can show what was sent and why it may have failed."""
resp = ToolTestResponse(
status="error",
status_code=405,
data=None,
error="Method Not Allowed",
duration_ms=12,
hint="HTTP 405 Method Not Allowed — the endpoint rejected the configured method (POST).",
request_method="POST",
request_url="https://example.com/thing",
request_headers={"Authorization": "********oken"},
request_body={"a": 1},
request_params=None,
)
assert resp.hint.startswith("HTTP 405")
assert resp.request_method == "POST"
assert resp.request_url == "https://example.com/thing"
assert resp.request_headers == {"Authorization": "********oken"}
assert resp.request_body == {"a": 1}
assert resp.request_params is None
def test_tool_test_response_request_fields_default_to_none_or_required():
"""hint/request_body/request_params are optional; request_method/url are required."""
resp = ToolTestResponse(
status="success",
duration_ms=5,
request_method="GET",
request_url="https://example.com/thing",
)
assert resp.hint is None
assert resp.request_headers == {}
assert resp.request_body is None
assert resp.request_params is None

View file

@ -0,0 +1,40 @@
"""Pagination bounds for the workflow-run and campaign-run list endpoints.
Regression for issue #553: `limit=0` raised an unhandled ZeroDivisionError
(HTTP 500) in the `total_pages` computation, and negative `limit`/`page`
produced nonsensical pagination. Both endpoints now validate the params
(`limit` in [1, 100], `page` >= 1), matching the sibling list endpoints.
"""
import pytest
async def _make_user(db_session, slug: str):
user, _ = await db_session.get_or_create_user_by_provider_id(f"{slug}_user")
org, _ = await db_session.get_or_create_organization_by_provider_id(
f"{slug}_org", user.id
)
await db_session.update_user_selected_organization(user.id, org.id)
return await db_session.get_user_by_id(user.id)
@pytest.mark.parametrize(
"path",
[
"/api/v1/workflow/1/runs",
"/api/v1/campaign/1/runs",
],
)
@pytest.mark.parametrize("query", ["limit=0", "limit=-5", "limit=101", "page=0"])
async def test_run_list_rejects_out_of_range_pagination(
test_client_factory, db_session, path, query
):
"""Out-of-range limit/page is a 422 validation error, never a 500."""
user = await _make_user(db_session, "paginate_bounds")
async with test_client_factory(user) as client:
response = await client.get(f"{path}?{query}")
assert response.status_code == 422, (
f"{path}?{query} expected 422, got {response.status_code}: {response.text}"
)

View file

@ -1,8 +1,11 @@
from pipecat.audio.vad.silero import SileroVADAnalyzer
from pipecat.frames.frames import BotStartedSpeakingFrame, TranscriptionFrame
from pipecat.turns.types import ProcessFrameResult
from pipecat.turns.user_start import (
ExternalUserTurnStartStrategy,
MinWordsUserTurnStartStrategy,
ProvisionalVADUserTurnStartStrategy,
TranscriptionUserTurnStartStrategy,
)
from pipecat.turns.user_start.vad_user_turn_start_strategy import (
VADUserTurnStartStrategy,
@ -134,14 +137,15 @@ def test_non_realtime_default_uses_external_start_for_external_turn_stt():
assert strategies[0]._enable_interruptions is True
def test_non_realtime_default_uses_vad_start_for_standard_stt():
def test_non_realtime_default_uses_transcription_fallback_and_vad_for_standard_stt():
strategies = _create_non_realtime_user_turn_start_strategies(
{},
uses_external_turns=False,
)
assert len(strategies) == 1
assert isinstance(strategies[0], VADUserTurnStartStrategy)
assert len(strategies) == 2
assert isinstance(strategies[0], TranscriptionUserTurnStartStrategy)
assert isinstance(strategies[1], VADUserTurnStartStrategy)
def test_non_realtime_can_use_min_words_start_strategy():
@ -199,6 +203,28 @@ def test_non_realtime_provisional_vad_uses_configured_pause_secs():
assert strategies[0]._pause_secs == 0.4
async def test_non_realtime_provisional_vad_starts_on_transcript_without_vad():
strategies = _create_non_realtime_user_turn_start_strategies(
{"turn_start_strategy": "provisional_vad"},
uses_external_turns=False,
)
strategy = strategies[0]
turn_started = False
@strategy.event_handler("on_user_turn_started")
async def on_user_turn_started(strategy, params):
nonlocal turn_started
turn_started = True
await strategy.process_frame(BotStartedSpeakingFrame())
result = await strategy.process_frame(
TranscriptionFrame(text="Hello", user_id="user", timestamp="")
)
assert result == ProcessFrameResult.STOP
assert turn_started is True
def test_non_realtime_uses_external_stop_for_external_turn_stt():
strategies = _create_non_realtime_user_turn_stop_strategies(
{},

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

View file

@ -24,10 +24,7 @@ def test_xai_tts_configuration_defaults():
assert XAI_TTS_VOICES == ["eve", "ara", "leo", "rex", "sal"]
@pytest.mark.parametrize("transport_out_sample_rate", [8000, 16000])
def test_create_xai_tts_service_uses_pipeline_compatible_audio_format(
transport_out_sample_rate,
):
def test_create_xai_tts_service_uses_websocket_streaming():
user_config = SimpleNamespace(
tts=SimpleNamespace(
provider=ServiceProviders.XAI.value,
@ -38,20 +35,18 @@ def test_create_xai_tts_service_uses_pipeline_compatible_audio_format(
)
)
audio_config = SimpleNamespace(
transport_out_sample_rate=transport_out_sample_rate,
transport_out_sample_rate=24000,
transport_in_sample_rate=16000,
)
with patch(
"api.services.pipecat.service_factory.XAIHttpTTSService"
) as mock_service:
with patch("api.services.pipecat.service_factory.XAITTSService") as mock_service:
create_tts_service(user_config, audio_config)
assert mock_service.call_count == 1
kwargs = mock_service.call_args.kwargs
assert kwargs["api_key"] == "test-key"
assert kwargs["sample_rate"] == transport_out_sample_rate
assert kwargs["encoding"] == "pcm"
# Sample rate is resolved from the pipeline StartFrame, like other providers.
assert "sample_rate" not in kwargs
assert kwargs["settings"].voice == "rex"
assert kwargs["settings"].language == Language.EN
@ -71,9 +66,7 @@ def test_create_xai_tts_service_converts_language():
transport_in_sample_rate=16000,
)
with patch(
"api.services.pipecat.service_factory.XAIHttpTTSService"
) as mock_service:
with patch("api.services.pipecat.service_factory.XAITTSService") as mock_service:
create_tts_service(user_config, audio_config)
kwargs = mock_service.call_args.kwargs
@ -95,9 +88,7 @@ def test_create_xai_tts_service_falls_back_to_english_for_unknown_language():
transport_in_sample_rate=16000,
)
with patch(
"api.services.pipecat.service_factory.XAIHttpTTSService"
) as mock_service:
with patch("api.services.pipecat.service_factory.XAITTSService") as mock_service:
create_tts_service(user_config, audio_config)
kwargs = mock_service.call_args.kwargs
@ -119,9 +110,7 @@ def test_create_xai_tts_service_preserves_auto_language():
transport_in_sample_rate=16000,
)
with patch(
"api.services.pipecat.service_factory.XAIHttpTTSService"
) as mock_service:
with patch("api.services.pipecat.service_factory.XAITTSService") as mock_service:
create_tts_service(user_config, audio_config)
kwargs = mock_service.call_args.kwargs

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'];

4
ui/package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "ui",
"version": "1.41.0",
"version": "1.42.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ui",
"version": "1.41.0",
"version": "1.42.0",
"dependencies": {
"@calcom/embed-react": "^1.5.3",
"@dagrejs/dagre": "^1.1.4",

View file

@ -0,0 +1,227 @@
// @vitest-environment node
import { NextRequest } from "next/server";
import { describe, expect, it, vi } from "vitest";
import { GET } from "./route";
const PROJECT_ID = "proj-123";
vi.mock("@/lib/auth/config", () => ({
getStackConfig: vi.fn(async () => ({
projectId: "proj-123",
publishableClientKey: "pck_test",
})),
}));
function makeRequest(
url: string,
{ cookie, forwardedProto }: { cookie?: string; forwardedProto?: string } = {},
) {
const headers = new Headers();
if (cookie) headers.set("cookie", cookie);
if (forwardedProto) headers.set("x-forwarded-proto", forwardedProto);
return new NextRequest(url, { headers });
}
function parseSetCookie(header: string) {
const [nameValue, ...attrParts] = header.split("; ");
const eq = nameValue.indexOf("=");
const attrs = attrParts.map((a) => a.toLowerCase());
return {
name: nameValue.slice(0, eq),
value: decodeURIComponent(nameValue.slice(eq + 1)),
partitioned: attrs.includes("partitioned"),
secure: attrs.includes("secure"),
maxAge: Number(
attrParts.find((a) => a.toLowerCase().startsWith("max-age="))?.slice(8),
),
domain: attrParts
.find((a) => a.toLowerCase().startsWith("domain="))
?.slice(7),
};
}
describe("GET /impersonate", () => {
it("returns 400 when refresh_token is missing", async () => {
const response = await GET(
makeRequest("https://app.dograh.com/impersonate"),
);
expect(response.status).toBe(400);
});
it("redirects to redirect_path on the same origin", async () => {
const response = await GET(
makeRequest(
"https://app.dograh.com/impersonate?refresh_token=rt-new&redirect_path=/workflow/42",
),
);
expect(response.status).toBe(307);
expect(response.headers.get("location")).toBe(
"https://app.dograh.com/workflow/42",
);
});
it("falls back to /workflow/create for a cross-origin redirect_path", async () => {
const response = await GET(
makeRequest(
"https://app.dograh.com/impersonate?refresh_token=rt-new&redirect_path=https://evil.com/phish",
),
);
expect(response.headers.get("location")).toBe(
"https://app.dograh.com/workflow/create",
);
});
it("falls back to /workflow/create for a malformed redirect_path", async () => {
const response = await GET(
makeRequest(
"https://app.dograh.com/impersonate?refresh_token=rt-new&redirect_path=https%3A%2F%2F",
),
);
expect(response.status).toBe(307);
expect(response.headers.get("location")).toBe(
"https://app.dograh.com/workflow/create",
);
});
it("clears presented session cookies in both jars and all domain scopes on https", async () => {
const hostRefresh = `__Host-hexclave-refresh-${PROJECT_ID}--default`;
const response = await GET(
makeRequest(
"https://app.dograh.com/impersonate?refresh_token=rt-new",
{
cookie: [
`${hostRefresh}=old-session`,
"hexclave-access=old-access",
`stack-refresh-${PROJECT_ID}=old-legacy`,
`hexclave-refresh-${PROJECT_ID}--custom-abc=old-custom`,
"stack-oauth-inner-xyz=oauth-state",
"hexclave-is-https=true",
"theme=dark",
].join("; "),
},
),
);
const cookies = response.headers.getSetCookie().map(parseSetCookie);
// Deletions exist for both jars for a regular-named cookie.
const accessDeletions = cookies.filter(
(c) => c.name === "hexclave-access" && c.maxAge === 0,
);
expect(accessDeletions.some((c) => c.partitioned)).toBe(true);
expect(accessDeletions.some((c) => !c.partitioned)).toBe(true);
// ...and for the host-only plus each parent-domain scope.
const accessDomains = new Set(accessDeletions.map((c) => c.domain));
expect(accessDomains).toEqual(
new Set([undefined, "app.dograh.com", "dograh.com"]),
);
// Enumerated --custom-* cookies are cleared too.
expect(
cookies.some(
(c) =>
c.name === `hexclave-refresh-${PROJECT_ID}--custom-abc` &&
c.maxAge === 0 &&
c.partitioned,
),
).toBe(true);
// __Host- deletions never carry a Domain attribute but still cover both jars.
const hostDeletions = cookies.filter(
(c) => c.name === hostRefresh && c.maxAge === 0,
);
expect(hostDeletions.length).toBeGreaterThan(0);
expect(hostDeletions.every((c) => c.domain === undefined)).toBe(true);
expect(hostDeletions.some((c) => c.partitioned)).toBe(true);
// The legacy raw refresh cookie is only ever deleted, never re-set.
expect(
cookies
.filter((c) => c.name === `stack-refresh-${PROJECT_ID}`)
.every((c) => c.maxAge === 0),
).toBe(true);
// Non-identity cookies are left alone: app cookies, in-flight OAuth
// state, and the SDK's is-https flag.
expect(cookies.some((c) => c.name === "theme")).toBe(false);
expect(cookies.some((c) => c.name === "stack-oauth-inner-xyz")).toBe(
false,
);
expect(cookies.some((c) => c.name === "hexclave-is-https")).toBe(false);
// Deletions cover only cookies the browser presented (plus the fresh
// set) — no blanket static list bloating the header block.
expect(cookies.some((c) => c.name === "stack-access")).toBe(false);
});
it("sets the fresh refresh cookie once with Partitioned, after the deletions", async () => {
const hostRefresh = `__Host-hexclave-refresh-${PROJECT_ID}--default`;
const response = await GET(
makeRequest(
"https://app.dograh.com/impersonate?refresh_token=rt-new",
{ cookie: `${hostRefresh}=old-session` },
),
);
const cookies = response.headers.getSetCookie().map(parseSetCookie);
// Exactly one fresh set: CHIPS browsers store it partitioned (where
// the SDK writes), non-CHIPS browsers ignore the attribute and store
// it in the regular jar (also where the SDK writes). A second copy in
// the other jar would become a stale shadow the SDK never updates.
const freshSets = cookies.filter(
(c) => c.name === hostRefresh && c.maxAge > 0,
);
expect(freshSets).toHaveLength(1);
expect(freshSets[0].partitioned).toBe(true);
expect(freshSets[0].secure).toBe(true);
expect(JSON.parse(freshSets[0].value).refresh_token).toBe("rt-new");
// The fresh set must come after every deletion of the same name,
// otherwise the browser would apply a deletion last.
const lastDeletionIdx = cookies.reduce(
(acc, c, i) =>
c.name === hostRefresh && c.maxAge === 0 ? i : acc,
-1,
);
const firstFreshIdx = cookies.findIndex(
(c) => c.name === hostRefresh && c.maxAge > 0,
);
expect(firstFreshIdx).toBeGreaterThan(lastDeletionIdx);
});
it("honors x-forwarded-proto case-insensitively when the request is http", async () => {
const response = await GET(
makeRequest("http://app.dograh.com/impersonate?refresh_token=rt", {
forwardedProto: "HTTPS",
}),
);
const cookies = response.headers.getSetCookie().map(parseSetCookie);
const freshSets = cookies.filter(
(c) =>
c.name === `__Host-hexclave-refresh-${PROJECT_ID}--default` &&
c.maxAge > 0,
);
expect(freshSets).toHaveLength(1);
expect(freshSets[0].partitioned).toBe(true);
});
it("uses no __Host- prefix and no partitioned attribute on plain http", async () => {
const response = await GET(
makeRequest("http://localhost:3010/impersonate?refresh_token=rt", {
cookie: `hexclave-refresh-${PROJECT_ID}--default=old`,
}),
);
const cookies = response.headers.getSetCookie().map(parseSetCookie);
expect(cookies.some((c) => c.partitioned)).toBe(false);
expect(cookies.some((c) => c.domain !== undefined)).toBe(false);
const freshSets = cookies.filter(
(c) =>
c.name === `hexclave-refresh-${PROJECT_ID}--default` &&
c.maxAge > 0,
);
expect(freshSets).toHaveLength(1);
expect(freshSets[0].secure).toBe(false);
});
});

View file

@ -3,13 +3,91 @@ import { NextRequest, NextResponse } from "next/server";
import { getStackConfig } from "@/lib/auth/config";
/**
* Helper route that receives a Stack refresh token via query parameters, stores
* it as the regular Stack SDK cookie *for the current sub-domain only* and finally
* redirects the user to the requested path.
* Helper route that receives a Stack refresh token via query parameters, wipes
* every Stack SDK session cookie the browser presented, stores the impersonated
* session as a fresh cookie and finally redirects to the requested path.
*
* On HTTPS Chrome the Stack SDK writes its cookies into the CHIPS-partitioned
* jar (`Secure; SameSite=None; Partitioned`), which coexists with the regular
* jar under the same cookie name. A Set-Cookie without the `Partitioned`
* attribute can neither delete nor overwrite the partitioned copy, and the
* SDK's own cookie parsing is first-occurrence-wins so a leftover partitioned
* cookie from a previous session silently keeps winning over anything this
* route sets in the regular jar, resurfacing the old user. Deletions below are
* therefore emitted for BOTH jars (and for possible parent-domain scopes),
* which is also why headers are appended manually: `response.cookies.set`
* dedupes Set-Cookie by name.
*
* The fresh cookie is deliberately written only ONCE, with the `Partitioned`
* attribute: CHIPS browsers store it in the partitioned jar and non-CHIPS
* browsers ignore the attribute and store it in the regular jar in both
* cases exactly the jar the SDK's own writes will later overwrite. Writing
* both jars instead would plant a copy the SDK never updates, recreating the
* stale-session bug this route exists to fix.
*
* Example usage (client side):
* /impersonate?refresh_token=<REFRESH>&redirect_path=/workflow/123
*/
// Stack SDK cookies that hold session identity: hexclave/stack access cookies
// and every refresh-cookie variant (bare legacy, project-scoped legacy,
// --default, --custom-<domain>, __Host- prefixed). Deliberately excludes
// non-identity SDK cookies (is-https flags, in-flight OAuth state) so an
// impersonation redirect can't abort an unrelated concurrent sign-in.
const SESSION_COOKIE_RE = /^(?:__Host-)?(?:stack|hexclave)-(?:access|refresh)(?:-|$)/;
/**
* Domains a cookie could have been scoped to from this host, e.g.
* "app.dograh.com" -> ["app.dograh.com", "dograh.com"]. Returns [] for
* localhost / IP hosts. Stops before the last label, which over-generates for
* multi-label public suffixes (app.example.co.uk also yields co.uk) the
* browser just rejects those deletions, so the cost is a wasted header.
*/
function parentDomains(hostname: string): string[] {
if (!hostname.includes(".") || /^[\d.]+$/.test(hostname)) {
return [];
}
const parts = hostname.split(".");
const domains: string[] = [];
for (let i = 0; i + 2 <= parts.length; i++) {
domains.push(parts.slice(i).join("."));
}
return domains;
}
interface SetCookieAttrs {
maxAge: number;
secure?: boolean;
domain?: string;
partitioned?: boolean;
}
// No HttpOnly: the Stack SDK reads these cookies from document.cookie.
function serializeSetCookie(
name: string,
value: string,
attrs: SetCookieAttrs,
): string {
const parts = [
`${name}=${encodeURIComponent(value)}`,
"Path=/",
`Max-Age=${attrs.maxAge}`,
];
if (attrs.domain) {
parts.push(`Domain=${attrs.domain}`);
}
if (attrs.partitioned) {
// CHIPS requires Secure and SameSite=None.
parts.push("Secure", "SameSite=None", "Partitioned");
} else {
if (attrs.secure) {
parts.push("Secure");
}
parts.push("SameSite=Lax");
}
return parts.join("; ");
}
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
@ -27,85 +105,80 @@ export async function GET(request: NextRequest) {
return new Response("Stack auth is not configured", { status: 400 });
}
const requestedRedirectUrl = new URL(redirectPath, request.url);
const fallbackRedirectUrl = new URL("/workflow/create", request.url);
const redirectUrl =
requestedRedirectUrl.origin === request.nextUrl.origin
? requestedRedirectUrl.toString()
: fallbackRedirectUrl.toString();
let redirectUrl = fallbackRedirectUrl.toString();
try {
const requestedRedirectUrl = new URL(redirectPath, request.url);
if (requestedRedirectUrl.origin === request.nextUrl.origin) {
redirectUrl = requestedRedirectUrl.toString();
}
} catch {
// Malformed redirect_path (e.g. "https://") — keep the fallback.
}
const response = NextResponse.redirect(redirectUrl);
const forwardedProto = request.headers
.get("x-forwarded-proto")
?.split(",")[0]
?.trim()
.toLowerCase();
const isSecure =
request.nextUrl.protocol === "https:" ||
request.headers.get("x-forwarded-proto") === "https";
const refreshCookieName = `${isSecure ? "__Host-" : ""}hexclave-refresh-${stackConfig.projectId}--default`;
const accessCookieName = "hexclave-access";
const refreshMaxAge = 60 * 60 * 24 * 365;
request.nextUrl.protocol === "https:" || forwardedProto === "https";
// Store the refresh token using the cookie name/shape Stack's current
// nextjs-cookie token store reads. The old route only set the legacy
// refresh cookie, which let a stale access cookie keep the browser on the
// previous app-domain session.
response.cookies.set(
refreshCookieName,
JSON.stringify({
refresh_token: refreshToken,
updated_at_millis: Date.now(),
}),
{
path: "/",
maxAge: refreshMaxAge,
secure: isSecure,
httpOnly: false, // Must be accessible from the browser for Stack SDK
sameSite: "lax",
},
);
const staleCookieNames = new Set([
accessCookieName,
`stack-refresh-${stackConfig.projectId}`,
"stack-refresh",
`stack-refresh-${stackConfig.projectId}--default`,
`__Host-stack-refresh-${stackConfig.projectId}--default`,
`hexclave-refresh-${stackConfig.projectId}--default`,
`__Host-hexclave-refresh-${stackConfig.projectId}--default`,
"stack-access",
]);
staleCookieNames.delete(refreshCookieName);
// Every scope a stale SDK cookie may live in: host-only plus each parent
// domain, each in the regular jar and (on https) its partitioned twin. The
// request's Cookie header is the complete list of names to clear: the SDK
// only sets Lax or None+Partitioned cookies, both of which the browser
// attaches to this top-level navigation.
const domains: (string | undefined)[] = [
undefined,
...parentDomains(request.nextUrl.hostname),
];
const jars = isSecure ? [false, true] : [false];
const setCookieHeaders: string[] = [];
for (const cookie of request.cookies.getAll()) {
const name = cookie.name;
if (name !== refreshCookieName && (
name.startsWith(`hexclave-refresh-${stackConfig.projectId}--custom-`) ||
name.startsWith(`stack-refresh-${stackConfig.projectId}--custom-`) ||
name.startsWith(`__Host-hexclave-refresh-${stackConfig.projectId}--`) ||
name.startsWith(`__Host-stack-refresh-${stackConfig.projectId}--`)
)) {
staleCookieNames.add(name);
if (!SESSION_COOKIE_RE.test(cookie.name)) {
continue;
}
const isHostPrefixed = cookie.name.startsWith("__Host-");
for (const partitioned of jars) {
for (const domain of domains) {
if (isHostPrefixed && domain) {
continue; // __Host- cookies never have a Domain attribute
}
setCookieHeaders.push(
serializeSetCookie(cookie.name, "", {
maxAge: 0,
secure: isHostPrefixed || isSecure,
domain,
partitioned,
}),
);
}
}
}
for (const name of staleCookieNames) {
response.cookies.set(name, "", {
path: "/",
maxAge: 0,
secure: name.startsWith("__Host-") || isSecure,
httpOnly: false,
sameSite: "lax",
});
}
// Keep writing the legacy project refresh cookie for compatibility with
// older Stack SDK builds, but the structured Hexclave cookies above are the
// source of truth for the current app.
response.cookies.set(`stack-refresh-${stackConfig.projectId}`, refreshToken, {
path: "/",
maxAge: refreshMaxAge,
secure: isSecure,
httpOnly: false, // Must be accessible from the browser for Stack SDK
sameSite: "lax",
// Fresh impersonated session, written AFTER the deletions so it survives
// them, in the name/shape Stack's nextjs-cookie token store reads. Single
// write with Partitioned (see the header comment for why).
const refreshCookieName = `${isSecure ? "__Host-" : ""}hexclave-refresh-${stackConfig.projectId}--default`;
const refreshCookieValue = JSON.stringify({
refresh_token: refreshToken,
updated_at_millis: Date.now(),
});
setCookieHeaders.push(
serializeSetCookie(refreshCookieName, refreshCookieValue, {
maxAge: 60 * 60 * 24 * 365,
secure: isSecure,
partitioned: isSecure,
}),
);
for (const header of setCookieHeaders) {
response.headers.append("set-cookie", header);
}
return response;
}

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

@ -0,0 +1,540 @@
"use client";
import { AlertTriangle, Loader2, Pencil } from "lucide-react";
import { useEffect, useState } from "react";
import { testToolApiV1ToolsToolUuidTestPost } from "@/client/sdk.gen";
import type { ToolTestResponse } from "@/client/types.gen";
import type { HttpMethod, PresetToolParameter, ToolParameter } from "@/components/http";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { detailFromError } from "@/lib/apiError";
import { useAuth } from "@/lib/auth";
import {
generateSampleValue,
isUnsafeHttpMethod,
parseTestParameterValues,
} from "./helpers";
type HttpToolTestDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
toolUuid: string;
httpMethod: HttpMethod;
url: string;
parameters: ToolParameter[];
presetParameters: PresetToolParameter[];
};
type TestParameter = ToolParameter | PresetToolParameter;
type ParameterSource = "llm" | "preset";
type JsonEditTarget = {
source: ParameterSource;
name: string;
};
type ParameterFieldsProps = {
idPrefix: string;
parameters: TestParameter[];
values: Record<string, string>;
onValueChange: (name: string, value: string) => void;
onEditJson: (name: string) => void;
};
function defaultInputValue(parameter: TestParameter): string {
if (
"valueTemplate" in parameter &&
parameter.valueTemplate &&
!parameter.valueTemplate.includes("{{")
) {
return parameter.valueTemplate;
}
switch (parameter.type) {
case "number":
return "0";
case "boolean":
return "true";
case "object":
return "{}";
case "array":
return "[]";
default:
return "";
}
}
function seedMissingValues(
previous: Record<string, string>,
parameters: TestParameter[]
): Record<string, string> {
const next = { ...previous };
let changed = false;
for (const parameter of parameters) {
if (!parameter.name || parameter.name in next) continue;
next[parameter.name] = defaultInputValue(parameter);
changed = true;
}
return changed ? next : previous;
}
function ParameterFields({
idPrefix,
parameters,
values,
onValueChange,
onEditJson,
}: ParameterFieldsProps) {
if (parameters.length === 0) {
return <p className="text-sm text-muted-foreground">No parameters configured.</p>;
}
return (
<div className="space-y-4">
{parameters.map((parameter) => {
const inputId = `${idPrefix}-${parameter.name}`;
const value = values[parameter.name] ?? defaultInputValue(parameter);
return (
<div key={parameter.name} className="space-y-1.5">
<div className="flex items-center gap-2">
<Label htmlFor={inputId} className="text-sm font-mono">
{parameter.name}
</Label>
<span className="rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
{parameter.type}
</span>
{parameter.required && <span className="text-xs text-destructive">required</span>}
</div>
{"description" in parameter && parameter.description && (
<p className="text-xs text-muted-foreground">{parameter.description}</p>
)}
{"valueTemplate" in parameter && parameter.valueTemplate && (
<p className="break-all text-xs text-muted-foreground">
Configured preset: <code>{parameter.valueTemplate}</code>
</p>
)}
{parameter.type === "boolean" ? (
<select
id={inputId}
value={value}
onChange={(event) => onValueChange(parameter.name, event.target.value)}
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm"
>
<option value="true">true</option>
<option value="false">false</option>
</select>
) : parameter.type === "number" ? (
<Input
id={inputId}
type="number"
value={value}
onChange={(event) => onValueChange(parameter.name, event.target.value)}
/>
) : parameter.type === "object" || parameter.type === "array" ? (
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => onEditJson(parameter.name)}
className="h-9 flex-1 truncate rounded-md border border-input bg-background px-3 py-1 text-left font-mono text-sm shadow-sm hover:bg-accent"
>
{!value || value === (parameter.type === "array" ? "[]" : "{}") ? (
<span className="text-muted-foreground">Empty</span>
) : (
value
)}
</button>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => onEditJson(parameter.name)}
aria-label={`Edit ${parameter.name}`}
>
<Pencil className="h-4 w-4" />
</Button>
</div>
) : (
<Input
id={inputId}
value={value}
onChange={(event) => onValueChange(parameter.name, event.target.value)}
placeholder={`Enter ${parameter.name}`}
/>
)}
</div>
);
})}
</div>
);
}
export function HttpToolTestDialog({
open,
onOpenChange,
toolUuid,
httpMethod,
url,
parameters,
presetParameters,
}: HttpToolTestDialogProps) {
const { getAccessToken } = useAuth();
const [llmParamValues, setLlmParamValues] = useState<Record<string, string>>({});
const [presetParamValues, setPresetParamValues] = useState<Record<string, string>>({});
const [result, setResult] = useState<ToolTestResponse | null>(null);
const [isTesting, setIsTesting] = useState(false);
const [testError, setTestError] = useState<string | null>(null);
const [jsonEditTarget, setJsonEditTarget] = useState<JsonEditTarget | null>(null);
const [jsonEditDraft, setJsonEditDraft] = useState("");
const [jsonEditError, setJsonEditError] = useState<string | null>(null);
useEffect(() => {
setLlmParamValues((previous) => seedMissingValues(previous, parameters));
}, [parameters]);
useEffect(() => {
setPresetParamValues((previous) => seedMissingValues(previous, presetParameters));
}, [presetParameters]);
const handleFillSampleValues = () => {
setLlmParamValues((previous) => {
const next = { ...previous };
for (const parameter of parameters) {
next[parameter.name] = generateSampleValue(parameter.type);
}
return next;
});
setPresetParamValues((previous) => {
const next = { ...previous };
for (const parameter of presetParameters) {
next[parameter.name] = generateSampleValue(parameter.type);
}
return next;
});
};
const closeJsonEditDialog = () => {
setJsonEditTarget(null);
setJsonEditDraft("");
setJsonEditError(null);
};
const openJsonEditDialog = (source: ParameterSource, parameterName: string) => {
const values = source === "llm" ? llmParamValues : presetParamValues;
const current = values[parameterName] ?? "";
let draft = current;
try {
draft = JSON.stringify(JSON.parse(current), null, 2);
} catch {
// Keep incomplete or invalid JSON as-is. Validation is triggered
// only when the user explicitly chooses Save or Format JSON.
}
setJsonEditTarget({ source, name: parameterName });
setJsonEditDraft(draft);
setJsonEditError(null);
};
const handleJsonEditDraftChange = (value: string) => {
setJsonEditDraft(value);
setJsonEditError(null);
};
const handleFormatJson = () => {
try {
const parsed = JSON.parse(jsonEditDraft);
setJsonEditDraft(JSON.stringify(parsed, null, 2));
setJsonEditError(null);
} catch (caughtError) {
setJsonEditError(caughtError instanceof Error ? caughtError.message : "Invalid JSON");
}
};
const handleSaveJsonEdit = () => {
if (jsonEditTarget === null) return;
const target = jsonEditTarget;
try {
const parsed = JSON.parse(jsonEditDraft);
const setValues = target.source === "llm" ? setLlmParamValues : setPresetParamValues;
setValues((previous) => ({ ...previous, [target.name]: JSON.stringify(parsed) }));
closeJsonEditDialog();
} catch (caughtError) {
setJsonEditError(caughtError instanceof Error ? caughtError.message : "Invalid JSON");
}
};
const handleTestTool = async () => {
try {
setIsTesting(true);
setTestError(null);
setResult(null);
const llmParams = parseTestParameterValues(parameters, llmParamValues);
const presetParams = parseTestParameterValues(presetParameters, presetParamValues);
const accessToken = await getAccessToken();
const response = await testToolApiV1ToolsToolUuidTestPost({
path: { tool_uuid: toolUuid },
headers: { Authorization: `Bearer ${accessToken}` },
body: {
llm_params: llmParams,
preset_params: presetParams,
},
});
if (response.error) {
setTestError(detailFromError(response.error, "Failed to test tool"));
return;
}
if (response.data) setResult(response.data);
} catch (caughtError) {
setTestError(caughtError instanceof Error ? caughtError.message : "Failed to test tool");
} finally {
setIsTesting(false);
}
};
const isSuccess =
result?.status === "success" &&
(result.status_code == null || (result.status_code >= 200 && result.status_code < 300));
const resultBadgeLabel = isSuccess ? "success" : result?.status === "success" ? "failed" : "error";
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] max-w-3xl grid-rows-[auto_minmax(0,1fr)]">
<DialogHeader>
<DialogTitle>Test Tool</DialogTitle>
<DialogDescription>
Run the saved configuration against the real endpoint.
</DialogDescription>
</DialogHeader>
<div className="space-y-6 overflow-y-auto pr-1">
<div className="rounded-lg border bg-muted/40 p-4">
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Request
</p>
<div className="flex items-start gap-3">
<span className="rounded bg-foreground px-2 py-1 font-mono text-xs font-semibold text-background">
{httpMethod}
</span>
<code className="break-all pt-0.5 text-sm">{url}</code>
</div>
</div>
{isUnsafeHttpMethod(httpMethod) && (
<div
role="alert"
className="flex gap-3 rounded-lg border border-amber-300 bg-amber-50 p-4 text-amber-900 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-200"
>
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0" />
<div className="space-y-1">
<p className="text-sm font-medium">This performs a real external request</p>
<p className="text-sm">
Testing sends an actual {httpMethod} request to this endpoint. Any configured
credential is used for the request, and the operation may modify external data.
</p>
</div>
</div>
)}
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Parameters</p>
<p className="text-xs text-muted-foreground">
Supply the values that would normally come from the model and configured presets.
</p>
</div>
{(parameters.length > 0 || presetParameters.length > 0) && (
<Button
type="button"
variant="outline"
size="sm"
onClick={handleFillSampleValues}
>
Fill sample values
</Button>
)}
</div>
</div>
<div className="space-y-3 border-t pt-4">
<div>
<p className="text-sm font-medium">LLM Parameters</p>
<p className="text-xs text-muted-foreground">
Values the model would provide at call time.
</p>
</div>
<ParameterFields
idPrefix="llm-param"
parameters={parameters}
values={llmParamValues}
onValueChange={(name, value) =>
setLlmParamValues((previous) => ({ ...previous, [name]: value }))
}
onEditJson={(name) => openJsonEditDialog("llm", name)}
/>
</div>
<div className="space-y-3 border-t pt-4">
<div>
<p className="text-sm font-medium">Preset Parameters</p>
<p className="text-xs text-muted-foreground">
Resolved values that Dograh would normally derive from each configured preset.
</p>
</div>
<ParameterFields
idPrefix="preset-param"
parameters={presetParameters}
values={presetParamValues}
onValueChange={(name, value) =>
setPresetParamValues((previous) => ({ ...previous, [name]: value }))
}
onEditJson={(name) => openJsonEditDialog("preset", name)}
/>
</div>
<div className="flex justify-end">
<Button onClick={handleTestTool} disabled={isTesting}>
{isTesting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Testing...
</>
) : (
"Test Tool"
)}
</Button>
</div>
{testError && (
<div className="rounded-lg border border-destructive/20 bg-destructive/10 p-3 text-sm text-destructive">
{testError}
</div>
)}
{result && (
<div className="space-y-3 border-t pt-4">
{(result.request_method || result.request_url) && (
<div className="space-y-1 overflow-auto rounded-lg bg-muted p-3 font-mono text-xs">
<p className="font-medium text-foreground">
{result.request_method} {result.request_url}
</p>
{result.request_headers && Object.keys(result.request_headers).length > 0 && (
<pre className="whitespace-pre-wrap">
Headers: {JSON.stringify(result.request_headers, null, 2)}
</pre>
)}
{result.request_body != null && (
<pre className="whitespace-pre-wrap">
Body: {JSON.stringify(result.request_body, null, 2)}
</pre>
)}
{result.request_params != null && (
<pre className="whitespace-pre-wrap">
Query:{" "}
{Object.entries(result.request_params)
.map(([key, value]) => `${key}=${value}`)
.join(" ")}
</pre>
)}
</div>
)}
<div className="flex items-center gap-3">
<span
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium ${
isSuccess
? "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"
: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"
}`}
>
<span>{isSuccess ? "✓" : "✗"}</span>
{resultBadgeLabel}
</span>
{result.status_code != null && (
<span className="text-sm text-muted-foreground">HTTP {result.status_code}</span>
)}
{result.duration_ms !== undefined && (
<span className="rounded bg-muted px-2 py-0.5 font-mono text-xs text-muted-foreground">
{result.duration_ms}ms
</span>
)}
</div>
{result.hint && (
<div className="rounded border border-amber-200 bg-amber-100 p-3 text-sm text-amber-900 dark:border-amber-900/40 dark:bg-amber-900/20 dark:text-amber-400">
{result.hint}
</div>
)}
{result.error && (
<div className="rounded border border-destructive/20 bg-destructive/10 p-3 text-sm text-destructive">
{result.error}
</div>
)}
{result.data != null && (
<div className="max-h-80 overflow-auto rounded-lg bg-muted p-4 font-mono text-sm">
<pre>{JSON.stringify(result.data, null, 2)}</pre>
</div>
)}
</div>
)}
</div>
</DialogContent>
</Dialog>
<Dialog
open={jsonEditTarget !== null}
onOpenChange={(isOpen) => {
if (!isOpen) closeJsonEditDialog();
}}
>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Edit {jsonEditTarget?.name}</DialogTitle>
<DialogDescription>
Edit the JSON value sent for this parameter when testing.
</DialogDescription>
</DialogHeader>
<textarea
value={jsonEditDraft}
onChange={(event) => handleJsonEditDraftChange(event.target.value)}
rows={12}
className="w-full resize-y rounded-md border border-input bg-background px-3 py-2 font-mono text-sm shadow-sm"
spellCheck={false}
/>
{jsonEditError && <p className="text-sm text-destructive">{jsonEditError}</p>}
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={handleFormatJson}
disabled={jsonEditDraft.length === 0}
>
Format JSON
</Button>
<Button type="button" variant="outline" onClick={closeJsonEditDialog}>
Cancel
</Button>
<Button type="button" onClick={handleSaveJsonEdit}>
Save
</Button>
</div>
</DialogContent>
</Dialog>
</>
);
}

View file

@ -0,0 +1,120 @@
import { describe, expect, it } from "vitest";
import {
buildHttpToolTestSnapshot,
generateSampleValue,
type HttpToolTestSnapshotFields,
isUnsafeHttpMethod,
parseTestParameterValues,
} from "./helpers";
describe("generateSampleValue", () => {
it.each([
["string", "sample_text"],
["number", "5"],
["boolean", "true"],
["array", "[]"],
["object", "{}"],
] as const)("returns %s sample input", (type, expected) => {
expect(generateSampleValue(type)).toBe(expected);
});
});
describe("isUnsafeHttpMethod", () => {
it("treats GET as safe", () => {
expect(isUnsafeHttpMethod("GET")).toBe(false);
});
it.each(["POST", "PUT", "PATCH", "DELETE"] as const)("treats %s as unsafe", (method) => {
expect(isUnsafeHttpMethod(method)).toBe(true);
});
});
describe("parseTestParameterValues", () => {
it("converts input strings to configured parameter types", () => {
expect(
parseTestParameterValues(
[
{ name: "query", type: "string", required: true },
{ name: "limit", type: "number", required: true },
{ name: "enabled", type: "boolean", required: true },
{ name: "filters", type: "object", required: true },
{ name: "tags", type: "array", required: true },
],
{
query: "cart",
limit: "5",
enabled: "false",
filters: '{"status":"open"}',
tags: '["new"]',
}
)
).toEqual({
query: "cart",
limit: 5,
enabled: false,
filters: { status: "open" },
tags: ["new"],
});
});
it("reports the parameter containing invalid JSON", () => {
expect(() =>
parseTestParameterValues(
[{ name: "filters", type: "object", required: true }],
{ filters: "{" }
)
).toThrow("filters: invalid JSON");
});
it("rejects missing required values", () => {
expect(() =>
parseTestParameterValues(
[{ name: "customer_id", type: "string", required: true }],
{ customer_id: "" }
)
).toThrow("customer_id: value is required");
});
});
describe("buildHttpToolTestSnapshot", () => {
const base: HttpToolTestSnapshotFields = {
name: "Search API",
description: "Search for records",
httpMethod: "GET",
url: "https://api.example.com",
credentialUuid: "",
headers: [],
parameters: [],
presetParameters: [],
timeoutMs: 5000,
customMessage: "",
customMessageType: "text",
customMessageRecordingId: "",
};
it("produces identical output for identical fields", () => {
expect(buildHttpToolTestSnapshot(base)).toBe(buildHttpToolTestSnapshot({ ...base }));
});
it("changes when a saved HTTP field changes", () => {
expect(buildHttpToolTestSnapshot(base)).not.toBe(
buildHttpToolTestSnapshot({ ...base, url: "https://api.example.com/v2" })
);
expect(buildHttpToolTestSnapshot(base)).not.toBe(
buildHttpToolTestSnapshot({
...base,
parameters: [{ name: "q", type: "string", description: "", required: true }],
})
);
expect(buildHttpToolTestSnapshot(base)).not.toBe(
buildHttpToolTestSnapshot({ ...base, name: "Updated Search API" })
);
expect(buildHttpToolTestSnapshot(base)).not.toBe(
buildHttpToolTestSnapshot({ ...base, description: "Updated description" })
);
expect(buildHttpToolTestSnapshot(base)).not.toBe(
buildHttpToolTestSnapshot({ ...base, customMessage: "Done" })
);
});
});

View file

@ -0,0 +1,80 @@
import type { HttpMethod, KeyValueItem, ParameterType, PresetToolParameter, ToolParameter } from "@/components/http";
const TYPE_SAMPLE_VALUES: Record<ParameterType, string> = {
string: "sample_text",
number: "5",
boolean: "true",
array: "[]",
object: "{}",
};
/**
* Type-based sample value used to pre-fill test-dialog inputs. Parameters do
* not currently include a schema, so objects and arrays can only be seeded
* with valid empty JSON containers.
*/
export function generateSampleValue(type: ParameterType): string {
return TYPE_SAMPLE_VALUES[type];
}
/** Whether testing this method may change state on the external service. */
export function isUnsafeHttpMethod(method: HttpMethod): boolean {
return method !== "GET";
}
export function parseTestParameterValues(
parameters: Array<Pick<ToolParameter, "name" | "type" | "required">>,
values: Record<string, string>
): Record<string, unknown> {
const parsedValues: Record<string, unknown> = {};
for (const parameter of parameters) {
const rawValue = values[parameter.name];
if (rawValue === undefined || rawValue === "") {
if (parameter.required) throw new Error(`${parameter.name}: value is required`);
continue;
}
if (parameter.type === "number") {
parsedValues[parameter.name] = Number(rawValue);
} else if (parameter.type === "boolean") {
parsedValues[parameter.name] = rawValue === "true";
} else if (parameter.type === "object" || parameter.type === "array") {
try {
parsedValues[parameter.name] = JSON.parse(rawValue);
} catch {
throw new Error(`${parameter.name}: invalid JSON`);
}
} else {
parsedValues[parameter.name] = rawValue;
}
}
return parsedValues;
}
export type HttpToolTestSnapshotFields = {
name: string;
description: string;
httpMethod: HttpMethod;
url: string;
credentialUuid: string;
headers: KeyValueItem[];
parameters: ToolParameter[];
presetParameters: PresetToolParameter[];
timeoutMs: number;
customMessage: string;
customMessageType: "text" | "audio";
customMessageRecordingId: string;
};
/**
* Canonical string for HTTP API fields that affect the saved configuration
* used by Test Tool.
*/
export function buildHttpToolTestSnapshot(fields: HttpToolTestSnapshotFields): string {
const normalizedHeaders = Object.fromEntries(
fields.headers.filter((header) => header.key).map((header) => [header.key, header.value])
);
return JSON.stringify({ ...fields, headers: normalizedHeaders });
}

View file

@ -0,0 +1,8 @@
export {
buildHttpToolTestSnapshot,
generateSampleValue,
type HttpToolTestSnapshotFields,
isUnsafeHttpMethod,
parseTestParameterValues,
} from "./helpers";
export { HttpToolTestDialog } from "./HttpToolTestDialog";

View file

@ -1,4 +1,5 @@
export { BuiltinToolConfig, type BuiltinToolConfigProps } from "./BuiltinToolConfig";
export { EndCallToolConfig, type EndCallToolConfigProps } from "./EndCallToolConfig";
export { buildHttpToolTestSnapshot, HttpToolTestDialog } from "./http-tool-test";
export { HttpApiToolConfig, type HttpApiToolConfigProps } from "./HttpApiToolConfig";
export { TransferCallToolConfig, type TransferCallToolConfigProps } from "./TransferCallToolConfig";

View file

@ -1,6 +1,6 @@
"use client";
import { ArrowLeft, Code, ExternalLink, Loader2, Save } from "lucide-react";
import { ArrowLeft, Code, ExternalLink, FlaskConical, Loader2, Save } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
@ -38,11 +38,14 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
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,
@ -54,7 +57,14 @@ import {
type ToolCategory,
type TransferDestinationSource,
} from "../config";
import { BuiltinToolConfig, EndCallToolConfig, HttpApiToolConfig, TransferCallToolConfig } from "./components";
import {
buildHttpToolTestSnapshot,
BuiltinToolConfig,
EndCallToolConfig,
HttpApiToolConfig,
HttpToolTestDialog,
TransferCallToolConfig,
} from "./components";
function normalizeParameterType(value: string | null | undefined): ParameterType {
switch (value) {
@ -76,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);
@ -84,6 +95,8 @@ export default function ToolDetailPage() {
const [error, setError] = useState<string | null>(null);
const [saveSuccess, setSaveSuccess] = useState(false);
const [showCodeDialog, setShowCodeDialog] = useState(false);
const [showTestDialog, setShowTestDialog] = useState(false);
const [savedHttpTestSnapshot, setSavedHttpTestSnapshot] = useState<string | null>(null);
// Common form state
const [name, setName] = useState("");
@ -128,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');
@ -227,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("");
@ -241,6 +268,9 @@ export default function ToolDetailPage() {
setTransferResolverWaitMessage("");
setTransferParameters([]);
setTransferPresetParameters([]);
setTransferContextMappingPath("");
setTransferContextDestinationRoutes([]);
setTransferFallbackDestination("");
}
} else if (tool.category === "mcp") {
// Populate MCP specific fields
@ -264,52 +294,73 @@ export default function ToolDetailPage() {
// Populate HTTP API specific fields
const config = tool.definition?.config as HttpApiToolDefinition["config"] | undefined;
if (config) {
setHttpMethod((config.method as HttpMethod) || "POST");
setUrl(config.url || "");
setCredentialUuid(config.credential_uuid || "");
setTimeoutMs(config.timeout_ms || 5000);
setCustomMessage(config.customMessage || "");
setCustomMessageType(config.customMessageType || "text");
setCustomMessageRecordingId(config.customMessageRecordingId || "");
const loadedHttpMethod = (config.method as HttpMethod) || "POST";
const loadedUrl = config.url || "";
const loadedCredentialUuid = config.credential_uuid || "";
const loadedTimeoutMs = config.timeout_ms || 5000;
const loadedCustomMessage = config.customMessage || "";
const loadedCustomMessageType = config.customMessageType || "text";
const loadedCustomMessageRecordingId = config.customMessageRecordingId || "";
setHttpMethod(loadedHttpMethod);
setUrl(loadedUrl);
setCredentialUuid(loadedCredentialUuid);
setTimeoutMs(loadedTimeoutMs);
setCustomMessage(loadedCustomMessage);
setCustomMessageType(loadedCustomMessageType);
setCustomMessageRecordingId(loadedCustomMessageRecordingId);
// Convert headers object to array
if (config.headers) {
setHeaders(
Object.entries(config.headers).map(([key, value]) => ({
key,
value: value as string,
}))
);
} else {
setHeaders([]);
}
const loadedHeaders = config.headers
? Object.entries(config.headers).map(([key, value]) => ({
key,
value: value as string,
}))
: [];
setHeaders(loadedHeaders);
// Load parameters
let loadedParameters: ToolParameter[] = [];
if (config.parameters && Array.isArray(config.parameters)) {
setParameters(
config.parameters.map((p) => ({
name: p.name || "",
type: normalizeParameterType(p.type),
description: p.description || "",
required: p.required ?? true,
}))
);
loadedParameters = config.parameters.map((p) => ({
name: p.name || "",
type: normalizeParameterType(p.type),
description: p.description || "",
required: p.required ?? true,
}));
setParameters(loadedParameters);
} else {
setParameters([]);
}
let loadedPresetParameters: PresetToolParameter[] = [];
if (config.preset_parameters && Array.isArray(config.preset_parameters)) {
setPresetParameters(
config.preset_parameters.map((p) => ({
name: p.name || "",
type: normalizeParameterType(p.type),
valueTemplate: p.value_template || "",
required: p.required ?? true,
}))
);
loadedPresetParameters = config.preset_parameters.map((p) => ({
name: p.name || "",
type: normalizeParameterType(p.type),
valueTemplate: p.value_template || "",
required: p.required ?? true,
}));
setPresetParameters(loadedPresetParameters);
} else {
setPresetParameters([]);
}
setSavedHttpTestSnapshot(
buildHttpToolTestSnapshot({
name: tool.name,
description: tool.description || "",
httpMethod: loadedHttpMethod,
url: loadedUrl,
credentialUuid: loadedCredentialUuid,
headers: loadedHeaders,
parameters: loadedParameters,
presetParameters: loadedPresetParameters,
timeoutMs: loadedTimeoutMs,
customMessage: loadedCustomMessage,
customMessageType: loadedCustomMessageType,
customMessageRecordingId: loadedCustomMessageRecordingId,
})
);
}
}
};
@ -382,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()) {
@ -406,6 +479,11 @@ export default function ToolDetailPage() {
setError("All parameters must have a name");
return;
}
const paramNames = parameters.map((p) => p.name.trim()).filter(Boolean);
if (new Set(paramNames).size !== paramNames.length) {
setError("Parameter names must be unique");
return;
}
const invalidPresetParams = presetParameters.filter(
(p) => !p.name.trim() || !p.valueTemplate.trim()
@ -500,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 = {
@ -580,6 +669,24 @@ export default function ToolDetailPage() {
setTool(response.data);
setSaveSuccess(true);
setTimeout(() => setSaveSuccess(false), 3000);
if (tool.category === "http_api") {
setSavedHttpTestSnapshot(
buildHttpToolTestSnapshot({
name,
description,
httpMethod,
url,
credentialUuid,
headers,
parameters,
presetParameters,
timeoutMs,
customMessage,
customMessageType,
customMessageRecordingId,
})
);
}
}
} catch (err) {
setError("Failed to save tool");
@ -681,6 +788,24 @@ const data = await response.json();`;
const isTransferCallTool = tool.category === "transfer_call";
const isBuiltinTool = tool.category === "calculator";
const isMcpTool = tool.category === "mcp";
const isHttpApiTool = tool.category === "http_api";
const hasUnsavedHttpChanges =
isHttpApiTool &&
(savedHttpTestSnapshot === null ||
buildHttpToolTestSnapshot({
name,
description,
httpMethod,
url,
credentialUuid,
headers,
parameters,
presetParameters,
timeoutMs,
customMessage,
customMessageType,
customMessageRecordingId,
}) !== savedHttpTestSnapshot);
const categoryConfig = getCategoryConfig(tool.category as ToolCategory);
return (
@ -716,7 +841,7 @@ const data = await response.json();`;
</div>
</div>
<div className="flex items-center gap-2">
{!isEndCallTool && !isTransferCallTool && !isBuiltinTool && !isMcpTool && (
{isHttpApiTool && (
<Button
variant="outline"
onClick={() => setShowCodeDialog(true)}
@ -799,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>
@ -903,6 +1035,18 @@ const data = await response.json();`;
/>
)}
{isHttpApiTool && (
<HttpToolTestDialog
open={showTestDialog}
onOpenChange={setShowTestDialog}
toolUuid={toolUuid}
httpMethod={httpMethod}
url={url}
parameters={parameters}
presetParameters={presetParameters}
/>
)}
{error && (
<div className="mt-4 p-4 bg-destructive/10 border border-destructive/20 rounded-lg text-destructive">
{error}
@ -915,7 +1059,34 @@ const data = await response.json();`;
</div>
)}
<div className="flex justify-end mt-6">
<div className="flex justify-end gap-2 mt-6">
{isHttpApiTool && (
hasUnsavedHttpChanges ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex" tabIndex={0}>
<Button type="button" variant="outline" disabled>
<FlaskConical className="w-4 h-4 mr-2" />
Test Tool
</Button>
</span>
</TooltipTrigger>
<TooltipContent side="top">
Save the tool before testing.
</TooltipContent>
</Tooltip>
) : (
<Button
type="button"
variant="outline"
onClick={() => setShowTestDialog(true)}
disabled={isSaving}
>
<FlaskConical className="w-4 h-4 mr-2" />
Test Tool
</Button>
)
)}
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? (
<>
@ -947,6 +1118,7 @@ const data = await response.json();`;
</div>
</DialogContent>
</Dialog>
</div>
);
}

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

@ -117,6 +117,7 @@ function RenderWorkflow({
onConnect,
onEdgesChange,
onNodesChange,
onDelete,
} = useWorkflowState({
initialWorkflowName,
workflowId,
@ -514,6 +515,7 @@ function RenderWorkflow({
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onDelete={onDelete}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
onConnect={isViewingHistoricalVersion ? undefined : onConnect}

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

@ -136,6 +136,7 @@ export const useWorkflowState = ({
templateContextVariables,
workflowConfigurations,
initializeWorkflow,
commitDeletion,
setNodes,
setEdges,
setWorkflowName,
@ -493,6 +494,10 @@ export const useWorkflowState = ({
[setNodes],
);
const onDelete = useCallback(() => {
commitDeletion();
}, [commitDeletion]);
const onRun = async (mode: string) => {
if (!user?.id) return;
const workflowRunName = `WR-${getRandomId()}`;
@ -638,6 +643,7 @@ export const useWorkflowState = ({
onConnect,
onEdgesChange,
onNodesChange,
onDelete,
onRun,
saveTemplateContextVariables,
saveWorkflowConfigurations,

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,
);
@ -647,6 +661,11 @@ function GeneralSection({
</Select>
<p className="text-xs text-muted-foreground">
{selectedTurnStartStrategy?.description}
{turnStartStrategy === "provisional_vad" && (
<span className="ml-2 inline-flex rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
Experimental
</span>
)}
</p>
</div>
{turnStartStrategy === "min_words" && (
@ -788,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>

View file

@ -0,0 +1,151 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { FlowEdge, FlowNode } from '@/components/flow/types';
import { useWorkflowStore } from './workflowStore';
const createNode = (id: string, x = 0): FlowNode => ({
id,
type: 'agentNode',
position: { x, y: 0 },
data: { name: id },
});
const createEdge = (id: string, source: string, target: string): FlowEdge => ({
id,
source,
target,
data: { condition: 'always', label: id },
});
const nodeIds = () => useWorkflowStore.getState().nodes.map((node) => node.id);
describe('workflow history', () => {
beforeEach(() => {
useWorkflowStore.getState().clearStore();
useWorkflowStore.getState().initializeWorkflow(1, 'Initial', [], []);
});
it('keeps every committed node state available to undo and redo', () => {
const store = useWorkflowStore.getState();
store.addNode(createNode('a'));
store.addNode(createNode('b'));
store.undo();
expect(nodeIds()).toEqual(['a']);
store.undo();
expect(nodeIds()).toEqual([]);
store.redo();
store.redo();
expect(nodeIds()).toEqual(['a', 'b']);
});
it('undoes a node and connected-edge deletion in one step', () => {
const firstNode = createNode('a');
const secondNode = createNode('b');
const edge = createEdge('a-b', 'a', 'b');
useWorkflowStore.getState().initializeWorkflow(
1,
'Initial',
[firstNode, secondNode],
[edge]
);
// React Flow emits connected-edge removals before node removals, then onDelete.
useWorkflowStore.getState().setEdges(
[],
[{ id: edge.id, type: 'remove' }]
);
useWorkflowStore.getState().setNodes(
[secondNode],
[{ id: firstNode.id, type: 'remove' }]
);
useWorkflowStore.getState().commitDeletion();
expect(nodeIds()).toEqual(['b']);
expect(useWorkflowStore.getState().edges).toEqual([]);
expect(useWorkflowStore.getState().history).toHaveLength(2);
useWorkflowStore.getState().undo();
expect(nodeIds()).toEqual(['a', 'b']);
expect(useWorkflowStore.getState().edges).toEqual([edge]);
useWorkflowStore.getState().redo();
expect(nodeIds()).toEqual(['b']);
expect(useWorkflowStore.getState().edges).toEqual([]);
});
it('coalesces active dragging into one committed final position', () => {
const initialNode = createNode('a');
useWorkflowStore.getState().initializeWorkflow(1, 'Initial', [initialNode], []);
for (const x of [10, 20]) {
useWorkflowStore.getState().setNodes(
[createNode('a', x)],
[{ id: 'a', type: 'position', position: { x, y: 0 }, dragging: true }]
);
}
useWorkflowStore.getState().setNodes(
[createNode('a', 30)],
[{ id: 'a', type: 'position', position: { x: 30, y: 0 }, dragging: false }]
);
expect(useWorkflowStore.getState().history).toHaveLength(2);
useWorkflowStore.getState().undo();
expect(useWorkflowStore.getState().nodes[0].position.x).toBe(0);
useWorkflowStore.getState().redo();
expect(useWorkflowStore.getState().nodes[0].position.x).toBe(30);
});
it('truncates redo history after a new edit', () => {
useWorkflowStore.getState().addNode(createNode('a'));
useWorkflowStore.getState().addNode(createNode('b'));
useWorkflowStore.getState().undo();
useWorkflowStore.getState().addNode(createNode('c'));
expect(nodeIds()).toEqual(['a', 'c']);
expect(useWorkflowStore.getState().canRedo()).toBe(false);
useWorkflowStore.getState().undo();
expect(nodeIds()).toEqual(['a']);
useWorkflowStore.getState().redo();
expect(nodeIds()).toEqual(['a', 'c']);
});
it('keeps the current state at the end of the bounded history', () => {
for (let index = 1; index <= 55; index += 1) {
useWorkflowStore.getState().setWorkflowName(`Edit ${index}`);
}
expect(useWorkflowStore.getState().history).toHaveLength(50);
expect(useWorkflowStore.getState().historyIndex).toBe(49);
let undoCount = 0;
while (useWorkflowStore.getState().canUndo()) {
useWorkflowStore.getState().undo();
undoCount += 1;
}
expect(undoCount).toBe(49);
expect(useWorkflowStore.getState().workflowName).toBe('Edit 6');
while (useWorkflowStore.getState().canRedo()) {
useWorkflowStore.getState().redo();
}
expect(useWorkflowStore.getState().workflowName).toBe('Edit 55');
});
it('does not track selection-only changes', () => {
const initialNode = createNode('a');
useWorkflowStore.getState().initializeWorkflow(1, 'Initial', [initialNode], []);
useWorkflowStore.getState().setNodes(
[{ ...initialNode, selected: true }],
[{ id: 'a', type: 'select', selected: true }]
);
expect(useWorkflowStore.getState().nodes[0].selected).toBe(true);
expect(useWorkflowStore.getState().history).toHaveLength(1);
expect(useWorkflowStore.getState().isDirty).toBe(false);
});
});

View file

@ -54,7 +54,7 @@ interface WorkflowActions {
) => void;
// History management
pushToHistory: () => void;
commitDeletion: () => void;
undo: () => void;
redo: () => void;
canUndo: () => boolean;
@ -99,6 +99,30 @@ type WorkflowStore = WorkflowState & WorkflowActions;
const MAX_HISTORY_SIZE = 50;
const commitHistory = (
state: Pick<WorkflowState, 'history' | 'historyIndex'>,
snapshot: HistoryState
): Pick<
WorkflowState,
'nodes' | 'edges' | 'workflowName' | 'history' | 'historyIndex' | 'isDirty'
> => {
const history = [
...state.history.slice(0, state.historyIndex + 1),
snapshot,
];
if (history.length > MAX_HISTORY_SIZE) {
history.shift();
}
return {
...snapshot,
history,
historyIndex: history.length - 1,
isDirty: true,
};
};
// Create the store
export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
// Initial state
@ -134,27 +158,14 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
});
},
pushToHistory: () => {
const state = get();
const currentState: HistoryState = {
nodes: state.nodes,
edges: state.edges,
workflowName: state.workflowName,
};
// Remove any forward history if we're not at the end
const newHistory = state.history.slice(0, state.historyIndex + 1);
newHistory.push(currentState);
// Limit history size
if (newHistory.length > MAX_HISTORY_SIZE) {
newHistory.shift();
}
set({
history: newHistory,
historyIndex: newHistory.length - 1,
});
commitDeletion: () => {
set((state) =>
commitHistory(state, {
nodes: state.nodes,
edges: state.edges,
workflowName: state.workflowName,
})
);
},
undo: () => {
@ -200,10 +211,10 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
setNodes: (nodes, changes) => {
// Determine whether to push to history and set isDirty based on change types
if (changes && changes.length > 0) {
// Check for add/remove changes (always push to history)
const hasAddRemoveChanges = changes.some(change =>
change.type === 'add' || change.type === 'remove'
);
const hasAddChanges = changes.some(change => change.type === 'add');
// React Flow emits edge and node removals separately. They are committed
// together from onDelete after both live-state updates have completed.
const hasRemoveChanges = changes.some(change => change.type === 'remove');
// Check for position changes - only push to history when drag ENDS (dragging: false)
// but still mark as dirty during dragging
@ -214,11 +225,16 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
change.type === 'position' && change.dragging === true
);
if (hasAddRemoveChanges || hasDragEndChanges) {
get().pushToHistory();
set({ nodes, isDirty: true });
} else if (isActiveDragging) {
// During active dragging, update nodes but don't push to history
if (hasAddChanges || hasDragEndChanges) {
set((state) =>
commitHistory(state, {
nodes,
edges: state.edges,
workflowName: state.workflowName,
})
);
} else if (hasRemoveChanges || isActiveDragging) {
// During active dragging or deletion, update nodes before committing.
set({ nodes, isDirty: true });
} else {
// For selection changes or dimension updates, don't push to history or set dirty
@ -231,49 +247,62 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
},
addNode: (node) => {
const state = get();
get().pushToHistory();
set({
nodes: [...state.nodes, node],
isDirty: true
set((state) => {
const nodes = [...state.nodes, node];
return commitHistory(state, {
nodes,
edges: state.edges,
workflowName: state.workflowName,
});
});
},
updateNode: (nodeId, updates) => {
const state = get();
get().pushToHistory();
set({
nodes: state.nodes.map((node) =>
set((state) => {
const nodes = state.nodes.map((node) =>
node.id === nodeId ? { ...node, ...updates } : node
),
isDirty: true,
);
return commitHistory(state, {
nodes,
edges: state.edges,
workflowName: state.workflowName,
});
});
},
deleteNode: (nodeId) => {
const state = get();
get().pushToHistory();
set({
nodes: state.nodes.filter((node) => node.id !== nodeId),
edges: state.edges.filter(
set((state) => {
const nodes = state.nodes.filter((node) => node.id !== nodeId);
const edges = state.edges.filter(
(edge) => edge.source !== nodeId && edge.target !== nodeId
),
isDirty: true,
);
return commitHistory(state, {
nodes,
edges,
workflowName: state.workflowName,
});
});
},
setEdges: (edges, changes) => {
// Determine whether to push to history and set isDirty based on change types
if (changes && changes.length > 0) {
// Check if any changes are user-initiated (not just selections)
const hasDirtyChanges = changes.some(change =>
const hasImmediateHistoryChanges = changes.some(change =>
change.type === 'add' ||
change.type === 'remove' ||
change.type === 'replace'
);
const hasRemoveChanges = changes.some(change => change.type === 'remove');
if (hasDirtyChanges) {
get().pushToHistory();
if (hasImmediateHistoryChanges) {
set((state) =>
commitHistory(state, {
nodes: state.nodes,
edges,
workflowName: state.workflowName,
})
);
} else if (hasRemoveChanges) {
// React Flow calls onDelete after all edge and node removals.
set({ edges, isDirty: true });
} else {
// For selection changes, don't push to history
@ -286,37 +315,48 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
},
addEdge: (edge) => {
const state = get();
get().pushToHistory();
set({
edges: [...state.edges, edge],
isDirty: true
set((state) => {
const edges = [...state.edges, edge];
return commitHistory(state, {
nodes: state.nodes,
edges,
workflowName: state.workflowName,
});
});
},
updateEdge: (edgeId, updates) => {
const state = get();
get().pushToHistory();
set({
edges: state.edges.map((edge) =>
set((state) => {
const edges = state.edges.map((edge) =>
edge.id === edgeId ? { ...edge, ...updates } : edge
),
isDirty: true,
);
return commitHistory(state, {
nodes: state.nodes,
edges,
workflowName: state.workflowName,
});
});
},
deleteEdge: (edgeId) => {
const state = get();
get().pushToHistory();
set({
edges: state.edges.filter((edge) => edge.id !== edgeId),
isDirty: true,
set((state) => {
const edges = state.edges.filter((edge) => edge.id !== edgeId);
return commitHistory(state, {
nodes: state.nodes,
edges,
workflowName: state.workflowName,
});
});
},
setWorkflowName: (workflowName) => {
get().pushToHistory();
set({ workflowName, isDirty: true });
set((state) =>
commitHistory(state, {
nodes: state.nodes,
edges: state.edges,
workflowName,
})
);
},
setTemplateContextVariables: (templateContextVariables) => {

File diff suppressed because one or more lines are too long

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;
};
/**
@ -5938,6 +6046,88 @@ export type ToolResponse = {
created_by?: CreatedByResponse | null;
};
/**
* ToolTestRequest
*
* Request body for testing an HTTP API tool outside a live call.
*/
export type ToolTestRequest = {
/**
* Llm Params
*
* Values for parameters normally supplied by the model.
*/
llm_params?: {
[key: string]: unknown;
};
/**
* Preset Params
*
* Resolved values for parameters normally supplied from presets.
*/
preset_params?: {
[key: string]: unknown;
};
};
/**
* ToolTestResponse
*
* Result of testing an HTTP API tool.
*/
export type ToolTestResponse = {
/**
* Status
*/
status: string;
/**
* Status Code
*/
status_code?: number | null;
/**
* Data
*/
data?: unknown | null;
/**
* Error
*/
error?: string | null;
/**
* Hint
*/
hint?: string | null;
/**
* Request Method
*/
request_method: string;
/**
* Request Url
*/
request_url: string;
/**
* Request Headers
*/
request_headers?: {
[key: string]: string;
};
/**
* Request Body
*/
request_body?: {
[key: string]: unknown;
} | null;
/**
* Request Params
*/
request_params?: {
[key: string]: unknown;
} | null;
/**
* Duration Ms
*/
duration_ms: number;
};
/**
* TransferCallConfig
*
@ -5947,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
*
@ -5990,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;
};
/**
@ -6453,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
*
@ -6718,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;
};
@ -8627,10 +8907,14 @@ export type GetWorkflowRunsApiV1WorkflowWorkflowIdRunsGetData = {
query?: {
/**
* Page
*
* Page number (starts from 1)
*/
page?: number;
/**
* Limit
*
* Number of items per page
*/
limit?: number;
/**
@ -9898,10 +10182,14 @@ export type GetCampaignRunsApiV1CampaignCampaignIdRunsGetData = {
query?: {
/**
* Page
*
* Page number (starts from 1)
*/
page?: number;
/**
* Limit
*
* Number of items per page
*/
limit?: number;
/**
@ -10664,6 +10952,50 @@ export type RefreshMcpToolsApiV1ToolsToolUuidMcpRefreshPostResponses = {
export type RefreshMcpToolsApiV1ToolsToolUuidMcpRefreshPostResponse = RefreshMcpToolsApiV1ToolsToolUuidMcpRefreshPostResponses[keyof RefreshMcpToolsApiV1ToolsToolUuidMcpRefreshPostResponses];
export type TestToolApiV1ToolsToolUuidTestPostData = {
body: ToolTestRequest;
headers?: {
/**
* Authorization
*/
authorization?: string | null;
/**
* X-Api-Key
*/
'X-API-Key'?: string | null;
};
path: {
/**
* Tool Uuid
*/
tool_uuid: string;
};
query?: never;
url: '/api/v1/tools/{tool_uuid}/test';
};
export type TestToolApiV1ToolsToolUuidTestPostErrors = {
/**
* Not found
*/
404: unknown;
/**
* Validation Error
*/
422: HttpValidationError;
};
export type TestToolApiV1ToolsToolUuidTestPostError = TestToolApiV1ToolsToolUuidTestPostErrors[keyof TestToolApiV1ToolsToolUuidTestPostErrors];
export type TestToolApiV1ToolsToolUuidTestPostResponses = {
/**
* Successful Response
*/
200: ToolTestResponse;
};
export type TestToolApiV1ToolsToolUuidTestPostResponse = TestToolApiV1ToolsToolUuidTestPostResponses[keyof TestToolApiV1ToolsToolUuidTestPostResponses];
export type UnarchiveToolApiV1ToolsToolUuidUnarchivePostData = {
body?: never;
headers?: {

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

5
ui/vercel.json Normal file
View file

@ -0,0 +1,5 @@
{
"git": {
"deploymentEnabled": false
}
}