mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-16 08:25:18 +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
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue