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:
Abhishek 2026-05-02 15:53:58 +05:30 committed by GitHub
parent 5cfdbeff02
commit 7fd3b96470
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
48 changed files with 1529 additions and 545 deletions

View file

@ -15,11 +15,6 @@ from api.db.base_client import BaseDBClient
from api.db.models import CampaignModel, TelephonyConfigurationModel
class TelephonyConfigurationDuplicateAccountError(Exception):
"""Raised when saving a config whose account_id collides with an existing
config of the same provider in the same organization."""
class TelephonyConfigurationInUseError(Exception):
"""Raised when deleting a config that is still referenced by a campaign."""
@ -67,29 +62,6 @@ class TelephonyConfigurationClient(BaseDBClient):
)
return result.scalars().first()
async def find_telephony_config_by_account(
self, provider: str, account_id_field: str, account_id: str
) -> Optional[TelephonyConfigurationModel]:
"""Global lookup used by the workflow-agnostic inbound dispatcher.
Returns the single config whose stored credentials contain
``credentials[account_id_field] == account_id``. Filters in Python
over the per-provider candidate set since credentials is JSON.
"""
if not account_id_field or not account_id:
return None
async with self.async_session() as session:
result = await session.execute(
select(TelephonyConfigurationModel).where(
TelephonyConfigurationModel.provider == provider,
)
)
for cand in result.scalars().all():
stored = (cand.credentials or {}).get(account_id_field)
if stored and stored == account_id:
return cand
return None
async def list_telephony_configurations_by_provider(
self, organization_id: int, provider: str
) -> List[TelephonyConfigurationModel]:
@ -126,19 +98,9 @@ class TelephonyConfigurationClient(BaseDBClient):
provider: str,
credentials: Dict[str, Any],
is_default_outbound: bool = False,
account_id_credential_field: Optional[str] = None,
) -> TelephonyConfigurationModel:
"""Create a new config. Raises ``TelephonyConfigurationDuplicateAccountError``
if the same provider+account_id is already configured for the org."""
if account_id_credential_field:
await self._guard_duplicate_account(
organization_id,
provider,
credentials.get(account_id_credential_field),
account_id_credential_field,
exclude_id=None,
)
"""Create a new config row. Duplicate-account guarding is the caller's
responsibility; this method does not enforce it."""
async with self.async_session() as session:
existing_count = await session.scalar(
select(func.count(TelephonyConfigurationModel.id)).where(
@ -172,22 +134,12 @@ class TelephonyConfigurationClient(BaseDBClient):
organization_id: int,
name: Optional[str] = None,
credentials: Optional[Dict[str, Any]] = None,
account_id_credential_field: Optional[str] = None,
) -> Optional[TelephonyConfigurationModel]:
async with self.async_session() as session:
row = await session.get(TelephonyConfigurationModel, config_id)
if not row or row.organization_id != organization_id:
return None
if credentials is not None and account_id_credential_field:
await self._guard_duplicate_account(
organization_id,
row.provider,
credentials.get(account_id_credential_field),
account_id_credential_field,
exclude_id=config_id,
)
if name is not None:
row.name = name
if credentials is not None:
@ -238,33 +190,6 @@ class TelephonyConfigurationClient(BaseDBClient):
await session.commit()
return True
async def _guard_duplicate_account(
self,
organization_id: int,
provider: str,
account_id: Optional[str],
credential_field: str,
exclude_id: Optional[int],
) -> None:
if not account_id:
return
async with self.async_session() as session:
result = await session.execute(
select(TelephonyConfigurationModel).where(
TelephonyConfigurationModel.organization_id == organization_id,
TelephonyConfigurationModel.provider == provider,
)
)
for row in result.scalars().all():
if exclude_id is not None and row.id == exclude_id:
continue
stored = (row.credentials or {}).get(credential_field)
if stored and stored == account_id:
raise TelephonyConfigurationDuplicateAccountError(
f"A {provider} configuration with this account is already "
f"registered (config id {row.id})."
)
@staticmethod
async def _clear_default_outbound(session, organization_id: int) -> None:
await session.execute(

View file

@ -123,6 +123,104 @@ class TelephonyPhoneNumberClient(BaseDBClient):
)
return result.scalars().first()
async def find_inbound_route_by_account(
self,
provider: str,
account_id_field: str,
account_id: str,
to_number: str,
country_hint: Optional[str] = None,
organization_id: Optional[int] = None,
) -> Optional[Tuple[TelephonyConfigurationModel, TelephonyPhoneNumberModel]]:
"""Combined primary-path lookup for inbound dispatch.
One SQL roundtrip that joins ``telephony_configurations`` and
``telephony_phone_numbers`` and matches all of:
provider, ``credentials[account_id_field] == account_id``,
``phone.address_normalized == canonical(to_number)``, and
``phone.is_active``. Replaces the previous pattern of resolving the
config and the phone number in two separate queries with a Python-side
loop over candidate configs.
Returns ``(config, phone_number)`` or None when the primary path
misses (e.g. legacy non-E.164 stored addresses); the caller should
fall back to the fuzzy ``numbers_match`` path in that case.
"""
if not (provider and account_id_field and account_id and to_number):
return None
normalized = normalize_telephony_address(to_number, country_hint=country_hint)
async with self.async_session() as session:
stmt = (
select(TelephonyConfigurationModel, TelephonyPhoneNumberModel)
.join(
TelephonyPhoneNumberModel,
TelephonyPhoneNumberModel.telephony_configuration_id
== TelephonyConfigurationModel.id,
)
.where(
TelephonyConfigurationModel.provider == provider,
TelephonyConfigurationModel.credentials.op("->>")(account_id_field)
== account_id,
TelephonyPhoneNumberModel.address_normalized
== normalized.canonical,
TelephonyPhoneNumberModel.is_active.is_(True),
)
)
if organization_id is not None:
stmt = stmt.where(
TelephonyConfigurationModel.organization_id == organization_id
)
result = await session.execute(stmt)
row = result.first()
if not row:
return None
return row[0], row[1]
async def find_inbound_routing_conflict(
self,
provider: str,
account_id_field: str,
account_id: str,
address: str,
country_hint: Optional[str] = None,
) -> Optional[Tuple[TelephonyConfigurationModel, TelephonyPhoneNumberModel]]:
"""Inbound dispatch keys on (provider, credentials[account_id_field],
address_normalized) see ``find_inbound_route_by_account``. That tuple
must be globally unique or two orgs would race for the same call.
Returns the conflicting (config, phone_number) possibly in another
org when inserting a row with this combination would break that
invariant, or None when the row is safe to insert. Returns None for
providers that don't carry an account_id (e.g. ARI), which use a
different inbound path.
"""
if not (provider and account_id_field and account_id):
return None
normalized = normalize_telephony_address(address, country_hint=country_hint)
async with self.async_session() as session:
stmt = (
select(TelephonyConfigurationModel, TelephonyPhoneNumberModel)
.join(
TelephonyPhoneNumberModel,
TelephonyPhoneNumberModel.telephony_configuration_id
== TelephonyConfigurationModel.id,
)
.where(
TelephonyConfigurationModel.provider == provider,
TelephonyConfigurationModel.credentials.op("->>")(account_id_field)
== account_id,
TelephonyPhoneNumberModel.address_normalized
== normalized.canonical,
)
)
result = await session.execute(stmt)
row = result.first()
return (row[0], row[1]) if row else None
async def create_phone_number(
self,
organization_id: int,

View file

@ -446,6 +446,37 @@ class WorkflowClient(BaseDBClient):
)
return result.scalars().first()
async def get_workflow_by_uuid(
self, workflow_uuid: str, organization_id: int
) -> WorkflowModel | None:
async with self.async_session() as session:
result = await session.execute(
select(WorkflowModel)
.options(
selectinload(WorkflowModel.current_definition),
selectinload(WorkflowModel.released_definition),
)
.where(
WorkflowModel.workflow_uuid == workflow_uuid,
WorkflowModel.organization_id == organization_id,
)
)
return result.scalars().first()
async def get_workflow_by_uuid_unscoped(
self, workflow_uuid: str
) -> WorkflowModel | None:
async with self.async_session() as session:
result = await session.execute(
select(WorkflowModel)
.options(
selectinload(WorkflowModel.current_definition),
selectinload(WorkflowModel.released_definition),
)
.where(WorkflowModel.workflow_uuid == workflow_uuid)
)
return result.scalars().first()
async def update_workflow(
self,
workflow_id: int,

View file

@ -28,6 +28,7 @@ class WorkflowRunClient(BaseDBClient):
call_type: CallType = CallType.OUTBOUND,
initial_context: dict = None,
gathered_context: dict = None,
logs: dict = None,
campaign_id: int = None,
queued_run_id: int = None,
use_draft: bool = False,
@ -91,6 +92,7 @@ class WorkflowRunClient(BaseDBClient):
definition_id=target_def.id if target_def else None,
initial_context=initial_context or default_context,
gathered_context=gathered_context or {},
logs=logs or {},
campaign_id=campaign_id,
queued_run_id=queued_run_id,
storage_backend=current_backend.value,