feat: refactor telephony to support multiple telephony configurations (#251)

Co-authored-by: Sabiha Khan <sabihak89@gmail.com>
This commit is contained in:
Abhishek 2026-04-29 11:39:57 +05:30 committed by GitHub
parent 2f860e7f6d
commit e16f6438bd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
101 changed files with 10906 additions and 5420 deletions

View file

@ -40,14 +40,17 @@ async def _get_org_concurrent_limit(organization_id: int) -> int:
async def _get_from_numbers_count(organization_id: int) -> int:
"""Get the number of configured from_numbers for an organization."""
"""Active phone-number count from the org's default telephony config.
Used to validate ``max_concurrency`` against caller-id supply."""
try:
config = await db_client.get_configuration(
organization_id,
OrganizationConfigurationKey.TELEPHONY_CONFIGURATION.value,
default_cfg = await db_client.get_default_telephony_configuration(
organization_id
)
if config and config.value:
return len(config.value.get("from_numbers", []))
if default_cfg:
addresses = await db_client.list_active_normalized_addresses_for_config(
default_cfg.id
)
return len(addresses)
except Exception:
pass
return 0
@ -151,6 +154,10 @@ class CreateCampaignRequest(BaseModel):
workflow_id: int
source_type: str = Field(..., pattern="^(google-sheet|csv)$")
source_id: str # Google Sheet URL or CSV file key
# Optional during the legacy → multi-config migration window. Required in
# a follow-up. When omitted, the dispatcher falls back to the org's
# default config.
telephony_configuration_id: Optional[int] = None
retry_config: Optional[RetryConfigRequest] = None
max_concurrency: Optional[int] = Field(default=None, ge=1, le=100)
schedule_config: Optional[ScheduleConfigRequest] = None
@ -316,7 +323,9 @@ async def create_campaign(
raise HTTPException(status_code=400, detail=validation_result.error.message)
# Validate template variables against source data columns
workflow = await db_client.get_workflow_by_id(request.workflow_id)
workflow = await db_client.get_workflow(
request.workflow_id, organization_id=user.selected_organization_id
)
if workflow:
from api.services.workflow.dto import ReactFlowDTO
from api.services.workflow.workflow import WorkflowGraph
@ -355,6 +364,25 @@ async def create_campaign(
request.max_concurrency, user.selected_organization_id
)
# Resolve which telephony config the campaign is pinned to. Explicit value
# wins; otherwise default to the org's default config so legacy clients keep
# working through the migration window.
telephony_configuration_id = request.telephony_configuration_id
if telephony_configuration_id:
cfg = await db_client.get_telephony_configuration_for_org(
telephony_configuration_id, user.selected_organization_id
)
if not cfg:
raise HTTPException(
status_code=400, detail="telephony_configuration_not_found"
)
else:
default_cfg = await db_client.get_default_telephony_configuration(
user.selected_organization_id
)
if default_cfg:
telephony_configuration_id = default_cfg.id
# Build retry_config dict if provided
retry_config = None
if request.retry_config:
@ -381,6 +409,7 @@ async def create_campaign(
max_concurrency=request.max_concurrency,
schedule_config=schedule_config,
circuit_breaker=circuit_breaker_config,
telephony_configuration_id=telephony_configuration_id,
)
return _build_campaign_response(campaign, workflow_name)
@ -441,28 +470,27 @@ async def start_campaign(
user: UserModel = Depends(get_user),
) -> CampaignResponse:
"""Start campaign execution"""
# Check if organization has TELEPHONY_CONFIGURATION configured
twilio_config = await db_client.get_configuration(
user.selected_organization_id,
OrganizationConfigurationKey.TELEPHONY_CONFIGURATION.value,
# Block start if the org has no telephony configuration at all.
configs = await db_client.list_telephony_configurations(
user.selected_organization_id
)
if not twilio_config or not twilio_config.value:
if not configs:
raise HTTPException(
status_code=401,
detail="You must configure telephony first by going to APP_URL/configure-telephony",
)
# Check Dograh quota before starting campaign
quota_result = await check_dograh_quota(user)
if not quota_result.has_quota:
raise HTTPException(status_code=402, detail=quota_result.error_message)
# Verify campaign exists and belongs to organization
campaign = await db_client.get_campaign(campaign_id, user.selected_organization_id)
if not campaign:
raise HTTPException(status_code=404, detail="Campaign not found")
# Check Dograh quota before starting campaign (apply per-workflow
# model_overrides so we evaluate the keys this campaign will use).
quota_result = await check_dograh_quota(user, workflow_id=campaign.workflow_id)
if not quota_result.has_quota:
raise HTTPException(status_code=402, detail=quota_result.error_message)
# Start the campaign using the runner service
try:
await campaign_runner_service.start_campaign(campaign_id)
@ -734,28 +762,27 @@ async def resume_campaign(
user: UserModel = Depends(get_user),
) -> CampaignResponse:
"""Resume a paused campaign"""
# Check if organization has TELEPHONY_CONFIGURATION configured
twilio_config = await db_client.get_configuration(
user.selected_organization_id,
OrganizationConfigurationKey.TELEPHONY_CONFIGURATION.value,
# Block resume if the org has no telephony configuration at all.
configs = await db_client.list_telephony_configurations(
user.selected_organization_id
)
if not twilio_config or not twilio_config.value:
if not configs:
raise HTTPException(
status_code=401,
detail="You must configure telephony first by going to APP_URL/configure-telephony",
)
# Check Dograh quota before resuming campaign
quota_result = await check_dograh_quota(user)
if not quota_result.has_quota:
raise HTTPException(status_code=402, detail=quota_result.error_message)
# Verify campaign exists and belongs to organization
campaign = await db_client.get_campaign(campaign_id, user.selected_organization_id)
if not campaign:
raise HTTPException(status_code=404, detail="Campaign not found")
# Check Dograh quota before resuming campaign (apply per-workflow
# model_overrides so we evaluate the keys this campaign will use).
quota_result = await check_dograh_quota(user, workflow_id=campaign.workflow_id)
if not quota_result.has_quota:
raise HTTPException(status_code=402, detail=quota_result.error_message)
# Resume the campaign using the runner service
try:
await campaign_runner_service.resume_campaign(campaign_id)

View file

@ -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

View file

@ -14,7 +14,7 @@ from pydantic import BaseModel
from api.db import db_client
from api.enums import TriggerState
from api.services.quota_service import check_dograh_quota_by_user_id
from api.services.telephony.factory import get_telephony_provider
from api.services.telephony.factory import get_default_telephony_provider
from api.utils.common import get_backend_endpoints
router = APIRouter(prefix="/public/agent")
@ -83,8 +83,11 @@ async def _initiate_call(
if trigger.state != TriggerState.ACTIVE.value:
raise HTTPException(status_code=404, detail="Agent trigger is not active")
# 4.5 Check Dograh quota before initiating the call
quota_result = await check_dograh_quota_by_user_id(api_key.created_by)
# 4.5 Check Dograh quota before initiating the call (apply the trigger's
# workflow's model_overrides so we evaluate the keys this run will use).
quota_result = await check_dograh_quota_by_user_id(
api_key.created_by, workflow_id=trigger.workflow_id
)
if not quota_result.has_quota:
raise HTTPException(status_code=402, detail=quota_result.error_message)
@ -111,8 +114,14 @@ async def _initiate_call(
detail="Trigger not found in the published Agent",
)
# 6. Get telephony provider for the organization
provider = await get_telephony_provider(trigger.organization_id)
# 6. Get telephony provider for the organization (using its default config).
try:
provider = await get_default_telephony_provider(trigger.organization_id)
except ValueError:
raise HTTPException(
status_code=400,
detail="Telephony provider not configured for this organization",
)
# Validate provider is configured
if not provider.validate_config():
@ -121,6 +130,10 @@ async def _initiate_call(
detail="Telephony provider not configured for this organization",
)
default_cfg = await db_client.get_default_telephony_configuration(
trigger.organization_id
)
# 7. Determine the workflow run mode based on provider type
workflow_run_mode = provider.PROVIDER_NAME
@ -136,6 +149,7 @@ async def _initiate_call(
"phone_number": request.phone_number,
"agent_uuid": uuid,
"trigger_mode": "test" if use_draft else "production",
"telephony_configuration_id": default_cfg.id if default_cfg else None,
**(request.initial_context or {}),
},
user_id=api_key.created_by,

File diff suppressed because it is too large Load diff

View file

@ -220,8 +220,9 @@ class SignalingManager:
if org_id:
set_current_org_id(org_id)
# Check Dograh quota before initiating the call
quota_result = await check_dograh_quota(user)
# Check Dograh quota before initiating the call (apply per-workflow
# model_overrides so we evaluate the keys this workflow will use).
quota_result = await check_dograh_quota(user, workflow_id=workflow_id)
if not quota_result.has_quota:
# Send error response for quota issues
await ws.send_json(

View file

@ -814,10 +814,15 @@ async def create_workflow_draft(
@router.get("/summary")
async def get_workflows_summary(
user: UserModel = Depends(get_user),
status: Optional[str] = Query(
None,
description="Filter by status (e.g. 'active' or 'archived'). Omit to return all.",
),
) -> List[WorkflowSummaryResponse]:
"""Get minimal workflow information (id and name only) for all workflows"""
workflows = await db_client.get_all_workflows(
organization_id=user.selected_organization_id
organization_id=user.selected_organization_id,
status=status,
)
return [
WorkflowSummaryResponse(id=workflow.id, name=workflow.name)