diff --git a/api/routes/organization.py b/api/routes/organization.py index 7d4a10a..f840df8 100644 --- a/api/routes/organization.py +++ b/api/routes/organization.py @@ -135,6 +135,8 @@ async def get_telephony_configuration(user: UserModel = Depends(get_user)): ws_client_name = config.value.get("ws_client_name", "") from_numbers = config.value.get("from_numbers", []) + inbound_workflow_id = config.value.get("inbound_workflow_id") + return TelephonyConfigurationResponse( ari=ARIConfigurationResponse( provider="ari", @@ -142,6 +144,7 @@ async def get_telephony_configuration(user: UserModel = Depends(get_user)): app_name=app_name, app_password=mask_key(app_password) if app_password else "", ws_client_name=ws_client_name, + inbound_workflow_id=inbound_workflow_id, from_numbers=from_numbers, ), ) @@ -208,6 +211,7 @@ async def save_telephony_configuration( "app_name": request.app_name, "app_password": request.app_password, "ws_client_name": request.ws_client_name, + "inbound_workflow_id": request.inbound_workflow_id, "from_numbers": request.from_numbers, } else: diff --git a/api/schemas/telephony_config.py b/api/schemas/telephony_config.py index 72c248f..da6d605 100644 --- a/api/schemas/telephony_config.py +++ b/api/schemas/telephony_config.py @@ -104,6 +104,9 @@ class ARIConfigurationRequest(BaseModel): default="", description="websocket_client.conf connection name for externalMedia (e.g., dograh_staging)", ) + inbound_workflow_id: Optional[int] = Field( + default=None, description="Workflow ID for inbound calls" + ) from_numbers: List[str] = Field( default_factory=list, description="List of SIP extensions/numbers for outbound calls (optional)", @@ -118,6 +121,7 @@ class ARIConfigurationResponse(BaseModel): app_name: str app_password: str # Masked ws_client_name: str = "" + inbound_workflow_id: Optional[int] = None from_numbers: List[str] diff --git a/api/services/telephony/ari_manager.py b/api/services/telephony/ari_manager.py index aa71e6f..2468972 100644 --- a/api/services/telephony/ari_manager.py +++ b/api/services/telephony/ari_manager.py @@ -24,10 +24,12 @@ from loguru import logger from api.constants import REDIS_URL from api.db import db_client -from api.enums import OrganizationConfigurationKey +from api.enums import CallType, OrganizationConfigurationKey, WorkflowRunMode +from api.services.quota_service import check_dograh_quota_by_user_id # Redis key pattern and TTL for channel-to-run mapping _CHANNEL_KEY_PREFIX = "ari:channel:" +_EXT_CHANNEL_KEY_PREFIX = "ari:ext_channel:" _CHANNEL_KEY_TTL = 3600 # 1 hour safety expiry @@ -41,12 +43,14 @@ class ARIConnection: app_name: str, app_password: str, ws_client_name: str = "", + inbound_workflow_id: int = None, ): self.organization_id = organization_id self.ari_endpoint = ari_endpoint.rstrip("/") self.app_name = app_name self.app_password = app_password self.ws_client_name = ws_client_name + self.inbound_workflow_id = inbound_workflow_id self._ws: Optional[websockets.ClientConnection] = None self._task: Optional[asyncio.Task] = None @@ -88,6 +92,23 @@ class ARIConnection: keys = [f"{_CHANNEL_KEY_PREFIX}{cid}" for cid in channel_ids] await r.delete(*keys) + async def _mark_ext_channel(self, channel_id: str): + """Mark a channel as an external media channel we created.""" + r = await self._get_redis() + await r.set( + f"{_EXT_CHANNEL_KEY_PREFIX}{channel_id}", "1", ex=_CHANNEL_KEY_TTL + ) + + async def _is_ext_channel(self, channel_id: str) -> bool: + """Check if a channel is an external media channel we created.""" + r = await self._get_redis() + return await r.exists(f"{_EXT_CHANNEL_KEY_PREFIX}{channel_id}") > 0 + + async def _delete_ext_channel(self, channel_id: str): + """Remove the external media channel marker.""" + r = await self._get_redis() + await r.delete(f"{_EXT_CHANNEL_KEY_PREFIX}{channel_id}") + @property def ws_url(self) -> str: """Build the ARI WebSocket URL.""" @@ -211,6 +232,16 @@ class ARIConnection: channel_state = channel.get("state", "unknown") if event_type == "StasisStart": + # Skip external media channels we created — they fire + # their own StasisStart but need no further handling. + if await self._is_ext_channel(channel_id): + logger.debug( + f"[ARI org={self.organization_id}] StasisStart for our " + f"externalMedia channel {channel_id}, ignoring" + ) + await self._delete_ext_channel(channel_id) + return + app_args = event.get("args", []) caller = channel.get("caller", {}) logger.info( @@ -220,31 +251,38 @@ class ARIConnection: f"args={app_args}" ) - # Parse args to extract workflow context - args_dict = {} - for arg in app_args: - for pair in arg.split(","): - if "=" in pair: - key, value = pair.split("=", 1) - args_dict[key.strip()] = value.strip() - - workflow_run_id = args_dict.get("workflow_run_id") - workflow_id = args_dict.get("workflow_id") - user_id = args_dict.get("user_id") - - if not workflow_run_id or not workflow_id or not user_id: - logger.warning( - f"[ARI org={self.organization_id}] StasisStart missing required args: " - f"workflow_run_id={workflow_run_id}, workflow_id={workflow_id}, user_id={user_id}" + if channel_state == "Ring": + # Inbound call — arrived from outside, not yet answered + asyncio.create_task( + self._handle_inbound_stasis_start(channel_id, channel_state, event) ) - return + else: + # Outbound call (state == "Up") — originated by us + # Parse args to extract workflow context + args_dict = {} + for arg in app_args: + for pair in arg.split(","): + if "=" in pair: + key, value = pair.split("=", 1) + args_dict[key.strip()] = value.strip() - # Start pipeline connection in background task - asyncio.create_task( - self._handle_stasis_start( - channel_id, channel_state, workflow_run_id, workflow_id, user_id + workflow_run_id = args_dict.get("workflow_run_id") + workflow_id = args_dict.get("workflow_id") + user_id = args_dict.get("user_id") + + if not workflow_run_id or not workflow_id or not user_id: + logger.warning( + f"[ARI org={self.organization_id}] StasisStart missing required args: " + f"workflow_run_id={workflow_run_id}, workflow_id={workflow_id}, user_id={user_id}" + ) + return + + # Start pipeline connection in background task + asyncio.create_task( + self._handle_stasis_start( + channel_id, channel_state, workflow_run_id, workflow_id, user_id + ) ) - ) elif event_type == "StasisEnd": logger.info( @@ -345,6 +383,7 @@ class ARIConnection: ) ext_channel_id = result.get("id", "") if ext_channel_id: + await self._mark_ext_channel(ext_channel_id) logger.info( f"[ARI org={self.organization_id}] Created external media channel: {ext_channel_id}" ) @@ -374,6 +413,93 @@ class ARIConnection: ) return bridge_id + async def _handle_inbound_stasis_start( + self, channel_id: str, channel_state: str, event: dict + ): + """Handle an inbound call (StasisStart with state=Ring). + + Validates quota, creates a workflow run, then delegates to the + standard answer→externalMedia→bridge pipeline. + """ + channel = event.get("channel", {}) + caller_number = channel.get("caller", {}).get("number", "unknown") + called_number = channel.get("dialplan", {}).get("exten", "unknown") + + try: + # 1. Check inbound_workflow_id is configured + if not self.inbound_workflow_id: + logger.warning( + f"[ARI org={self.organization_id}] Inbound call on channel {channel_id} " + f"but no inbound_workflow_id configured — hanging up" + ) + await self._delete_channel(channel_id) + return + + # 2. Load workflow to get user_id and verify organization + workflow = await db_client.get_workflow( + self.inbound_workflow_id, organization_id=self.organization_id + ) + if not workflow: + logger.warning( + f"[ARI org={self.organization_id}] Workflow {self.inbound_workflow_id} " + f"not found or doesn't belong to this organization — hanging up" + ) + await self._delete_channel(channel_id) + return + + user_id = workflow.user_id + + # 3. Check quota + quota_result = await check_dograh_quota_by_user_id(user_id) + if not quota_result.has_quota: + logger.warning( + f"[ARI org={self.organization_id}] Quota exceeded for user {user_id} " + f"— hanging up inbound call {channel_id}" + ) + await self._delete_channel(channel_id) + return + + # 4. Create workflow run + call_id = channel_id + workflow_run = await db_client.create_workflow_run( + name=f"ARI Inbound {caller_number}", + workflow_id=self.inbound_workflow_id, + mode=WorkflowRunMode.ARI.value, + user_id=user_id, + call_type=CallType.INBOUND, + initial_context={ + "caller_number": caller_number, + "called_number": called_number, + "direction": "inbound", + "call_id": call_id, + "provider": "ari", + }, + ) + + logger.info( + f"[ARI org={self.organization_id}] Created inbound workflow run " + f"{workflow_run.id} for channel {channel_id} " + f"(caller={caller_number}, called={called_number})" + ) + + # 5. Delegate to the standard pipeline + await self._handle_stasis_start( + channel_id, + channel_state, + str(workflow_run.id), + str(self.inbound_workflow_id), + str(user_id), + ) + except Exception as e: + logger.error( + f"[ARI org={self.organization_id}] Error handling inbound StasisStart " + f"for channel {channel_id}: {e}" + ) + try: + await self._delete_channel(channel_id) + except Exception: + pass + async def _handle_stasis_start( self, channel_id: str, @@ -588,9 +714,15 @@ class ARIManager: app_name = config["app_name"] app_password = config["app_password"] ws_client_name = config["ws_client_name"] + inbound_workflow_id = config.get("inbound_workflow_id") conn = ARIConnection( - org_id, ari_endpoint, app_name, app_password, ws_client_name + org_id, + ari_endpoint, + app_name, + app_password, + ws_client_name, + inbound_workflow_id=inbound_workflow_id, ) key = conn.connection_key @@ -604,9 +736,12 @@ class ARIManager: self._connections[key] = conn await conn.start() else: - # Existing configuration - check if password changed + # Existing configuration - check if password or inbound_workflow_id changed existing = self._connections[key] - if existing.app_password != app_password: + if ( + existing.app_password != app_password + or existing.inbound_workflow_id != inbound_workflow_id + ): logger.info( f"[ARI Manager] Config changed for org {org_id}, reconnecting..." ) @@ -666,6 +801,7 @@ class ARIManager: "app_name": app_name, "app_password": app_password, "ws_client_name": ws_client_name, + "inbound_workflow_id": value.get("inbound_workflow_id"), } ) diff --git a/api/services/telephony/factory.py b/api/services/telephony/factory.py index ac03d6f..0e2bb6c 100644 --- a/api/services/telephony/factory.py +++ b/api/services/telephony/factory.py @@ -82,6 +82,7 @@ async def load_telephony_config(organization_id: int) -> Dict[str, Any]: "ari_endpoint": config.value.get("ari_endpoint"), "app_name": config.value.get("app_name"), "app_password": config.value.get("app_password"), + "inbound_workflow_id": config.value.get("inbound_workflow_id"), "from_numbers": config.value.get("from_numbers", []), } else: diff --git a/api/services/telephony/providers/ari_provider.py b/api/services/telephony/providers/ari_provider.py index 67b449c..139065a 100644 --- a/api/services/telephony/providers/ari_provider.py +++ b/api/services/telephony/providers/ari_provider.py @@ -50,6 +50,7 @@ class ARIProvider(TelephonyProvider): self.ari_endpoint = config.get("ari_endpoint", "").rstrip("/") self.app_name = config.get("app_name", "") self.app_password = config.get("app_password", "") + self.inbound_workflow_id = config.get("inbound_workflow_id") self.from_numbers = config.get("from_numbers", []) if isinstance(self.from_numbers, str): diff --git a/docs/docs.json b/docs/docs.json index c4e3a2b..c6e8d42 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -90,6 +90,7 @@ "integrations/telephony/vonage", "integrations/telephony/cloudonix", "integrations/telephony/vobiz", + "integrations/telephony/asterisk-ari", "integrations/telephony/webhooks", "integrations/telephony/custom" ] diff --git a/docs/integrations/telephony/asterisk-ari.mdx b/docs/integrations/telephony/asterisk-ari.mdx new file mode 100644 index 0000000..da2c3c7 --- /dev/null +++ b/docs/integrations/telephony/asterisk-ari.mdx @@ -0,0 +1,215 @@ +--- +title: "Asterisk ARI Integration" +description: "Connect Dograh AI to your Asterisk PBX using the Asterisk REST Interface (ARI)" +--- + +## Overview + +Asterisk ARI (Asterisk REST Interface) allows you to connect Dograh AI voice agents to your existing Asterisk PBX. ARI provides a WebSocket-based event model for controlling calls via Stasis applications, giving Dograh full control over call flow and audio streaming. + +This guide focuses on the Dograh-specific configuration. For general Asterisk installation and administration, refer to the [official Asterisk documentation](https://docs.asterisk.org/). + +## Prerequisites + +Before setting up the ARI integration, ensure you have: + +- A running Asterisk instance (version 16 or later recommended) +- ARI module enabled in Asterisk +- `chan_websocket` (WebSocket channel driver) enabled in your Asterisk build +- Network connectivity between your Dograh instance and Asterisk +- Dograh AI instance running and accessible + + +If you compiled Asterisk from source, ensure `chan_websocket` is included during the build. This module is required for external media streaming between Asterisk and Dograh. Refer to the [Asterisk build system documentation](https://docs.asterisk.org/) for details on enabling modules. + + +## Asterisk Configuration + +The following Asterisk configuration files need to be set up to work with Dograh. These are minimal examples focused on the Dograh integration -- refer to the [Asterisk documentation](https://docs.asterisk.org/) for full configuration details. + +### Enable ARI (`ari.conf`) + +Create an ARI user that Dograh will use to authenticate: + +```ini +[general] +enabled = yes + +[dograh] +type = user +read_only = no +password = your_secure_password +``` + + +The username (section name, e.g., `dograh`) and password here must match the **Stasis App Name** and **App Password** you configure in Dograh. + + +### Enable the HTTP Server (`http.conf`) + +ARI requires the Asterisk HTTP server to be enabled: + +```ini +[general] +enabled = yes +bindaddr = 0.0.0.0 +bindport = 8088 +``` + +### Configure the Stasis Dialplan (`extensions.conf`) + +Route incoming calls to your Stasis application so Dograh can handle them: + +```ini +[from-external] +exten => _X.,1,NoOp(Incoming call to ${EXTEN}) + same => n,Stasis(dograh) + same => n,Hangup() +``` + +Replace `dograh` with the app name you configured in `ari.conf` and in Dograh. + +### Configure External Media Streaming (`websocket_client.conf`) + +Dograh uses Asterisk's external media streaming to send and receive audio over WebSocket. Configure a WebSocket client connection that points to your Dograh instance: + +```ini +[dograh_staging] +type = websocket_client +uri = ws://your-dograh-host:port/ws/audio +protocols = audio +``` + + +The section name (e.g., `dograh_staging`) is the **WebSocket Client Name** you'll enter in the Dograh telephony configuration. This name tells Asterisk which WebSocket connection to use for external media streaming during calls. + + +Refer to the [Asterisk WebSocket documentation](https://docs.asterisk.org/) for additional `websocket_client.conf` options and TLS configuration. + +## Configuration in Dograh + +### Step 1: Navigate to Telephony Settings + +1. Go to **Workflow** → **Phone Call** → **Configure Telephony** +2. Select **Asterisk (ARI)** as your provider + +### Step 2: Enter Your ARI Credentials + +Configure the following fields: + +| Field | Description | Example | +|-------|-------------|---------| +| **ARI Endpoint URL** | HTTP base URL of your Asterisk ARI server | `http://asterisk.example.com:8088` | +| **Stasis App Name** | The ARI username configured in `ari.conf` | `dograh` | +| **App Password** | The ARI password configured in `ari.conf` | `your_secure_password` | +| **WebSocket Client Name** | The connection name from `websocket_client.conf` | `dograh_staging` | +| **Inbound Workflow ID** | The workflow to activate for inbound calls (optional) | `42` | +| **SIP Extensions / Numbers** | Optional SIP extensions or trunk numbers for outbound calls | `PJSIP/6001` or `6001` | + +### Step 3: Save and Test + +1. Click **Save Configuration** +2. Create a test workflow +3. Initiate a test call to verify the connection + +## Inbound Calling + +Unlike other telephony providers that use HTTP webhooks for inbound calls, ARI delivers inbound calls as **StasisStart events on the ARI WebSocket**. Dograh automatically detects these events and activates the configured workflow. + +### How It Works + +1. An external call arrives at Asterisk and the dialplan routes it to `Stasis(dograh)` +2. Asterisk fires a StasisStart event over the ARI WebSocket with the channel in `Ring` state +3. Dograh identifies this as an inbound call, validates your quota, and creates a workflow run +4. The call is answered, bridged to an external media channel, and your voice agent workflow begins + +### Setting Up Inbound Calls + +**Step 1: Configure the Asterisk dialplan** + +Ensure your dialplan routes inbound calls to the Stasis application as shown in the [dialplan configuration above](#configure-the-stasis-dialplan-extensionsconf). + +**Step 2: Set the Inbound Workflow ID in Dograh** + +1. Go to **Workflow** → **Phone Call** → **Configure Telephony** +2. In the ARI configuration, enter the **Inbound Workflow ID** — this is the ID of the workflow you want to activate when an inbound call arrives +3. Click **Save Configuration** + +You can find a workflow's ID in the URL when viewing it (e.g., `/workflows/42` means the ID is `42`). + + +If no Inbound Workflow ID is configured, inbound calls will be hung up immediately. You must set this field for inbound calling to work. + + +**Step 3: Test an inbound call** + +Place a call to a number or extension routed to your Stasis application. You should see the workflow activate and the voice agent respond. + +### Inbound Call Context + +When an inbound call activates a workflow, the following context is available to your workflow: + +| Field | Description | +|-------|-------------| +| `caller_number` | The caller's phone number or extension | +| `called_number` | The dialed number or extension | +| `direction` | Always `inbound` | +| `call_id` | The Asterisk channel ID | +| `provider` | Always `ari` | + +## Troubleshooting + + + + - Verify the ARI endpoint URL is correct and reachable from your Dograh instance + - Check that the Asterisk HTTP server is running (`http.conf` has `enabled = yes`) + - Ensure firewall rules allow traffic on the ARI port (default: 8088) + - Confirm the ARI module is loaded: run `module show like res_ari` in the Asterisk CLI + + + + - Verify the Stasis App Name matches the ARI user section name in `ari.conf` + - Check the App Password matches the password in `ari.conf` + - Ensure there are no extra spaces in the credentials + + + + - Verify `chan_websocket` is loaded: run `module show like chan_websocket` in the Asterisk CLI + - Check that `websocket_client.conf` is correctly configured with the right Dograh URI + - Ensure the WebSocket Client Name in Dograh matches the section name in `websocket_client.conf` + - Verify network connectivity and firewall rules allow WebSocket traffic between Asterisk and Dograh + + + + - Ensure the dialplan routes calls to `Stasis(your_app_name)` + - Verify the app name in the dialplan matches the ARI user in `ari.conf` + - Check Asterisk CLI for errors: `asterisk -rvvv` + - Confirm the ARI WebSocket connection is active + + + + - Verify the **Inbound Workflow ID** is set in your ARI telephony configuration + - Confirm the workflow ID exists and belongs to the same organization as the ARI config + - Check that your organization has available quota + - Review Dograh logs for warnings mentioning "no inbound_workflow_id configured" + + + + - Check the URI in `websocket_client.conf` points to the correct Dograh host and port + - Verify the Dograh instance is running and accepting WebSocket connections + - If using TLS, ensure certificates are correctly configured on both sides + + + +## Best Practices + +- Keep your Asterisk instance on the same network or a low-latency connection to Dograh for optimal audio quality +- Use strong passwords for ARI authentication +- Restrict ARI access to known IP addresses using firewall rules +- Monitor Asterisk logs alongside Dograh logs when debugging call issues +- Keep Asterisk updated to the latest stable version for security and compatibility + +## Further Reading + +- [Asterisk Documentation](https://docs.asterisk.org/) -- official reference for all Asterisk configuration +- [ARI Documentation](https://docs.asterisk.org/Configuration/Interfaces/Asterisk-REST-Interface-ARI/) -- detailed ARI configuration and API reference diff --git a/docs/integrations/telephony/inbound.mdx b/docs/integrations/telephony/inbound.mdx index b087749..5e7452c 100644 --- a/docs/integrations/telephony/inbound.mdx +++ b/docs/integrations/telephony/inbound.mdx @@ -19,6 +19,9 @@ Dograh AI supports inbound calling across all supported telephony providers. Whe Cloud-based telephony with global reach and competitive pricing + + Connect to your own Asterisk PBX via the Asterisk REST Interface + @@ -46,6 +49,7 @@ The telephony configuration for inbound calling is **identical** to outbound cal - [Twilio Configuration](/integrations/telephony/twilio#configuration) - [Cloudonix Configuration](/integrations/telephony/cloudonix#configuration) - [Vobiz Configuration](/integrations/telephony/vobiz#configuration) +- [Asterisk ARI Configuration](/integrations/telephony/asterisk-ari#configuration-in-dograh) ### Step 2: Get Your Workflow Webhook URL @@ -75,6 +79,7 @@ Each telephony provider requires additional configuration to route incoming call - [Vonage Inbound Setup](/integrations/telephony/vonage#inbound-calling-setup) - [Cloudonix Inbound Setup](/integrations/telephony/cloudonix#inbound-calling-setup) - [Vobiz Inbound Setup](/integrations/telephony/vobiz#inbound-calling-setup) +- [Asterisk ARI Inbound Setup](/integrations/telephony/asterisk-ari#inbound-calling) ## Testing Inbound Calls diff --git a/ui/src/app/telephony-configurations/page.tsx b/ui/src/app/telephony-configurations/page.tsx index a02ca97..acc126d 100644 --- a/ui/src/app/telephony-configurations/page.tsx +++ b/ui/src/app/telephony-configurations/page.tsx @@ -58,6 +58,7 @@ interface TelephonyConfigForm { app_name?: string; app_password?: string; ws_client_name?: string; + inbound_workflow_id?: number; // Common field - multiple phone numbers from_numbers: string[]; } @@ -155,6 +156,10 @@ export default function ConfigureTelephonyPage() { setValue("app_name", ariConfig.app_name); setValue("app_password", ariConfig.app_password); setValue("ws_client_name", ariConfig.ws_client_name); + setValue( + "inbound_workflow_id", + typeof ariConfig.inbound_workflow_id === "number" ? ariConfig.inbound_workflow_id : undefined + ); setValue("from_numbers", ariConfig.from_numbers?.length > 0 ? ariConfig.from_numbers : [""]); } } @@ -257,6 +262,7 @@ export default function ConfigureTelephonyPage() { app_name: data.app_name!, app_password: data.app_password!, ws_client_name: data.ws_client_name || "", + inbound_workflow_id: data.inbound_workflow_id || undefined, } as AriConfigurationRequest; } @@ -913,6 +919,19 @@ export default function ConfigureTelephonyPage() {

+
+ + +

+ Workflow to activate for inbound calls received via ARI +

+
+
{fromNumbers.map((number, index) => ( diff --git a/ui/src/client/types.gen.ts b/ui/src/client/types.gen.ts index fb93eb7..8e1a683 100644 --- a/ui/src/client/types.gen.ts +++ b/ui/src/client/types.gen.ts @@ -40,6 +40,10 @@ export type AriConfigurationRequest = { * websocket_client.conf connection name for externalMedia (e.g., dograh_staging) */ ws_client_name?: string; + /** + * Workflow ID for inbound calls + */ + inbound_workflow_id?: number | null; /** * List of SIP extensions/numbers for outbound calls (optional) */ @@ -55,6 +59,7 @@ export type AriConfigurationResponse = { app_name: string; app_password: string; ws_client_name?: string; + inbound_workflow_id?: number | null; from_numbers: Array; };