mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-07-07 11:02:12 +02:00
feat: refactor telephony to support multiple telephony configurations (#251)
Co-authored-by: Sabiha Khan <sabihak89@gmail.com>
This commit is contained in:
parent
2f860e7f6d
commit
e16f6438bd
101 changed files with 10906 additions and 5420 deletions
|
|
@ -1,303 +1,672 @@
|
|||
from typing import List, Optional, Union
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from api.constants import DEFAULT_CAMPAIGN_RETRY_CONFIG, DEFAULT_ORG_CONCURRENCY_LIMIT
|
||||
from api.db import db_client
|
||||
from api.db.models import UserModel
|
||||
from api.db.telephony_configuration_client import (
|
||||
TelephonyConfigurationDuplicateAccountError,
|
||||
TelephonyConfigurationInUseError,
|
||||
)
|
||||
from api.enums import OrganizationConfigurationKey, PostHogEvent
|
||||
from api.schemas.telephony_config import (
|
||||
ARIConfigurationRequest,
|
||||
ARIConfigurationResponse,
|
||||
CloudonixConfigurationRequest,
|
||||
CloudonixConfigurationResponse,
|
||||
PlivoConfigurationRequest,
|
||||
PlivoConfigurationResponse,
|
||||
TelephonyConfigRequest,
|
||||
TelephonyConfigurationCreateRequest,
|
||||
TelephonyConfigurationDetail,
|
||||
TelephonyConfigurationListItem,
|
||||
TelephonyConfigurationListResponse,
|
||||
TelephonyConfigurationResponse,
|
||||
TelnyxConfigurationRequest,
|
||||
TelnyxConfigurationResponse,
|
||||
TwilioConfigurationRequest,
|
||||
TwilioConfigurationResponse,
|
||||
VobizConfigurationRequest,
|
||||
VobizConfigurationResponse,
|
||||
VonageConfigurationRequest,
|
||||
VonageConfigurationResponse,
|
||||
TelephonyConfigurationUpdateRequest,
|
||||
)
|
||||
from api.schemas.telephony_phone_number import (
|
||||
PhoneNumberCreateRequest,
|
||||
PhoneNumberListResponse,
|
||||
PhoneNumberResponse,
|
||||
PhoneNumberUpdateRequest,
|
||||
ProviderSyncStatus,
|
||||
)
|
||||
from api.services.auth.depends import get_user
|
||||
from api.services.configuration.masking import is_mask_of, mask_key
|
||||
from api.services.posthog_client import capture_event
|
||||
from api.services.telephony import registry as telephony_registry
|
||||
from api.services.telephony.factory import get_telephony_provider_by_id
|
||||
from api.services.worker_sync.manager import get_worker_sync_manager
|
||||
from api.services.worker_sync.protocol import WorkerSyncEventType
|
||||
from api.utils.common import get_backend_endpoints
|
||||
|
||||
router = APIRouter(prefix="/organizations", tags=["organizations"])
|
||||
|
||||
# Provider configuration constants
|
||||
PROVIDER_MASKED_FIELDS = {
|
||||
"twilio": ["account_sid", "auth_token"],
|
||||
"plivo": ["auth_id", "auth_token"],
|
||||
"vonage": ["private_key", "api_key", "api_secret"],
|
||||
"vobiz": ["auth_id", "auth_token"],
|
||||
"cloudonix": ["bearer_token"],
|
||||
"ari": ["app_password"],
|
||||
"telnyx": ["api_key"],
|
||||
}
|
||||
|
||||
def _sensitive_fields(provider_name: str) -> List[str]:
|
||||
"""Field names that should be masked when displaying stored config.
|
||||
|
||||
Sourced from ProviderUIField.sensitive in the registry — the same source
|
||||
of truth that drives the form-rendering UI.
|
||||
"""
|
||||
spec = telephony_registry.get_optional(provider_name)
|
||||
if spec is None or spec.ui_metadata is None:
|
||||
return []
|
||||
return [f.name for f in spec.ui_metadata.fields if f.sensitive]
|
||||
|
||||
|
||||
# TODO: Make endpoints provider-agnostic
|
||||
@router.get("/telephony-config", response_model=TelephonyConfigurationResponse)
|
||||
async def get_telephony_configuration(user: UserModel = Depends(get_user)):
|
||||
"""Get telephony configuration for the user's organization with masked sensitive fields."""
|
||||
def _mask_sensitive(provider_name: str, value: dict) -> dict:
|
||||
"""Return a copy of ``value`` with sensitive fields masked for display."""
|
||||
out = dict(value)
|
||||
for field_name in _sensitive_fields(provider_name):
|
||||
v = out.get(field_name)
|
||||
if v:
|
||||
out[field_name] = mask_key(v)
|
||||
return out
|
||||
|
||||
|
||||
class TelephonyProviderUIField(BaseModel):
|
||||
"""One form field on a telephony provider's configuration UI."""
|
||||
|
||||
name: str
|
||||
label: str
|
||||
type: str
|
||||
required: bool
|
||||
sensitive: bool
|
||||
description: Optional[str] = None
|
||||
placeholder: Optional[str] = None
|
||||
|
||||
|
||||
class TelephonyProviderMetadata(BaseModel):
|
||||
"""UI form metadata for a single telephony provider."""
|
||||
|
||||
provider: str
|
||||
display_name: str
|
||||
fields: List[TelephonyProviderUIField]
|
||||
docs_url: Optional[str] = None
|
||||
|
||||
|
||||
class TelephonyProvidersMetadataResponse(BaseModel):
|
||||
"""List of UI form definitions used by the telephony-config screen."""
|
||||
|
||||
providers: List[TelephonyProviderMetadata]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/telephony-providers/metadata",
|
||||
response_model=TelephonyProvidersMetadataResponse,
|
||||
)
|
||||
async def get_telephony_providers_metadata(user: UserModel = Depends(get_user)):
|
||||
"""Return the list of available telephony providers and their form schemas.
|
||||
|
||||
The UI uses this to render the configuration form generically instead of
|
||||
hard-coding fields per provider. Adding a new provider only requires
|
||||
declaring its ui_metadata in providers/<name>/__init__.py.
|
||||
"""
|
||||
if not user.selected_organization_id:
|
||||
raise HTTPException(status_code=400, detail="No organization selected")
|
||||
|
||||
config = await db_client.get_configuration(
|
||||
user.selected_organization_id,
|
||||
OrganizationConfigurationKey.TELEPHONY_CONFIGURATION.value,
|
||||
providers = []
|
||||
for spec in telephony_registry.all_specs():
|
||||
if spec.ui_metadata is None:
|
||||
continue
|
||||
providers.append(
|
||||
TelephonyProviderMetadata(
|
||||
provider=spec.name,
|
||||
display_name=spec.ui_metadata.display_name,
|
||||
fields=[
|
||||
TelephonyProviderUIField(
|
||||
name=f.name,
|
||||
label=f.label,
|
||||
type=f.type,
|
||||
required=f.required,
|
||||
sensitive=f.sensitive,
|
||||
description=f.description,
|
||||
placeholder=f.placeholder,
|
||||
)
|
||||
for f in spec.ui_metadata.fields
|
||||
],
|
||||
docs_url=spec.ui_metadata.docs_url,
|
||||
)
|
||||
)
|
||||
return TelephonyProvidersMetadataResponse(providers=providers)
|
||||
|
||||
|
||||
def _account_id_field(provider: str) -> str:
|
||||
"""The credential field that uniquely identifies the provider account.
|
||||
|
||||
Empty string for providers without an account-id concept (e.g. ARI).
|
||||
Drives the duplicate-account guard at save time and account-id matching
|
||||
at inbound webhook time.
|
||||
"""
|
||||
spec = telephony_registry.get_optional(provider)
|
||||
return spec.account_id_credential_field if spec else ""
|
||||
|
||||
|
||||
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]
|
||||
|
||||
|
||||
def _credentials_from_payload(config: TelephonyConfigRequest) -> dict:
|
||||
"""Provider credentials only — strip provider/from_numbers from the payload."""
|
||||
payload = config.model_dump()
|
||||
payload.pop("provider", None)
|
||||
payload.pop("from_numbers", None)
|
||||
return payload
|
||||
|
||||
|
||||
def _phone_number_to_response(
|
||||
row, inbound_workflow_name: Optional[str] = None
|
||||
) -> PhoneNumberResponse:
|
||||
response = PhoneNumberResponse.model_validate(row)
|
||||
response.inbound_workflow_name = inbound_workflow_name
|
||||
return response
|
||||
|
||||
|
||||
async def _sync_inbound_for_phone_number(
|
||||
config_id: int, address: str
|
||||
) -> ProviderSyncStatus:
|
||||
"""Push inbound webhook configuration to the provider.
|
||||
|
||||
``attach=True``: ask the provider to route this number's inbound calls
|
||||
to our workflow-agnostic dispatcher (``/api/v1/telephony/inbound/run``).
|
||||
``attach=False``: ask the provider to detach. The dispatcher resolves
|
||||
the workflow from the called number's ``inbound_workflow_id``, so the
|
||||
webhook URL is the same for every assignment — providers only need to
|
||||
bind/unbind the number, not rewrite per-workflow URLs.
|
||||
"""
|
||||
try:
|
||||
provider = await get_telephony_provider_by_id(config_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load telephony provider for config {config_id}: {e}")
|
||||
return ProviderSyncStatus(ok=False, message=f"Provider load failed: {e}")
|
||||
|
||||
backend_endpoint, _ = await get_backend_endpoints()
|
||||
webhook_url = f"{backend_endpoint}/api/v1/telephony/inbound/run"
|
||||
|
||||
try:
|
||||
result = await provider.configure_inbound(address, webhook_url)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Provider configure_inbound raised for config {config_id} "
|
||||
f"address {address}: {e}"
|
||||
)
|
||||
return ProviderSyncStatus(ok=False, message=f"Provider sync failed: {e}")
|
||||
|
||||
return ProviderSyncStatus(ok=result.ok, message=result.message)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Multi-config CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/telephony-configs", response_model=TelephonyConfigurationListResponse)
|
||||
async def list_telephony_configurations(user: UserModel = Depends(get_user)):
|
||||
"""List the org's telephony configurations with phone-number counts."""
|
||||
if not user.selected_organization_id:
|
||||
raise HTTPException(status_code=400, detail="No organization selected")
|
||||
|
||||
rows = await db_client.list_telephony_configurations(user.selected_organization_id)
|
||||
items: List[TelephonyConfigurationListItem] = []
|
||||
for row in rows:
|
||||
numbers = await db_client.list_phone_numbers_for_config(row.id)
|
||||
items.append(
|
||||
TelephonyConfigurationListItem(
|
||||
id=row.id,
|
||||
name=row.name,
|
||||
provider=row.provider,
|
||||
is_default_outbound=row.is_default_outbound,
|
||||
phone_number_count=len([n for n in numbers if n.is_active]),
|
||||
created_at=row.created_at,
|
||||
updated_at=row.updated_at,
|
||||
)
|
||||
)
|
||||
return TelephonyConfigurationListResponse(configurations=items)
|
||||
|
||||
|
||||
@router.post("/telephony-configs", response_model=TelephonyConfigurationDetail)
|
||||
async def create_telephony_configuration(
|
||||
request: TelephonyConfigurationCreateRequest,
|
||||
user: UserModel = Depends(get_user),
|
||||
):
|
||||
"""Create a new telephony configuration for the org."""
|
||||
if not user.selected_organization_id:
|
||||
raise HTTPException(status_code=400, detail="No organization selected")
|
||||
|
||||
credentials = _credentials_from_payload(request.config)
|
||||
|
||||
try:
|
||||
row = await db_client.create_telephony_configuration(
|
||||
organization_id=user.selected_organization_id,
|
||||
name=request.name,
|
||||
provider=request.config.provider,
|
||||
credentials=credentials,
|
||||
is_default_outbound=request.is_default_outbound,
|
||||
account_id_credential_field=_account_id_field(request.config.provider),
|
||||
)
|
||||
except TelephonyConfigurationDuplicateAccountError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
except IntegrityError as e:
|
||||
raise HTTPException(status_code=409, detail=f"Duplicate name: {e}")
|
||||
|
||||
capture_event(
|
||||
distinct_id=str(user.provider_id),
|
||||
event=PostHogEvent.TELEPHONY_CONFIGURED,
|
||||
properties={
|
||||
"provider": request.config.provider,
|
||||
"organization_id": user.selected_organization_id,
|
||||
"config_id": row.id,
|
||||
},
|
||||
)
|
||||
|
||||
if not config or not config.value:
|
||||
return _detail_response(row)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/telephony-configs/{config_id}", response_model=TelephonyConfigurationDetail
|
||||
)
|
||||
async def get_telephony_configuration_by_id(
|
||||
config_id: int, user: UserModel = Depends(get_user)
|
||||
):
|
||||
if not user.selected_organization_id:
|
||||
raise HTTPException(status_code=400, detail="No organization selected")
|
||||
|
||||
row = await db_client.get_telephony_configuration_for_org(
|
||||
config_id, user.selected_organization_id
|
||||
)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Telephony configuration not found")
|
||||
return _detail_response(row)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/telephony-configs/{config_id}", response_model=TelephonyConfigurationDetail
|
||||
)
|
||||
async def update_telephony_configuration(
|
||||
config_id: int,
|
||||
request: TelephonyConfigurationUpdateRequest,
|
||||
user: UserModel = Depends(get_user),
|
||||
):
|
||||
if not user.selected_organization_id:
|
||||
raise HTTPException(status_code=400, detail="No organization selected")
|
||||
|
||||
existing = await db_client.get_telephony_configuration_for_org(
|
||||
config_id, user.selected_organization_id
|
||||
)
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="Telephony configuration not found")
|
||||
|
||||
credentials = None
|
||||
if request.config is not None:
|
||||
if request.config.provider != existing.provider:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Provider cannot be changed; create a new configuration instead.",
|
||||
)
|
||||
credentials = _credentials_from_payload(request.config)
|
||||
preserve_masked_fields(
|
||||
existing.provider, credentials, existing.credentials or {}
|
||||
)
|
||||
|
||||
try:
|
||||
row = await db_client.update_telephony_configuration(
|
||||
config_id=config_id,
|
||||
organization_id=user.selected_organization_id,
|
||||
name=request.name,
|
||||
credentials=credentials,
|
||||
account_id_credential_field=_account_id_field(existing.provider),
|
||||
)
|
||||
except TelephonyConfigurationDuplicateAccountError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
|
||||
return _detail_response(row)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/telephony-configs/{config_id}/set-default-outbound",
|
||||
response_model=TelephonyConfigurationDetail,
|
||||
)
|
||||
async def set_default_outbound(config_id: int, user: UserModel = Depends(get_user)):
|
||||
if not user.selected_organization_id:
|
||||
raise HTTPException(status_code=400, detail="No organization selected")
|
||||
|
||||
row = await db_client.set_default_telephony_configuration(
|
||||
config_id, user.selected_organization_id
|
||||
)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Telephony configuration not found")
|
||||
return _detail_response(row)
|
||||
|
||||
|
||||
@router.delete("/telephony-configs/{config_id}")
|
||||
async def delete_telephony_configuration(
|
||||
config_id: int, user: UserModel = Depends(get_user)
|
||||
):
|
||||
if not user.selected_organization_id:
|
||||
raise HTTPException(status_code=400, detail="No organization selected")
|
||||
|
||||
try:
|
||||
deleted = await db_client.delete_telephony_configuration(
|
||||
config_id, user.selected_organization_id
|
||||
)
|
||||
except TelephonyConfigurationInUseError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Telephony configuration not found")
|
||||
return {"message": "Telephony configuration deleted"}
|
||||
|
||||
|
||||
def _detail_response(row) -> TelephonyConfigurationDetail:
|
||||
masked = _mask_sensitive(row.provider, row.credentials or {})
|
||||
return TelephonyConfigurationDetail(
|
||||
id=row.id,
|
||||
name=row.name,
|
||||
provider=row.provider,
|
||||
is_default_outbound=row.is_default_outbound,
|
||||
credentials=masked,
|
||||
created_at=row.created_at,
|
||||
updated_at=row.updated_at,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phone numbers (nested under a config)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _ensure_config_belongs_to_org(config_id: int, organization_id: int):
|
||||
cfg = await db_client.get_telephony_configuration_for_org(
|
||||
config_id, organization_id
|
||||
)
|
||||
if not cfg:
|
||||
raise HTTPException(status_code=404, detail="Telephony configuration not found")
|
||||
return cfg
|
||||
|
||||
|
||||
async def _ensure_workflow_belongs_to_org(workflow_id: int, organization_id: int):
|
||||
workflow = await db_client.get_workflow(
|
||||
workflow_id, organization_id=organization_id
|
||||
)
|
||||
if not workflow:
|
||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||
return workflow
|
||||
|
||||
|
||||
@router.get(
|
||||
"/telephony-configs/{config_id}/phone-numbers",
|
||||
response_model=PhoneNumberListResponse,
|
||||
)
|
||||
async def list_phone_numbers(config_id: int, user: UserModel = Depends(get_user)):
|
||||
if not user.selected_organization_id:
|
||||
raise HTTPException(status_code=400, detail="No organization selected")
|
||||
await _ensure_config_belongs_to_org(config_id, user.selected_organization_id)
|
||||
|
||||
rows = await db_client.list_phone_numbers_with_workflow_name_for_config(config_id)
|
||||
return PhoneNumberListResponse(
|
||||
phone_numbers=[_phone_number_to_response(r, name) for r, name in rows]
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/telephony-configs/{config_id}/phone-numbers",
|
||||
response_model=PhoneNumberResponse,
|
||||
)
|
||||
async def create_phone_number(
|
||||
config_id: int,
|
||||
request: PhoneNumberCreateRequest,
|
||||
user: UserModel = Depends(get_user),
|
||||
):
|
||||
if not user.selected_organization_id:
|
||||
raise HTTPException(status_code=400, detail="No organization selected")
|
||||
await _ensure_config_belongs_to_org(config_id, user.selected_organization_id)
|
||||
|
||||
if request.inbound_workflow_id is not None:
|
||||
await _ensure_workflow_belongs_to_org(
|
||||
request.inbound_workflow_id, user.selected_organization_id
|
||||
)
|
||||
|
||||
try:
|
||||
row = await db_client.create_phone_number(
|
||||
organization_id=user.selected_organization_id,
|
||||
telephony_configuration_id=config_id,
|
||||
address=request.address,
|
||||
country_code=request.country_code,
|
||||
label=request.label,
|
||||
inbound_workflow_id=request.inbound_workflow_id,
|
||||
is_active=request.is_active,
|
||||
is_default_caller_id=request.is_default_caller_id,
|
||||
extra_metadata=request.extra_metadata,
|
||||
)
|
||||
except IntegrityError:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="A phone number with this address already exists in the org.",
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
response = _phone_number_to_response(row)
|
||||
if request.inbound_workflow_id is not None:
|
||||
response.provider_sync = await _sync_inbound_for_phone_number(
|
||||
config_id, row.address
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@router.get(
|
||||
"/telephony-configs/{config_id}/phone-numbers/{phone_number_id}",
|
||||
response_model=PhoneNumberResponse,
|
||||
)
|
||||
async def get_phone_number(
|
||||
config_id: int,
|
||||
phone_number_id: int,
|
||||
user: UserModel = Depends(get_user),
|
||||
):
|
||||
if not user.selected_organization_id:
|
||||
raise HTTPException(status_code=400, detail="No organization selected")
|
||||
await _ensure_config_belongs_to_org(config_id, user.selected_organization_id)
|
||||
|
||||
row = await db_client.get_phone_number_for_config(phone_number_id, config_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Phone number not found")
|
||||
return _phone_number_to_response(row)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/telephony-configs/{config_id}/phone-numbers/{phone_number_id}",
|
||||
response_model=PhoneNumberResponse,
|
||||
)
|
||||
async def update_phone_number(
|
||||
config_id: int,
|
||||
phone_number_id: int,
|
||||
request: PhoneNumberUpdateRequest,
|
||||
user: UserModel = Depends(get_user),
|
||||
):
|
||||
if not user.selected_organization_id:
|
||||
raise HTTPException(status_code=400, detail="No organization selected")
|
||||
await _ensure_config_belongs_to_org(config_id, user.selected_organization_id)
|
||||
|
||||
existing = await db_client.get_phone_number_for_config(phone_number_id, config_id)
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="Phone number not found")
|
||||
|
||||
if request.inbound_workflow_id is not None:
|
||||
await _ensure_workflow_belongs_to_org(
|
||||
request.inbound_workflow_id, user.selected_organization_id
|
||||
)
|
||||
|
||||
row = await db_client.update_phone_number(
|
||||
phone_number_id=phone_number_id,
|
||||
telephony_configuration_id=config_id,
|
||||
label=request.label,
|
||||
inbound_workflow_id=request.inbound_workflow_id,
|
||||
is_active=request.is_active,
|
||||
country_code=request.country_code,
|
||||
extra_metadata=request.extra_metadata,
|
||||
clear_inbound_workflow=request.clear_inbound_workflow,
|
||||
)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Phone number not found")
|
||||
|
||||
response = _phone_number_to_response(row)
|
||||
|
||||
# Sync the provider application or address with the inbound
|
||||
# calling webhook address
|
||||
response.provider_sync = await _sync_inbound_for_phone_number(
|
||||
config_id, row.address
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@router.post(
|
||||
"/telephony-configs/{config_id}/phone-numbers/{phone_number_id}/set-default-caller",
|
||||
response_model=PhoneNumberResponse,
|
||||
)
|
||||
async def set_default_caller_id(
|
||||
config_id: int,
|
||||
phone_number_id: int,
|
||||
user: UserModel = Depends(get_user),
|
||||
):
|
||||
if not user.selected_organization_id:
|
||||
raise HTTPException(status_code=400, detail="No organization selected")
|
||||
await _ensure_config_belongs_to_org(config_id, user.selected_organization_id)
|
||||
|
||||
row = await db_client.set_default_caller_id(phone_number_id, config_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Phone number not found")
|
||||
return _phone_number_to_response(row)
|
||||
|
||||
|
||||
@router.delete("/telephony-configs/{config_id}/phone-numbers/{phone_number_id}")
|
||||
async def delete_phone_number(
|
||||
config_id: int,
|
||||
phone_number_id: int,
|
||||
user: UserModel = Depends(get_user),
|
||||
):
|
||||
if not user.selected_organization_id:
|
||||
raise HTTPException(status_code=400, detail="No organization selected")
|
||||
await _ensure_config_belongs_to_org(config_id, user.selected_organization_id)
|
||||
|
||||
existing = await db_client.get_phone_number_for_config(phone_number_id, config_id)
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="Phone number not found")
|
||||
|
||||
deleted = await db_client.delete_phone_number(phone_number_id, config_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Phone number not found")
|
||||
|
||||
return {"message": "Phone number deleted"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Legacy single-config shim
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/telephony-config", response_model=TelephonyConfigurationResponse)
|
||||
async def get_telephony_configuration(user: UserModel = Depends(get_user)):
|
||||
"""Legacy: returns the org's default config in the original per-provider
|
||||
response shape so the existing single-form UI keeps working. Prefer the
|
||||
multi-config endpoints (``/telephony-configs``) for new clients.
|
||||
"""
|
||||
if not user.selected_organization_id:
|
||||
raise HTTPException(status_code=400, detail="No organization selected")
|
||||
|
||||
cfg = await db_client.get_default_telephony_configuration(
|
||||
user.selected_organization_id
|
||||
)
|
||||
if not cfg:
|
||||
return TelephonyConfigurationResponse()
|
||||
|
||||
stored_provider = config.value.get("provider", "twilio")
|
||||
|
||||
if stored_provider == "twilio":
|
||||
account_sid = config.value.get("account_sid", "")
|
||||
auth_token = config.value.get("auth_token", "")
|
||||
from_numbers = (
|
||||
config.value.get("from_numbers", []) if account_sid and auth_token else []
|
||||
)
|
||||
|
||||
return TelephonyConfigurationResponse(
|
||||
twilio=TwilioConfigurationResponse(
|
||||
provider="twilio",
|
||||
account_sid=mask_key(account_sid) if account_sid else "",
|
||||
auth_token=mask_key(auth_token) if auth_token else "",
|
||||
from_numbers=from_numbers,
|
||||
),
|
||||
plivo=None,
|
||||
vonage=None,
|
||||
vobiz=None,
|
||||
cloudonix=None,
|
||||
)
|
||||
elif stored_provider == "plivo":
|
||||
auth_id = config.value.get("auth_id", "")
|
||||
auth_token = config.value.get("auth_token", "")
|
||||
from_numbers = (
|
||||
config.value.get("from_numbers", []) if auth_id and auth_token else []
|
||||
)
|
||||
|
||||
return TelephonyConfigurationResponse(
|
||||
twilio=None,
|
||||
plivo=PlivoConfigurationResponse(
|
||||
provider="plivo",
|
||||
auth_id=mask_key(auth_id) if auth_id else "",
|
||||
auth_token=mask_key(auth_token) if auth_token else "",
|
||||
from_numbers=from_numbers,
|
||||
),
|
||||
vonage=None,
|
||||
vobiz=None,
|
||||
cloudonix=None,
|
||||
)
|
||||
elif stored_provider == "vonage":
|
||||
application_id = config.value.get("application_id", "")
|
||||
private_key = config.value.get("private_key", "")
|
||||
api_key = config.value.get("api_key", "")
|
||||
api_secret = config.value.get("api_secret", "")
|
||||
from_numbers = (
|
||||
config.value.get("from_numbers", [])
|
||||
if application_id and private_key
|
||||
else []
|
||||
)
|
||||
|
||||
return TelephonyConfigurationResponse(
|
||||
twilio=None,
|
||||
plivo=None,
|
||||
vonage=VonageConfigurationResponse(
|
||||
provider="vonage",
|
||||
application_id=application_id,
|
||||
private_key=mask_key(private_key) if private_key else "",
|
||||
api_key=mask_key(api_key) if api_key else None,
|
||||
api_secret=mask_key(api_secret) if api_secret else None,
|
||||
from_numbers=from_numbers,
|
||||
),
|
||||
vobiz=None,
|
||||
cloudonix=None,
|
||||
)
|
||||
elif stored_provider == "vobiz":
|
||||
auth_id = config.value.get("auth_id", "")
|
||||
auth_token = config.value.get("auth_token", "")
|
||||
from_numbers = (
|
||||
config.value.get("from_numbers", []) if auth_id and auth_token else []
|
||||
)
|
||||
|
||||
return TelephonyConfigurationResponse(
|
||||
twilio=None,
|
||||
plivo=None,
|
||||
vonage=None,
|
||||
vobiz=VobizConfigurationResponse(
|
||||
provider="vobiz",
|
||||
auth_id=mask_key(auth_id) if auth_id else "",
|
||||
auth_token=mask_key(auth_token) if auth_token else "",
|
||||
from_numbers=from_numbers,
|
||||
),
|
||||
cloudonix=None,
|
||||
)
|
||||
elif stored_provider == "cloudonix":
|
||||
bearer_token = config.value.get("bearer_token", "")
|
||||
domain_id = config.value.get("domain_id", "")
|
||||
from_numbers = config.value.get("from_numbers", [])
|
||||
|
||||
return TelephonyConfigurationResponse(
|
||||
twilio=None,
|
||||
plivo=None,
|
||||
vonage=None,
|
||||
cloudonix=CloudonixConfigurationResponse(
|
||||
provider="cloudonix",
|
||||
bearer_token=mask_key(bearer_token) if bearer_token else "",
|
||||
domain_id=domain_id,
|
||||
from_numbers=from_numbers,
|
||||
),
|
||||
vobiz=None,
|
||||
)
|
||||
elif stored_provider == "ari":
|
||||
ari_endpoint = config.value.get("ari_endpoint", "")
|
||||
app_name = config.value.get("app_name", "")
|
||||
app_password = config.value.get("app_password", "")
|
||||
ws_client_name = config.value.get("ws_client_name", "")
|
||||
from_numbers = config.value.get("from_numbers", [])
|
||||
|
||||
inbound_workflow_id = config.value.get("inbound_workflow_id")
|
||||
|
||||
return TelephonyConfigurationResponse(
|
||||
ari=ARIConfigurationResponse(
|
||||
provider="ari",
|
||||
ari_endpoint=ari_endpoint,
|
||||
app_name=app_name,
|
||||
app_password=mask_key(app_password) if app_password else "",
|
||||
ws_client_name=ws_client_name,
|
||||
inbound_workflow_id=inbound_workflow_id,
|
||||
from_numbers=from_numbers,
|
||||
),
|
||||
)
|
||||
elif stored_provider == "telnyx":
|
||||
api_key = config.value.get("api_key", "")
|
||||
connection_id = config.value.get("connection_id", "")
|
||||
from_numbers = config.value.get("from_numbers", [])
|
||||
|
||||
return TelephonyConfigurationResponse(
|
||||
telnyx=TelnyxConfigurationResponse(
|
||||
provider="telnyx",
|
||||
api_key=mask_key(api_key) if api_key else "",
|
||||
connection_id=connection_id,
|
||||
from_numbers=from_numbers,
|
||||
),
|
||||
)
|
||||
else:
|
||||
spec = telephony_registry.get_optional(cfg.provider)
|
||||
if spec is None:
|
||||
return TelephonyConfigurationResponse()
|
||||
|
||||
addresses = await db_client.list_active_normalized_addresses_for_config(cfg.id)
|
||||
masked = _mask_sensitive(cfg.provider, cfg.credentials or {})
|
||||
payload = {**masked, "provider": cfg.provider, "from_numbers": addresses}
|
||||
response_obj = spec.config_response_cls.model_validate(payload)
|
||||
return TelephonyConfigurationResponse(**{cfg.provider: response_obj})
|
||||
|
||||
|
||||
@router.post("/telephony-config")
|
||||
async def save_telephony_configuration(
|
||||
request: Union[
|
||||
TwilioConfigurationRequest,
|
||||
PlivoConfigurationRequest,
|
||||
VonageConfigurationRequest,
|
||||
VobizConfigurationRequest,
|
||||
CloudonixConfigurationRequest,
|
||||
ARIConfigurationRequest,
|
||||
TelnyxConfigurationRequest,
|
||||
],
|
||||
request: TelephonyConfigRequest,
|
||||
user: UserModel = Depends(get_user),
|
||||
):
|
||||
"""Save telephony configuration for the user's organization."""
|
||||
"""Legacy: upserts the org's default config (and its phone numbers) in the
|
||||
original payload shape so existing UI clients keep working. Prefer the
|
||||
multi-config + phone-number endpoints for new clients.
|
||||
"""
|
||||
if not user.selected_organization_id:
|
||||
raise HTTPException(status_code=400, detail="No organization selected")
|
||||
|
||||
# Fetch existing configuration to handle masked values
|
||||
existing_config = await db_client.get_configuration(
|
||||
user.selected_organization_id,
|
||||
OrganizationConfigurationKey.TELEPHONY_CONFIGURATION.value,
|
||||
payload = request.model_dump()
|
||||
new_addresses = payload.pop("from_numbers", []) or []
|
||||
payload.pop("provider", None)
|
||||
field = _account_id_field(request.provider)
|
||||
|
||||
default = await db_client.get_default_telephony_configuration(
|
||||
user.selected_organization_id
|
||||
)
|
||||
|
||||
# Build single-provider configuration
|
||||
if request.provider == "twilio":
|
||||
config_value = {
|
||||
"provider": "twilio",
|
||||
"account_sid": request.account_sid,
|
||||
"auth_token": request.auth_token,
|
||||
"from_numbers": request.from_numbers,
|
||||
}
|
||||
elif request.provider == "plivo":
|
||||
config_value = {
|
||||
"provider": "plivo",
|
||||
"auth_id": request.auth_id,
|
||||
"auth_token": request.auth_token,
|
||||
"from_numbers": request.from_numbers,
|
||||
}
|
||||
elif request.provider == "vonage":
|
||||
config_value = {
|
||||
"provider": "vonage",
|
||||
"application_id": request.application_id,
|
||||
"private_key": request.private_key,
|
||||
"api_key": getattr(request, "api_key", None),
|
||||
"api_secret": getattr(request, "api_secret", None),
|
||||
"from_numbers": request.from_numbers,
|
||||
}
|
||||
elif request.provider == "vobiz":
|
||||
config_value = {
|
||||
"provider": "vobiz",
|
||||
"auth_id": request.auth_id,
|
||||
"auth_token": request.auth_token,
|
||||
"from_numbers": request.from_numbers,
|
||||
}
|
||||
elif request.provider == "cloudonix":
|
||||
config_value = {
|
||||
"provider": "cloudonix",
|
||||
"bearer_token": request.bearer_token,
|
||||
"domain_id": request.domain_id,
|
||||
"from_numbers": request.from_numbers,
|
||||
}
|
||||
elif request.provider == "telnyx":
|
||||
config_value = {
|
||||
"provider": "telnyx",
|
||||
"api_key": request.api_key,
|
||||
"connection_id": request.connection_id,
|
||||
"from_numbers": request.from_numbers,
|
||||
}
|
||||
elif request.provider == "ari":
|
||||
config_value = {
|
||||
"provider": "ari",
|
||||
"ari_endpoint": request.ari_endpoint,
|
||||
"app_name": request.app_name,
|
||||
"app_password": request.app_password,
|
||||
"ws_client_name": request.ws_client_name,
|
||||
"inbound_workflow_id": request.inbound_workflow_id,
|
||||
"from_numbers": request.from_numbers,
|
||||
}
|
||||
if default and default.provider == request.provider:
|
||||
preserve_masked_fields(request.provider, payload, default.credentials or {})
|
||||
try:
|
||||
row = await db_client.update_telephony_configuration(
|
||||
config_id=default.id,
|
||||
organization_id=user.selected_organization_id,
|
||||
credentials=payload,
|
||||
account_id_credential_field=field,
|
||||
)
|
||||
except TelephonyConfigurationDuplicateAccountError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Unsupported provider: {request.provider}"
|
||||
)
|
||||
try:
|
||||
row = await db_client.create_telephony_configuration(
|
||||
organization_id=user.selected_organization_id,
|
||||
name=f"{request.provider.title()} Default",
|
||||
provider=request.provider,
|
||||
credentials=payload,
|
||||
is_default_outbound=True,
|
||||
account_id_credential_field=field,
|
||||
)
|
||||
except TelephonyConfigurationDuplicateAccountError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
|
||||
if existing_config and existing_config.value:
|
||||
existing_provider = existing_config.value.get("provider")
|
||||
|
||||
if existing_provider == request.provider:
|
||||
preserve_masked_fields(request, existing_config, config_value)
|
||||
|
||||
await db_client.upsert_configuration(
|
||||
user.selected_organization_id,
|
||||
OrganizationConfigurationKey.TELEPHONY_CONFIGURATION.value,
|
||||
config_value,
|
||||
)
|
||||
# Replace the phone-number set with the inline payload.
|
||||
existing_numbers = await db_client.list_phone_numbers_for_config(row.id)
|
||||
existing_by_address = {n.address: n for n in existing_numbers}
|
||||
incoming_set = set(new_addresses)
|
||||
for addr in new_addresses:
|
||||
if addr in existing_by_address:
|
||||
continue
|
||||
try:
|
||||
await db_client.create_phone_number(
|
||||
organization_id=user.selected_organization_id,
|
||||
telephony_configuration_id=row.id,
|
||||
address=addr,
|
||||
)
|
||||
except IntegrityError:
|
||||
logger.warning(
|
||||
f"Skipping duplicate phone number {addr!r} for config {row.id}"
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.warning(f"Skipping invalid phone number {addr!r}: {e}")
|
||||
for n in existing_numbers:
|
||||
if n.address not in incoming_set:
|
||||
await db_client.delete_phone_number(n.id, row.id)
|
||||
|
||||
capture_event(
|
||||
distinct_id=str(user.provider_id),
|
||||
event=PostHogEvent.TELEPHONY_CONFIGURED,
|
||||
properties={
|
||||
"provider": request.provider,
|
||||
"phone_number_count": len(request.from_numbers),
|
||||
"phone_number_count": len(new_addresses),
|
||||
"organization_id": user.selected_organization_id,
|
||||
},
|
||||
)
|
||||
|
|
@ -305,20 +674,6 @@ async def save_telephony_configuration(
|
|||
return {"message": "Telephony configuration saved successfully"}
|
||||
|
||||
|
||||
def preserve_masked_fields(request, existing_config, config_value):
|
||||
provider = request.provider
|
||||
masked_fields = PROVIDER_MASKED_FIELDS.get(provider, [])
|
||||
|
||||
for field_name in masked_fields:
|
||||
if hasattr(request, field_name):
|
||||
field_value = getattr(request, field_name)
|
||||
# Check if field has a value and is a masked version of the existing value
|
||||
if field_value and is_mask_of(
|
||||
field_value, existing_config.value.get(field_name, "")
|
||||
):
|
||||
config_value[field_name] = existing_config.value[field_name]
|
||||
|
||||
|
||||
class LangfuseCredentialsRequest(BaseModel):
|
||||
host: str
|
||||
public_key: str
|
||||
|
|
@ -486,16 +841,18 @@ async def get_campaign_defaults(user: UserModel = Depends(get_user)):
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
# Get from_numbers count from telephony configuration
|
||||
# Phone-number count from the org's default telephony config (used by the
|
||||
# campaign UI to validate max_concurrency against caller-id supply).
|
||||
from_numbers_count = 0
|
||||
try:
|
||||
telephony_config = await db_client.get_configuration(
|
||||
user.selected_organization_id,
|
||||
OrganizationConfigurationKey.TELEPHONY_CONFIGURATION.value,
|
||||
default_cfg = await db_client.get_default_telephony_configuration(
|
||||
user.selected_organization_id
|
||||
)
|
||||
if telephony_config and telephony_config.value:
|
||||
from_numbers = telephony_config.value.get("from_numbers", [])
|
||||
from_numbers_count = len(from_numbers)
|
||||
if default_cfg:
|
||||
addresses = await db_client.list_active_normalized_addresses_for_config(
|
||||
default_cfg.id
|
||||
)
|
||||
from_numbers_count = len(addresses)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue