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
138
api/routes/agent_stream.py
Normal file
138
api/routes/agent_stream.py
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
"""Agent-stream WebSocket endpoint.
|
||||
|
||||
A single ``/agent-stream/{workflow_uuid}`` socket where a caller can drive
|
||||
an agent run by passing everything inline in the query string — including
|
||||
provider credentials. The standard ``/telephony/ws/...`` path requires a
|
||||
``TelephonyConfigurationModel`` row stored in the org; this one does not.
|
||||
|
||||
Auth: the workflow UUID itself acts as the identifier — no API key.
|
||||
Routing: when ``?provider=<registered>`` matches a telephony provider, we
|
||||
dispatch to that provider's ``handle_external_websocket``. The raw-audio
|
||||
branch (no provider) is reserved for a future protocol decision and
|
||||
currently rejects with 1011.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, WebSocket
|
||||
from loguru import logger
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
from api.db import db_client
|
||||
from api.enums import CallType, WorkflowRunState
|
||||
from api.services.quota_service import check_dograh_quota_by_user_id
|
||||
from api.services.telephony import registry as telephony_registry
|
||||
from pipecat.utils.run_context import set_current_org_id, set_current_run_id
|
||||
|
||||
router = APIRouter(prefix="/agent-stream")
|
||||
|
||||
|
||||
@router.websocket("/{workflow_uuid}")
|
||||
async def agent_stream_websocket(
|
||||
websocket: WebSocket,
|
||||
workflow_uuid: str,
|
||||
):
|
||||
"""Generic agent-stream WebSocket.
|
||||
|
||||
Query params:
|
||||
provider: registered telephony provider name (e.g. ``cloudonix``)
|
||||
from / to / callId: call metadata persisted on the workflow run
|
||||
...: provider-specific credentials/identifiers (e.g. ``session``,
|
||||
``AccountSid``, ``CallSid`` for cloudonix)
|
||||
|
||||
Without ``provider`` the raw-audio branch is currently not implemented.
|
||||
"""
|
||||
await websocket.accept()
|
||||
params = dict(websocket.query_params)
|
||||
provider_name: Optional[str] = params.get("provider")
|
||||
|
||||
if not provider_name:
|
||||
logger.warning(
|
||||
f"agent-stream raw audio branch not yet supported "
|
||||
f"(workflow_uuid={workflow_uuid})"
|
||||
)
|
||||
await websocket.close(code=1011, reason="Raw audio stream not yet implemented")
|
||||
return
|
||||
|
||||
spec = telephony_registry.get_optional(provider_name)
|
||||
if spec is None:
|
||||
logger.warning(f"agent-stream unknown provider: {provider_name}")
|
||||
await websocket.close(code=1008, reason=f"Unknown provider: {provider_name}")
|
||||
return
|
||||
|
||||
workflow = await db_client.get_workflow_by_uuid_unscoped(workflow_uuid)
|
||||
if not workflow:
|
||||
logger.warning(f"agent-stream workflow {workflow_uuid} not found")
|
||||
await websocket.close(code=1008, reason="Workflow not found")
|
||||
return
|
||||
|
||||
quota_result = await check_dograh_quota_by_user_id(
|
||||
workflow.user_id, workflow_id=workflow.id
|
||||
)
|
||||
if not quota_result.has_quota:
|
||||
logger.warning(
|
||||
f"agent-stream quota exceeded for user {workflow.user_id}: "
|
||||
f"{quota_result.error_message}"
|
||||
)
|
||||
await websocket.close(
|
||||
code=1008, reason=quota_result.error_message or "Quota exceeded"
|
||||
)
|
||||
return
|
||||
|
||||
numeric_suffix = int(str(uuid.uuid4()).replace("-", "")[:8], 16) % 100000000
|
||||
workflow_run_name = f"WR-AGS-{numeric_suffix:08d}"
|
||||
call_id = params.get("callId") or params.get("CallSid")
|
||||
initial_context = {
|
||||
**(workflow.template_context_variables or {}),
|
||||
"provider": provider_name,
|
||||
"caller_number": params.get("from"),
|
||||
"called_number": params.get("to"),
|
||||
"direction": "inbound",
|
||||
}
|
||||
workflow_run = await db_client.create_workflow_run(
|
||||
workflow_run_name,
|
||||
workflow.id,
|
||||
provider_name,
|
||||
user_id=workflow.user_id,
|
||||
call_type=CallType.INBOUND,
|
||||
initial_context=initial_context,
|
||||
gathered_context={"call_id": call_id} if call_id else {},
|
||||
logs={
|
||||
"inbound_webhook": {
|
||||
"domain": params.get("Domain"),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
set_current_run_id(workflow_run.id)
|
||||
set_current_org_id(workflow.organization_id)
|
||||
|
||||
await db_client.update_workflow_run(
|
||||
run_id=workflow_run.id, state=WorkflowRunState.RUNNING.value
|
||||
)
|
||||
|
||||
provider_instance = spec.provider_cls({})
|
||||
try:
|
||||
await provider_instance.handle_external_websocket(
|
||||
websocket,
|
||||
organization_id=workflow.organization_id,
|
||||
workflow_id=workflow.id,
|
||||
user_id=workflow.user_id,
|
||||
workflow_run_id=workflow_run.id,
|
||||
params=params,
|
||||
)
|
||||
except NotImplementedError as e:
|
||||
logger.warning(f"agent-stream provider {provider_name} not supported: {e}")
|
||||
try:
|
||||
await websocket.close(code=1011, reason=str(e))
|
||||
except RuntimeError:
|
||||
pass
|
||||
except WebSocketDisconnect as e:
|
||||
logger.info(f"agent-stream disconnected: code={e.code} reason={e.reason}")
|
||||
except Exception as e:
|
||||
logger.error(f"agent-stream error for run {workflow_run.id}: {e}")
|
||||
try:
|
||||
await websocket.close(1011, "Internal server error")
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
|
@ -2,6 +2,7 @@ from fastapi import APIRouter
|
|||
from loguru import logger
|
||||
from pydantic import BaseModel
|
||||
|
||||
from api.routes.agent_stream import router as agent_stream_router
|
||||
from api.routes.auth import router as auth_router
|
||||
from api.routes.campaign import router as campaign_router
|
||||
from api.routes.credentials import router as credentials_router
|
||||
|
|
@ -56,6 +57,7 @@ router.include_router(knowledge_base_router)
|
|||
router.include_router(workflow_recording_router)
|
||||
router.include_router(auth_router)
|
||||
router.include_router(node_types_router)
|
||||
router.include_router(agent_stream_router)
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ from api.services.telephony.factory import (
|
|||
get_default_telephony_provider,
|
||||
get_telephony_provider,
|
||||
get_telephony_provider_by_id,
|
||||
get_telephony_provider_for_run,
|
||||
)
|
||||
from api.services.telephony.transfer_event_protocol import (
|
||||
TransferEvent,
|
||||
|
|
@ -77,14 +78,14 @@ async def initiate_call(
|
|||
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:
|
||||
try:
|
||||
provider = await get_telephony_provider_by_id(
|
||||
telephony_configuration_id, user.selected_organization_id
|
||||
)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="telephony_configuration_not_found"
|
||||
)
|
||||
provider = await get_telephony_provider_by_id(telephony_configuration_id)
|
||||
else:
|
||||
try:
|
||||
provider = await get_default_telephony_provider(
|
||||
|
|
@ -281,6 +282,7 @@ async def _validate_inbound_request(
|
|||
Validate all aspects of inbound request.
|
||||
Returns: (is_valid, error_type, workflow_context, provider_instance)
|
||||
"""
|
||||
from api.services.telephony import registry as telephony_registry
|
||||
|
||||
workflow = await db_client.get_workflow(workflow_id)
|
||||
if not workflow:
|
||||
|
|
@ -290,34 +292,60 @@ async def _validate_inbound_request(
|
|||
user_id = workflow.user_id
|
||||
provider = normalized_data.provider
|
||||
|
||||
# Resolve which of the org's configs this webhook came from (account_id match).
|
||||
(
|
||||
validation_result,
|
||||
telephony_configuration_id,
|
||||
) = await _resolve_inbound_telephony_config(
|
||||
organization_id, provider_class, normalized_data.account_id
|
||||
)
|
||||
if validation_result != TelephonyError.VALID:
|
||||
return False, validation_result, {}, None
|
||||
# Primary path: one combined query that resolves config + phone number
|
||||
# together (joins configs and phone_numbers with provider, account_id,
|
||||
# and called-number filters). Falls back to the two-step config-then-
|
||||
# phone resolution to cover providers without account_id (ARI) and
|
||||
# legacy non-E.164 stored addresses.
|
||||
spec = telephony_registry.get_optional(provider_class.PROVIDER_NAME)
|
||||
account_field = spec.account_id_credential_field if spec else ""
|
||||
|
||||
# Verify the called number is registered to that config.
|
||||
phone_number_id = await _verify_organization_phone_number(
|
||||
normalized_data.to_number,
|
||||
organization_id,
|
||||
telephony_configuration_id,
|
||||
provider_class.PROVIDER_NAME,
|
||||
normalized_data.to_country,
|
||||
normalized_data.from_country,
|
||||
)
|
||||
if phone_number_id is None:
|
||||
return False, TelephonyError.PHONE_NUMBER_NOT_CONFIGURED, {}, None
|
||||
telephony_configuration_id: Optional[int] = None
|
||||
phone_number_id: Optional[int] = None
|
||||
|
||||
if account_field and normalized_data.account_id:
|
||||
match = await db_client.find_inbound_route_by_account(
|
||||
provider=provider_class.PROVIDER_NAME,
|
||||
account_id_field=account_field,
|
||||
account_id=normalized_data.account_id,
|
||||
to_number=normalized_data.to_number,
|
||||
country_hint=normalized_data.to_country,
|
||||
organization_id=organization_id,
|
||||
)
|
||||
if match:
|
||||
cfg_row, phone_row = match
|
||||
telephony_configuration_id = cfg_row.id
|
||||
phone_number_id = phone_row.id
|
||||
|
||||
if telephony_configuration_id is None:
|
||||
(
|
||||
validation_result,
|
||||
telephony_configuration_id,
|
||||
) = await _resolve_inbound_telephony_config(
|
||||
organization_id, provider_class, normalized_data.account_id
|
||||
)
|
||||
if validation_result != TelephonyError.VALID:
|
||||
return False, validation_result, {}, None
|
||||
|
||||
phone_number_id = await _verify_organization_phone_number(
|
||||
normalized_data.to_number,
|
||||
organization_id,
|
||||
telephony_configuration_id,
|
||||
provider_class.PROVIDER_NAME,
|
||||
normalized_data.to_country,
|
||||
normalized_data.from_country,
|
||||
)
|
||||
if phone_number_id is None:
|
||||
return False, TelephonyError.PHONE_NUMBER_NOT_CONFIGURED, {}, None
|
||||
|
||||
# Verify webhook signature using the matched config's credentials. The
|
||||
# provider extracts its own signature/timestamp/nonce headers from the
|
||||
# dict, so this dispatcher stays generic.
|
||||
backend_endpoint, _ = await get_backend_endpoints()
|
||||
webhook_url = f"{backend_endpoint}/api/v1/telephony/inbound/{workflow_id}"
|
||||
provider_instance = await get_telephony_provider_by_id(telephony_configuration_id)
|
||||
provider_instance = await get_telephony_provider_by_id(
|
||||
telephony_configuration_id, organization_id
|
||||
)
|
||||
signature_valid = await provider_instance.verify_inbound_signature(
|
||||
webhook_url, webhook_data, headers, raw_body
|
||||
)
|
||||
|
|
@ -365,17 +393,21 @@ async def _create_inbound_workflow_run(
|
|||
"caller_number": normalized_data.from_number,
|
||||
"called_number": normalized_data.to_number,
|
||||
"direction": "inbound",
|
||||
"account_id": normalized_data.account_id,
|
||||
"provider": provider,
|
||||
"from_country": normalized_data.from_country,
|
||||
"to_country": normalized_data.to_country,
|
||||
"raw_webhook_data": normalized_data.raw_data,
|
||||
"telephony_configuration_id": telephony_configuration_id,
|
||||
"from_phone_number_id": from_phone_number_id,
|
||||
},
|
||||
gathered_context={
|
||||
"call_id": call_id,
|
||||
},
|
||||
logs={
|
||||
"inbound_webhook": {
|
||||
"account_id": normalized_data.account_id,
|
||||
"from_country": normalized_data.from_country,
|
||||
"to_country": normalized_data.to_country,
|
||||
"from_phone_number_id": from_phone_number_id,
|
||||
"raw_webhook_data": normalized_data.raw_data,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
|
|
@ -515,8 +547,9 @@ async def _handle_telephony_websocket(
|
|||
f"WebSocket connected for {provider_type} provider, workflow_run {workflow_run_id}"
|
||||
)
|
||||
|
||||
# Get the telephony provider instance
|
||||
provider = await get_telephony_provider(workflow.organization_id)
|
||||
provider = await get_telephony_provider_for_run(
|
||||
workflow_run, workflow.organization_id
|
||||
)
|
||||
|
||||
# Verify the provider matches what was stored
|
||||
if provider.PROVIDER_NAME != provider_type:
|
||||
|
|
@ -590,62 +623,37 @@ async def handle_inbound_run(request: Request):
|
|||
)
|
||||
return generic_hangup_response()
|
||||
|
||||
# 1. Resolve config globally from (provider, account_id).
|
||||
# 1. Resolve (config, phone_number) in a single SQL roundtrip that
|
||||
# joins telephony_configurations and telephony_phone_numbers and
|
||||
# filters on (provider, credentials[account_id_field], called number
|
||||
# canonical address, is_active). The phone-number row's existence in
|
||||
# the matched config simultaneously identifies the org — we never
|
||||
# match a config from one org against a phone owned by another.
|
||||
spec = telephony_registry.get_optional(provider_class.PROVIDER_NAME)
|
||||
account_field = spec.account_id_credential_field if spec else ""
|
||||
config = await db_client.find_telephony_config_by_account(
|
||||
provider_class.PROVIDER_NAME,
|
||||
account_field,
|
||||
normalized_data.account_id or "",
|
||||
)
|
||||
if not config:
|
||||
logger.warning(
|
||||
f"/inbound/run: no config matched provider="
|
||||
f"{provider_class.PROVIDER_NAME} account_id={normalized_data.account_id}"
|
||||
)
|
||||
return provider_class.generate_validation_error_response(
|
||||
TelephonyError.ACCOUNT_VALIDATION_FAILED
|
||||
)
|
||||
|
||||
organization_id = config.organization_id
|
||||
telephony_configuration_id = config.id
|
||||
|
||||
# 2. Resolve workflow via the called number's inbound_workflow_id.
|
||||
phone_row = await db_client.find_active_phone_number_for_inbound(
|
||||
organization_id,
|
||||
normalized_data.to_number,
|
||||
provider_class.PROVIDER_NAME,
|
||||
match = await db_client.find_inbound_route_by_account(
|
||||
provider=provider_class.PROVIDER_NAME,
|
||||
account_id_field=account_field,
|
||||
account_id=normalized_data.account_id or "",
|
||||
to_number=normalized_data.to_number,
|
||||
country_hint=normalized_data.to_country,
|
||||
)
|
||||
# Legacy fallback for non-E.164 stored addresses.
|
||||
if (
|
||||
not phone_row
|
||||
or phone_row.telephony_configuration_id != telephony_configuration_id
|
||||
):
|
||||
phone_row = None
|
||||
for row in await db_client.list_phone_numbers_for_config(
|
||||
telephony_configuration_id
|
||||
):
|
||||
if not row.is_active:
|
||||
continue
|
||||
if numbers_match(
|
||||
normalized_data.to_number,
|
||||
row.address,
|
||||
normalized_data.to_country,
|
||||
normalized_data.from_country,
|
||||
):
|
||||
phone_row = row
|
||||
break
|
||||
|
||||
if not phone_row:
|
||||
if not match:
|
||||
logger.warning(
|
||||
f"/inbound/run: number {normalized_data.to_number} not registered "
|
||||
f"in config {telephony_configuration_id}"
|
||||
f"/inbound/run: no inbound route matched "
|
||||
f"provider={provider_class.PROVIDER_NAME} "
|
||||
f"account_id={normalized_data.account_id} "
|
||||
f"to={normalized_data.to_number}"
|
||||
)
|
||||
return provider_class.generate_validation_error_response(
|
||||
TelephonyError.PHONE_NUMBER_NOT_CONFIGURED
|
||||
)
|
||||
|
||||
config, phone_row = match
|
||||
telephony_configuration_id = config.id
|
||||
|
||||
if not phone_row.inbound_workflow_id:
|
||||
logger.warning(
|
||||
f"/inbound/run: number {normalized_data.to_number} has no "
|
||||
|
|
@ -656,8 +664,13 @@ async def handle_inbound_run(request: Request):
|
|||
)
|
||||
|
||||
workflow_id = phone_row.inbound_workflow_id
|
||||
workflow = await db_client.get_workflow(workflow_id)
|
||||
workflow = await db_client.get_workflow(
|
||||
workflow_id, organization_id=config.organization_id
|
||||
)
|
||||
if not workflow:
|
||||
logger.warning(
|
||||
f"/inbound/run: workflow not found {workflow_id} for org {config.organization_id}"
|
||||
)
|
||||
return provider_class.generate_validation_error_response(
|
||||
TelephonyError.WORKFLOW_NOT_FOUND
|
||||
)
|
||||
|
|
@ -667,7 +680,7 @@ async def handle_inbound_run(request: Request):
|
|||
backend_endpoint, wss_backend_endpoint = await get_backend_endpoints()
|
||||
webhook_url = f"{backend_endpoint}/api/v1/telephony/inbound/run"
|
||||
provider_instance = await get_telephony_provider_by_id(
|
||||
telephony_configuration_id
|
||||
telephony_configuration_id, config.organization_id
|
||||
)
|
||||
signature_valid = await provider_instance.verify_inbound_signature(
|
||||
webhook_url, webhook_data, headers, raw_body
|
||||
|
|
|
|||
|
|
@ -240,6 +240,7 @@ class WorkflowResponse(BaseModel):
|
|||
workflow_configurations: dict | None = None
|
||||
version_number: int | None = None
|
||||
version_status: str | None = None
|
||||
workflow_uuid: str | None = None
|
||||
|
||||
|
||||
class WorkflowListResponse(BaseModel):
|
||||
|
|
@ -695,6 +696,7 @@ async def get_workflow(
|
|||
"workflow_configurations": workflow_configs,
|
||||
"version_number": active_def.version_number if active_def else None,
|
||||
"version_status": active_def.status if active_def else None,
|
||||
"workflow_uuid": workflow.workflow_uuid,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue