mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-07-25 12:01:04 +02:00
feat: add vici dial controls from UI (#563)
* ViciDial Working * feat(telephony): add FreeSWITCH provider to upstream-PBX seam Generalize the upstream-PBX capture and control paths to dispatch by provider. FreeSWITCH bridges in with X-PBX-* headers and is driven over the Event Socket Library (uuid_kill / uuid_transfer) by the channel UUID, alongside the existing VICIdial ra_call_control path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * add vici specific configs * feat(telephony): env-based VICIdial config + address3 post-call routing Move VICIdial agent/non-agent API and FreeSWITCH ESL connection settings out of hardcoded POC values into environment variables so the same image works against a PBX on another server. Add VICIdial update_lead forwarding: X-VICI-UPDATE-LEAD_* extracted variables are mapped to lead columns and pushed via the non-agent API before a transfer, with reserved API-control params dropped. Add hardcoded address3 disposition routing (Y/N -> in-group transfer, else hang up) and a synchronous final variable extraction so the transfer path sees the freshest conversation state. Skip upstream_pbx capture on non-PJSIP channels to avoid Asterisk 500s. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: run formatter * feat: make vici configurable from UI * chore: clean up PR * fix: incorporate review comments * chore: incorporate review comments * chore: generate client * chore: incporporate review comments * chore: incorporate review comments --------- Co-authored-by: Dograh POC <payment@dograh.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2f7b47a1b4
commit
d8cd34e8d2
51 changed files with 2886 additions and 102 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ from api.services.tool_management import (
|
|||
build_tool_response,
|
||||
create_tool_for_user,
|
||||
refresh_mcp_tool_for_user,
|
||||
validate_external_pbx_tool_definition,
|
||||
validate_tool_credential_references,
|
||||
)
|
||||
from api.services.tool_management import (
|
||||
|
|
@ -375,6 +376,18 @@ async def update_tool(
|
|||
if request.definition:
|
||||
definition = request.definition.model_dump()
|
||||
try:
|
||||
existing_tool = await db_client.get_tool_by_uuid(
|
||||
tool_uuid,
|
||||
user.selected_organization_id,
|
||||
include_archived=True,
|
||||
)
|
||||
await validate_external_pbx_tool_definition(
|
||||
definition,
|
||||
organization_id=user.selected_organization_id,
|
||||
existing_definition=(
|
||||
existing_tool.definition if existing_tool else None
|
||||
),
|
||||
)
|
||||
await validate_tool_credential_references(
|
||||
definition,
|
||||
organization_id=user.selected_organization_id,
|
||||
|
|
|
|||
|
|
@ -44,6 +44,11 @@ from api.services.mps_service_key_client import mps_service_key_client
|
|||
from api.services.posthog_client import capture_event
|
||||
from api.services.reports import generate_workflow_report_csv
|
||||
from api.services.storage import storage_fs
|
||||
from api.services.workflow.configuration_policy import (
|
||||
ExternalPBXConfigurationDisabledError,
|
||||
WorkflowConfigurationNotFoundError,
|
||||
apply_external_pbx_mapping_policy,
|
||||
)
|
||||
from api.services.workflow.dto import ReactFlowDTO, sanitize_workflow_definition
|
||||
from api.services.workflow.duplicate import duplicate_workflow
|
||||
from api.services.workflow.errors import ItemKind, WorkflowError
|
||||
|
|
@ -1050,6 +1055,16 @@ async def update_workflow(
|
|||
if request.workflow_configurations is not None
|
||||
else None
|
||||
)
|
||||
try:
|
||||
workflow_configurations = await apply_external_pbx_mapping_policy(
|
||||
workflow_configurations,
|
||||
workflow_id=workflow_id,
|
||||
organization_id=user.selected_organization_id,
|
||||
)
|
||||
except WorkflowConfigurationNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except ExternalPBXConfigurationDisabledError as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||
if workflow_configurations and workflow_configurations.get(
|
||||
WORKFLOW_MODEL_CONFIGURATION_V2_OVERRIDE_KEY
|
||||
):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue