mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-25 08:48:13 +02:00
feat: agent stream for cloudonix OPBX (#261)
* feat: agent stream for cloudonix OPBX * feat: make cloudonix app name optional * feat: create application while configuring telephony config * fix: get telephony configuration from stamped workflow run * fix: fix vobiz hangup URL
This commit is contained in:
parent
5cfdbeff02
commit
7fd3b96470
48 changed files with 1529 additions and 545 deletions
|
|
@ -8,10 +8,7 @@ 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.db.telephony_configuration_client import TelephonyConfigurationInUseError
|
||||
from api.enums import OrganizationConfigurationKey, PostHogEvent
|
||||
from api.schemas.telephony_config import (
|
||||
TelephonyConfigRequest,
|
||||
|
|
@ -130,17 +127,6 @@ async def get_telephony_providers_metadata(user: UserModel = Depends(get_user)):
|
|||
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):
|
||||
|
|
@ -157,6 +143,14 @@ def _credentials_from_payload(config: TelephonyConfigRequest) -> dict:
|
|||
return payload
|
||||
|
||||
|
||||
async def _run_preprocess_hook(provider: str, credentials: dict) -> dict:
|
||||
"""Invoke the provider's optional credentials preprocessor before save."""
|
||||
spec = telephony_registry.get_optional(provider)
|
||||
if spec and spec.preprocess_credentials_on_save:
|
||||
return await spec.preprocess_credentials_on_save(credentials)
|
||||
return credentials
|
||||
|
||||
|
||||
def _phone_number_to_response(
|
||||
row, inbound_workflow_name: Optional[str] = None
|
||||
) -> PhoneNumberResponse:
|
||||
|
|
@ -166,7 +160,7 @@ def _phone_number_to_response(
|
|||
|
||||
|
||||
async def _sync_inbound_for_phone_number(
|
||||
config_id: int, address: str
|
||||
config_id: int, organization_id: int, address: str
|
||||
) -> ProviderSyncStatus:
|
||||
"""Push inbound webhook configuration to the provider.
|
||||
|
||||
|
|
@ -178,7 +172,7 @@ async def _sync_inbound_for_phone_number(
|
|||
bind/unbind the number, not rewrite per-workflow URLs.
|
||||
"""
|
||||
try:
|
||||
provider = await get_telephony_provider_by_id(config_id)
|
||||
provider = await get_telephony_provider_by_id(config_id, organization_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}")
|
||||
|
|
@ -237,6 +231,7 @@ async def create_telephony_configuration(
|
|||
raise HTTPException(status_code=400, detail="No organization selected")
|
||||
|
||||
credentials = _credentials_from_payload(request.config)
|
||||
credentials = await _run_preprocess_hook(request.config.provider, credentials)
|
||||
|
||||
try:
|
||||
row = await db_client.create_telephony_configuration(
|
||||
|
|
@ -245,12 +240,20 @@ async def create_telephony_configuration(
|
|||
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}")
|
||||
if "uq_telephony_configurations_org_name" in str(e):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
f"A telephony configuration named '{request.name}' already "
|
||||
f"exists in this organization. Pick a different name."
|
||||
),
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Telephony configuration violates a uniqueness constraint.",
|
||||
)
|
||||
|
||||
capture_event(
|
||||
distinct_id=str(user.provider_id),
|
||||
|
|
@ -310,17 +313,14 @@ async def update_telephony_configuration(
|
|||
preserve_masked_fields(
|
||||
existing.provider, credentials, existing.credentials or {}
|
||||
)
|
||||
credentials = await _run_preprocess_hook(existing.provider, credentials)
|
||||
|
||||
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))
|
||||
row = await db_client.update_telephony_configuration(
|
||||
config_id=config_id,
|
||||
organization_id=user.selected_organization_id,
|
||||
name=request.name,
|
||||
credentials=credentials,
|
||||
)
|
||||
|
||||
return _detail_response(row)
|
||||
|
||||
|
|
@ -422,13 +422,49 @@ async def create_phone_number(
|
|||
):
|
||||
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)
|
||||
cfg = 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
|
||||
)
|
||||
|
||||
# Inbound dispatch (find_inbound_route_by_account) keys on (provider,
|
||||
# credentials[account_id_field], address_normalized) without the org, so
|
||||
# that tuple has to be globally unique. Reject up front if another config —
|
||||
# in this org or any other — already owns the same combination.
|
||||
spec = telephony_registry.get_optional(cfg.provider)
|
||||
account_field = spec.account_id_credential_field if spec else ""
|
||||
account_id = (cfg.credentials or {}).get(account_field) if account_field else None
|
||||
if account_id:
|
||||
try:
|
||||
conflict = await db_client.find_inbound_routing_conflict(
|
||||
provider=cfg.provider,
|
||||
account_id_field=account_field,
|
||||
account_id=account_id,
|
||||
address=request.address,
|
||||
country_hint=request.country_code,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
if conflict:
|
||||
existing_cfg, existing_phone = conflict
|
||||
same_org = existing_cfg.organization_id == user.selected_organization_id
|
||||
scope = (
|
||||
f"telephony configuration '{existing_cfg.name}'"
|
||||
if same_org
|
||||
else "another organization using the same provider account"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
f"Phone number {existing_phone.address} is already registered "
|
||||
f"under {scope}. Inbound calls cannot be uniquely routed when "
|
||||
f"the same number is configured against the same provider "
|
||||
f"account in more than one place."
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
row = await db_client.create_phone_number(
|
||||
organization_id=user.selected_organization_id,
|
||||
|
|
@ -452,7 +488,7 @@ async def create_phone_number(
|
|||
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
|
||||
config_id, user.selected_organization_id, row.address
|
||||
)
|
||||
return response
|
||||
|
||||
|
|
@ -517,7 +553,7 @@ async def update_phone_number(
|
|||
# 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
|
||||
config_id, user.selected_organization_id, row.address
|
||||
)
|
||||
return response
|
||||
|
||||
|
|
@ -608,7 +644,6 @@ async def save_telephony_configuration(
|
|||
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
|
||||
|
|
@ -616,27 +651,19 @@ async def save_telephony_configuration(
|
|||
|
||||
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))
|
||||
row = await db_client.update_telephony_configuration(
|
||||
config_id=default.id,
|
||||
organization_id=user.selected_organization_id,
|
||||
credentials=payload,
|
||||
)
|
||||
else:
|
||||
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))
|
||||
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,
|
||||
)
|
||||
|
||||
# Replace the phone-number set with the inline payload.
|
||||
existing_numbers = await db_client.list_phone_numbers_for_config(row.id)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue