mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-07-25 12:01:04 +02:00
Merge remote-tracking branch 'origin/main' into fix/org-scoped-access
# Conflicts: # api/routes/agent_stream.py # api/routes/telephony.py # api/routes/webrtc_signaling.py # docs/api-reference/openapi.json # sdk/python/src/dograh_sdk/_generated_models.py
This commit is contained in:
commit
d22c073cb5
38 changed files with 2332 additions and 471 deletions
|
|
@ -26,6 +26,10 @@ from loguru import logger
|
|||
from api.constants import REDIS_URL
|
||||
from api.db import db_client
|
||||
from api.enums import CallType, WorkflowRunMode
|
||||
from api.services.call_concurrency import (
|
||||
CallConcurrencyLimitError,
|
||||
call_concurrency,
|
||||
)
|
||||
from api.services.quota_service import authorize_workflow_run_start
|
||||
from api.services.telephony.call_transfer_manager import get_call_transfer_manager
|
||||
from api.services.telephony.transfer_event_protocol import (
|
||||
|
|
@ -527,6 +531,8 @@ class ARIConnection:
|
|||
channel = event.get("channel", {})
|
||||
caller_number = channel.get("caller", {}).get("number", "unknown")
|
||||
called_number = channel.get("dialplan", {}).get("exten", "unknown")
|
||||
concurrency_slot = None
|
||||
workflow_run = None
|
||||
|
||||
try:
|
||||
# 1. Resolve the workflow from the called extension via the
|
||||
|
|
@ -573,6 +579,20 @@ class ARIConnection:
|
|||
|
||||
user_id = workflow.user_id
|
||||
|
||||
try:
|
||||
concurrency_slot = await call_concurrency.acquire_org_slot(
|
||||
self.organization_id,
|
||||
source="ari_inbound",
|
||||
timeout=0,
|
||||
)
|
||||
except CallConcurrencyLimitError:
|
||||
logger.warning(
|
||||
f"[ARI org={self.organization_id}] Concurrent call limit "
|
||||
f"reached; hanging up inbound channel {channel_id}"
|
||||
)
|
||||
await self._delete_channel(channel_id)
|
||||
return
|
||||
|
||||
# 3. Create workflow run
|
||||
call_id = channel_id
|
||||
workflow_run = await db_client.create_workflow_run(
|
||||
|
|
@ -593,6 +613,7 @@ class ARIConnection:
|
|||
},
|
||||
organization_id=self.organization_id,
|
||||
)
|
||||
await call_concurrency.bind_workflow_run(concurrency_slot, workflow_run.id)
|
||||
|
||||
logger.info(
|
||||
f"[ARI org={self.organization_id}] Created inbound workflow run "
|
||||
|
|
@ -612,6 +633,7 @@ class ARIConnection:
|
|||
f"[ARI org={self.organization_id}] Quota exceeded for user {user_id} "
|
||||
f"— hanging up inbound call {channel_id}"
|
||||
)
|
||||
await call_concurrency.release_workflow_run_slot(workflow_run.id)
|
||||
await self._delete_channel(channel_id)
|
||||
return
|
||||
|
||||
|
|
@ -627,6 +649,10 @@ class ARIConnection:
|
|||
str(user_id),
|
||||
)
|
||||
except Exception as e:
|
||||
if workflow_run:
|
||||
await call_concurrency.release_workflow_run_slot(workflow_run.id)
|
||||
elif concurrency_slot:
|
||||
await call_concurrency.release_slot(concurrency_slot)
|
||||
logger.error(
|
||||
f"[ARI org={self.organization_id}] Error handling inbound StasisStart "
|
||||
f"for channel {channel_id}: {e}"
|
||||
|
|
@ -762,6 +788,14 @@ class ARIConnection:
|
|||
the bridge and both channels — like endConferenceOnExit.
|
||||
"""
|
||||
try:
|
||||
# Release the org concurrency slot. Normally the pipeline's own
|
||||
# teardown does this when the ext media websocket closes, but if
|
||||
# the pipeline never started (caller hung up before external
|
||||
# media connected, ext media creation failed, ...) this is the
|
||||
# only cleanup that runs before the Redis stale timeout. No-op
|
||||
# when the slot was already released.
|
||||
await call_concurrency.unregister_active_call(int(workflow_run_id))
|
||||
|
||||
workflow_run = await db_client.get_workflow_run_by_id(int(workflow_run_id))
|
||||
if not workflow_run or not workflow_run.gathered_context:
|
||||
logger.warning(
|
||||
|
|
|
|||
|
|
@ -365,6 +365,10 @@ class TelephonyProvider(ABC):
|
|||
the caller carries a provider stream protocol. ``organization_id`` is
|
||||
passed so providers can scope any config lookups to the workflow's org.
|
||||
Default raises so providers that haven't opted in fail loudly.
|
||||
|
||||
The route holds an org concurrency slot while this runs, so
|
||||
implementations must bound their pre-pipeline handshake reads with a
|
||||
timeout — an idle socket must not hold the slot indefinitely.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
f"Agent-stream not supported for provider {self.PROVIDER_NAME}"
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
Cloudonix implementation of the TelephonyProvider interface.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import random
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional
|
||||
|
|
@ -25,6 +26,11 @@ if TYPE_CHECKING:
|
|||
|
||||
CLOUDONIX_API_BASE_URL = "https://api.cloudonix.io"
|
||||
|
||||
# Cloudonix sends the connected/start handshake immediately after the media
|
||||
# stream opens. The agent-stream route holds an org concurrency slot while we
|
||||
# wait, so an idle socket must not be able to hold it indefinitely.
|
||||
AGENT_STREAM_HANDSHAKE_TIMEOUT_S = 10
|
||||
|
||||
|
||||
class CloudonixProvider(TelephonyProvider):
|
||||
"""
|
||||
|
|
@ -533,14 +539,31 @@ class CloudonixProvider(TelephonyProvider):
|
|||
from api.services.pipecat.run_pipeline import run_pipeline_telephony
|
||||
|
||||
try:
|
||||
first_msg = await websocket.receive_text()
|
||||
msg = json.loads(first_msg)
|
||||
if msg.get("event") != "connected":
|
||||
logger.error(f"Expected 'connected' event, got: {msg.get('event')}")
|
||||
await websocket.close(code=4400, reason="Expected connected event")
|
||||
try:
|
||||
first_msg = await asyncio.wait_for(
|
||||
websocket.receive_text(),
|
||||
timeout=AGENT_STREAM_HANDSHAKE_TIMEOUT_S,
|
||||
)
|
||||
msg = json.loads(first_msg)
|
||||
if msg.get("event") != "connected":
|
||||
logger.error(f"Expected 'connected' event, got: {msg.get('event')}")
|
||||
await websocket.close(code=4400, reason="Expected connected event")
|
||||
return
|
||||
|
||||
start_msg = json.loads(
|
||||
await asyncio.wait_for(
|
||||
websocket.receive_text(),
|
||||
timeout=AGENT_STREAM_HANDSHAKE_TIMEOUT_S,
|
||||
)
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(
|
||||
f"Cloudonix agent-stream handshake timed out for workflow_run "
|
||||
f"{workflow_run_id}"
|
||||
)
|
||||
await websocket.close(code=4408, reason="Handshake timeout")
|
||||
return
|
||||
|
||||
start_msg = json.loads(await websocket.receive_text())
|
||||
if start_msg.get("event") != "start":
|
||||
logger.error("Expected 'start' event second")
|
||||
await websocket.close(code=4400, reason="Expected start event")
|
||||
|
|
|
|||
|
|
@ -144,8 +144,9 @@ async def _process_status_update(workflow_run_id: int, status: StatusCallbackReq
|
|||
f"[run {workflow_run_id}] Call completed with duration: {status.duration}s"
|
||||
)
|
||||
|
||||
await campaign_call_dispatcher.release_call_slot(workflow_run_id)
|
||||
|
||||
if workflow_run.campaign_id:
|
||||
await campaign_call_dispatcher.release_call_slot(workflow_run_id)
|
||||
await circuit_breaker.record_and_evaluate(
|
||||
workflow_run.campaign_id, is_failure=False
|
||||
)
|
||||
|
|
@ -162,8 +163,9 @@ async def _process_status_update(workflow_run_id: int, status: StatusCallbackReq
|
|||
f"[run {workflow_run_id}] Call failed with status: {normalized_status.value}"
|
||||
)
|
||||
|
||||
await campaign_call_dispatcher.release_call_slot(workflow_run_id)
|
||||
|
||||
if workflow_run.campaign_id:
|
||||
await campaign_call_dispatcher.release_call_slot(workflow_run_id)
|
||||
is_failure = normalized_status in FAILURE_NOT_CONNECTED_STATUSES
|
||||
await circuit_breaker.record_and_evaluate(
|
||||
workflow_run.campaign_id,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue