diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..3fdd478 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,6 @@ +{ + "effortLevel": "high", + "env": { + "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1" + } +} diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 2baa8f5..c969b2d 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.13.0" + ".": "1.14.0" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index cd715c6..c29a02c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [1.14.0](https://github.com/dograh-hq/dograh/compare/dograh-v1.13.0...dograh-v1.14.0) (2026-02-16) + + +### Features + +* telephony call transfer ([#155](https://github.com/dograh-hq/dograh/issues/155)) ([c711920](https://github.com/dograh-hq/dograh/commit/c71192016561333a109393186cf0d3b70bbd894d)) + + +### Bug Fixes + +* add check for workflow run mode in transfer call ([#160](https://github.com/dograh-hq/dograh/issues/160)) ([67e92e6](https://github.com/dograh-hq/dograh/commit/67e92e6b9c508b31db4e5256f3d6afb2aadd0eb4)) +* limit cloudonix transport to 20 ms packets ([559c0ca](https://github.com/dograh-hq/dograh/commit/559c0ca767389b1212b5a4726338df127a8d630a)) +* llm generation to annouce failed transfer call ([28eaa93](https://github.com/dograh-hq/dograh/commit/28eaa934f3430aacd5d0151e07e8ae86274a4148)) + ## [1.13.0](https://github.com/dograh-hq/dograh/compare/dograh-v1.12.0...dograh-v1.13.0) (2026-02-13) diff --git a/api/alembic/versions/1a7d74d54e8f_add_transfer_call_tool_category.py b/api/alembic/versions/1a7d74d54e8f_add_transfer_call_tool_category.py new file mode 100644 index 0000000..2fd6285 --- /dev/null +++ b/api/alembic/versions/1a7d74d54e8f_add_transfer_call_tool_category.py @@ -0,0 +1,50 @@ +"""add transfer_call tool category + +Revision ID: 1a7d74d54e8f +Revises: 34c8537dfde5 +Create Date: 2026-02-03 11:18:11.417837 + +""" + +from typing import Sequence, Union + +from alembic import op +from alembic_postgresql_enum import TableReference + +# revision identifiers, used by Alembic. +revision: str = "1a7d74d54e8f" +down_revision: Union[str, None] = "34c8537dfde5" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.sync_enum_values( + enum_schema="public", + enum_name="tool_category", + new_values=["http_api", "end_call", "transfer_call", "native", "integration"], + affected_columns=[ + TableReference( + table_schema="public", table_name="tools", column_name="category" + ) + ], + enum_values_to_rename=[], + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.sync_enum_values( + enum_schema="public", + enum_name="tool_category", + new_values=["http_api", "end_call", "native", "integration"], + affected_columns=[ + TableReference( + table_schema="public", table_name="tools", column_name="category" + ) + ], + enum_values_to_rename=[], + ) + # ### end Alembic commands ### diff --git a/api/alembic/versions/6d2f94baf4b7_add_ari_mode.py b/api/alembic/versions/6d2f94baf4b7_add_ari_mode.py new file mode 100644 index 0000000..3173e2d --- /dev/null +++ b/api/alembic/versions/6d2f94baf4b7_add_ari_mode.py @@ -0,0 +1,71 @@ +"""add ari mode + +Revision ID: 6d2f94baf4b7 +Revises: 1a7d74d54e8f +Create Date: 2026-02-15 13:52:29.285583 + +""" + +from typing import Sequence, Union + +from alembic import op +from alembic_postgresql_enum import TableReference + +# revision identifiers, used by Alembic. +revision: str = "6d2f94baf4b7" +down_revision: Union[str, None] = "1a7d74d54e8f" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.sync_enum_values( + enum_schema="public", + enum_name="workflow_run_mode", + new_values=[ + "ari", + "twilio", + "vonage", + "vobiz", + "cloudonix", + "webrtc", + "smallwebrtc", + "stasis", + "VOICE", + "CHAT", + ], + affected_columns=[ + TableReference( + table_schema="public", table_name="workflow_runs", column_name="mode" + ) + ], + enum_values_to_rename=[], + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.sync_enum_values( + enum_schema="public", + enum_name="workflow_run_mode", + new_values=[ + "twilio", + "vonage", + "vobiz", + "cloudonix", + "stasis", + "webrtc", + "smallwebrtc", + "VOICE", + "CHAT", + ], + affected_columns=[ + TableReference( + table_schema="public", table_name="workflow_runs", column_name="mode" + ) + ], + enum_values_to_rename=[], + ) + # ### end Alembic commands ### diff --git a/api/app.py b/api/app.py index dbe0a0d..a0dacd3 100644 --- a/api/app.py +++ b/api/app.py @@ -2,7 +2,7 @@ import sentry_sdk -from api.constants import DEPLOYMENT_MODE, ENABLE_TELEMETRY, REDIS_URL, SENTRY_DSN +from api.constants import DEPLOYMENT_MODE, ENABLE_TELEMETRY, SENTRY_DSN from api.logging_config import ENVIRONMENT, setup_logging # Set up logging and get the listener for cleanup @@ -21,62 +21,27 @@ if SENTRY_DSN and ( from contextlib import asynccontextmanager -from typing import Optional -import redis.asyncio as aioredis from fastapi import APIRouter, FastAPI from fastapi.middleware.cors import CORSMiddleware from loguru import logger from api.routes.main import router as main_router -from api.services.telephony.worker_event_subscriber import ( - WorkerEventSubscriber, - setup_worker_subscriber, -) from api.tasks.arq import get_arq_redis API_PREFIX = "/api/v1" -# Global reference to worker subscriber for graceful shutdown -worker_subscriber_instance: Optional[WorkerEventSubscriber] = None - @asynccontextmanager async def lifespan(app: FastAPI): - global worker_subscriber_instance - # warmup arq pool await get_arq_redis() - # Setup Redis connection for distributed mode - redis = await aioredis.from_url(REDIS_URL, decode_responses=True) - - # Setup worker subscriber (ARI Manager runs separately) - worker_subscriber = await setup_worker_subscriber(redis) - worker_subscriber_instance = worker_subscriber - - # Store worker ID in app state for health check - app.state.worker_id = worker_subscriber.worker_id - app.state.worker_subscriber = worker_subscriber - yield # Run app # Shutdown sequence - this runs when FastAPI is shutting down logger.info("Starting graceful shutdown...") - # First, try graceful shutdown with timeout - if worker_subscriber: - try: - # Check if we should do graceful shutdown (e.g., if SIGTERM was received) - # For now, we'll attempt graceful shutdown for all shutdowns - await worker_subscriber.graceful_shutdown(max_wait_seconds=300) - except Exception as e: - logger.error(f"Error during graceful shutdown: {e}") - # Fall back to immediate stop - await worker_subscriber.stop() - - await redis.aclose() - app = FastAPI( title="Dograh API", diff --git a/api/assets/transfer_hold_ring_16000.wav b/api/assets/transfer_hold_ring_16000.wav new file mode 100644 index 0000000..5cab021 Binary files /dev/null and b/api/assets/transfer_hold_ring_16000.wav differ diff --git a/api/assets/transfer_hold_ring_8000.wav b/api/assets/transfer_hold_ring_8000.wav new file mode 100644 index 0000000..b1133b3 Binary files /dev/null and b/api/assets/transfer_hold_ring_8000.wav differ diff --git a/api/constants.py b/api/constants.py index 34fcba7..6f55e8d 100644 --- a/api/constants.py +++ b/api/constants.py @@ -104,6 +104,15 @@ DEFAULT_CAMPAIGN_RETRY_CONFIG = { } +# Circuit breaker defaults for campaign call failure detection +DEFAULT_CIRCUIT_BREAKER_CONFIG = { + "enabled": True, + "failure_threshold": 0.5, # 50% failure rate trips the breaker + "window_seconds": 120, # 2-minute sliding window + "min_calls_in_window": 5, # Don't trip until at least 5 outcomes +} + + TURN_SECRET = os.getenv("TURN_SECRET") TURN_HOST = os.getenv("TURN_HOST", "localhost") TURN_PORT = int(os.getenv("TURN_PORT", "3478")) diff --git a/api/db/campaign_client.py b/api/db/campaign_client.py index 2e0a53b..6a873cd 100644 --- a/api/db/campaign_client.py +++ b/api/db/campaign_client.py @@ -21,6 +21,7 @@ class CampaignClient(BaseDBClient): organization_id: int, retry_config: Optional[dict] = None, max_concurrency: Optional[int] = None, + schedule_config: Optional[dict] = None, ) -> CampaignModel: """Create a new campaign""" async with self.async_session() as session: @@ -28,6 +29,8 @@ class CampaignClient(BaseDBClient): orchestrator_metadata = {} if max_concurrency is not None: orchestrator_metadata["max_concurrency"] = max_concurrency + if schedule_config is not None: + orchestrator_metadata["schedule_config"] = schedule_config campaign = CampaignModel( name=name, diff --git a/api/db/organization_configuration_client.py b/api/db/organization_configuration_client.py index a169b89..def483a 100644 --- a/api/db/organization_configuration_client.py +++ b/api/db/organization_configuration_client.py @@ -1,4 +1,4 @@ -from typing import Any, Optional +from typing import Any, Dict, List, Optional from sqlalchemy.future import select @@ -94,3 +94,27 @@ class OrganizationConfigurationClient(BaseDBClient): """Get the value of a configuration, returning default if not found.""" config = await self.get_configuration(organization_id, key) return config.value if config else default + + async def get_configurations_by_provider( + self, key: str, provider: str + ) -> List[Dict[str, Any]]: + """Get all organization configurations for a given key filtered by provider. + + Returns a list of dicts with organization_id and the config value. + """ + async with self.async_session() as session: + result = await session.execute( + select(OrganizationConfigurationModel).where( + OrganizationConfigurationModel.key == key, + ) + ) + configs = result.scalars().all() + + return [ + { + "organization_id": config.organization_id, + "value": config.value, + } + for config in configs + if config.value and config.value.get("provider") == provider + ] diff --git a/api/db/workflow_run_client.py b/api/db/workflow_run_client.py index 657804e..3cbf1bc 100644 --- a/api/db/workflow_run_client.py +++ b/api/db/workflow_run_client.py @@ -321,8 +321,11 @@ class WorkflowRunClient(BaseDBClient): state: str | None = None, ) -> WorkflowRunModel: async with self.async_session() as session: + # Use SELECT FOR UPDATE to lock the row during the update result = await session.execute( - select(WorkflowRunModel).where(WorkflowRunModel.id == run_id) + select(WorkflowRunModel) + .where(WorkflowRunModel.id == run_id) + .with_for_update() ) run = result.scalars().first() if not run: diff --git a/api/enums.py b/api/enums.py index 8b19bcc..03848e2 100644 --- a/api/enums.py +++ b/api/enums.py @@ -18,16 +18,17 @@ class CallType(Enum): class WorkflowRunMode(Enum): + ARI = "ari" TWILIO = "twilio" VONAGE = "vonage" VOBIZ = "vobiz" CLOUDONIX = "cloudonix" - STASIS = "stasis" WEBRTC = "webrtc" SMALLWEBRTC = "smallwebrtc" # Historical, not used anymore. Don't # use and don't remove + STASIS = "stasis" VOICE = "VOICE" CHAT = "CHAT" @@ -122,7 +123,8 @@ class ToolCategory(Enum): HTTP_API = "http_api" # Custom HTTP API calls (implemented) END_CALL = "end_call" # End call tool - NATIVE = "native" # Built-in integrations (future: call_transfer, dtmf_input) + TRANSFER_CALL = "transfer_call" # Transfer call to phone number (Twilio only) + NATIVE = "native" # Built-in integrations (future: dtmf_input) INTEGRATION = "integration" # Third-party integrations (future: Google Calendar, Salesforce, etc.) diff --git a/api/pyproject.toml b/api/pyproject.toml index 6bf079e..a0e6ff2 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -1,5 +1,5 @@ [project] name = "dograh-api" -version = "1.13.0" +version = "1.14.0" description = "Backend API for Dograh voice AI platform" requires-python = ">=3.12" diff --git a/api/routes/campaign.py b/api/routes/campaign.py index 44a3ef4..5c978b5 100644 --- a/api/routes/campaign.py +++ b/api/routes/campaign.py @@ -1,9 +1,10 @@ import json from datetime import datetime from typing import List, Optional +from zoneinfo import ZoneInfo from fastapi import APIRouter, Depends, HTTPException, Query -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator, model_validator from api.constants import DEFAULT_CAMPAIGN_RETRY_CONFIG, DEFAULT_ORG_CONCURRENCY_LIMIT from api.db import db_client @@ -46,6 +47,28 @@ async def _get_from_numbers_count(organization_id: int) -> int: return 0 +async def _validate_max_concurrency(max_concurrency: int, organization_id: int) -> None: + """Validate max_concurrency against org limit and configured phone numbers. + + Raises HTTPException(400) if the value exceeds the effective limit. + """ + org_limit = await _get_org_concurrent_limit(organization_id) + from_numbers_count = await _get_from_numbers_count(organization_id) + effective_limit = ( + min(org_limit, from_numbers_count) if from_numbers_count > 0 else org_limit + ) + if max_concurrency > effective_limit: + if from_numbers_count > 0 and from_numbers_count < org_limit: + raise HTTPException( + status_code=400, + detail=f"max_concurrency ({max_concurrency}) cannot exceed {effective_limit}. You have {from_numbers_count} phone number(s) configured. Add more CLIs in telephony configuration to increase concurrency.", + ) + raise HTTPException( + status_code=400, + detail=f"max_concurrency ({max_concurrency}) cannot exceed organization limit ({effective_limit})", + ) + + class RetryConfigRequest(BaseModel): enabled: bool = True max_retries: int = Field(default=2, ge=0, le=10) @@ -64,6 +87,45 @@ class RetryConfigResponse(BaseModel): retry_on_voicemail: bool +class TimeSlotRequest(BaseModel): + day_of_week: int = Field(..., ge=0, le=6) + start_time: str = Field(..., pattern=r"^\d{2}:\d{2}$") + end_time: str = Field(..., pattern=r"^\d{2}:\d{2}$") + + @model_validator(mode="after") + def validate_times(self): + if self.start_time >= self.end_time: + raise ValueError("start_time must be before end_time") + return self + + +class ScheduleConfigRequest(BaseModel): + enabled: bool = True + timezone: str = "UTC" + slots: List[TimeSlotRequest] = Field(..., min_length=1, max_length=50) + + @field_validator("timezone") + @classmethod + def validate_timezone(cls, v: str) -> str: + try: + ZoneInfo(v) + except (KeyError, Exception): + raise ValueError(f"Invalid timezone: {v}") + return v + + +class TimeSlotResponse(BaseModel): + day_of_week: int + start_time: str + end_time: str + + +class ScheduleConfigResponse(BaseModel): + enabled: bool + timezone: str + slots: List[TimeSlotResponse] + + class CreateCampaignRequest(BaseModel): name: str = Field(..., min_length=1, max_length=255) workflow_id: int @@ -71,6 +133,14 @@ class CreateCampaignRequest(BaseModel): source_id: str # Google Sheet URL or CSV file key retry_config: Optional[RetryConfigRequest] = None max_concurrency: Optional[int] = Field(default=None, ge=1, le=100) + schedule_config: Optional[ScheduleConfigRequest] = None + + +class UpdateCampaignRequest(BaseModel): + name: Optional[str] = Field(None, min_length=1, max_length=255) + retry_config: Optional[RetryConfigRequest] = None + max_concurrency: Optional[int] = Field(default=None, ge=1, le=100) + schedule_config: Optional[ScheduleConfigRequest] = None class CampaignResponse(BaseModel): @@ -89,6 +159,7 @@ class CampaignResponse(BaseModel): completed_at: Optional[datetime] retry_config: RetryConfigResponse max_concurrency: Optional[int] = None + schedule_config: Optional[ScheduleConfigResponse] = None class CampaignsResponse(BaseModel): @@ -138,10 +209,18 @@ def _build_campaign_response(campaign, workflow_name: str) -> CampaignResponse: else DEFAULT_CAMPAIGN_RETRY_CONFIG ) - # Get max_concurrency from orchestrator_metadata + # Get max_concurrency and schedule_config from orchestrator_metadata max_concurrency = None + schedule_config = None if campaign.orchestrator_metadata: max_concurrency = campaign.orchestrator_metadata.get("max_concurrency") + sc = campaign.orchestrator_metadata.get("schedule_config") + if sc: + schedule_config = ScheduleConfigResponse( + enabled=sc.get("enabled", False), + timezone=sc.get("timezone", "UTC"), + slots=[TimeSlotResponse(**slot) for slot in sc.get("slots", [])], + ) return CampaignResponse( id=campaign.id, @@ -159,6 +238,7 @@ def _build_campaign_response(campaign, workflow_name: str) -> CampaignResponse: completed_at=campaign.completed_at, retry_config=RetryConfigResponse(**retry_config), max_concurrency=max_concurrency, + schedule_config=schedule_config, ) @@ -181,31 +261,21 @@ async def create_campaign( if not validation_result.is_valid: raise HTTPException(status_code=400, detail=validation_result.error.message) - # Validate max_concurrency against effective limit (min of org limit and from_numbers count) if request.max_concurrency is not None: - org_limit = await _get_org_concurrent_limit(user.selected_organization_id) - from_numbers_count = await _get_from_numbers_count( - user.selected_organization_id + await _validate_max_concurrency( + request.max_concurrency, user.selected_organization_id ) - effective_limit = ( - min(org_limit, from_numbers_count) if from_numbers_count > 0 else org_limit - ) - if request.max_concurrency > effective_limit: - if from_numbers_count > 0 and from_numbers_count < org_limit: - raise HTTPException( - status_code=400, - detail=f"max_concurrency ({request.max_concurrency}) cannot exceed {effective_limit}. You have {from_numbers_count} phone number(s) configured. Add more CLIs in telephony configuration to increase concurrency.", - ) - raise HTTPException( - status_code=400, - detail=f"max_concurrency ({request.max_concurrency}) cannot exceed organization limit ({effective_limit})", - ) # Build retry_config dict if provided retry_config = None if request.retry_config: retry_config = request.retry_config.model_dump() + # Build schedule_config dict if provided + schedule_config = None + if request.schedule_config: + schedule_config = request.schedule_config.model_dump() + campaign = await db_client.create_campaign( name=request.name, workflow_id=request.workflow_id, @@ -215,6 +285,7 @@ async def create_campaign( organization_id=user.selected_organization_id, retry_config=retry_config, max_concurrency=request.max_concurrency, + schedule_config=schedule_config, ) return _build_campaign_response(campaign, workflow_name) @@ -322,6 +393,62 @@ async def pause_campaign( return _build_campaign_response(campaign, workflow_name or "Unknown") +@router.patch("/{campaign_id}") +async def update_campaign( + campaign_id: int, + request: UpdateCampaignRequest, + user: UserModel = Depends(get_user), +) -> CampaignResponse: + """Update campaign settings (name, retry config, max concurrency, schedule)""" + campaign = await db_client.get_campaign(campaign_id, user.selected_organization_id) + if not campaign: + raise HTTPException(status_code=404, detail="Campaign not found") + + if campaign.state in ["completed", "failed"]: + raise HTTPException( + status_code=400, + detail=f"Cannot update a {campaign.state} campaign", + ) + + if request.max_concurrency is not None: + await _validate_max_concurrency( + request.max_concurrency, user.selected_organization_id + ) + + # Build update kwargs + update_kwargs = {} + + if request.name is not None: + update_kwargs["name"] = request.name + + if request.retry_config is not None: + update_kwargs["retry_config"] = request.retry_config.model_dump() + + # Merge max_concurrency and schedule_config into orchestrator_metadata + metadata = campaign.orchestrator_metadata or {} + metadata_changed = False + + if request.max_concurrency is not None: + metadata["max_concurrency"] = request.max_concurrency + metadata_changed = True + + if request.schedule_config is not None: + metadata["schedule_config"] = request.schedule_config.model_dump() + metadata_changed = True + + if metadata_changed: + update_kwargs["orchestrator_metadata"] = metadata + + if update_kwargs: + await db_client.update_campaign(campaign_id=campaign_id, **update_kwargs) + + # Re-fetch to return updated data + campaign = await db_client.get_campaign(campaign_id, user.selected_organization_id) + workflow_name = await db_client.get_workflow_name(campaign.workflow_id, user.id) + + return _build_campaign_response(campaign, workflow_name or "Unknown") + + @router.get("/{campaign_id}/runs") async def get_campaign_runs( campaign_id: int, diff --git a/api/routes/organization.py b/api/routes/organization.py index 71ab78c..f840df8 100644 --- a/api/routes/organization.py +++ b/api/routes/organization.py @@ -8,6 +8,8 @@ from api.db import db_client from api.db.models import UserModel from api.enums import OrganizationConfigurationKey from api.schemas.telephony_config import ( + ARIConfigurationRequest, + ARIConfigurationResponse, CloudonixConfigurationRequest, CloudonixConfigurationResponse, TelephonyConfigurationResponse, @@ -29,6 +31,7 @@ PROVIDER_MASKED_FIELDS = { "vonage": ["private_key", "api_key", "api_secret"], "vobiz": ["auth_id", "auth_token"], "cloudonix": ["bearer_token"], + "ari": ["app_password"], } @@ -125,6 +128,26 @@ async def get_telephony_configuration(user: UserModel = Depends(get_user)): ), vobiz=None, ) + elif stored_provider == "ari": + ari_endpoint = config.value.get("ari_endpoint", "") + app_name = config.value.get("app_name", "") + app_password = config.value.get("app_password", "") + 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", + ari_endpoint=ari_endpoint, + 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, + ), + ) else: return TelephonyConfigurationResponse() @@ -136,6 +159,7 @@ async def save_telephony_configuration( VonageConfigurationRequest, VobizConfigurationRequest, CloudonixConfigurationRequest, + ARIConfigurationRequest, ], user: UserModel = Depends(get_user), ): @@ -180,6 +204,16 @@ async def save_telephony_configuration( "domain_id": request.domain_id, "from_numbers": request.from_numbers, } + elif request.provider == "ari": + config_value = { + "provider": "ari", + "ari_endpoint": request.ari_endpoint, + "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: raise HTTPException( status_code=400, detail=f"Unsupported provider: {request.provider}" diff --git a/api/routes/stasis_rtp.py b/api/routes/stasis_rtp.py deleted file mode 100644 index 0fede33..0000000 --- a/api/routes/stasis_rtp.py +++ /dev/null @@ -1,45 +0,0 @@ -import random - -from loguru import logger - -from api.db import db_client -from api.enums import WorkflowRunMode -from api.services.pipecat.run_pipeline import run_pipeline_ari_stasis -from api.services.telephony.stasis_rtp_connection import StasisRTPConnection -from pipecat.utils.run_context import set_current_run_id - - -async def on_stasis_call(call: StasisRTPConnection, call_context_vars: dict): - workflow_id = call_context_vars.get("workflow_id") or call_context_vars.get( - "campaign_id" - ) - user_id = call_context_vars.get("user_id") - - assert workflow_id is not None - assert user_id is not None - - try: - workflow_id = int(workflow_id) - user_id = int(user_id) - except ValueError: - logger.error(f"Invalid workflow ID or user ID: {workflow_id} or {user_id}") - return - - workflow_run_name = f"WR-ARI-{random.randint(1000, 9999)}" - workflow_run = await db_client.create_workflow_run( - workflow_run_name, workflow_id, WorkflowRunMode.STASIS.value, user_id - ) - - set_current_run_id(workflow_run.id) - - # Store the workflow_run_id in the connection for later use - call.workflow_run_id = workflow_run.id - - # Connect channelID with Workflow run ID in logs - logger.info( - f"channelID: {call.caller_channel_id} run_id: {workflow_run.id} " - f"Received call for workflow ID {workflow_id}, user ID {user_id}" - ) - await run_pipeline_ari_stasis( - call, workflow_id, workflow_run.id, user_id, call_context_vars - ) diff --git a/api/routes/telephony.py b/api/routes/telephony.py index e93dd60..4020839 100644 --- a/api/routes/telephony.py +++ b/api/routes/telephony.py @@ -8,9 +8,16 @@ import uuid from datetime import UTC, datetime from typing import Optional -from fastapi import APIRouter, Depends, Header, HTTPException, Request, WebSocket +from fastapi import ( + APIRouter, + Depends, + Header, + HTTPException, + Request, + WebSocket, +) from loguru import logger -from pydantic import BaseModel +from pydantic import BaseModel, field_validator from sqlalchemy import text from sqlalchemy.future import select from starlette.responses import HTMLResponse @@ -25,11 +32,17 @@ from api.errors.telephony_errors import TelephonyError from api.services.auth.depends import get_user from api.services.campaign.campaign_call_dispatcher import campaign_call_dispatcher from api.services.campaign.campaign_event_publisher import get_campaign_event_publisher +from api.services.campaign.circuit_breaker import circuit_breaker from api.services.quota_service import check_dograh_quota, check_dograh_quota_by_user_id +from api.services.telephony.call_transfer_manager import get_call_transfer_manager from api.services.telephony.factory import ( get_all_telephony_providers, get_telephony_provider, ) +from api.services.telephony.transfer_event_protocol import ( + TransferEvent, + TransferEventType, +) from api.utils.common import get_backend_endpoints from api.utils.telephony_helper import ( generic_hangup_response, @@ -157,7 +170,8 @@ async def initiate_call( if not phone_number: raise HTTPException( status_code=400, - detail="Phone number must be provided in request or set in user configuration", + detail="Phone number must be provided in request or set in user " + "configuration", ) workflow_run_id = request.workflow_run_id @@ -510,13 +524,47 @@ async def handle_ncco_webhook( return json.loads(response_content) +@router.websocket("/ws/ari") +async def websocket_ari_endpoint(websocket: WebSocket): + """WebSocket endpoint for ARI chan_websocket external media. + + Asterisk connects here via chan_websocket. Routing params are passed as + query params (appended by the v() dial string option in externalMedia). + """ + workflow_id = websocket.query_params.get("workflow_id") + user_id = websocket.query_params.get("user_id") + workflow_run_id = websocket.query_params.get("workflow_run_id") + + if not workflow_id or not user_id or not workflow_run_id: + logger.error( + f"ARI WebSocket missing query params: " + f"workflow_id={workflow_id}, user_id={user_id}, workflow_run_id={workflow_run_id}" + ) + await websocket.close(code=4400, reason="Missing required query params") + return + + # Accept with "media" subprotocol — chan_websocket sends + # Sec-WebSocket-Protocol: media and requires it echoed back. + await websocket.accept(subprotocol="media") + + await _handle_telephony_websocket( + websocket, int(workflow_id), int(user_id), int(workflow_run_id) + ) + + @router.websocket("/ws/{workflow_id}/{user_id}/{workflow_run_id}") async def websocket_endpoint( websocket: WebSocket, workflow_id: int, user_id: int, workflow_run_id: int ): """WebSocket endpoint for real-time call handling - routes to provider-specific handlers.""" await websocket.accept() + await _handle_telephony_websocket(websocket, workflow_id, user_id, workflow_run_id) + +async def _handle_telephony_websocket( + websocket: WebSocket, workflow_id: int, user_id: int, workflow_run_id: int +): + """Shared WebSocket handler logic (connection already accepted).""" try: # Set the run context set_current_run_id(workflow_run_id) @@ -713,6 +761,9 @@ async def _process_status_update(workflow_run_id: int, status: StatusCallbackReq # Release concurrent slot if this was a campaign call 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 + ) # Mark workflow run as completed await db_client.update_workflow_run( @@ -729,6 +780,9 @@ async def _process_status_update(workflow_run_id: int, status: StatusCallbackReq # Release concurrent slot for terminal statuses if this was a campaign call 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=True + ) # Check if retry is needed for campaign calls (busy/no-answer) if status.status in ["busy", "no-answer"] and workflow_run.campaign_id: @@ -1480,3 +1534,210 @@ async def handle_cloudonix_cdr(request: Request): ) return {"status": "success"} + + +class TransferCallRequest(BaseModel): + """Request model for initiating a call transfer.""" + + destination: str # E.164 format phone number (required) + organization_id: int # Organization ID for provider configuration + transfer_id: str # Unique identifier for tracking this transfer + conference_name: str # Conference name for the transfer + timeout: Optional[int] = 20 # seconds to wait for answer + + @field_validator("destination") + @classmethod + def validate_destination(cls, destination: str) -> str: + """Validate destination is in E.164 format.""" + import re + + if not destination or not destination.strip(): + raise ValueError("Destination phone number is required") + + E164_PHONE_REGEX = r"^\+[1-9]\d{1,14}$" + if not re.match(E164_PHONE_REGEX, destination.strip()): + raise ValueError( + f"Invalid phone number format: {destination}. Must be E.164 format (e.g., +1234567890)" + ) + + return destination.strip() + + +@router.post("/call-transfer") +async def initiate_call_transfer(request: TransferCallRequest): + """Initiate a call transfer via the telephony provider. + + This endpoint only initiates the outbound call. Transfer context + (original_call_sid, etc.) is stored by the caller + before invoking this endpoint. + """ + logger.info( + f"Starting call transfer to {request.destination} with transfer_id: {request.transfer_id}" + ) + + try: + try: + provider = await get_telephony_provider(request.organization_id) + except ValueError as e: + logger.error(f"Transfer provider validation failed: {e}") + raise HTTPException( + status_code=400, detail=f"Call transfer not supported: {str(e)}" + ) + + if not provider.supports_transfers(): + raise HTTPException( + status_code=400, + detail=f"Provider '{provider.PROVIDER_NAME}' does not support call transfers", + ) + + if not provider.validate_config(): + logger.error(f"Provider {provider.PROVIDER_NAME} configuration is invalid") + raise HTTPException( + status_code=400, + detail=f"Telephony provider '{provider.PROVIDER_NAME}' is not properly configured for transfers", + ) + + logger.info(f"Initiating transfer call via {provider.PROVIDER_NAME} provider") + try: + transfer_result = await provider.transfer_call( + destination=request.destination, + transfer_id=request.transfer_id, + conference_name=request.conference_name, + timeout=request.timeout, + ) + except NotImplementedError as e: + logger.error( + f"Provider {provider.PROVIDER_NAME} doesn't support transfers: {e}" + ) + raise HTTPException( + status_code=400, + detail=f"Provider '{provider.PROVIDER_NAME}' does not support call transfers", + ) + except Exception as e: + logger.error(f"Provider transfer call failed: {e}") + raise HTTPException( + status_code=500, detail=f"Transfer call failed: {str(e)}" + ) + + call_sid = transfer_result.get("call_sid") + logger.info(f"Transfer call initiated successfully: {call_sid}") + logger.debug(f"Transfer result: {transfer_result}") + + return { + "status": "transfer_initiated", + "call_id": call_sid, + "message": f"Calling {request.destination}...", + "transfer_id": request.transfer_id, + "provider": provider.PROVIDER_NAME, + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Unexpected error during transfer call: {e}") + raise HTTPException( + status_code=500, detail=f"Internal error during transfer: {str(e)}" + ) + + +@router.post("/transfer-result/{transfer_id}") +async def complete_transfer_function_call(transfer_id: str, request: Request): + """Webhook endpoint to complete the function call with transfer result. + + Called by Twilio's StatusCallback when the transfer call status changes. + """ + form_data = await request.form() + data = dict(form_data) + + call_status = data.get("CallStatus", "") + call_sid = data.get("CallSid", "") + + logger.info( + f"Transfer result(call status) webhook: {transfer_id} status={call_status}" + ) + + # Get transfer context from Redis for additional information + call_transfer_manager = await get_call_transfer_manager() + transfer_context = await call_transfer_manager.get_transfer_context(transfer_id) + + original_call_sid = transfer_context.original_call_sid if transfer_context else None + conference_name = transfer_context.conference_name if transfer_context else None + + # Determine the result based on call status with user-friendly messaging + if call_status in ("in-progress", "answered"): + result = { + "status": "success", + "message": "Great! The destination number answered. Let me transfer you now.", + "action": "transfer_success", + "conference_id": conference_name, + "transfer_call_sid": call_sid, # The outbound transfer call SID + "original_call_sid": original_call_sid, # The original caller's SID + "end_call": False, # Continue with transfer + } + elif call_status == "no-answer": + result = { + "status": "transfer_failed", + "reason": "no_answer", + "message": "The transfer call was not answered. The person may be busy or unavailable right now.", + "action": "transfer_failed", + "call_sid": call_sid, + "end_call": True, + } + elif call_status == "busy": + result = { + "status": "transfer_failed", + "reason": "busy", + "message": "The transfer call encountered a busy signal. The person is likely on another call.", + "action": "transfer_failed", + "call_sid": call_sid, + "end_call": True, + } + elif call_status == "failed": + result = { + "status": "transfer_failed", + "reason": "call_failed", + "message": "The transfer call failed to connect. There may be a network issue or the number is unavailable.", + "action": "transfer_failed", + "call_sid": call_sid, + "end_call": True, + } + else: + # Intermediate status (ringing, in-progress, etc.), don't complete yet + logger.info( + f"Received intermediate status {call_status}, waiting for final status" + ) + return {"status": "pending"} + + # Complete the function call with Redis event publishing + try: + # Determine event type based on result status + if result["status"] == "success": + event_type = TransferEventType.TRANSFER_COMPLETED + elif result.get("reason") == "timeout": + event_type = TransferEventType.TRANSFER_TIMEOUT + else: + event_type = TransferEventType.TRANSFER_FAILED + + transfer_event = TransferEvent( + type=event_type, + transfer_id=transfer_id, + original_call_sid=original_call_sid or "", + transfer_call_sid=call_sid, + conference_name=conference_name, + message=result.get("message", ""), + status=result["status"], + action=result.get("action", ""), + reason=result.get("reason"), + end_call=result.get("end_call", False), + ) + + # Publish the event via Redis + await call_transfer_manager.publish_transfer_event(transfer_event) + logger.info( + f"Published {event_type} event for {transfer_id} with result: {result['status']}" + ) + + except Exception as e: + logger.error(f"Error completing transfer {transfer_id}: {e}") + + return {"status": "completed", "result": result} diff --git a/api/routes/tool.py b/api/routes/tool.py index f6ee635..6430b1a 100644 --- a/api/routes/tool.py +++ b/api/routes/tool.py @@ -1,10 +1,11 @@ """API routes for managing tools.""" +import re from datetime import datetime from typing import Annotated, Any, Dict, List, Literal, Optional, Union from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator from api.db import db_client from api.db.models import UserModel @@ -56,6 +57,42 @@ class EndCallConfig(BaseModel): ) +class TransferCallConfig(BaseModel): + """Configuration for Transfer Call tools.""" + + destination: str = Field( + description="Phone number to transfer the call to (E.164 format, e.g., +1234567890)" + ) + messageType: Literal["none", "custom"] = Field( + default="none", description="Type of message to play before transfer" + ) + customMessage: Optional[str] = Field( + default=None, description="Custom message to play before transferring the call" + ) + timeout: int = Field( + default=30, + ge=5, + le=120, + description="Maximum time in seconds to wait for destination to answer (5-120 seconds)", + ) + + @field_validator("destination") + @classmethod + def validate_destination(cls, v: str) -> str: + """Validate that destination is a valid E.164 phone number.""" + # Allow empty string for initial creation (like HTTP API tools with empty URL) + if not v.strip(): + return v + + # E.164 format: +[1-9]\d{1,14} + e164_pattern = r"^\+[1-9]\d{1,14}$" + if not re.match(e164_pattern, v): + raise ValueError( + "Destination must be a valid E.164 phone number (e.g., +1234567890)" + ) + return v + + class HttpApiToolDefinition(BaseModel): """Tool definition for HTTP API tools.""" @@ -72,9 +109,17 @@ class EndCallToolDefinition(BaseModel): config: EndCallConfig = Field(description="End Call configuration") +class TransferCallToolDefinition(BaseModel): + """Tool definition for Transfer Call tools.""" + + schema_version: int = Field(default=1, description="Schema version") + type: Literal["transfer_call"] = Field(description="Tool type") + config: TransferCallConfig = Field(description="Transfer Call configuration") + + # Union type for tool definitions - Pydantic will discriminate based on 'type' field ToolDefinition = Annotated[ - Union[HttpApiToolDefinition, EndCallToolDefinition], + Union[HttpApiToolDefinition, EndCallToolDefinition, TransferCallToolDefinition], Field(discriminator="type"), ] @@ -89,6 +134,17 @@ class CreateToolRequest(BaseModel): icon_color: Optional[str] = Field(default="#3B82F6", max_length=7) definition: ToolDefinition + @field_validator("category") + @classmethod + def validate_category(cls, v: str) -> str: + """Validate that category is a valid ToolCategory value.""" + valid_categories = [c.value for c in ToolCategory] + if v not in valid_categories: + raise ValueError( + f"Invalid category '{v}'. Must be one of: {', '.join(valid_categories)}" + ) + return v + class UpdateToolRequest(BaseModel): """Request schema for updating a tool.""" diff --git a/api/schemas/telephony_config.py b/api/schemas/telephony_config.py index 9bc20ea..da6d605 100644 --- a/api/schemas/telephony_config.py +++ b/api/schemas/telephony_config.py @@ -89,6 +89,42 @@ class CloudonixConfigurationResponse(BaseModel): from_numbers: List[str] +class ARIConfigurationRequest(BaseModel): + """Request schema for Asterisk ARI configuration.""" + + provider: str = Field(default="ari") + ari_endpoint: str = Field( + ..., description="ARI base URL (e.g., http://asterisk.example.com:8088)" + ) + app_name: str = Field( + ..., description="Stasis application name registered in Asterisk" + ) + app_password: str = Field(..., description="ARI user password") + ws_client_name: str = Field( + 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)", + ) + + +class ARIConfigurationResponse(BaseModel): + """Response schema for ARI configuration with masked sensitive fields.""" + + provider: str + ari_endpoint: str + app_name: str + app_password: str # Masked + ws_client_name: str = "" + inbound_workflow_id: Optional[int] = None + from_numbers: List[str] + + class TelephonyConfigurationResponse(BaseModel): """Top-level telephony configuration response.""" @@ -96,3 +132,4 @@ class TelephonyConfigurationResponse(BaseModel): vonage: Optional[VonageConfigurationResponse] = None vobiz: Optional[VobizConfigurationResponse] = None cloudonix: Optional[CloudonixConfigurationResponse] = None + ari: Optional[ARIConfigurationResponse] = None diff --git a/api/services/campaign/campaign_event_protocol.py b/api/services/campaign/campaign_event_protocol.py index 32ad1f9..1e0a416 100644 --- a/api/services/campaign/campaign_event_protocol.py +++ b/api/services/campaign/campaign_event_protocol.py @@ -33,6 +33,9 @@ class CampaignEventType(str, Enum): RETRY_SCHEDULED = "retry_scheduled" RETRY_FAILED = "retry_failed" + # Circuit breaker events + CIRCUIT_BREAKER_TRIPPED = "circuit_breaker_tripped" + class RetryReason(str, Enum): """Reasons for retry.""" @@ -218,6 +221,18 @@ class RetryFailedEvent(BaseCampaignEvent): last_reason: str = "" # RetryReason value +@dataclass +class CircuitBreakerTrippedEvent(BaseCampaignEvent): + """Event sent when the circuit breaker trips and pauses a campaign.""" + + type: str = CampaignEventType.CIRCUIT_BREAKER_TRIPPED + failure_rate: float = 0.0 + failure_count: int = 0 + success_count: int = 0 + threshold: float = 0.0 + window_seconds: int = 0 + + def parse_campaign_event(data: str) -> Any: """Parse a campaign event message.""" try: @@ -239,6 +254,7 @@ def parse_campaign_event(data: str) -> Any: CampaignEventType.RETRY_NEEDED: RetryNeededEvent, CampaignEventType.RETRY_SCHEDULED: RetryScheduledEvent, CampaignEventType.RETRY_FAILED: RetryFailedEvent, + CampaignEventType.CIRCUIT_BREAKER_TRIPPED: CircuitBreakerTrippedEvent, } event_class = event_class_map.get(event_type) diff --git a/api/services/campaign/campaign_event_publisher.py b/api/services/campaign/campaign_event_publisher.py index 4f6438d..3903e95 100644 --- a/api/services/campaign/campaign_event_publisher.py +++ b/api/services/campaign/campaign_event_publisher.py @@ -14,6 +14,7 @@ from api.services.campaign.campaign_event_protocol import ( BatchCompletedEvent, BatchFailedEvent, CampaignCompletedEvent, + CircuitBreakerTrippedEvent, RetryNeededEvent, SyncCompletedEvent, ) @@ -123,6 +124,32 @@ class CampaignEventPublisher: await self.redis.publish(RedisChannel.CAMPAIGN_EVENTS.value, event.to_json()) + async def publish_circuit_breaker_tripped( + self, + campaign_id: int, + failure_rate: float, + failure_count: int, + success_count: int, + threshold: float, + window_seconds: int, + ): + """Publish circuit breaker tripped event.""" + event = CircuitBreakerTrippedEvent( + campaign_id=campaign_id, + failure_rate=failure_rate, + failure_count=failure_count, + success_count=success_count, + threshold=threshold, + window_seconds=window_seconds, + ) + + await self.redis.publish(RedisChannel.CAMPAIGN_EVENTS.value, event.to_json()) + + logger.warning( + f"Published circuit breaker tripped event for campaign {campaign_id}: " + f"failure_rate={failure_rate:.2%} ({failure_count} failures)" + ) + # Global publisher instance with lazy Redis connection async def get_campaign_event_publisher() -> CampaignEventPublisher: diff --git a/api/services/campaign/campaign_orchestrator.py b/api/services/campaign/campaign_orchestrator.py index d701e51..06148c2 100644 --- a/api/services/campaign/campaign_orchestrator.py +++ b/api/services/campaign/campaign_orchestrator.py @@ -14,6 +14,7 @@ import asyncio import signal from datetime import UTC, datetime, timedelta from typing import Dict +from zoneinfo import ZoneInfo import redis.asyncio as aioredis from loguru import logger @@ -25,11 +26,13 @@ from api.enums import RedisChannel from api.services.campaign.campaign_event_protocol import ( BatchCompletedEvent, BatchFailedEvent, + CircuitBreakerTrippedEvent, RetryNeededEvent, SyncCompletedEvent, parse_campaign_event, ) from api.services.campaign.campaign_event_publisher import CampaignEventPublisher +from api.services.campaign.circuit_breaker import circuit_breaker from api.tasks.arq import enqueue_job from api.tasks.function_names import FunctionNames @@ -165,6 +168,14 @@ class CampaignOrchestrator: await self._schedule_next_batch(campaign_id) self._last_activity[campaign_id] = datetime.now(UTC) + elif isinstance(event, CircuitBreakerTrippedEvent): + # Circuit breaker tripped - clear state for this campaign + logger.warning( + f"campaign_id: {campaign_id} - Circuit breaker tripped event received: " + f"failure_rate={event.failure_rate:.2%}" + ) + self._clear_campaign_state(campaign_id) + async def _handle_retry_event(self, event: RetryNeededEvent): """Process retry event and schedule if eligible (from campaign_retry_manager).""" @@ -274,6 +285,53 @@ class CampaignOrchestrator: f"last reason: {reason}" ) + def _is_within_schedule(self, campaign: CampaignModel) -> bool: + """Check if the current time falls within the campaign's schedule windows. + + Returns True (allow scheduling) if: + - No schedule_config in metadata + - Schedule is disabled + - No slots configured + - Invalid timezone (fail open) + - Current time matches a slot + """ + if not campaign.orchestrator_metadata: + return True + + schedule_config = campaign.orchestrator_metadata.get("schedule_config") + if not schedule_config: + return True + + if not schedule_config.get("enabled", False): + return True + + slots = schedule_config.get("slots") + if not slots: + return True + + timezone_str = schedule_config.get("timezone", "UTC") + try: + tz = ZoneInfo(timezone_str) + except (KeyError, Exception): + logger.warning( + f"campaign_id: {campaign.id} - Invalid timezone '{timezone_str}' in schedule_config, " + f"failing open (allowing scheduling)" + ) + return True + + now = datetime.now(tz) + current_day = now.weekday() # 0=Monday through 6=Sunday + current_time = now.strftime("%H:%M") + + for slot in slots: + if slot.get("day_of_week") == current_day: + start = slot.get("start_time", "") + end = slot.get("end_time", "") + if start <= current_time < end: + return True + + return False + async def _schedule_next_batch(self, campaign_id: int): """Schedule next batch immediately if work available.""" @@ -302,6 +360,40 @@ class CampaignOrchestrator: ) return + # Check schedule window before scheduling + if not self._is_within_schedule(campaign): + logger.info( + f"campaign_id: {campaign_id} - Outside scheduled time window, skipping batch" + ) + return + + # Safety net: check circuit breaker before scheduling + cb_config = None + if campaign.orchestrator_metadata: + cb_config = campaign.orchestrator_metadata.get("circuit_breaker") + + is_open, stats = await circuit_breaker.is_circuit_open( + campaign_id=campaign_id, + config=cb_config, + ) + + if is_open and stats: + logger.warning( + f"campaign_id: {campaign_id} - Circuit breaker is open, " + f"pausing campaign. Stats: {stats}" + ) + await db_client.update_campaign(campaign_id=campaign_id, state="paused") + await self.publisher.publish_circuit_breaker_tripped( + campaign_id=campaign_id, + failure_rate=stats["failure_rate"], + failure_count=stats["failure_count"], + success_count=stats["success_count"], + threshold=stats["threshold"], + window_seconds=stats["window_seconds"], + ) + self._clear_campaign_state(campaign_id) + return + # Check for available work (queued runs + due retries) has_work = await self._has_pending_work(campaign_id) @@ -399,6 +491,12 @@ class CampaignOrchestrator: if campaign_id not in self._batch_in_progress: has_work = await self._has_pending_work(campaign_id) if has_work: + if not self._is_within_schedule(campaign): + logger.info( + f"campaign_id: {campaign_id} - Found orphaned work but outside " + f"schedule window, skipping" + ) + continue logger.info( f"campaign_id: {campaign_id} - Found orphaned work (likely new retries), " f"scheduling batch to process" @@ -428,6 +526,12 @@ class CampaignOrchestrator: # Check for any pending work has_work = await self._has_pending_work(campaign_id) if has_work: + # If outside schedule window, don't mark complete — work remains for next window + if not self._is_within_schedule(campaign): + logger.debug( + f"campaign_id: {campaign_id} - Outside schedule window with pending work, " + f"not marking complete" + ) return False # Check in-memory last activity diff --git a/api/services/campaign/circuit_breaker.py b/api/services/campaign/circuit_breaker.py new file mode 100644 index 0000000..7a8f13a --- /dev/null +++ b/api/services/campaign/circuit_breaker.py @@ -0,0 +1,301 @@ +"""Campaign circuit breaker for automatic pause on high failure rates. + +Uses two Redis sorted sets (ZSETs) per campaign — one for failures, one for +successes — as sliding windows. ZCARD gives O(1) counts without iterating +members, keeping the Lua scripts simple. +""" + +import time +from typing import Optional, Tuple + +import redis.asyncio as aioredis +from loguru import logger + +from api.constants import DEFAULT_CIRCUIT_BREAKER_CONFIG, REDIS_URL +from api.db import db_client +from api.services.campaign.campaign_event_publisher import get_campaign_event_publisher + + +class CircuitBreaker: + """Sliding window circuit breaker for campaign call failures.""" + + def __init__(self): + self.redis_client: Optional[aioredis.Redis] = None + + async def _get_redis(self) -> aioredis.Redis: + """Get or create Redis connection.""" + if self.redis_client is None: + self.redis_client = await aioredis.from_url( + REDIS_URL, decode_responses=True + ) + return self.redis_client + + @staticmethod + def _keys(campaign_id: int) -> Tuple[str, str]: + """Return (failures_key, successes_key) for a campaign.""" + return f"cb_failures:{campaign_id}", f"cb_successes:{campaign_id}" + + async def record_call_outcome( + self, + campaign_id: int, + is_failure: bool, + config: Optional[dict] = None, + ) -> Tuple[bool, Optional[dict]]: + """Record a call outcome and check if the circuit breaker should trip. + + Args: + campaign_id: The campaign ID. + is_failure: True if the call failed, False if succeeded. + config: Optional per-campaign circuit breaker config override. + Falls back to DEFAULT_CIRCUIT_BREAKER_CONFIG. + + Returns: + Tuple of (tripped: bool, stats: dict or None). + If tripped is True, stats contains failure_rate, failure_count, + success_count, threshold, window_seconds. + """ + cb_config = {**DEFAULT_CIRCUIT_BREAKER_CONFIG, **(config or {})} + + if not cb_config.get("enabled", True): + return False, None + + redis_client = await self._get_redis() + + window_seconds = cb_config["window_seconds"] + threshold = cb_config["failure_threshold"] + min_calls = cb_config["min_calls_in_window"] + + now = time.time() + window_start = now - window_seconds + + fail_key, succ_key = self._keys(campaign_id) + + lua_script = """ + local fail_key = KEYS[1] + local succ_key = KEYS[2] + local now = tonumber(ARGV[1]) + local window_start = tonumber(ARGV[2]) + local is_failure = tonumber(ARGV[3]) + local threshold = tonumber(ARGV[4]) + local min_calls = tonumber(ARGV[5]) + local ttl = tonumber(ARGV[6]) + + -- Trim both sets to the sliding window + redis.call('ZREMRANGEBYSCORE', fail_key, 0, window_start) + redis.call('ZREMRANGEBYSCORE', succ_key, 0, window_start) + + -- Add the new outcome to the appropriate set + if is_failure == 1 then + redis.call('ZADD', fail_key, now, now) + else + redis.call('ZADD', succ_key, now, now) + end + + -- Refresh TTL on both keys + redis.call('EXPIRE', fail_key, ttl) + redis.call('EXPIRE', succ_key, ttl) + + -- Count via ZCARD (O(1)) + local failures = redis.call('ZCARD', fail_key) + local successes = redis.call('ZCARD', succ_key) + local total = failures + successes + + -- Check trip condition + if total >= min_calls and (failures / total) >= threshold then + return {1, failures, successes, total} + end + + return {0, failures, successes, total} + """ + + try: + result = await redis_client.eval( + lua_script, + 2, + fail_key, + succ_key, + now, + window_start, + 1 if is_failure else 0, + threshold, + min_calls, + window_seconds + 60, # TTL with buffer + ) + + tripped = bool(result[0]) + failure_count = int(result[1]) + success_count = int(result[2]) + total = int(result[3]) + failure_rate = failure_count / total if total > 0 else 0.0 + + if tripped: + logger.warning( + f"Circuit breaker TRIPPED for campaign {campaign_id}: " + f"failure_rate={failure_rate:.2%} ({failure_count}/{total}) " + f"threshold={threshold:.2%} window={window_seconds}s" + ) + + stats = { + "failure_rate": failure_rate, + "failure_count": failure_count, + "success_count": success_count, + "threshold": threshold, + "window_seconds": window_seconds, + } + return tripped, stats + + except Exception as e: + logger.error(f"Circuit breaker error for campaign {campaign_id}: {e}") + # Fail open - do NOT trip on errors + return False, None + + async def is_circuit_open( + self, + campaign_id: int, + config: Optional[dict] = None, + ) -> Tuple[bool, Optional[dict]]: + """Check if the circuit breaker is in open (tripped) state without recording. + + Used as a safety net check before scheduling batches. + """ + cb_config = {**DEFAULT_CIRCUIT_BREAKER_CONFIG, **(config or {})} + + if not cb_config.get("enabled", True): + return False, None + + redis_client = await self._get_redis() + + window_seconds = cb_config["window_seconds"] + threshold = cb_config["failure_threshold"] + min_calls = cb_config["min_calls_in_window"] + + now = time.time() + window_start = now - window_seconds + + fail_key, succ_key = self._keys(campaign_id) + + lua_script = """ + local fail_key = KEYS[1] + local succ_key = KEYS[2] + local window_start = tonumber(ARGV[1]) + local threshold = tonumber(ARGV[2]) + local min_calls = tonumber(ARGV[3]) + + -- Trim both sets + redis.call('ZREMRANGEBYSCORE', fail_key, 0, window_start) + redis.call('ZREMRANGEBYSCORE', succ_key, 0, window_start) + + -- Count via ZCARD + local failures = redis.call('ZCARD', fail_key) + local successes = redis.call('ZCARD', succ_key) + local total = failures + successes + + if total >= min_calls and (failures / total) >= threshold then + return {1, failures, successes, total} + end + + return {0, failures, successes, total} + """ + + try: + result = await redis_client.eval( + lua_script, + 2, + fail_key, + succ_key, + window_start, + threshold, + min_calls, + ) + + is_open = bool(result[0]) + failure_count = int(result[1]) + success_count = int(result[2]) + total = int(result[3]) + failure_rate = failure_count / total if total > 0 else 0.0 + + stats = { + "failure_rate": failure_rate, + "failure_count": failure_count, + "success_count": success_count, + "threshold": threshold, + "window_seconds": window_seconds, + } + return is_open, stats + + except Exception as e: + logger.error(f"Circuit breaker check error for campaign {campaign_id}: {e}") + return False, None + + async def record_and_evaluate(self, campaign_id: int, is_failure: bool) -> None: + """Record a call outcome, and if the breaker trips, pause the campaign. + + This is the main entry point called from telephony status callbacks. + It handles fetching campaign config, recording the outcome, and + pausing + publishing an event if the breaker trips. + + Exceptions are caught internally so this never disrupts the caller. + """ + try: + campaign = await db_client.get_campaign_by_id(campaign_id) + if not campaign or campaign.state != "running": + return + + cb_config = {} + if campaign.orchestrator_metadata: + cb_config = campaign.orchestrator_metadata.get("circuit_breaker", {}) + + tripped, stats = await self.record_call_outcome( + campaign_id=campaign_id, + is_failure=is_failure, + config=cb_config, + ) + + if tripped and stats: + logger.warning( + f"Circuit breaker tripped for campaign {campaign_id}, " + f"pausing campaign. Stats: {stats}" + ) + + await db_client.update_campaign(campaign_id=campaign_id, state="paused") + + publisher = await get_campaign_event_publisher() + await publisher.publish_circuit_breaker_tripped( + campaign_id=campaign_id, + failure_rate=stats["failure_rate"], + failure_count=stats["failure_count"], + success_count=stats["success_count"], + threshold=stats["threshold"], + window_seconds=stats["window_seconds"], + ) + + except Exception as e: + logger.error(f"Error in circuit breaker for campaign {campaign_id}: {e}") + + async def reset(self, campaign_id: int) -> bool: + """Reset the circuit breaker state for a campaign. + + Called when a campaign is resumed to give it a clean slate. + """ + redis_client = await self._get_redis() + fail_key, succ_key = self._keys(campaign_id) + + try: + await redis_client.delete(fail_key, succ_key) + logger.info(f"Circuit breaker reset for campaign {campaign_id}") + return True + except Exception as e: + logger.error( + f"Error resetting circuit breaker for campaign {campaign_id}: {e}" + ) + return False + + async def close(self): + """Close Redis connection.""" + if self.redis_client: + await self.redis_client.close() + self.redis_client = None + + +# Global circuit breaker instance +circuit_breaker = CircuitBreaker() diff --git a/api/services/campaign/runner.py b/api/services/campaign/runner.py index f1b397c..7955599 100644 --- a/api/services/campaign/runner.py +++ b/api/services/campaign/runner.py @@ -4,6 +4,7 @@ from typing import Any, Dict from loguru import logger from api.db import db_client +from api.services.campaign.circuit_breaker import circuit_breaker from api.tasks.arq import enqueue_job from api.tasks.function_names import FunctionNames @@ -67,6 +68,9 @@ class CampaignRunnerService: # stale campaign checker would do that if there are pending work. await db_client.update_campaign(campaign_id=campaign_id, state="running") + # Reset circuit breaker so the resumed campaign starts with a clean slate + await circuit_breaker.reset(campaign_id) + logger.info(f"Campaign {campaign_id} resumed") async def get_campaign_status(self, campaign_id: int) -> Dict[str, Any]: diff --git a/api/services/configuration/registry.py b/api/services/configuration/registry.py index c9e3675..ab28a0d 100644 --- a/api/services/configuration/registry.py +++ b/api/services/configuration/registry.py @@ -278,7 +278,15 @@ class DograhTTSService(BaseTTSConfiguration): SARVAM_TTS_MODELS = ["bulbul:v2", "bulbul:v3"] -SARVAM_V2_VOICES = ["anushka", "manisha", "vidya", "arya", "abhilash", "karun", "hitesh"] +SARVAM_V2_VOICES = [ + "anushka", + "manisha", + "vidya", + "arya", + "abhilash", + "karun", + "hitesh", +] SARVAM_V3_VOICES = [ "shubh", "aditya", diff --git a/api/services/pipecat/audio_config.py b/api/services/pipecat/audio_config.py index 6bb0e8c..0a5bd54 100644 --- a/api/services/pipecat/audio_config.py +++ b/api/services/pipecat/audio_config.py @@ -87,18 +87,18 @@ def create_audio_config(transport_type: str) -> AudioConfig: """Create audio configuration based on transport type. Args: - transport_type: Type of transport ("webrtc", "twilio", "vonage", "vobiz", "cloudonix", "stasis") + transport_type: Type of transport ("webrtc", "twilio", "vonage", "vobiz", "cloudonix") Returns: AudioConfig instance with appropriate settings """ if transport_type in ( - WorkflowRunMode.STASIS.value, WorkflowRunMode.TWILIO.value, WorkflowRunMode.VOBIZ.value, WorkflowRunMode.CLOUDONIX.value, + WorkflowRunMode.ARI.value, ): - # Twilio, Cloudonix, Vobiz, and Stasis use MULAW at 8kHz + # Twilio, Cloudonix, Vobiz, and ARI use MULAW at 8kHz return AudioConfig( transport_in_sample_rate=8000, transport_out_sample_rate=8000, diff --git a/api/services/pipecat/event_handlers.py b/api/services/pipecat/event_handlers.py index fea4a6f..b8d4111 100644 --- a/api/services/pipecat/event_handlers.py +++ b/api/services/pipecat/event_handlers.py @@ -80,6 +80,7 @@ def register_event_handlers( @transport.event_handler("on_client_disconnected") async def on_client_disconnected(_transport, _participant): call_disposed = engine.is_call_disposed() + logger.debug( f"In on_client_disconnected callback handler. Call disposed: {call_disposed}" ) @@ -87,7 +88,6 @@ def register_event_handlers( # Stop recordings await audio_buffer.stop_recording() - # End the call await engine.end_call_with_reason( EndTaskReason.USER_HANGUP.value, abort_immediately=True ) diff --git a/api/services/pipecat/run_pipeline.py b/api/services/pipecat/run_pipeline.py index f0107e9..6939c43 100644 --- a/api/services/pipecat/run_pipeline.py +++ b/api/services/pipecat/run_pipeline.py @@ -32,15 +32,14 @@ from api.services.pipecat.service_factory import ( ) from api.services.pipecat.tracing_config import setup_pipeline_tracing from api.services.pipecat.transport_setup import ( + create_ari_transport, create_cloudonix_transport, - create_stasis_transport, create_twilio_transport, create_vobiz_transport, create_vonage_transport, create_webrtc_transport, ) from api.services.pipecat.ws_sender_registry import get_ws_sender -from api.services.telephony.stasis_rtp_connection import StasisRTPConnection from api.services.workflow.dto import ReactFlowDTO from api.services.workflow.pipecat_engine import PipecatEngine from api.services.workflow.workflow import WorkflowGraph @@ -199,6 +198,63 @@ async def run_pipeline_vonage( raise +async def run_pipeline_ari( + websocket_client: WebSocket, + channel_id: str, + workflow_id: int, + workflow_run_id: int, + user_id: int, +) -> None: + """Run pipeline for Asterisk ARI WebSocket connections. + + ARI uses raw 16-bit signed linear PCM (SLIN16) at 16kHz + transmitted as binary WebSocket frames via chan_websocket. + """ + logger.info(f"Starting ARI pipeline for workflow run {workflow_run_id}") + set_current_run_id(workflow_run_id) + + # Store call ID (channel_id) in cost_info + cost_info = {"call_id": channel_id} + await db_client.update_workflow_run(workflow_run_id, cost_info=cost_info) + + # Get workflow to extract configurations + workflow = await db_client.get_workflow(workflow_id, user_id) + vad_config = None + ambient_noise_config = None + if workflow and workflow.workflow_configurations: + if "vad_configuration" in workflow.workflow_configurations: + vad_config = workflow.workflow_configurations["vad_configuration"] + if "ambient_noise_configuration" in workflow.workflow_configurations: + ambient_noise_config = workflow.workflow_configurations[ + "ambient_noise_configuration" + ] + + try: + audio_config = create_audio_config(WorkflowRunMode.ARI.value) + + transport = await create_ari_transport( + websocket_client, + channel_id, + workflow_run_id, + audio_config, + workflow.organization_id, + vad_config, + ambient_noise_config, + ) + + await _run_pipeline( + transport, + workflow_id, + workflow_run_id, + user_id, + audio_config=audio_config, + ) + + except Exception as e: + logger.error(f"Error in ARI pipeline: {e}") + raise + + async def run_pipeline_vobiz( websocket_client: WebSocket, stream_id: str, @@ -364,52 +420,6 @@ async def run_pipeline_smallwebrtc( ) -async def run_pipeline_ari_stasis( - stasis_connection: StasisRTPConnection, - workflow_id: int, - workflow_run_id: int, - user_id: int, - call_context_vars: dict, -) -> None: - """Run pipeline for ARI connections""" - logger.debug( - f"Running pipeline for ARI connection with workflow_id: {workflow_id} and workflow_run_id: {workflow_run_id}" - ) - set_current_run_id(workflow_run_id) - - # Get workflow to extract all pipeline configurations - workflow = await db_client.get_workflow(workflow_id, user_id) - vad_config = None - ambient_noise_config = None - if workflow and workflow.workflow_configurations: - if "vad_configuration" in workflow.workflow_configurations: - vad_config = workflow.workflow_configurations["vad_configuration"] - if "ambient_noise_configuration" in workflow.workflow_configurations: - ambient_noise_config = workflow.workflow_configurations[ - "ambient_noise_configuration" - ] - - # Create audio configuration for Stasis - audio_config = create_audio_config(WorkflowRunMode.STASIS.value) - - transport = create_stasis_transport( - stasis_connection, - workflow_run_id, - audio_config, - vad_config, - ambient_noise_config, - ) - await _run_pipeline( - transport, - workflow_id, - workflow_run_id, - user_id, - call_context_vars=call_context_vars, - audio_config=audio_config, - stasis_connection=stasis_connection, # Pass connection for immediate transfers - ) - - async def _run_pipeline( transport, workflow_id: int, @@ -417,7 +427,6 @@ async def _run_pipeline( user_id: int, call_context_vars: dict = {}, audio_config: AudioConfig = None, - stasis_connection: Optional[StasisRTPConnection] = None, ) -> None: """ Run the pipeline with the given transport and configuration @@ -552,15 +561,12 @@ async def _run_pipeline( embeddings_base_url=embeddings_base_url, ) - # Create pipeline components with audio configuration + # Create pipeline components audio_buffer, context = create_pipeline_components(audio_config) - # Set the context and audio_buffer after creation + # Set the context, audio_config, and audio_buffer after creation engine.set_context(context) - - # Set Stasis connection for immediate transfers (if available) - if stasis_connection: - engine.set_stasis_connection(stasis_connection) + engine.set_audio_config(audio_config) assistant_params = LLMAssistantAggregatorParams( expect_stripped_words=True, diff --git a/api/services/pipecat/service_factory.py b/api/services/pipecat/service_factory.py index a346252..ee76263 100644 --- a/api/services/pipecat/service_factory.py +++ b/api/services/pipecat/service_factory.py @@ -156,7 +156,7 @@ def create_tts_service(user_config, audio_config: "AudioConfig"): Args: user_config: User configuration containing TTS settings - transport_type: Type of transport (e.g., 'stasis', 'twilio', 'webrtc') + transport_type: Type of transport (e.g., 'twilio', 'webrtc') """ logger.info( f"Creating TTS service: provider={user_config.tts.provider}, model={user_config.tts.model}" diff --git a/api/services/pipecat/transport_setup.py b/api/services/pipecat/transport_setup.py index 6cee7fb..db18380 100644 --- a/api/services/pipecat/transport_setup.py +++ b/api/services/pipecat/transport_setup.py @@ -6,14 +6,9 @@ from api.constants import APP_ROOT_DIR from api.db import db_client from api.enums import OrganizationConfigurationKey from api.services.pipecat.audio_config import AudioConfig -from api.services.telephony.stasis_rtp_connection import StasisRTPConnection -from api.services.telephony.stasis_rtp_serializer import StasisRTPFrameSerializer -from api.services.telephony.stasis_rtp_transport import ( - StasisRTPTransport, - StasisRTPTransportParams, -) from pipecat.audio.mixers.silence_mixer import SilenceAudioMixer from pipecat.audio.mixers.soundfile_mixer import SoundfileMixer +from pipecat.serializers.asterisk import AsteriskFrameSerializer from pipecat.serializers.twilio import TwilioFrameSerializer from pipecat.serializers.vobiz import VobizFrameSerializer from pipecat.serializers.vonage import VonageFrameSerializer @@ -130,6 +125,71 @@ async def create_cloudonix_transport( bearer_token=bearer_token, ) + return FastAPIWebsocketTransport( + websocket=websocket_client, + params=FastAPIWebsocketParams( + audio_in_enabled=True, + audio_out_enabled=True, + audio_in_sample_rate=audio_config.transport_in_sample_rate, + audio_out_sample_rate=audio_config.transport_out_sample_rate, + audio_out_mixer=( + SoundfileMixer( + sound_files={ + "office": APP_ROOT_DIR + / "assets" + / f"office-ambience-{audio_config.transport_out_sample_rate}-mono.wav" + }, + default_sound="office", + volume=ambient_noise_config.get("volume", 0.3), + ) + if ambient_noise_config and ambient_noise_config.get("enabled", False) + else SilenceAudioMixer() + ), + serializer=serializer, + audio_out_10ms_chunks=2, + ), + ) + + +async def create_ari_transport( + websocket_client: WebSocket, + channel_id: str, + workflow_run_id: int, + audio_config: AudioConfig, + organization_id: int, + vad_config: dict | None = None, + ambient_noise_config: dict | None = None, +): + """Create a transport for Asterisk ARI connections""" + + from api.services.telephony.factory import load_telephony_config + + config = await load_telephony_config(organization_id) + + if config.get("provider") != "ari": + raise ValueError(f"Expected ARI provider, got {config.get('provider')}") + + ari_endpoint = config.get("ari_endpoint") + app_name = config.get("app_name") + app_password = config.get("app_password") + + if not ari_endpoint or not app_name or not app_password: + raise ValueError( + f"Incomplete ARI configuration for organization {organization_id}. " + f"Required: ari_endpoint, app_name, app_password" + ) + + serializer = AsteriskFrameSerializer( + channel_id=channel_id, + ari_endpoint=ari_endpoint, + app_name=app_name, + app_password=app_password, + params=AsteriskFrameSerializer.InputParams( + asterisk_sample_rate=audio_config.transport_in_sample_rate, + sample_rate=audio_config.pipeline_sample_rate, + ), + ) + return FastAPIWebsocketTransport( websocket=websocket_client, params=FastAPIWebsocketParams( @@ -344,47 +404,6 @@ def create_webrtc_transport( ) -def create_stasis_transport( - stasis_connection: StasisRTPConnection, - workflow_run_id: int, - audio_config: AudioConfig, - vad_config: dict | None = None, - ambient_noise_config: dict | None = None, -): - """Create a transport for ARI connections""" - - serializer = StasisRTPFrameSerializer( - StasisRTPFrameSerializer.InputParams( - sample_rate=audio_config.transport_in_sample_rate - ) - ) - - return StasisRTPTransport( - stasis_connection, - params=StasisRTPTransportParams( - audio_in_enabled=True, - audio_out_enabled=True, - audio_out_sample_rate=audio_config.transport_out_sample_rate, - audio_in_sample_rate=audio_config.transport_in_sample_rate, - # audio_out_10ms_chunks=2, # ToDo: Check if we cant support 40 ms packets? - audio_out_mixer=( - SoundfileMixer( - sound_files={ - "office": APP_ROOT_DIR - / "assets" - / f"office-ambience-{audio_config.transport_out_sample_rate}-mono.wav" - }, - default_sound="office", - volume=ambient_noise_config.get("volume", 0.3), - ) - if ambient_noise_config and ambient_noise_config.get("enabled", False) - else SilenceAudioMixer() - ), - serializer=serializer, - ), - ) - - def create_internal_transport( workflow_run_id: int, audio_config: AudioConfig, diff --git a/api/services/smart_turn/app.py b/api/services/smart_turn/app.py index 6bbd0ab..66ccf5a 100644 --- a/api/services/smart_turn/app.py +++ b/api/services/smart_turn/app.py @@ -21,9 +21,10 @@ from fastapi import ( status, ) from fastapi.websockets import WebSocketState -from pipecat.audio.turn.smart_turn.local_smart_turn_v2 import LocalSmartTurnAnalyzerV2 from scipy.io import wavfile +from pipecat.audio.turn.smart_turn.local_smart_turn_v2 import LocalSmartTurnAnalyzerV2 + LOG_LEVEL = ( logging.DEBUG if os.environ.get("LOG_LEVEL", "DEBUG").lower() == "debug" diff --git a/api/services/telephony/ari_client.py b/api/services/telephony/ari_client.py deleted file mode 100644 index a2a2a39..0000000 --- a/api/services/telephony/ari_client.py +++ /dev/null @@ -1,765 +0,0 @@ -""" -Dynamic ARI client that generates methods from Swagger/OpenAPI specification. -Pure asyncio implementation without anyio dependencies. -""" - -import asyncio -import json -from dataclasses import dataclass, field -from typing import Callable, Dict, List, Optional -from urllib.parse import urljoin - -import aiohttp -from loguru import logger - - -class SwaggerMethod: - """Represents a Swagger API method.""" - - def __init__( - self, client: "AsyncARIClient", path: str, method: str, operation: dict - ): - self.client = client - self.path = path - self.http_method = method.upper() - self.operation = operation - self.operation_id = operation.get("operationId", "") - self.parameters = operation.get("parameters", []) - self.description = operation.get("description", "") - - def _build_path(self, **kwargs) -> str: - """Build the actual path by substituting path parameters.""" - path = self.path - - # Replace path parameters like {channelId} with actual values - for param in self.parameters: - # Swagger spec uses 'paramType' not 'in' - if param.get("paramType", param.get("in")) == "path": - param_name = param["name"] - if param_name in kwargs: - path = path.replace(f"{{{param_name}}}", str(kwargs[param_name])) - - return path - - def _build_params(self, **kwargs) -> dict: - """Extract query parameters from kwargs.""" - params = {} - - for param in self.parameters: - # Swagger spec uses 'paramType' not 'in' - if param.get("paramType", param.get("in")) == "query": - param_name = param["name"] - if param_name in kwargs: - params[param_name] = kwargs[param_name] - - return params - - def _build_body(self, **kwargs) -> dict: - """Extract body parameters from kwargs.""" - body = {} - - for param in self.parameters: - # Swagger 1.2 uses 'paramType' = 'body' for body parameters - if param.get("paramType", param.get("in")) == "body": - param_name = param["name"] - if param_name in kwargs: - # In Swagger 1.2, body param is usually the whole body - return ( - kwargs[param_name] - if isinstance(kwargs[param_name], dict) - else {param_name: kwargs[param_name]} - ) - - return body - - async def __call__(self, **kwargs): - """Execute the API method.""" - path = self._build_path(**kwargs) - params = self._build_params(**kwargs) - - # Check if there's a body parameter defined in the spec - body_data = self._build_body(**kwargs) - - # If no body param in spec, use remaining kwargs for body (backward compat) - if not body_data: - # Remove path and query parameters from kwargs (leaving body params) - # Swagger spec uses 'paramType' not 'in' - path_param_names = { - p["name"] - for p in self.parameters - if p.get("paramType", p.get("in")) == "path" - } - query_param_names = { - p["name"] - for p in self.parameters - if p.get("paramType", p.get("in")) == "query" - } - body_param_names = { - p["name"] - for p in self.parameters - if p.get("paramType", p.get("in")) == "body" - } - body_data = { - k: v - for k, v in kwargs.items() - if k not in path_param_names - and k not in query_param_names - and k not in body_param_names - } - - # Debug logging for externalMedia - if "externalMedia" in path: - logger.debug( - f"externalMedia call - method: {self.http_method}, path: {path}, params: {params}" - ) - - if self.http_method == "GET": - return await self.client.api_get(path, **params) - elif self.http_method == "POST": - return await self.client.api_post( - path, json_data=body_data if body_data else None, **params - ) - elif self.http_method == "PUT": - return await self.client.api_put( - path, json_data=body_data if body_data else None, **params - ) - elif self.http_method == "DELETE": - return await self.client.api_delete(path, **params) - else: - raise ValueError(f"Unsupported HTTP method: {self.http_method}") - - -class ResourceAPI: - """Represents a resource API (like channels, bridges, etc.).""" - - def __init__(self, client: "AsyncARIClient", resource_name: str): - self.client = client - self.resource_name = resource_name - self._methods = {} - - def add_method(self, method_name: str, swagger_method: SwaggerMethod): - """Add a method to this resource.""" - self._methods[method_name] = swagger_method - - def __getattr__(self, name): - """Dynamically return methods.""" - if name in self._methods: - return self._methods[name] - raise AttributeError(f"'{self.resource_name}' has no method '{name}'") - - -@dataclass -class Channel: - """Channel model with dynamic method support.""" - - id: str - name: str = "" - state: str = "" - caller: Dict[str, str] = field(default_factory=dict) - connected: Dict[str, str] = field(default_factory=dict) - accountcode: str = "" - dialplan: Dict[str, str] = field(default_factory=dict) - creationtime: str = "" - language: str = "en" - - # Store reference to client for method calls - _client: Optional["AsyncARIClient"] = field(default=None, repr=False) - - @classmethod - def from_dict(cls, data: dict, client=None) -> "Channel": - """Create Channel from API response.""" - channel = cls( - id=data.get("id", ""), - name=data.get("name", ""), - state=data.get("state", ""), - caller=data.get("caller", {}), - connected=data.get("connected", {}), - accountcode=data.get("accountcode", ""), - dialplan=data.get("dialplan", {}), - creationtime=data.get("creationtime", ""), - language=data.get("language", "en"), - _client=client, - ) - return channel - - async def continueInDialplan( - self, - context: str = None, - extension: str = None, - priority: int = None, - label: str = None, - ): - """Continue channel in dialplan.""" - if not self._client: - raise RuntimeError("Channel not associated with a client") - - params = {"channelId": self.id} - if context: - params["context"] = context - if extension: - params["extension"] = extension - if priority is not None: - params["priority"] = priority - if label: - params["label"] = label - - # The ARI API method is named 'continueInDialplan' - channels_api = self._client.channels - if hasattr(channels_api, "continueInDialplan"): - await channels_api.continueInDialplan(**params) - else: - # Fallback to direct API call - await self._client.api_post(f"/channels/{self.id}/continue", **params) - - async def hangup(self, reason: str = "normal"): - """Hangup the channel.""" - if not self._client: - raise RuntimeError("Channel not associated with a client") - await self._client.channels.hangup(channelId=self.id, reason=reason) - - async def answer(self): - """Answer the channel.""" - if not self._client: - raise RuntimeError("Channel not associated with a client") - await self._client.channels.answer(channelId=self.id) - - async def getChannelVar(self, variable: str): - """Get a channel variable.""" - if not self._client: - raise RuntimeError("Channel not associated with a client") - return await self._client.channels.getChannelVar( - channelId=self.id, variable=variable - ) - - -@dataclass -class Bridge: - """Bridge model with dynamic method support.""" - - id: str - technology: str = "" - bridge_type: str = "" - bridge_class: str = "" - creator: str = "" - name: str = "" - channels: List[str] = field(default_factory=list) - - _client: Optional["AsyncARIClient"] = field(default=None, repr=False) - - @classmethod - def from_dict(cls, data: dict, client=None) -> "Bridge": - """Create Bridge from API response.""" - return cls( - id=data.get("id", ""), - technology=data.get("technology", ""), - bridge_type=data.get("bridge_type", ""), - bridge_class=data.get("bridge_class", ""), - creator=data.get("creator", ""), - name=data.get("name", ""), - channels=data.get("channels", []), - _client=client, - ) - - async def addChannel(self, channel: str): - """Add channel to bridge.""" - if not self._client: - raise RuntimeError("Bridge not associated with a client") - await self._client.bridges.addChannel(bridgeId=self.id, channel=channel) - - async def removeChannel(self, channel: str): - """Remove channel from bridge.""" - if not self._client: - raise RuntimeError("Bridge not associated with a client") - await self._client.bridges.removeChannel(bridgeId=self.id, channel=channel) - - async def destroy(self): - """Destroy the bridge.""" - if not self._client: - raise RuntimeError("Bridge not associated with a client") - await self._client.bridges.destroy(bridgeId=self.id) - - -class AsyncARIClient: - """ARI client that dynamically generates methods from Swagger spec.""" - - def __init__(self, base_url: str, username: str, password: str, app: str): - self.base_url = base_url.rstrip("/") - self.username = username - self.password = password - self.app = app - - # REST API URL - self.api_url = self.base_url.replace("ws://", "http://").replace( - "wss://", "https://" - ) - - # WebSocket URL - self.ws_url = ( - f"{self.base_url}/ari/events?app={app}&api_key={username}:{password}" - ) - - # Session and WebSocket - self._session: Optional[aiohttp.ClientSession] = None - self._websocket: Optional[aiohttp.ClientWebSocketResponse] = None - self._running = False - - # Event handling - self._event_handlers: Dict[str, List[Callable]] = {} - self._event_queue: asyncio.Queue = asyncio.Queue(maxsize=1000) - - # Resource APIs (will be populated from Swagger) - self.channels: Optional[ResourceAPI] = None - self.bridges: Optional[ResourceAPI] = None - self.endpoints: Optional[ResourceAPI] = None - self.recordings: Optional[ResourceAPI] = None - self.sounds: Optional[ResourceAPI] = None - self.playbacks: Optional[ResourceAPI] = None - self.asterisk: Optional[ResourceAPI] = None - self.applications: Optional[ResourceAPI] = None - self.deviceStates: Optional[ResourceAPI] = None - self.mailboxes: Optional[ResourceAPI] = None - - # Swagger spec cache - self._swagger_spec: Optional[dict] = None - - async def connect(self): - """Connect to ARI and load Swagger spec.""" - # Create HTTP session - auth = aiohttp.BasicAuth(self.username, self.password) - self._session = aiohttp.ClientSession(auth=auth) - - try: - # Load Swagger spec and generate methods - await self._load_swagger_spec() - - # Connect WebSocket - self._websocket = await self._session.ws_connect( - self.ws_url, heartbeat=30, autoping=True - ) - self._running = True - logger.info(f"Connected to ARI at {self.ws_url}") - - except Exception as e: - await self._session.close() - raise Exception(f"Failed to connect to ARI: {e}") - - async def _load_swagger_spec(self): - """Load Swagger spec and generate dynamic methods.""" - spec_loaded = False - try: - # Get Swagger spec from ARI - url = f"{self.api_url}/ari/api-docs/resources.json" - async with self._session.get(url) as resp: - resp.raise_for_status() - resources = await resp.json() - - # Store the spec - self._swagger_spec = resources - - # Create resource APIs - for api_info in resources.get("apis", []): - resource_path = api_info["path"] - - # Fix the path - remove .{format} and add proper prefix - resource_path = resource_path.replace(".{format}", ".json") - - # Load detailed spec for this resource - # The resource_path already contains /api-docs/, so we just need the base URL - url = f"{self.api_url}/ari{resource_path}" - try: - async with self._session.get(url) as resp: - resp.raise_for_status() - spec = await resp.json() - - self._process_swagger_spec(spec) - spec_loaded = True - except Exception as e: - logger.warning(f"Failed to load spec for {resource_path}: {e}") - - if spec_loaded: - logger.info("Loaded Swagger spec and generated dynamic methods") - else: - raise Exception("No individual specs could be loaded") - - except Exception as e: - logger.warning(f"Failed to load Swagger spec, using fallback methods: {e}") - self._create_fallback_methods() - - def _process_swagger_spec(self, spec: dict): - """Process a Swagger spec and create dynamic methods.""" - # basePath is available in spec but not currently used - - for api in spec.get("apis", []): - path = api["path"] - - for operation in api.get("operations", []): - self._create_method_from_operation(path, operation) - - def _create_method_from_operation(self, path: str, operation: dict): - """Create a method from a Swagger operation.""" - # Swagger spec uses 'httpMethod' not 'method' - method = operation.get("httpMethod", operation.get("method", "GET")) - operation_id = operation.get("nickname", "") - - if not operation_id: - return - - # Determine resource from path (e.g., /channels/{channelId} -> channels) - path_parts = path.strip("/").split("/") - if path_parts: - resource_name = path_parts[0] - - # Create resource API if it doesn't exist - if not hasattr(self, resource_name) or getattr(self, resource_name) is None: - setattr(self, resource_name, ResourceAPI(self, resource_name)) - - resource_api = getattr(self, resource_name) - - # Extract method name from operation ID - # e.g., "channels_continue" -> "continue_" - # or "channels_get" -> "get" - method_name = operation_id - if method_name.startswith(resource_name + "_"): - method_name = method_name[len(resource_name) + 1 :] - - # Handle special cases - if method_name == "continue": - method_name = "continue_" # Avoid Python keyword - - # Create and add the method - swagger_method = SwaggerMethod(self, path, method, operation) - resource_api.add_method(method_name, swagger_method) - - def _create_fallback_methods(self): - """Create fallback methods if Swagger spec is not available.""" - # Create basic resource APIs - self.channels = ResourceAPI(self, "channels") - self.bridges = ResourceAPI(self, "bridges") - - # Add essential channel methods - self.channels.add_method( - "get", - SwaggerMethod( - self, - "/channels/{channelId}", - "GET", - { - "operationId": "get", - "parameters": [{"name": "channelId", "in": "path"}], - }, - ), - ) - self.channels.add_method( - "hangup", - SwaggerMethod( - self, - "/channels/{channelId}", - "DELETE", - { - "operationId": "hangup", - "parameters": [ - {"name": "channelId", "in": "path"}, - {"name": "reason", "in": "query"}, - ], - }, - ), - ) - self.channels.add_method( - "answer", - SwaggerMethod( - self, - "/channels/{channelId}/answer", - "POST", - { - "operationId": "answer", - "parameters": [{"name": "channelId", "in": "path"}], - }, - ), - ) - self.channels.add_method( - "continueInDialplan", - SwaggerMethod( - self, - "/channels/{channelId}/continue", - "POST", - { - "operationId": "continueInDialplan", - "parameters": [ - {"name": "channelId", "in": "path"}, - {"name": "context", "in": "query"}, - {"name": "extension", "in": "query"}, - {"name": "priority", "in": "query"}, - {"name": "label", "in": "query"}, - ], - }, - ), - ) - self.channels.add_method( - "externalMedia", - SwaggerMethod( - self, - "/channels/externalMedia", - "POST", - { - "operationId": "externalMedia", - "parameters": [ - {"name": "channelId", "in": "query"}, # Add channelId parameter - {"name": "app", "in": "query"}, - {"name": "external_host", "in": "query"}, - {"name": "format", "in": "query"}, - {"name": "encapsulation", "in": "query"}, - {"name": "transport", "in": "query"}, - {"name": "connection_type", "in": "query"}, - {"name": "direction", "in": "query"}, - ], - }, - ), - ) - self.channels.add_method( - "getChannelVar", - SwaggerMethod( - self, - "/channels/{channelId}/variable", - "GET", - { - "operationId": "getChannelVar", - "parameters": [ - {"name": "channelId", "in": "path"}, - {"name": "variable", "in": "query"}, - ], - }, - ), - ) - - # Add essential bridge methods - self.bridges.add_method( - "get", - SwaggerMethod( - self, - "/bridges/{bridgeId}", - "GET", - { - "operationId": "get", - "parameters": [{"name": "bridgeId", "in": "path"}], - }, - ), - ) - self.bridges.add_method( - "create", - SwaggerMethod( - self, - "/bridges", - "POST", - { - "operationId": "create", - "parameters": [ - {"name": "type", "in": "query"}, - {"name": "name", "in": "query"}, - ], - }, - ), - ) - self.bridges.add_method( - "addChannel", - SwaggerMethod( - self, - "/bridges/{bridgeId}/addChannel", - "POST", - { - "operationId": "addChannel", - "parameters": [ - {"name": "bridgeId", "in": "path"}, - {"name": "channel", "in": "query"}, - ], - }, - ), - ) - self.bridges.add_method( - "removeChannel", - SwaggerMethod( - self, - "/bridges/{bridgeId}/removeChannel", - "POST", - { - "operationId": "removeChannel", - "parameters": [ - {"name": "bridgeId", "in": "path"}, - {"name": "channel", "in": "query"}, - ], - }, - ), - ) - self.bridges.add_method( - "destroy", - SwaggerMethod( - self, - "/bridges/{bridgeId}", - "DELETE", - { - "operationId": "destroy", - "parameters": [{"name": "bridgeId", "in": "path"}], - }, - ), - ) - - async def disconnect(self): - """Disconnect from ARI.""" - self._running = False - - if self._websocket: - await self._websocket.close() - - if self._session: - await self._session.close() - - async def run(self): - """Main event loop.""" - if not self._websocket: - raise RuntimeError("Not connected") - - processor_task = asyncio.create_task(self._process_events()) - - try: - async for msg in self._websocket: - if msg.type == aiohttp.WSMsgType.TEXT: - try: - event = json.loads(msg.data) - # Wrap channel/bridge objects - if "channel" in event and isinstance(event["channel"], dict): - event["channel"] = Channel.from_dict(event["channel"], self) - if "bridge" in event and isinstance(event["bridge"], dict): - event["bridge"] = Bridge.from_dict(event["bridge"], self) - await self._event_queue.put(event) - except json.JSONDecodeError: - logger.error(f"Invalid JSON: {msg.data}") - - elif msg.type == aiohttp.WSMsgType.ERROR: - logger.error(f"WebSocket error: {self._websocket.exception()}") - break - - elif msg.type == aiohttp.WSMsgType.CLOSED: - logger.info("WebSocket closed") - break - - finally: - self._running = False - processor_task.cancel() - await asyncio.gather(processor_task, return_exceptions=True) - - async def _process_events(self): - """Process events from queue.""" - while self._running: - try: - event = await asyncio.wait_for(self._event_queue.get(), timeout=1.0) - event_type = event.get("type") - if event_type: - await self._dispatch_event(event_type, event) - except asyncio.TimeoutError: - continue - except asyncio.CancelledError: - break - except Exception as e: - logger.error(f"Error processing event: {e}") - - async def _dispatch_event(self, event_type: str, event: dict): - """Dispatch event to handlers.""" - handlers = self._event_handlers.get(event_type, []) - if handlers: - logger.debug( - f"AsyncARIClient: Dispatching {event_type} to {len(handlers)} handlers" - ) - for i, handler in enumerate(handlers): - try: - logger.debug( - f" AsyncARIClient: Calling {event_type} handler {i + 1}/{len(handlers)}" - ) - await handler(event) - except Exception as e: - logger.error(f"Handler {i + 1} error for {event_type}: {e}") - - def on_event(self, event_type: str, handler: Callable): - """Register event handler.""" - if event_type not in self._event_handlers: - self._event_handlers[event_type] = [] - logger.debug( - f"AsyncARIClient: Registering handler for {event_type}. Current count: {len(self._event_handlers.get(event_type, []))}" - ) - self._event_handlers[event_type].append(handler) - logger.debug( - f"AsyncARIClient: After registration, {event_type} handler count: {len(self._event_handlers[event_type])}" - ) - - # REST API methods - async def api_get(self, path: str, **params) -> dict: - """GET request.""" - # Ensure path starts with /ari if not already - if not path.startswith("/ari"): - path = f"/ari{path}" if path.startswith("/") else f"/ari/{path}" - url = urljoin(self.api_url, path.lstrip("/")) - async with self._session.get(url, params=params) as resp: - resp.raise_for_status() - data = await resp.json() - # Wrap known objects - if isinstance(data, list): - # Handle lists of channels/bridges - if "/channels" in path: - return [ - Channel.from_dict(item, self) - if isinstance(item, dict) - else item - for item in data - ] - elif "/bridges" in path: - return [ - Bridge.from_dict(item, self) if isinstance(item, dict) else item - for item in data - ] - return data - elif isinstance(data, dict): - if "/channels/" in path and "id" in data: - return Channel.from_dict(data, self) - elif "/bridges/" in path and "id" in data: - return Bridge.from_dict(data, self) - return data - - async def api_post(self, path: str, json_data: dict = None, **params) -> dict: - """POST request.""" - # Ensure path starts with /ari if not already - if not path.startswith("/ari"): - path = f"/ari{path}" if path.startswith("/") else f"/ari/{path}" - url = urljoin(self.api_url, path.lstrip("/")) - async with self._session.post(url, json=json_data, params=params) as resp: - resp.raise_for_status() - if resp.content_length and resp.content_length > 0: - data = await resp.json() - # Wrap known objects - if "id" in data and "state" in data: - return Channel.from_dict(data, self) - elif "id" in data and "bridge_type" in data: - return Bridge.from_dict(data, self) - return data - return {} - - async def api_put(self, path: str, json_data: dict = None, **params) -> dict: - """PUT request.""" - # Ensure path starts with /ari if not already - if not path.startswith("/ari"): - path = f"/ari{path}" if path.startswith("/") else f"/ari/{path}" - url = urljoin(self.api_url, path.lstrip("/")) - async with self._session.put(url, json=json_data, params=params) as resp: - resp.raise_for_status() - if resp.content_length and resp.content_length > 0: - return await resp.json() - return {} - - async def api_delete(self, path: str, **params) -> dict: - """DELETE request.""" - # Ensure path starts with /ari if not already - if not path.startswith("/ari"): - path = f"/ari{path}" if path.startswith("/") else f"/ari/{path}" - url = urljoin(self.api_url, path.lstrip("/")) - async with self._session.delete(url, params=params) as resp: - resp.raise_for_status() - if resp.content_length and resp.content_length > 0: - return await resp.json() - return {} diff --git a/api/services/telephony/ari_client_manager.py b/api/services/telephony/ari_client_manager.py deleted file mode 100644 index c47fcd4..0000000 --- a/api/services/telephony/ari_client_manager.py +++ /dev/null @@ -1,437 +0,0 @@ -""" -ARI Client Manager using the new Async ARI Client. -Drop-in replacement for the existing ari_client_manager.py. -""" - -import asyncio -import json -import os -import random -import time -from typing import Awaitable, Callable, Optional - -import httpx -from loguru import logger - -from api.services.telephony.ari_client import AsyncARIClient, Channel -from api.services.telephony.ari_client_singleton import ari_client_singleton - - -class ARIClientManager: - """Manages ARI client connection and event handling. - - This is a compatibility wrapper around AsyncARIClient. - """ - - def __init__( - self, - ari_client: AsyncARIClient, - app_endpoint: str, - _conn_ctx=None, # Not used with AsyncARIClient - ): - """Initialize the ARI client manager. - - Parameters - ---------- - ari_client: AsyncARIClient - The connected ARI client. - app_endpoint: str - The app endpoint for external media. - _conn_ctx: - Not used, kept for compatibility. - """ - self._ari_client = ari_client - self._app_endpoint = app_endpoint - self._conn_ctx = _conn_ctx # Not used but kept for compatibility - self._start_handlers = [] - self._end_handlers = [] - self._running = False - self._handlers_registered = False # Track if handlers are registered - - def register_start_handler( - self, handler: Callable[[Channel, dict], Awaitable[None]] - ): - """Register a handler for StasisStart events.""" - logger.debug( - f"Registering start handler. Current count: {len(self._start_handlers)}" - ) - self._start_handlers.append(handler) - logger.debug(f"After registration, handler count: {len(self._start_handlers)}") - - def register_end_handler(self, handler: Callable[[str], Awaitable[None]]): - """Register a handler for StasisEnd events.""" - self._end_handlers.append(handler) - - async def update_client(self, new_client: AsyncARIClient, new_conn_ctx=None): - """Update to a new client (for reconnection).""" - logger.info("Updating ARI client for reconnection") - self._ari_client = new_client - self._conn_ctx = new_conn_ctx - # Clear old event handlers from the client before re-registering - # to prevent duplicate handler registrations - if hasattr(new_client, "_event_handlers"): - new_client._event_handlers.clear() - # Re-register event handlers - self._register_handlers() - - def _register_handlers(self): - """Register event handlers with the client.""" - logger.debug( - f"_register_handlers called. Start handlers count: {len(self._start_handlers)}, End handlers count: {len(self._end_handlers)}" - ) - - async def on_stasis_start(event): - """Handle StasisStart events.""" - channel = event.get("channel") - - # Only handle PJSIP and SIP channels - if channel and hasattr(channel, "name"): - if not ( - channel.name.startswith("PJSIP") or channel.name.startswith("SIP") - ): - logger.debug( - f"Ignoring StasisStart for non-SIP channel: {channel.name}" - ) - return - - # Log the event - logger.info( - f"StasisStart event for channel: {channel.id if channel else 'unknown'}" - ) - - # Extract call context variables - call_context_vars = {} - try: - # Get channel variables - var_result = await channel.getChannelVar( - variable="LOCAL_ARI_CALL_VARIABLES" - ) - call_context_vars = json.loads(var_result.get("value", "{}")) - - # Try to get phone number and fetch additional data - phone_number = call_context_vars.get("phone") - ari_data_uri = os.getenv("ARI_DATA_FETCHING_URI") - - if phone_number and ari_data_uri: - try: - start_time = time.time() - fetch_url = f"{ari_data_uri}{phone_number}" - - async with httpx.AsyncClient() as client: - response = await client.get(fetch_url, timeout=10.0) - response.raise_for_status() - - # Parse the response - get the latest line if multiple lines - response_text = response.text.strip() - if response_text: - lines = response_text.split("\n") - latest_line = lines[-1].strip() - - if latest_line: - # Parse the pipe-delimited data - fields = latest_line.split("|") - field_names = [ - "status", - "user", - "vendor_lead_code", - "source_id", - "list_id", - "gmt_offset_now", - "phone_code", - "phone_number", - "title", - "first_name", - "middle_initial", - "last_name", - "address1", - "address2", - "address3", - "city", - "state", - "province", - "postal_code", - "country_code", - "gender", - "date_of_birth", - "alt_phone", - "email", - "security_phrase", - "comments", - "called_count", - "last_local_call_time", - "rank", - "owner", - "entry_list_id", - "lead_id", - ] - - # Map fields to call_context_vars - for i, field_name in enumerate(field_names): - try: - call_context_vars[field_name] = fields[i] - except IndexError: - logger.error( - f"channelID: {channel.id} IndexError while accessing fields {i}" - ) - - elapsed_time = time.time() - start_time - logger.info( - f"channelID: {channel.id} Successfully fetched user details for phone: {phone_number} in {elapsed_time:.3f} seconds" - ) - - except Exception as e: - elapsed_time = time.time() - start_time - logger.error( - f"channelID: {channel.id} Failed to fetch user details from ARI_DATA_FETCHING_URI after {elapsed_time:.3f} seconds: {e}" - ) - - logger.debug( - f"channelID: {channel.id} call context variables: {call_context_vars}" - ) - - except ( - KeyError, - AttributeError, - httpx.HTTPStatusError, - json.JSONDecodeError, - ) as e: - logger.debug(f"could not find variable LOCAL_ARI_CALL_VARIABLES: {e}") - - # Call all registered handlers with call_context_vars - logger.debug( - f"Calling {len(self._start_handlers)} start handlers for channel {channel.id}" - ) - for i, handler in enumerate(self._start_handlers): - try: - logger.debug( - f" Calling start handler {i + 1}/{len(self._start_handlers)}" - ) - await handler(channel, call_context_vars) - except Exception as e: - logger.error(f"Error in StasisStart handler {i + 1}: {e}") - - async def on_stasis_end(event): - """Handle StasisEnd events.""" - channel = event.get("channel", {}) - channel_id = channel.id if hasattr(channel, "id") else channel.get("id", "") - - # # Only handle PJSIP and SIP channels - # if channel: - # channel_name = channel.name if hasattr(channel, 'name') else channel.get("name", "") - # if channel_name and not (channel_name.startswith("PJSIP") or channel_name.startswith("SIP")): - # logger.debug(f"Ignoring StasisEnd for non-SIP channel: {channel_name}") - # return - - logger.info(f"StasisEnd event for channel: {channel_id}") - - # Call all registered handlers - for handler in self._end_handlers: - try: - await handler(channel_id) - except Exception as e: - logger.error(f"Error in StasisEnd handler: {e}") - - # Register with the AsyncARIClient - logger.debug(f"Registering StasisStart and StasisEnd with AsyncARIClient") - self._ari_client.on_event("StasisStart", on_stasis_start) - self._ari_client.on_event("StasisEnd", on_stasis_end) - logger.debug(f"Event handlers registered with client") - - async def run(self): - """Run the event loop. - - The actual WebSocket handling is done by AsyncARIClient. - This just registers handlers and waits. - """ - logger.debug("Running ARIClientManager") - self._running = True - # Register handlers only once, on first run - if not self._handlers_registered: - self._register_handlers() - self._handlers_registered = True - - try: - # The AsyncARIClient.run() method handles WebSocket - # We don't call it here as it's called by the supervisor - while self._running: - await asyncio.sleep(1) - except asyncio.CancelledError: - logger.debug(f"ARIClientManager run cancelled") - self._running = False - raise - finally: - self._running = False - - -class _ARIClientManagerSupervisor: - """Supervisor that maintains ARI connection with automatic reconnection. - - This replaces the asyncari-based supervisor with AsyncARIClient. - """ - - # Reconnection parameters - _INITIAL_BACKOFF = 1 # Start with 1 second - _MAX_BACKOFF = 60 # Max 60 seconds between retries - - def __init__( - self, - on_channel_start: Callable[[Channel, dict], Awaitable[None]], - on_channel_end: Optional[Callable[[str], Awaitable[None]]] = None, - ): - self._on_channel_start = on_channel_start - self._on_channel_end = on_channel_end - self._shutting_down = False - - async def start(self): - """Start the supervisor and maintain connection.""" - await self._runner() - - async def stop(self): - """Stop the supervisor.""" - logger.info("Stopping ARI Client Manager Supervisor") - self._shutting_down = True - - async def __aenter__(self): - """Async context manager entry.""" - asyncio.create_task(self.start()) - return self - - async def __aexit__(self, *args): - """Async context manager exit.""" - await self.stop() - - async def _runner(self): - """Main reconnection loop using AsyncARIClient.""" - backoff = self._INITIAL_BACKOFF - ari_client_manager: Optional[ARIClientManager] = None - - while not self._shutting_down: - client = None - - try: - logger.debug("Going to connect with ARI") - - # Get configuration from environment - base_url = os.getenv("ARI_STASIS_ENDPOINT") - username = os.getenv("ARI_STASIS_USER") - password = os.getenv("ARI_STASIS_USER_PASSWORD") - app = os.getenv("ARI_STASIS_APP_NAME") - - # Convert HTTP to WebSocket URL - ws_url = base_url.replace("http://", "ws://").replace( - "https://", "wss://" - ) - - # Create and connect the AsyncARIClient - client = AsyncARIClient(ws_url, username, password, app) - await client.connect() - - # Update the singleton with the new client - ari_client_singleton.set_client(client) - - if ari_client_manager is None: - # First connection - create new manager - logger.debug("Creating new ARIClientManager (first connection)") - ari_client_manager = ARIClientManager( - client, - os.getenv("ARI_STASIS_APP_ENDPOINT"), - _conn_ctx=None, # Not needed with AsyncARIClient - ) - logger.debug(f"Registering handlers with new manager") - ari_client_manager.register_start_handler(self._on_channel_start) - if self._on_channel_end: - ari_client_manager.register_end_handler(self._on_channel_end) - else: - # Reconnection - update existing manager - logger.debug("Updating existing ARIClientManager (reconnection)") - # Don't re-register start and end handlers as they're already registered - await ari_client_manager.update_client(client, None) - - logger.info("Connected to ARI — supervisor entering event loop") - - # Reset backoff after successful connection - backoff = self._INITIAL_BACKOFF - - # Create tasks for both the client and manager - client_task = asyncio.create_task(client.run()) - manager_task = asyncio.create_task(ari_client_manager.run()) - - # Wait for either to complete (likely due to disconnection) - done, pending = await asyncio.wait( - {client_task, manager_task}, return_when=asyncio.FIRST_COMPLETED - ) - - # Cancel the other task - for task in pending: - task.cancel() - try: - await task - except asyncio.CancelledError: - pass - - except asyncio.CancelledError: - # Check if we're shutting down - if self._shutting_down or asyncio.current_task().cancelled(): - logger.debug("ARI supervisor task cancelled — shutting down") - break - - # Otherwise it's a transient connection error - logger.warning("ARI connection lost due to CancelledError — will retry") - - # Force a context switch to reset event loop state - await asyncio.sleep(0) - - except Exception as exc: - # Check if we're shutting down - if self._shutting_down or asyncio.current_task().cancelled(): - logger.warning("Exiting due to shutdown during exception handling") - break - - # Log and retry - logger.warning(f"ARI connection failed or lost: {exc!r} - will retry") - - finally: - # Disconnect client if connected - if client: - try: - await client.disconnect() - except Exception as e: - logger.warning(f"Error disconnecting client: {e}") - # Clear the singleton when disconnecting - ari_client_singleton.clear() - - # Check if we're shutting down before sleeping - if self._shutting_down: - logger.debug("Exiting reconnection loop due to shutdown") - break - - # Exponential back-off with jitter before the next attempt - jitter = random.uniform(0.1, backoff) - logger.debug(f"Waiting {jitter:.1f} seconds before reconnecting...") - - # Sleep with proper event loop handling - await asyncio.sleep(0) # Yield control first - await asyncio.sleep(jitter) - - logger.debug(f"Finished sleeping for {jitter} seconds") - backoff = min(backoff * 2, self._MAX_BACKOFF) - logger.debug(f"New backoff value: {backoff}, continuing loop...") - - -async def setup_ari_client_supervisor( - on_channel_start: Callable[[Channel, dict], Awaitable[None]], - on_channel_end: Callable[[str], Awaitable[None]] | None = None, -) -> "_ARIClientManagerSupervisor | None": - """Start a background supervisor that keeps the ARI connection alive. - - This is a drop-in replacement for the asyncari-based function. - Uses AsyncARIClient instead of asyncari. - """ - logger.info("Starting ARI Client Supervisor with AsyncARIClient") - - supervisor = _ARIClientManagerSupervisor(on_channel_start, on_channel_end) - - # Start the supervisor in the background - asyncio.create_task(supervisor.start()) - - return supervisor diff --git a/api/services/telephony/ari_client_singleton.py b/api/services/telephony/ari_client_singleton.py deleted file mode 100644 index 8ebc5fe..0000000 --- a/api/services/telephony/ari_client_singleton.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Singleton holder for the current ARI client instance. - -This module provides a thread-safe singleton that holds the current -ARI client instance, which can be updated during reconnections. -""" - -from typing import Optional - -from loguru import logger - -from api.services.telephony.ari_client import AsyncARIClient - - -class ARIClientSingleton: - """Singleton holder for the current ARI client instance.""" - - _instance: Optional["ARIClientSingleton"] = None - _client: Optional[AsyncARIClient] = None - - def __new__(cls): - """Ensure only one instance exists.""" - if cls._instance is None: - cls._instance = super().__new__(cls) - return cls._instance - - def set_client(self, client: AsyncARIClient) -> None: - """Update the ARI client instance. - - Args: - client: The new ARI client instance. - """ - self._client = client - logger.info("ARI client singleton updated with new client instance") - - def get_client(self) -> Optional[AsyncARIClient]: - """Get the current ARI client instance. - - Returns: - The current ARI client, or None if not set. - """ - return self._client - - def clear(self) -> None: - """Clear the current client instance.""" - self._client = None - logger.info("ARI client singleton cleared") - - -# Global singleton instance -ari_client_singleton = ARIClientSingleton() diff --git a/api/services/telephony/ari_manager.py b/api/services/telephony/ari_manager.py index 78e4ed3..a033c5a 100644 --- a/api/services/telephony/ari_manager.py +++ b/api/services/telephony/ari_manager.py @@ -1,749 +1,840 @@ -"""Standalone ARI Manager Service for distributed architecture. - -This service maintains the single WebSocket connection to Asterisk ARI -and distributes events to multiple FastAPI workers via Redis pub/sub. - -ARIManager creates an instance of ARIClientSupervisor and registers the callbacks -on_channel_start and on_channel_end. It is responsible to take in caller_channel -and setup ARIManagerConnection, i.e create bridge for externalMedia. +"""ARI WebSocket Event Listener Manager. +Standalone process that: +1. Queries the database for all organizations with ARI telephony configuration +2. Creates WebSocket connections to each ARI instance +3. Handles reconnection logic with exponential backoff +4. Processes StasisStart/StasisEnd events +5. Periodically refreshes configuration to detect new/removed organizations """ -import asyncio -import json -import os -import signal -import time -from typing import Dict, Optional - -from api.constants import ENABLE_ARI_STASIS, REDIS_URL - -# --- Add logging setup before importing loguru --- from api.logging_config import setup_logging -from api.services.telephony.stasis_event_protocol import ( - BaseWorkerToARIManagerCommand, - DisconnectCommand, - RedisChannels, - RedisKeys, - SocketClosedCommand, - StasisEndEvent, - StasisStartEvent, - TransferCommand, - parse_command, -) setup_logging() +import asyncio +import json +import signal +from typing import Dict, Optional, Set +from urllib.parse import urlparse +import aiohttp import redis.asyncio as aioredis -import redis.exceptions +import websockets from loguru import logger -from api.services.telephony.ari_client import Channel -from api.services.telephony.ari_client_manager import ( - ARIClientManager, - setup_ari_client_supervisor, -) -from api.services.telephony.ari_manager_connection import ARIManagerConnection -from pipecat.utils.enums import EndTaskReason +from api.constants import REDIS_URL +from api.db import db_client +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 + + +class ARIConnection: + """Manages a single ARI WebSocket connection for an organization.""" + + def __init__( + self, + organization_id: int, + ari_endpoint: str, + 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 + self._running = False + self._reconnect_delay = 1 # Start with 1 second + self._max_reconnect_delay = 300 # Max 300 seconds + self._ping_interval = 30 # Send ping every 30 seconds + + # Redis client for channel-to-run reverse mapping (lazy init) + self._redis_client: Optional[aioredis.Redis] = None + + async def _get_redis(self) -> aioredis.Redis: + """Get Redis client instance (lazy init).""" + if not self._redis_client: + self._redis_client = await aioredis.from_url( + REDIS_URL, decode_responses=True + ) + return self._redis_client + + async def _set_channel_run(self, channel_id: str, workflow_run_id: str): + """Store channel_id -> workflow_run_id mapping in Redis.""" + r = await self._get_redis() + await r.set( + f"{_CHANNEL_KEY_PREFIX}{channel_id}", + workflow_run_id, + ex=_CHANNEL_KEY_TTL, + ) + + async def _get_channel_run(self, channel_id: str) -> Optional[str]: + """Look up workflow_run_id for a channel_id from Redis.""" + r = await self._get_redis() + return await r.get(f"{_CHANNEL_KEY_PREFIX}{channel_id}") + + async def _delete_channel_run(self, *channel_ids: str): + """Delete channel-to-run mapping(s) from Redis.""" + if not channel_ids: + return + r = await self._get_redis() + 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.""" + parsed = urlparse(self.ari_endpoint) + ws_scheme = "wss" if parsed.scheme == "https" else "ws" + return ( + f"{ws_scheme}://{parsed.netloc}/ari/events" + f"?api_key={self.app_name}:{self.app_password}" + f"&app={self.app_name}" + f"&subscribeAll=true" + ) + + @property + def connection_key(self) -> str: + """Unique key for this connection based on config.""" + return f"{self.organization_id}:{self.ari_endpoint}:{self.app_name}" + + async def start(self): + """Start the WebSocket connection in a background task.""" + if self._running: + return + self._running = True + self._task = asyncio.create_task(self._connection_loop()) + logger.info( + f"[ARI org={self.organization_id}] Started connection to {self.ari_endpoint}" + ) + + async def stop(self): + """Stop the WebSocket connection.""" + self._running = False + if self._ws: + await self._ws.close() + if self._task and not self._task.done(): + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + logger.info( + f"[ARI org={self.organization_id}] Stopped connection to {self.ari_endpoint}" + ) + + async def _connection_loop(self): + """Main connection loop with reconnection logic.""" + while self._running: + try: + await self._connect_and_listen() + except asyncio.CancelledError: + break + except Exception as e: + if not self._running: + break + logger.warning( + f"[ARI org={self.organization_id}] Connection error: {e}. " + f"Reconnecting in {self._reconnect_delay}s..." + ) + await asyncio.sleep(self._reconnect_delay) + # Exponential backoff + self._reconnect_delay = min( + self._reconnect_delay * 2, self._max_reconnect_delay + ) + + async def _connect_and_listen(self): + """Establish WebSocket connection and listen for events.""" + ws_url = self.ws_url + logger.info( + f"[ARI org={self.organization_id}] Connecting to {self.ari_endpoint}..." + ) + + async for ws in websockets.connect( + ws_url, + ping_interval=self._ping_interval, + ping_timeout=10, + close_timeout=5, + ): + try: + self._ws = ws + + # Reset reconnect delay on successful connection + self._reconnect_delay = 1 + + logger.info( + f"[ARI org={self.organization_id}] WebSocket connected to {self.ari_endpoint}" + ) + + async for message in ws: + if not self._running: + return + + if isinstance(message, str): + await self._handle_event(message) + else: + logger.debug( + f"[ARI org={self.organization_id}] Received binary message, ignoring" + ) + + except websockets.ConnectionClosed as e: + if not self._running: + return + logger.warning( + f"[ARI org={self.organization_id}] WebSocket closed: " + f"code={e.code}, reason={e.reason}. Reconnecting..." + ) + continue + finally: + self._ws = None + + async def _handle_event(self, raw_data: str): + """Handle an ARI WebSocket event.""" + try: + event = json.loads(raw_data) + except json.JSONDecodeError: + logger.warning( + f"[ARI org={self.organization_id}] Invalid JSON: {raw_data[:200]}" + ) + return + + event_type = event.get("type", "unknown") + channel = event.get("channel", {}) + channel_id = channel.get("id", "unknown") + 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" + ) + return + + app_args = event.get("args", []) + caller = channel.get("caller", {}) + logger.info( + f"[ARI org={self.organization_id}] StasisStart: " + f"channel={channel_id}, state={channel_state}, " + f"caller={caller.get('number', 'unknown')}, " + f"args={app_args}" + ) + + 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) + ) + 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() + + 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( + f"[ARI org={self.organization_id}] StasisEnd: channel={channel_id}" + ) + workflow_run_id = await self._get_channel_run(channel_id) + if workflow_run_id: + asyncio.create_task( + self._handle_stasis_end(channel_id, workflow_run_id) + ) + + elif event_type == "ChannelStateChange": + logger.debug( + f"[ARI org={self.organization_id}] ChannelStateChange: " + f"channel={channel_id}, state={channel_state}" + ) + + elif event_type == "ChannelDestroyed": + cause = channel.get("cause", 0) + cause_txt = channel.get("cause_txt", "unknown") + logger.info( + f"[ARI org={self.organization_id}] ChannelDestroyed: " + f"channel={channel_id}, cause={cause} ({cause_txt})" + ) + + elif event_type == "ChannelDtmfReceived": + digit = event.get("digit", "") + logger.debug( + f"[ARI org={self.organization_id}] DTMF: " + f"channel={channel_id}, digit={digit}" + ) + + else: + logger.debug( + f"[ARI org={self.organization_id}] Event: {event_type} " + f"channel={channel_id}" + ) + + async def _ari_request(self, method: str, path: str, **kwargs) -> dict: + """Make an ARI REST API request.""" + + url = f"{self.ari_endpoint}/ari{path}" + auth = aiohttp.BasicAuth(self.app_name, self.app_password) + + async with aiohttp.ClientSession() as session: + async with session.request(method, url, auth=auth, **kwargs) as response: + response_text = await response.text() + if response.status not in (200, 201, 204): + logger.error( + f"[ARI org={self.organization_id}] REST API error: " + f"{method} {path} -> {response.status}: {response_text}" + ) + return {} + if response_text: + return json.loads(response_text) + return {} + + async def _answer_channel(self, channel_id: str) -> bool: + """Answer an ARI channel.""" + await self._ari_request("POST", f"/channels/{channel_id}/answer") + # answer returns 204 No Content on success, so empty dict is OK + logger.info(f"[ARI org={self.organization_id}] Answered channel {channel_id}") + return True + + async def _create_external_media( + self, + workflow_id: str, + user_id: str, + workflow_run_id: str, + ) -> str: + """Create an external media channel via chan_websocket. + + Uses ARI externalMedia with transport=websocket so Asterisk connects + to our backend over WebSocket (via websocket_client.conf). + Dynamic routing params are passed as URI query params via v() in transport_data. + """ + # v() appends URI query params to the websocket_client.conf URL + # e.g. wss://api.dograh.com/ws/ari?workflow_id=1&user_id=2&workflow_run_id=3 + transport_data = ( + f"v(workflow_id={workflow_id}," + f"user_id={user_id}," + f"workflow_run_id={workflow_run_id})" + ) + + result = await self._ari_request( + "POST", + "/channels/externalMedia", + params={ + "app": self.app_name, + "external_host": self.ws_client_name, + "format": "ulaw", + "transport": "websocket", + "encapsulation": "none", + "connection_type": "client", + "direction": "both", + "transport_data": transport_data, + }, + ) + 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}" + ) + return ext_channel_id + + async def _create_bridge_and_add_channels(self, channel_ids: list) -> str: + """Create a bridge and add channels to it.""" + # Create bridge + bridge_result = await self._ari_request( + "POST", + "/bridges", + params={"type": "mixing", "name": f"bridge-{channel_ids[0]}"}, + ) + bridge_id = bridge_result.get("id", "") + if not bridge_id: + logger.error(f"[ARI org={self.organization_id}] Failed to create bridge") + return "" + + # Add channels to bridge + await self._ari_request( + "POST", + f"/bridges/{bridge_id}/addChannel", + params={"channel": ",".join(channel_ids)}, + ) + logger.info( + f"[ARI org={self.organization_id}] Bridge {bridge_id} created with channels: {channel_ids}" + ) + 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. Answer the inbound channel + await self._answer_channel(channel_id) + + # 6. 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, + channel_state: str, + workflow_run_id: str, + workflow_id: str, + user_id: str, + ): + """Handle StasisStart by creating external media and bridging.""" + try: + logger.info( + f"[ARI org={self.organization_id}] Setting up external media for " + f"channel {channel_id} via ws_client={self.ws_client_name}" + ) + + # 1. Track channel for StasisEnd cleanup (Redis) + await self._set_channel_run(channel_id, workflow_run_id) + + # 2. Create external media channel via chan_websocket + # Asterisk connects to our backend using websocket_client.conf config, + # with routing params appended as URI query params via v() + ext_channel_id = await self._create_external_media( + workflow_id, user_id, workflow_run_id + ) + if not ext_channel_id: + logger.error( + f"[ARI org={self.organization_id}] Failed to create external media for {channel_id}" + ) + return + + # 3. Track ext channel for StasisEnd cleanup (Redis) + await self._set_channel_run(ext_channel_id, workflow_run_id) + + # 4. Bridge the call channel with the external media channel + bridge_id = await self._create_bridge_and_add_channels( + [channel_id, ext_channel_id] + ) + if not bridge_id: + logger.error( + f"[ARI org={self.organization_id}] Failed to bridge channels" + ) + return + + # 5. Store ARI resource IDs in gathered_context for cleanup/debugging + await db_client.update_workflow_run( + run_id=int(workflow_run_id), + gathered_context={ + "ext_channel_id": ext_channel_id, + "bridge_id": bridge_id, + }, + ) + except Exception as e: + logger.error( + f"[ARI org={self.organization_id}] Error handling StasisStart " + f"for channel {channel_id}: {e}" + ) + + async def _handle_stasis_end(self, channel_id: str, workflow_run_id: str): + """Full teardown of all ARI resources on any channel's StasisEnd. + + When either channel (call or ext) fires StasisEnd, we tear down + the bridge and both channels — like endConferenceOnExit. + """ + try: + 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( + f"[ARI org={self.organization_id}] StasisEnd: no gathered_context " + f"for workflow_run {workflow_run_id}" + ) + # Still clean up the Redis key for the channel that ended + await self._delete_channel_run(channel_id) + return + + ctx = workflow_run.gathered_context + call_id = ctx.get("call_id") + ext_channel_id = ctx.get("ext_channel_id") + bridge_id = ctx.get("bridge_id") + + # Delete the bridge first (removes channels from it) + if bridge_id: + await self._delete_bridge(bridge_id) + + # Destroy both channels, skipping the one that already ended + for cid in (call_id, ext_channel_id): + if cid and cid != channel_id: + await self._delete_channel(cid) + + # Clean up all Redis reverse-mapping keys + keys_to_delete = [ + cid for cid in (call_id, ext_channel_id, channel_id) if cid + ] + if keys_to_delete: + await self._delete_channel_run(*keys_to_delete) + + # Clean up the Redis marker for external channel + await self._delete_ext_channel(ext_channel_id) + + logger.info( + f"[ARI org={self.organization_id}] StasisEnd full teardown for " + f"channel={channel_id}, call={call_id}, ext={ext_channel_id}, bridge={bridge_id}" + ) + except Exception as e: + logger.error( + f"[ARI org={self.organization_id}] Error cleaning up StasisEnd " + f"for channel {channel_id}: {e}" + ) + + async def _delete_bridge(self, bridge_id: str): + """Delete an ARI bridge. Ignores 404 (already gone).""" + + url = f"{self.ari_endpoint}/ari/bridges/{bridge_id}" + auth = aiohttp.BasicAuth(self.app_name, self.app_password) + + async with aiohttp.ClientSession() as session: + async with session.delete(url, auth=auth) as response: + if response.status in (200, 204): + logger.info( + f"[ARI org={self.organization_id}] Deleted bridge {bridge_id}" + ) + elif response.status == 404: + logger.debug( + f"[ARI org={self.organization_id}] Bridge {bridge_id} already gone" + ) + else: + text = await response.text() + logger.error( + f"[ARI org={self.organization_id}] Failed to delete bridge {bridge_id}: " + f"{response.status} {text}" + ) + + async def _delete_channel(self, channel_id: str): + """Delete (hang up) an ARI channel. Ignores 404 (already gone).""" + + url = f"{self.ari_endpoint}/ari/channels/{channel_id}" + auth = aiohttp.BasicAuth(self.app_name, self.app_password) + + async with aiohttp.ClientSession() as session: + async with session.delete(url, auth=auth) as response: + if response.status in (200, 204): + logger.info( + f"[ARI org={self.organization_id}] Deleted channel {channel_id}" + ) + elif response.status == 404: + logger.debug( + f"[ARI org={self.organization_id}] Channel {channel_id} already gone" + ) + else: + text = await response.text() + logger.error( + f"[ARI org={self.organization_id}] Failed to delete channel {channel_id}: " + f"{response.status} {text}" + ) class ARIManager: - """Manages ARI connection and distributes events to workers via Redis.""" + """Manages ARI WebSocket connections for all organizations.""" - def __init__(self, redis_client: aioredis.Redis): - self.redis = redis_client - self.stasis_manager: Optional[ARIClientManager] = None + def __init__(self): + self._connections: Dict[str, ARIConnection] = {} # key -> connection self._running = False - self._ari_client_supervisor = None - self._tasks: Dict[str, asyncio.Task] = {} - self._pubsubs: Dict[ - str, aioredis.client.PubSub - ] = {} # Track pubsub connections - self._active_channels: set[str] = ( - set() - ) # Track channels managed by this instance - self._port_range = range(4000, 5000, 2) # Even ports only - self._channel_connections: Dict[ - str, ARIManagerConnection - ] = {} # Track connections by channel ID - self._channel_disposed: Dict[str, bool] = {} # Track channel disposed state - self._socket_closed: Dict[str, bool] = {} # Track socket closed state - self._active_workers: list[str] = [] # Cached list of active workers - self._worker_discovery_task: Optional[asyncio.Task] = None - self._channel_to_worker: Dict[str, str] = {} # Map channel to worker + self._config_refresh_interval = 60 # Check for config changes every 60 seconds - async def on_channel_start(self, caller_channel: Channel, call_context_vars: dict): - """Handle new channel from ARIClientManager with atomically allocated port.""" - try: - # Atomically allocate port for this channel (prevents race conditions) - port = await self._get_and_allocate_port_atomic(caller_channel.id) + async def start(self): + """Start the ARI manager.""" + self._running = True + logger.info("ARI Manager starting...") - # Create connection with allocated port - connection = ARIManagerConnection( - caller_channel=caller_channel, - host=os.getenv("ARI_STASIS_APP_ENDPOINT"), - port=port, - ) - - # Track the connection - self._channel_connections[caller_channel.id] = connection - # Initialize channel state flags - self._channel_disposed[caller_channel.id] = False - self._socket_closed[caller_channel.id] = False - - # Handle the connection - await self._on_stasis_call(connection, call_context_vars) - - except Exception as e: - logger.exception(f"Error handling new channel {caller_channel.id}: {e}") - # Release port if allocation failed - await self._release_port_for_channel(caller_channel.id) - - async def on_channel_end(self, channel_id: str): - """Handle channel end notification from ARIClientManager.""" - logger.info(f"channelID: {channel_id} Received channel end notification") - - # Find the connection for this channel - connection = None - caller_channel_id = None - - # Check if it's a caller channel - if channel_id in self._channel_connections: - connection = self._channel_connections[channel_id] - caller_channel_id = channel_id - else: - # TODO: We are currently not handling StasisEnd on ExternalMedia - for conn_channel_id, conn in self._channel_connections.items(): - if conn.em_channel_id and conn.em_channel_id == channel_id: - logger.debug( - f"channelID: {channel_id} ExternalMedia StasisEnd - Ignoring" - ) - # connection = conn - # caller_channel_id = conn_channel_id - break - - # Publish StasisEnd event to worker immediately - if connection and caller_channel_id: - worker_id = self._get_worker_for_channel(caller_channel_id) - event = StasisEndEvent( - channel_id=caller_channel_id, - reason=EndTaskReason.USER_HANGUP.value, - ) - await self.redis.publish( - RedisChannels.worker_events(worker_id), event.to_json() - ) - logger.info(f"channelID: {channel_id} Published StasisEnd event") - - # Notify the connection about channel end - await connection.notify_channel_end() - - # Mark channel as disposed - if caller_channel_id in self._channel_disposed: - self._channel_disposed[caller_channel_id] = True - # Check if both flags are set to cleanup - await self._check_and_cleanup_channel(caller_channel_id) - - async def _on_stasis_call( - self, connection: ARIManagerConnection, call_context_vars: dict - ): - """Handle new Stasis call by setting up the connection and publishing to Redis.""" - try: - # Setup the connection (create bridge and external media) - await connection.setup_call() - - if not connection.is_connected(): - logger.warning("Connection is not connected, skipping") - return - - # Extract all necessary information after bridge is created - channel_id = connection.caller_channel_id - em_channel_id = connection.em_channel_id - bridge_id = connection.bridge_id - - # Track this channel as active - self._active_channels.add(channel_id) - - # Create event with all connection details - event = StasisStartEvent( - channel_id=channel_id, - caller_channel_id=channel_id, - em_channel_id=em_channel_id, - bridge_id=bridge_id, - local_addr=list(connection.local_addr), - remote_addr=list(connection.remote_addr) - if connection.remote_addr - else None, - call_context_vars=call_context_vars, - ) - - # Select worker using round-robin - worker_id = await self._select_worker() - if worker_id is None: - logger.error(f"channelID: {channel_id} No active workers available") - await connection.disconnect() - return - - # Track channel to worker mapping - self._channel_to_worker[channel_id] = worker_id - channel = RedisChannels.worker_events(worker_id) - - # Publish event to specific worker - await self.redis.publish(channel, event.to_json()) - logger.info( - f"channelID: {channel_id} Published stasis_start event to worker {worker_id}" - ) - - # Start monitoring for commands from workers - self._tasks[channel_id] = asyncio.create_task( - self._monitor_channel_commands(channel_id, connection) - ) - - except Exception as e: - logger.exception(f"Error handling stasis call: {e}") - - async def _get_and_allocate_port_atomic(self, channel_id: str) -> int: - """Atomically find and allocate an available port using Redis Lua script. - - This method prevents race conditions by using a Lua script that executes - atomically in Redis, ensuring that two concurrent calls cannot allocate - the same port. - """ - # Lua script for atomic port allocation - lua_script = """ - local port_range_start = tonumber(ARGV[1]) - local port_range_end = tonumber(ARGV[2]) - local port_range_step = tonumber(ARGV[3]) - local channel_id = KEYS[1] - local timestamp = ARGV[4] - - -- Check if channel already has a port allocated - local existing_port = redis.call('HGET', 'channel_ports', channel_id) - if existing_port then - return tonumber(existing_port) - end - - -- Find first available port - for port = port_range_start, port_range_end, port_range_step do - local port_str = tostring(port) - local exists = redis.call('HEXISTS', 'port_channels', port_str) - if exists == 0 then - -- Atomically allocate the port - redis.call('HSET', 'channel_ports', channel_id, port) - redis.call('HSET', 'port_channels', port_str, channel_id) - redis.call('HSET', 'channel_allocation_time', channel_id, timestamp) - return port - end - end - - return -1 -- No ports available - """ - - # Execute the Lua script with port range parameters - port_start = min(self._port_range) - port_end = max(self._port_range) - port_step = self._port_range.step - timestamp = int(time.time()) - - port = await self.redis.eval( - lua_script, - 1, # Number of keys - channel_id, # KEYS[1] - port_start, # ARGV[1] - port_end, # ARGV[2] - port_step, # ARGV[3] - timestamp, # ARGV[4] - ) - - if port == -1: - # If all ports exhausted, clean up orphaned ports and retry - await self._cleanup_orphaned_ports() - - # Retry after cleanup - port = await self.redis.eval( - lua_script, 1, channel_id, port_start, port_end, port_step, timestamp - ) - - if port == -1: - raise RuntimeError( - "No available ports in configured range after cleanup" - ) - - logger.debug(f"Atomically allocated port {port} for channel {channel_id}") - return port - - async def _release_port_for_channel(self, channel_id: str): - """Atomically release port when channel ends. - - Uses a Lua script to ensure all cleanup operations happen atomically, - preventing partial cleanup or race conditions during release. - """ - lua_script = """ - local channel_id = KEYS[1] - - -- Get the port allocated to this channel - local port = redis.call('HGET', 'channel_ports', channel_id) - - if port then - -- Atomically clean up all related entries - redis.call('HDEL', 'channel_ports', channel_id) - redis.call('HDEL', 'port_channels', port) - redis.call('HDEL', 'channel_allocation_time', channel_id) - return port - end - - return nil - """ - - port = await self.redis.eval(lua_script, 1, channel_id) - - if port: - logger.debug(f"Atomically released port {port} for channel {channel_id}") - else: - logger.debug(f"No port was allocated for channel {channel_id}") - - async def _discover_workers(self): - """Periodically discover active workers from Redis.""" - try: - while self._running: - try: - # Get all worker IDs from the set - worker_ids = await self.redis.smembers(RedisKeys.workers_set()) - - # Filter to only active workers - active_workers = [] - for worker_id in worker_ids: - worker_id = ( - worker_id.decode() - if isinstance(worker_id, bytes) - else worker_id - ) - worker_key = RedisKeys.worker_active(worker_id) - worker_data = await self.redis.get(worker_key) - - if worker_data: - try: - data = json.loads(worker_data) - # Only include workers that are ready (not draining) - if data.get("status") == "ready": - active_workers.append(worker_id) - except json.JSONDecodeError: - logger.warning(f"Invalid worker data for {worker_id}") - - # Update the cached list atomically - self._active_workers = active_workers - logger.info(f"Discovered {len(active_workers)} active workers") - - except Exception as e: - logger.error(f"Error discovering workers: {e}") - - # Check every 5 seconds - await asyncio.sleep(5) - - except asyncio.CancelledError: - logger.debug("Worker discovery task cancelled") - - async def _select_worker(self) -> Optional[str]: - """Select a worker using round-robin.""" - if not self._active_workers: - return None - - # Use Redis to maintain round-robin index across restarts - try: - index = await self.redis.incr(RedisKeys.round_robin_index()) - worker_index = (index - 1) % len(self._active_workers) - return self._active_workers[worker_index] - except Exception as e: - logger.error(f"Error selecting worker: {e}") - # Fallback to first worker if Redis operation fails - return self._active_workers[0] if self._active_workers else None - - def _get_worker_for_channel(self, channel_id: str) -> str: - """Get the assigned worker for a channel (for sending commands).""" - # Return the worker ID that was assigned to this channel - return self._channel_to_worker.get(channel_id, "") - - async def _monitor_channel_commands( - self, channel_id: str, connection: ARIManagerConnection - ): - """Listen for commands from workers for this channel.""" - # TODO: Not sure if its a good idea to monitor command for every channel - # using pubsub. What happens if there are more number of calls than number - # of tcp connections redis can support? We can do something similar to - # Campaign Orchestrator, where we can subscribe to one channel and have - # commands for every channel there. - command_channel = RedisChannels.channel_commands(channel_id) - pubsub = None - - try: - pubsub = self.redis.pubsub() - await pubsub.subscribe(command_channel) - - # Store the pubsub connection for cleanup - self._pubsubs[channel_id] = pubsub - - logger.debug(f"channelID: {channel_id} Monitoring commands for channel") - - async for message in pubsub.listen(): - if message["type"] == "message": - try: - command = parse_command(message["data"]) - if command: - await self._handle_worker_command( - channel_id, command, connection - ) - else: - logger.warning( - f"Failed to parse command for {channel_id}: {message['data']}" - ) - except Exception as e: - logger.exception( - f"Error handling command for {channel_id}: {e}" - ) - - except asyncio.CancelledError: - logger.debug(f"channelID: {channel_id} Command monitor cancelled") - raise # Re-raise to maintain proper cancellation semantics - except (ConnectionError, redis.exceptions.ConnectionError) as e: - # We close the pubsub before cancelling the task. So, the code - # flow will arrive here - pass - except Exception as e: - logger.exception(f"Error in command monitor for {channel_id}: {e}") - - async def _handle_worker_command( - self, - channel_id: str, - command: BaseWorkerToARIManagerCommand, - connection: ARIManagerConnection, - ): - """Execute commands from workers.""" - if isinstance(command, DisconnectCommand): - logger.info(f"channelID: {channel_id} Worker requested disconnect") - await connection.disconnect() - - elif isinstance(command, TransferCommand): - logger.info(f"channelID: {channel_id} Worker requested transfer") - await connection.transfer(command.context) - - elif isinstance(command, SocketClosedCommand): - logger.info(f"channelID: {channel_id} Worker notified socket closed") - - # Mark socket as closed - if channel_id in self._socket_closed: - self._socket_closed[channel_id] = True - - # Release port immediately - await self._release_port_for_channel(channel_id) - - # Check if both flags are set to cleanup - await self._check_and_cleanup_channel(channel_id) - else: - logger.warning( - f"channelID: {channel_id} Received unknown command: {command}" - ) - - async def _check_and_cleanup_channel(self, channel_id: str): - """Check if both flags are set and cleanup channel if so.""" - channel_disposed = self._channel_disposed.get(channel_id, False) - socket_closed = self._socket_closed.get(channel_id, False) - - logger.debug( - f"channelID: {channel_id} Check cleanup - disposed: {channel_disposed}, socket_closed: {socket_closed}" - ) - - if channel_disposed and socket_closed: - # Remove from active channels and connections - self._active_channels.discard(channel_id) - self._channel_connections.pop(channel_id, None) - - # Close pubsub connection first (before cancelling task) - if channel_id in self._pubsubs: - pubsub = self._pubsubs[channel_id] - try: - command_channel = RedisChannels.channel_commands(channel_id) - await pubsub.unsubscribe(command_channel) - await pubsub.aclose() - logger.debug( - f"channelID: {channel_id} Closed pubsub connection in cleanup" - ) - except Exception as e: - logger.warning(f"Error closing pubsub for {channel_id}: {e}") - finally: - del self._pubsubs[channel_id] - - # Cancel command monitor task - if channel_id in self._tasks: - task = self._tasks[channel_id] - if not task.done(): - # Task is still running, cancel it - task.cancel() - try: - # Wait for task to complete - await task - logger.debug( - f"channelID: {channel_id} Task completed after cancel" - ) - except asyncio.CancelledError: - logger.debug( - f"channelID: {channel_id} Task cancelled successfully" - ) - except Exception as e: - logger.warning( - f"channelID: {channel_id} Task raised exception: {e}" - ) - else: - # Task already completed - logger.debug( - f"channelID: {channel_id} Monitor task already completed" - ) - try: - # Still await to get any exception that might have occurred - await task - except Exception as e: - logger.warning( - f"channelID: {channel_id} Completed task had exception: {e}" - ) - - del self._tasks[channel_id] - - # Clean up the flag tracking - self._channel_disposed.pop(channel_id, None) - self._socket_closed.pop(channel_id, None) - - logger.info(f"channelID: {channel_id} Completed cleanup of all resources") - - async def _cleanup_orphaned_ports(self): - """Clean up ports from previous ungraceful shutdowns.""" - try: - # Get all channel-port mappings - channel_ports = await self.redis.hgetall("channel_ports") - if not channel_ports: - return - - logger.info( - f"Found {len(channel_ports)} existing port allocations, checking for orphans..." - ) - - cleaned = 0 - current_time = int(time.time()) - max_age_seconds = 3600 # 1 hour - - # On startup, we can safely assume any existing allocations are orphaned - # since this is a fresh instance with no active channels yet - if not self._active_channels: - # Clean up all existing allocations on startup - for channel_id, port in channel_ports.items(): - allocation_time = await self.redis.hget( - "channel_allocation_time", channel_id - ) - age_str = "" - if allocation_time: - age = current_time - int(allocation_time) - age_str = f" (aged {age}s)" - - await self._release_port_for_channel(channel_id) - logger.info( - f"Cleaned up orphaned port {port} for channel {channel_id}{age_str}" - ) - cleaned += 1 - else: - # During runtime, only clean up channels not being tracked - for channel_id, port in channel_ports.items(): - if channel_id not in self._active_channels: - # Check allocation age - allocation_time = await self.redis.hget( - "channel_allocation_time", channel_id - ) - if allocation_time: - age = current_time - int(allocation_time) - if age > max_age_seconds: - # Too old, clean up regardless - await self._release_port_for_channel(channel_id) - logger.info( - f"Cleaned up stale port {port} for channel {channel_id} (aged {age}s)" - ) - cleaned += 1 - continue - - # Not tracked by this instance, might be orphaned - # For safety, only clean up if reasonably old (5 minutes) - if ( - allocation_time - and (current_time - int(allocation_time)) > 300 - ): - await self._release_port_for_channel(channel_id) - logger.info( - f"Cleaned up orphaned port {port} for untracked channel {channel_id}" - ) - cleaned += 1 - - if cleaned > 0: - logger.info(f"Cleaned up {cleaned} orphaned port allocations") - - except Exception as e: - logger.exception(f"Error during orphaned port cleanup: {e}") - - async def _periodic_cleanup(self): - """Periodically clean up orphaned ports.""" - cleanup_interval = 1800 # 30 minutes + # Initial load of configurations + await self._refresh_connections() + # Start periodic config refresh while self._running: - try: - await asyncio.sleep(cleanup_interval) - if self._running: # Check again after sleep - logger.info("Running periodic orphaned port cleanup...") - await self._cleanup_orphaned_ports() - except asyncio.CancelledError: - logger.debug("Periodic cleanup task cancelled") - break - except Exception as e: - logger.exception(f"Error in periodic cleanup: {e}") + await asyncio.sleep(self._config_refresh_interval) + if self._running: + await self._refresh_connections() - async def run(self): - """Main run loop for ARI Manager.""" - if not ENABLE_ARI_STASIS: - logger.info("ARI Stasis integration disabled via environment variable") + async def stop(self): + """Stop all connections and clean up.""" + self._running = False + logger.info("ARI Manager stopping...") + + # Stop all connections + for conn in self._connections.values(): + await conn.stop() + self._connections.clear() + logger.info("ARI Manager stopped") + + async def _refresh_connections(self): + """ + Refresh connections based on current database configurations. + + - Starts new connections for new ARI configurations + - Stops connections for removed configurations + - Restarts connections if configuration changed + """ + try: + active_configs = await self._load_ari_configs() + except Exception as e: + logger.error(f"Failed to load ARI configurations: {e}") return - # Setup ARI connection with supervisor - self._running = True + active_keys: Set[str] = set() - try: - self._ari_client_supervisor = await setup_ari_client_supervisor( - self.on_channel_start, self.on_channel_end + for config in active_configs: + org_id = config["organization_id"] + ari_endpoint = config["ari_endpoint"] + 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, + inbound_workflow_id=inbound_workflow_id, ) - if not self._ari_client_supervisor: - logger.error("Failed to setup ARI connection") - return + key = conn.connection_key - # Start worker discovery task - self._worker_discovery_task = asyncio.create_task(self._discover_workers()) + active_keys.add(key) - # Wait a moment for initial worker discovery - await asyncio.sleep(1) - - logger.info( - f"ARI Manager started with {len(self._active_workers)} active workers" - ) - - # Clean up any orphaned ports from previous runs - await self._cleanup_orphaned_ports() - - # Start periodic cleanup task - cleanup_task = asyncio.create_task(self._periodic_cleanup()) - - # Keep running until shutdown - while self._running: - await asyncio.sleep(1) - - logger.debug("ARIManager._running is false. Will cleanup and shutdown") - - # Cancel cleanup task - cleanup_task.cancel() - try: - await cleanup_task - except asyncio.CancelledError: - pass - - except Exception as e: - logger.exception(f"ARI Manager error: {e}") - finally: - if self._ari_client_supervisor: - await self._ari_client_supervisor.close() - logger.info("ARI Manager stopped") - - async def shutdown(self): - """Graceful shutdown.""" - logger.info("Shutting down ARI Manager...") - - # Close supervisor first to prevent reconnection attempts - if self._ari_client_supervisor: - await self._ari_client_supervisor.close() - - # Cancel worker discovery task - if self._worker_discovery_task: - self._worker_discovery_task.cancel() - try: - await self._worker_discovery_task - except asyncio.CancelledError: - pass - self._worker_discovery_task = None - - # Now set running to False - self._running = False - - # Clean up all active channel ports before shutting down - if self._active_channels: - logger.info(f"Cleaning up {len(self._active_channels)} active channels...") - for channel_id in list( - self._active_channels - ): # Copy to avoid modification during iteration - await self._release_port_for_channel(channel_id) + if key not in self._connections: + # New configuration - start connection logger.info( - f"Released port for active channel {channel_id} during shutdown" + f"[ARI Manager] New ARI config for org {org_id}: {ari_endpoint}" ) - self._active_channels.clear() + self._connections[key] = conn + await conn.start() + else: + # Existing configuration - check if password or inbound_workflow_id changed + existing = self._connections[key] + 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..." + ) + await existing.stop() + self._connections[key] = conn + await conn.start() - # Clear flag tracking - self._channel_disposed.clear() - self._socket_closed.clear() + # Stop connections for removed configurations + removed_keys = set(self._connections.keys()) - active_keys + for key in removed_keys: + conn = self._connections.pop(key) + logger.info( + f"[ARI Manager] Removing connection for org {conn.organization_id}" + ) + await conn.stop() - # Cancel all monitoring tasks - for task in self._tasks.values(): - task.cancel() + if active_configs: + logger.info( + f"[ARI Manager] Active connections: {len(self._connections)} " + f"(orgs: {[c['organization_id'] for c in active_configs]})" + ) + else: + logger.debug("[ARI Manager] No ARI configurations found") - # Wait for tasks to complete - if self._tasks: - await asyncio.gather(*self._tasks.values(), return_exceptions=True) + async def _load_ari_configs(self) -> list: + """Load all ARI telephony configurations from the database.""" + rows = await db_client.get_configurations_by_provider( + OrganizationConfigurationKey.TELEPHONY_CONFIGURATION.value, "ari" + ) + + configs = [] + for row in rows: + org_id = row["organization_id"] + value = row["value"] + + ari_endpoint = value.get("ari_endpoint") + app_name = value.get("app_name") + app_password = value.get("app_password") + ws_client_name = value.get("ws_client_name", "") + + if not all([ari_endpoint, app_name, app_password]): + logger.warning( + f"[ARI Manager] Incomplete ARI config for org {org_id}, skipping" + ) + continue + + if not ws_client_name: + logger.warning( + f"[ARI Manager] Missing ws_client_name for org {org_id}, " + f"externalMedia WebSocket won't work" + ) + + configs.append( + { + "organization_id": org_id, + "ari_endpoint": ari_endpoint, + "app_name": app_name, + "app_password": app_password, + "ws_client_name": ws_client_name, + "inbound_workflow_id": value.get("inbound_workflow_id"), + } + ) + + return configs async def main(): - """Main entry point for ARI Manager service.""" - # Setup Redis connection - redis = await aioredis.from_url(REDIS_URL, decode_responses=True) + """Entry point for the ARI manager process.""" + manager = ARIManager() - # Create and run manager - manager = ARIManager(redis) - - # Create a shutdown event for clean coordination + # Handle graceful shutdown + loop = asyncio.get_running_loop() shutdown_event = asyncio.Event() - # Setup signal handlers - loop = asyncio.get_event_loop() - - def signal_handler(signum): - logger.info(f"Received shutdown signal {signum}") - # Set the shutdown event which will trigger shutdown + def signal_handler(): + logger.info("Received shutdown signal") shutdown_event.set() for sig in (signal.SIGTERM, signal.SIGINT): - loop.add_signal_handler(sig, lambda s=sig: signal_handler(s)) + loop.add_signal_handler(sig, signal_handler) - # Run manager with shutdown monitoring - manager_task = asyncio.create_task(manager.run()) - shutdown_task = asyncio.create_task(shutdown_event.wait()) + # Start manager in background + manager_task = asyncio.create_task(manager.start()) + # Wait for shutdown signal + await shutdown_event.wait() + + # Clean up + await manager.stop() + manager_task.cancel() try: - # Wait for either normal completion or shutdown signal - done, pending = await asyncio.wait( - [manager_task, shutdown_task], return_when=asyncio.FIRST_COMPLETED - ) + await manager_task + except asyncio.CancelledError: + pass - # If shutdown was triggered, perform graceful shutdown - if shutdown_task in done: - await manager.shutdown() - # Cancel the manager task if still running - if manager_task in pending: - manager_task.cancel() - try: - await manager_task - except asyncio.CancelledError: - pass - finally: - await redis.aclose() + logger.info("ARI Manager exited cleanly") if __name__ == "__main__": - # Configure logging - logger.add("logs/ari_manager.log", rotation="10 MB") asyncio.run(main()) diff --git a/api/services/telephony/ari_manager_connection.py b/api/services/telephony/ari_manager_connection.py deleted file mode 100644 index ac77b09..0000000 --- a/api/services/telephony/ari_manager_connection.py +++ /dev/null @@ -1,323 +0,0 @@ -"""ARI-specific Stasis connection for use by ARI Manager. - -This connection has direct access to the ARI client and manages -the actual Asterisk channels, bridges, and RTP setup. -""" - -import json -import os -import uuid -from typing import Optional - -import httpx -from loguru import logger - -from api.services.telephony.ari_client import AsyncARIClient, Bridge, Channel -from api.services.telephony.ari_client_singleton import ari_client_singleton -from pipecat.utils.base_object import BaseObject - - -class ARIManagerConnection(BaseObject): - """ARI Manager's connection that directly controls Asterisk resources. - - This class is used only by the ARI Manager process and has full - access to the ARI client for creating bridges, channels, etc. - """ - - def __init__( - self, - caller_channel: Channel, - host: str, - port: int, - ) -> None: - """Initialize ARI Stasis connection. - - Args: - caller_channel: The caller's channel object. - host: Host address for RTP transport. - port: Port number for RTP transport. - """ - super().__init__() - - # External dependencies. - self._host: str = host - self._port: int = port - - # Store channel IDs instead of Channel objects to avoid stale references - self.caller_channel_id: str = caller_channel.id - self.em_channel_id: Optional[str] = None # externalMedia channel ID - - # Store bridge ID to avoid stale references after reconnection - self.bridge_id: Optional[str] = None - - # RTP addressing information - self.local_addr = ("0.0.0.0", port) - self.remote_addr = None - - # Internal state. - self._closed: bool = False - self._is_connected: bool = False - - def is_connected(self) -> bool: - """Check if the connection is established.""" - return self._is_connected and not self._closed - - @property - def _ari(self) -> Optional[AsyncARIClient]: - """Get the current ARI client from singleton.""" - return ari_client_singleton.get_client() - - async def _get_channel(self, channel_id: str) -> Optional[Channel]: - """Safely get a channel object by ID. - - Returns None if the channel doesn't exist or can't be fetched. - """ - if not channel_id: - return None - try: - # Get current client from singleton - client = self._ari - if not client: - logger.warning( - f"Cannot get channel {channel_id} - No ARI client available" - ) - return None - # Check if the session is still active - if not client._session or client._session.closed: - logger.warning( - f"Cannot get channel {channel_id} - ARI session is closed" - ) - return None - return await client.channels.get(channelId=channel_id) - except Exception as e: - logger.warning(f"Could not get channel {channel_id} - {e}") - return None - - async def _get_bridge(self, bridge_id: str) -> Optional[Bridge]: - """Safely get a bridge object by ID. - - Returns None if the bridge doesn't exist or can't be fetched. - """ - if not bridge_id: - return None - try: - # Get current client from singleton - client = self._ari - if not client: - logger.warning( - f"Cannot get bridge {bridge_id} - No ARI client available" - ) - return None - # Check if the session is still active - if not client._session or client._session.closed: - logger.warning(f"Cannot get bridge {bridge_id} - ARI session is closed") - return None - return await client.bridges.get(bridgeId=bridge_id) - except Exception as e: - logger.warning(f"Could not get bridge {bridge_id}: {e}") - return None - - async def _cleanup_resources(self): - """Clean up external media channel and bridge.""" - # Cleanup external media channel - try: - if self.em_channel_id: - em_channel = await self._get_channel(self.em_channel_id) - if em_channel: - await em_channel.hangup() - logger.debug( - f"channelID: {self.em_channel_id} Hung up external media" - ) - self.em_channel_id = None - except Exception as exc: - logger.warning( - f"Failed to hang-up externalMedia channel: {self.em_channel_id}" - f"Error: {exc}" - ) - - # Cleanup bridge - try: - if self.bridge_id: - bridge = await self._get_bridge(self.bridge_id) - if bridge: - await bridge.destroy() - logger.debug(f"bridgeID: {self.bridge_id} Destroyed bridge") - self.bridge_id = None - except Exception as exc: - logger.warning(f"Failed to destroy bridge: {self.bridge_id}Error: {exc}") - - async def _sync_call_data(self, call_transfer_context: dict): - """Sync call data to ARI_DATA_SYNCING_URI.""" - if not os.getenv("ARI_DATA_SYNCING_URI"): - return - - lead_id = call_transfer_context.get("lead_id") - status = call_transfer_context.get("disposition") - - # {'lead_id': '299154', 'disposition': 'VM', 'agent_name': 'Alex', 'decision_maker': 'False', 'employment': 'N/A', 'debts': 'N/A', 'number_of_credit_cards': 'N/A', 'time': '2025-08-07T13:16:02-04:00'} - - full_name = call_transfer_context.get("full_name", "") - phone = call_transfer_context.get("phone", "") - debts = call_transfer_context.get("debts", "") - employment = call_transfer_context.get("employment", "") - time = call_transfer_context.get("time", "") - - comment = f"Type:Qualified!NName:{full_name}!NPhone:{phone}!NDebts:{debts}!NCC:N/A!NDM:Yes!NEmployment:{employment}!NTime:{time}!NVendor Id:!NStatus:{status}" - - try: - if lead_id and status: - ari_data_uri = os.getenv("ARI_DATA_SYNCING_URI") - # Add URL params to the base URL - sync_url = f"{ari_data_uri}&lead_id={lead_id}&status={status}&comments={comment}" - - logger.debug( - f"channelID: {self.caller_channel_id} Syncing data to ARI_DATA_SYNCING_URI: {sync_url}" - ) - - async with httpx.AsyncClient() as client: - response = await client.post(sync_url, timeout=10.0) - response.raise_for_status() - logger.info( - f"channelID: {self.caller_channel_id} Successfully synced data for lead_id: {lead_id} with status: {status}" - ) - else: - logger.warning( - f"channelID: {self.caller_channel_id} Missing lead_id or status for syncing" - ) - except Exception as e: - logger.error( - f"channelID: {self.caller_channel_id} Failed to sync data to ARI_DATA_SYNCING_URI: {e}" - ) - - async def disconnect(self): - """Instruct Asterisk to hang-up the call and perform cleanup.""" - if self._closed: - return - - # Lets mark it as closed so that when we receive StasisEnd, we don't - # try to cleanup resource again - self._closed = True - - # Clean up resources first - await self._cleanup_resources() - - try: - if self.caller_channel_id: - caller_channel = await self._get_channel(self.caller_channel_id) - if caller_channel: - logger.debug( - f"channelID: {self.caller_channel_id} Hanging up caller channel" - ) - await caller_channel.hangup() - except Exception: - logger.exception("Failed to hangup caller channel") - - async def transfer(self, call_transfer_context: dict): - """Transfer the call by continuing in dialplan with extracted variables.""" - if self._closed: - return - - # Lets mark it as closed so that when we receive StasisEnd, we don't - # try to cleanup resource again - self._closed = True - - try: - # Clean up resources before transferring - await self._cleanup_resources() - - if self.caller_channel_id: - caller_channel = await self._get_channel(self.caller_channel_id) - if caller_channel: - logger.debug( - f"channelID: {self.caller_channel_id} User qualified, continuing in dialplan " - f"REMOTE_DISPO_CALL_VARIABLES: {json.dumps(call_transfer_context)}" - ) - - # Sync data to ARI_DATA_SYNCING_URI - await self._sync_call_data( - call_transfer_context=call_transfer_context - ) - - await caller_channel.continueInDialplan() - except Exception: - logger.exception("Failed to transfer caller channel") - - async def setup_call(self): - """Setup the bridge and external media channel. - - This must be called after initialization to establish the connection. - """ - await self._setup_call(self._host, self._port) - - async def _setup_call(self, host: str, port: int): - """Create externalMedia + bridge and notify that the call is connected.""" - try: - em_channel_id = str(uuid.uuid4()) - logger.debug( - f"channelID: {em_channel_id} Creating externalMedia channel on {host}:{port}" - ) - - client = self._ari - if not client: - raise RuntimeError("No ARI client available") - - em_channel = await client.channels.externalMedia( - app=client.app, - channelId=em_channel_id, - external_host=f"{host}:{port}", - format="ulaw", - direction="both", - ) - - # Store the channel ID - self.em_channel_id = em_channel.id - - # Create a mixing bridge and add both legs. - bridge = await client.bridges.create(type="mixing") - self.bridge_id = bridge.id - # Add channels individually as AsyncARIClient expects single channel per call - await bridge.addChannel(channel=self.caller_channel_id) - await bridge.addChannel(channel=self.em_channel_id) - - # TODO: Figure out how can we get the remote public IP. Till then - # just pick it from the environment variable - # Get RTP addressing information - # ip = await em_channel.getChannelVar( - # variable="UNICASTRTP_LOCAL_ADDRESS" - # ) - port = await em_channel.getChannelVar(variable="UNICASTRTP_LOCAL_PORT") - - self.remote_addr = ( - os.environ.get("ASTERISK_REMOTE_IP"), - int(port["value"]), - ) - - logger.debug( - f"channelID: {self.caller_channel_id} ARIManagerConnection connection resources ready " - f"(bridgeID: {self.bridge_id}), (emChannelID: {self.em_channel_id})" - f"remote address: {self.remote_addr}, local address: {self.local_addr}" - ) - - self._is_connected = True - - except Exception as exc: - logger.exception(f"Error setting up ARIManagerConnection: {exc}") - await self._cleanup_resources() - - async def notify_channel_end(self): - """Notify that a channel has ended. Received after we get StasisEnd on the caller channel""" - if self._closed: - return - - self._closed = True - self._is_connected = False - - # Cleanup resources using the shared method - await self._cleanup_resources() - - def __repr__(self): - """Return string representation of connection.""" - return ( - f"" - ) diff --git a/api/services/telephony/base.py b/api/services/telephony/base.py index ee1d05f..a154946 100644 --- a/api/services/telephony/base.py +++ b/api/services/telephony/base.py @@ -309,3 +309,46 @@ class TelephonyProvider(ABC): Tuple of (Response, media_type) - Response object and content type """ pass + + # ======== CALL TRANSFER METHODS ======== + + @abstractmethod + async def transfer_call( + self, + destination: str, + transfer_id: str, + conference_name: str, + timeout: int = 30, + **kwargs: Any, + ) -> Dict[str, Any]: + """ + Initiate a call transfer to a destination number. + + Args: + destination: The destination phone number (E.164 format) + transfer_id: Unique identifier for tracking this transfer + conference_name: Name of the conference to join the destination into + timeout: Transfer timeout in seconds + **kwargs: Provider-specific additional parameters + + Returns: + Dict containing: + - call_sid: Provider's call identifier + - status: Transfer initiation status + - provider: Provider name + + Raises: + NotImplementedError: If provider doesn't support transfers + ValueError: If provider configuration is invalid + """ + pass + + @abstractmethod + def supports_transfers(self) -> bool: + """ + Check if this provider supports call transfers. + + Returns: + True if provider supports call transfers, False otherwise + """ + pass diff --git a/api/services/telephony/call_transfer_manager.py b/api/services/telephony/call_transfer_manager.py new file mode 100644 index 0000000..f843850 --- /dev/null +++ b/api/services/telephony/call_transfer_manager.py @@ -0,0 +1,200 @@ +"""Redis-based transfer event coordination service + +Handles transfer event publishing, subscription, and context storage +""" + +import asyncio +import time +from typing import Dict, Optional + +import redis.asyncio as aioredis +from loguru import logger + +from api.constants import REDIS_URL +from api.services.telephony.transfer_event_protocol import ( + TransferContext, + TransferEvent, + TransferEventType, + TransferRedisChannels, +) + + +class CallTransferManager: + """Manages call transfer events and context storage using Redis.""" + + def __init__(self, redis_client: Optional[aioredis.Redis] = None): + self._redis_client = redis_client + self._pubsub_connections: Dict[str, aioredis.client.PubSub] = {} + + async def _get_redis(self) -> aioredis.Redis: + """Get Redis client instance.""" + if not self._redis_client: + self._redis_client = await aioredis.from_url( + REDIS_URL, decode_responses=True + ) + return self._redis_client + + async def store_transfer_context( + self, context: TransferContext, ttl: int = 300 + ) -> None: + """Store transfer context in Redis with TTL. + + Args: + context: Transfer context data + ttl: Time to live in seconds (default 5 minutes) + """ + try: + redis = await self._get_redis() + key = TransferRedisChannels.transfer_context_key(context.transfer_id) + await redis.setex(key, ttl, context.to_json()) + logger.debug(f"Stored transfer context for {context.transfer_id}") + except Exception as e: + logger.error(f"Failed to store transfer context: {e}") + + async def get_transfer_context(self, transfer_id: str) -> Optional[TransferContext]: + """Retrieve transfer context from Redis. + + Args: + transfer_id: Transfer identifier + + Returns: + Transfer context if found, None otherwise + """ + try: + redis = await self._get_redis() + key = TransferRedisChannels.transfer_context_key(transfer_id) + data = await redis.get(key) + if data: + return TransferContext.from_json(data) + return None + except Exception as e: + logger.error(f"Failed to get transfer context: {e}") + return None + + async def remove_transfer_context(self, transfer_id: str) -> None: + """Remove transfer context from Redis. + + Args: + transfer_id: Transfer identifier + """ + try: + redis = await self._get_redis() + key = TransferRedisChannels.transfer_context_key(transfer_id) + await redis.delete(key) + logger.debug(f"Removed transfer context for {transfer_id}") + except Exception as e: + logger.error(f"Failed to remove transfer context: {e}") + + async def publish_transfer_event(self, event: TransferEvent) -> None: + """Publish transfer event to Redis channel. + + Args: + event: Transfer event to publish + """ + try: + # Add timestamp if not present + if event.timestamp is None: + event.timestamp = time.time() + + redis = await self._get_redis() + channel = TransferRedisChannels.transfer_events(event.transfer_id) + await redis.publish(channel, event.to_json()) + logger.info(f"Published {event.type} event for {event.transfer_id}") + except Exception as e: + logger.error(f"Failed to publish transfer event: {e}") + + async def wait_for_transfer_completion( + self, transfer_id: str, timeout_seconds: float = 30.0 + ) -> Optional[TransferEvent]: + """Wait for transfer completion event using Redis pub/sub. + + Args: + transfer_id: Transfer identifier to wait for + timeout_seconds: Maximum time to wait + + Returns: + Transfer completion event if received, None on timeout + """ + channel = TransferRedisChannels.transfer_events(transfer_id) + redis = await self._get_redis() + pubsub = redis.pubsub() + + try: + await pubsub.subscribe(channel) + logger.info( + f"Waiting for transfer completion on {channel} (timeout: {timeout_seconds}s)" + ) + + # Wait for completion event with timeout + async def wait_for_message(): + async for message in pubsub.listen(): + if message["type"] == "message": + try: + event = TransferEvent.from_json(message["data"]) + logger.info( + f"Received {event.type} event for {transfer_id}" + ) + + # Check if this is a completion event + if ( + event.type + in [ + TransferEventType.TRANSFER_ANSWERED, # Call answered = transfer successful + TransferEventType.TRANSFER_COMPLETED, + TransferEventType.TRANSFER_FAILED, + TransferEventType.TRANSFER_CANCELLED, + TransferEventType.TRANSFER_TIMEOUT, + ] + ): + return event + except Exception as e: + logger.error(f"Failed to parse transfer event: {e}") + continue + return None + + # Wait with timeout + result = await asyncio.wait_for(wait_for_message(), timeout=timeout_seconds) + return result + + except asyncio.TimeoutError: + logger.debug(f"Transfer completion wait timed out for {transfer_id}") + return None + except Exception as e: + logger.error(f"Error waiting for transfer completion: {e}") + return None + finally: + try: + await pubsub.unsubscribe(channel) + await pubsub.close() + except Exception as e: + logger.error(f"Error closing pubsub connection: {e}") + + async def cleanup(self): + """Clean up Redis connections.""" + try: + # Close pubsub connections + for pubsub in self._pubsub_connections.values(): + try: + await pubsub.close() + except: + pass + self._pubsub_connections.clear() + + # Close main Redis connection + if self._redis_client: + await self._redis_client.close() + self._redis_client = None + except Exception as e: + logger.error(f"Error during transfer coordinator cleanup: {e}") + + +# Global call transfer manager instance +_call_transfer_manager: Optional[CallTransferManager] = None + + +async def get_call_transfer_manager() -> CallTransferManager: + """Get or create the global call transfer manager instance.""" + global _call_transfer_manager + if not _call_transfer_manager: + _call_transfer_manager = CallTransferManager() + return _call_transfer_manager diff --git a/api/services/telephony/factory.py b/api/services/telephony/factory.py index a79ae1c..0e2bb6c 100644 --- a/api/services/telephony/factory.py +++ b/api/services/telephony/factory.py @@ -11,6 +11,7 @@ from loguru import logger from api.db import db_client from api.enums import OrganizationConfigurationKey from api.services.telephony.base import TelephonyProvider +from api.services.telephony.providers.ari_provider import ARIProvider from api.services.telephony.providers.cloudonix_provider import CloudonixProvider from api.services.telephony.providers.twilio_provider import TwilioProvider from api.services.telephony.providers.vobiz_provider import VobizProvider @@ -75,6 +76,15 @@ async def load_telephony_config(organization_id: int) -> Dict[str, Any]: "domain_id": config.value.get("domain_id"), "from_numbers": config.value.get("from_numbers", []), } + elif provider == "ari": + return { + "provider": "ari", + "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: raise ValueError(f"Unknown provider in config: {provider}") @@ -115,6 +125,9 @@ async def get_telephony_provider(organization_id: int) -> TelephonyProvider: elif provider_type == "cloudonix": return CloudonixProvider(config) + elif provider_type == "ari": + return ARIProvider(config) + else: raise ValueError(f"Unknown telephony provider: {provider_type}") @@ -127,4 +140,10 @@ async def get_all_telephony_providers() -> List[Type[TelephonyProvider]]: Returns: List of provider classes that can be used for webhook detection """ - return [CloudonixProvider, TwilioProvider, VobizProvider, VonageProvider] + return [ + ARIProvider, + CloudonixProvider, + TwilioProvider, + VobizProvider, + VonageProvider, + ] diff --git a/api/services/telephony/providers/ari_provider.py b/api/services/telephony/providers/ari_provider.py new file mode 100644 index 0000000..139065a --- /dev/null +++ b/api/services/telephony/providers/ari_provider.py @@ -0,0 +1,420 @@ +""" +Asterisk ARI (Asterisk REST Interface) implementation of the TelephonyProvider interface. + +Uses ARI REST API to originate calls into a Stasis application. +The ARI WebSocket event listener runs as a separate process (ari_manager.py). +""" + +import json +from typing import TYPE_CHECKING, Any, Dict, List, Optional +from urllib.parse import urlparse + +import aiohttp +from fastapi import HTTPException +from loguru import logger + +from api.db import db_client +from api.enums import WorkflowRunMode +from api.services.telephony.base import ( + CallInitiationResult, + NormalizedInboundData, + TelephonyProvider, +) + +if TYPE_CHECKING: + from fastapi import WebSocket + + +class ARIProvider(TelephonyProvider): + """ + Asterisk ARI implementation of TelephonyProvider. + + Uses ARI REST API for call control and relies on a separate + ari_manager process for WebSocket event listening. + """ + + PROVIDER_NAME = WorkflowRunMode.ARI.value + WEBHOOK_ENDPOINT = None # ARI uses WebSocket events, not webhooks + + def __init__(self, config: Dict[str, Any]): + """ + Initialize ARIProvider with configuration. + + Args: + config: Dictionary containing: + - ari_endpoint: ARI base URL (e.g., http://asterisk:8088) + - app_name: Stasis application name + - app_password: ARI user password + - from_numbers: List of SIP extensions/numbers (optional) + """ + 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): + self.from_numbers = [self.from_numbers] + + self.base_url = f"{self.ari_endpoint}/ari" + + def _get_auth(self) -> aiohttp.BasicAuth: + """Generate BasicAuth for ARI API requests.""" + return aiohttp.BasicAuth(self.app_name, self.app_password) + + async def initiate_call( + self, + to_number: str, + webhook_url: str, + workflow_run_id: Optional[int] = None, + from_number: Optional[str] = None, + **kwargs: Any, + ) -> CallInitiationResult: + """ + Initiate an outbound call via ARI. + + Creates a channel in Asterisk using the ARI channels endpoint. + The channel is placed into the Stasis application where + the ari_manager will receive the StasisStart event. + """ + if not self.validate_config(): + raise ValueError("ARI provider not properly configured") + + endpoint = f"{self.base_url}/channels" + + # Build the SIP endpoint string + # to_number can be a SIP URI or extension + if to_number.startswith("SIP/") or to_number.startswith("PJSIP/"): + sip_endpoint = to_number + else: + # Default to PJSIP technology + sip_endpoint = f"PJSIP/{to_number}" + + # Prepare channel creation data + params = { + "endpoint": sip_endpoint, + "app": self.app_name, + "appArgs": ",".join( + filter( + None, + [ + f"workflow_run_id={workflow_run_id}", + f"workflow_id={kwargs.get('workflow_id', '')}", + f"user_id={kwargs.get('user_id', '')}", + ], + ) + ), + } + + if from_number: + params["callerId"] = from_number + + logger.info( + f"[ARI] Initiating call to {sip_endpoint} " + f"via app={self.app_name}, workflow_run_id={workflow_run_id}" + ) + + async with aiohttp.ClientSession() as session: + async with session.post( + endpoint, + params=params, + auth=self._get_auth(), + ) as response: + response_text = await response.text() + + if response.status != 200: + logger.error( + f"[ARI] Channel creation failed: " + f"HTTP {response.status} - {response_text}" + ) + raise HTTPException( + status_code=response.status, + detail=f"Failed to create ARI channel: {response_text}", + ) + + response_data = json.loads(response_text) + channel_id = response_data.get("id", "") + + logger.info( + f"[ARI] Channel created: {channel_id} " + f"state={response_data.get('state')}" + ) + + return CallInitiationResult( + call_id=channel_id, + status=response_data.get("state", "created"), + provider_metadata={ + "call_id": channel_id, + "channel_name": response_data.get("name", ""), + }, + raw_response=response_data, + ) + + async def get_call_status(self, call_id: str) -> Dict[str, Any]: + """Get channel status from ARI.""" + if not self.validate_config(): + raise ValueError("ARI provider not properly configured") + + endpoint = f"{self.base_url}/channels/{call_id}" + + async with aiohttp.ClientSession() as session: + async with session.get(endpoint, auth=self._get_auth()) as response: + if response.status != 200: + error_data = await response.text() + raise Exception(f"Failed to get channel status: {error_data}") + return await response.json() + + async def get_available_phone_numbers(self) -> List[str]: + """Return configured extensions/numbers.""" + return self.from_numbers + + def validate_config(self) -> bool: + """Validate ARI configuration.""" + return bool(self.ari_endpoint and self.app_name and self.app_password) + + async def verify_webhook_signature( + self, url: str, params: Dict[str, Any], signature: str + ) -> bool: + """ARI does not use webhook signatures - events come via WebSocket.""" + return True + + async def get_webhook_response( + self, workflow_id: int, user_id: int, workflow_run_id: int + ) -> str: + """ARI does not use webhook responses - call control is via REST API.""" + logger.warning( + "get_webhook_response called for ARI - this should not happen. " + "ARI uses REST API for call control, not webhooks." + ) + return "" + + async def get_call_cost(self, call_id: str) -> Dict[str, Any]: + """ARI/Asterisk does not provide call cost information.""" + return { + "cost_usd": 0.0, + "duration": 0, + "status": "unknown", + "error": "ARI does not support cost retrieval", + } + + def parse_status_callback(self, data: Dict[str, Any]) -> Dict[str, Any]: + """ + Parse ARI event data into generic status callback format. + + ARI events come from the WebSocket listener, not HTTP callbacks. + """ + # Map ARI channel states to common status format + state_map = { + "Up": "answered", + "Down": "completed", + "Ringing": "ringing", + "Ring": "ringing", + "Busy": "busy", + "Unavailable": "failed", + } + + channel_state = data.get("channel", {}).get("state", "") + event_type = data.get("type", "") + + # Determine status from event type + if event_type == "StasisStart": + status = "answered" + elif event_type == "StasisEnd": + status = "completed" + elif event_type == "ChannelDestroyed": + status = "completed" + else: + status = state_map.get(channel_state, channel_state.lower()) + + channel = data.get("channel", {}) + return { + "call_id": channel.get("id", ""), + "status": status, + "from_number": channel.get("caller", {}).get("number"), + "to_number": channel.get("dialplan", {}).get("exten"), + "direction": None, + "duration": None, + "extra": data, + } + + async def handle_websocket( + self, + websocket: "WebSocket", + workflow_id: int, + user_id: int, + workflow_run_id: int, + ) -> None: + """ + Handle WebSocket connection from ARI externalMedia channel. + + Unlike Twilio (which sends "connected" and "start" JSON messages), + Asterisk chan_websocket starts streaming audio immediately. + """ + from api.services.pipecat.run_pipeline import run_pipeline_ari + + # Get channel_id from workflow run context + workflow_run = await db_client.get_workflow_run(workflow_run_id, user_id) + channel_id = "" + if workflow_run and workflow_run.gathered_context: + channel_id = workflow_run.gathered_context.get("call_id", "") + + logger.info( + f"[ARI] Starting pipeline for workflow_run {workflow_run_id}, channel={channel_id}" + ) + + await run_pipeline_ari( + websocket, channel_id, workflow_id, workflow_run_id, user_id + ) + + # ======== INBOUND CALL METHODS ======== + + @classmethod + def can_handle_webhook( + cls, webhook_data: Dict[str, Any], headers: Dict[str, str] + ) -> bool: + """ + ARI does not use HTTP webhooks for inbound calls. + Inbound calls are received via the ARI WebSocket event listener. + """ + return False + + @staticmethod + def parse_inbound_webhook(webhook_data: Dict[str, Any]) -> NormalizedInboundData: + """Parse ARI event data into normalized inbound format.""" + channel = webhook_data.get("channel", {}) + caller = channel.get("caller", {}) + connected = channel.get("connected", {}) + + return NormalizedInboundData( + provider=ARIProvider.PROVIDER_NAME, + call_id=channel.get("id", ""), + from_number=caller.get("number", ""), + to_number=channel.get("dialplan", {}).get("exten", ""), + direction="inbound", + call_status=channel.get("state", ""), + account_id=None, + raw_data=webhook_data, + ) + + @staticmethod + def validate_account_id(config_data: dict, webhook_account_id: str) -> bool: + """ARI doesn't use account IDs for validation.""" + return True + + def normalize_phone_number(self, phone_number: str) -> str: + """Normalize phone number - ARI uses extensions as-is.""" + return phone_number or "" + + async def verify_inbound_signature( + self, url: str, webhook_data: Dict[str, Any], signature: str + ) -> bool: + """ARI authenticates via WebSocket connection credentials, not signatures.""" + return True + + @staticmethod + async def generate_inbound_response( + websocket_url: str, workflow_run_id: int = None + ) -> tuple: + """ARI does not generate HTTP responses for inbound calls.""" + from fastapi import Response + + return Response(content="", status_code=204) + + @staticmethod + def generate_error_response(error_type: str, message: str) -> tuple: + """Generate a generic JSON error response.""" + from fastapi import Response + + return Response( + content=json.dumps({"error": error_type, "message": message}), + media_type="application/json", + ) + + @staticmethod + def generate_validation_error_response(error_type) -> tuple: + """Generate JSON error response for validation failures.""" + from fastapi import Response + + from api.errors.telephony_errors import TELEPHONY_ERROR_MESSAGES, TelephonyError + + message = TELEPHONY_ERROR_MESSAGES.get( + error_type, TELEPHONY_ERROR_MESSAGES[TelephonyError.GENERAL_AUTH_FAILED] + ) + + return Response( + content=json.dumps({"error": str(error_type), "message": message}), + media_type="application/json", + ) + + # ======== CALL TRANSFER METHODS ======== + + def supports_transfers(self) -> bool: + """ARI does not currently support call transfers.""" + return False + + async def transfer_call( + self, + destination: str, + transfer_id: str, + conference_name: str, + timeout: int = 30, + **kwargs: Any, + ) -> Dict[str, Any]: + """ARI call transfers are not yet implemented.""" + raise NotImplementedError("ARI provider does not support call transfers") + + # ======== ARI-SPECIFIC METHODS ======== + + async def hangup_channel(self, channel_id: str, reason: str = "normal") -> bool: + """Hang up an ARI channel.""" + endpoint = f"{self.base_url}/channels/{channel_id}" + params = {"reason_code": reason} + + try: + async with aiohttp.ClientSession() as session: + async with session.delete( + endpoint, params=params, auth=self._get_auth() + ) as response: + if response.status in (200, 204): + logger.info(f"[ARI] Channel {channel_id} hung up") + return True + else: + error = await response.text() + logger.error( + f"[ARI] Failed to hangup channel {channel_id}: {error}" + ) + return False + except Exception as e: + logger.error(f"[ARI] Exception hanging up channel {channel_id}: {e}") + return False + + async def answer_channel(self, channel_id: str) -> bool: + """Answer an ARI channel.""" + endpoint = f"{self.base_url}/channels/{channel_id}/answer" + + try: + async with aiohttp.ClientSession() as session: + async with session.post(endpoint, auth=self._get_auth()) as response: + if response.status in (200, 204): + logger.info(f"[ARI] Channel {channel_id} answered") + return True + else: + error = await response.text() + logger.error( + f"[ARI] Failed to answer channel {channel_id}: {error}" + ) + return False + except Exception as e: + logger.error(f"[ARI] Exception answering channel {channel_id}: {e}") + return False + + def get_ws_url(self) -> str: + """Get the ARI WebSocket URL for event listening.""" + parsed = urlparse(self.ari_endpoint) + ws_scheme = "wss" if parsed.scheme == "https" else "ws" + return ( + f"{ws_scheme}://{parsed.netloc}/ari/events" + f"?api_key={self.app_name}:{self.app_password}" + f"&app={self.app_name}" + f"&subscribeAll=true" + ) diff --git a/api/services/telephony/providers/cloudonix_provider.py b/api/services/telephony/providers/cloudonix_provider.py index c6b2a55..d26849b 100644 --- a/api/services/telephony/providers/cloudonix_provider.py +++ b/api/services/telephony/providers/cloudonix_provider.py @@ -680,3 +680,30 @@ class CloudonixProvider(TelephonyProvider): """ return Response(content=twiml, media_type="application/xml"), "application/xml" + + # ======== CALL TRANSFER METHODS ======== + + async def transfer_call( + self, + destination: str, + transfer_id: str, + conference_name: str, + timeout: int = 30, + **kwargs: Any, + ) -> Dict[str, Any]: + """ + Cloudonix provider does not support call transfers. + + Raises: + NotImplementedError: Cloudonix call transfers are yet to be implemented + """ + raise NotImplementedError("Cloudonix provider does not support call transfers") + + def supports_transfers(self) -> bool: + """ + Cloudonix does not support call transfers. + + Returns: + False - Cloudonix provider does not support call transfers + """ + return False diff --git a/api/services/telephony/providers/twilio_provider.py b/api/services/telephony/providers/twilio_provider.py index 3c020b0..764227e 100644 --- a/api/services/telephony/providers/twilio_provider.py +++ b/api/services/telephony/providers/twilio_provider.py @@ -72,6 +72,7 @@ class TwilioProvider(TelephonyProvider): if from_number is None: from_number = random.choice(self.from_numbers) logger.info(f"Selected phone number {from_number} for outbound call") + logger.info(f"Webhook url received - {webhook_url}") # Prepare call data data = {"To": to_number, "From": from_number, "Url": webhook_url} @@ -172,6 +173,7 @@ class TwilioProvider(TelephonyProvider): """ + logger.info(f"Twiml content generated - {twiml_content}") return twiml_content async def get_call_cost(self, call_id: str) -> Dict[str, Any]: @@ -459,3 +461,129 @@ class TwilioProvider(TelephonyProvider): """ return Response(content=twiml_content, media_type="application/xml") + + # ======== CALL TRANSFER METHODS ======== + + async def transfer_call( + self, + destination: str, + transfer_id: str, + conference_name: str, + timeout: int = 30, + **kwargs: Any, + ) -> Dict[str, Any]: + """ + Initiate a call transfer via Twilio. + + Uses inline TwiML to put the destination into a conference when they answer, + and a status callback to track the transfer outcome. + + Args: + destination: The destination phone number (E.164 format) + transfer_id: Unique identifier for tracking this transfer + conference_name: Name of the conference to join the destination into + timeout: Transfer timeout in seconds + **kwargs: Additional Twilio-specific parameters + + Returns: + Dict containing transfer result information + + Raises: + ValueError: If provider configuration is invalid + Exception: If Twilio API call fails + """ + if not self.validate_config(): + raise ValueError("Twilio provider not properly configured") + + # Select a random phone number for the transfer + from_number = random.choice(self.from_numbers) + logger.info(f"Selected phone number {from_number} for transfer call") + + backend_endpoint, _ = await get_backend_endpoints() + + status_callback_url = ( + f"{backend_endpoint}/api/v1/telephony/transfer-result/{transfer_id}" + ) + + # Inline TwiML: when the destination answers, put them into the conference + twiml = f""" + + You have answered a transfer call. Connecting you now. + + {conference_name} + +""" + + # Prepare Twilio API call data + endpoint = f"{self.base_url}/Calls.json" + data = { + "To": destination, + "From": from_number, + "Timeout": timeout, + "Twiml": twiml, + "StatusCallback": status_callback_url, + "StatusCallbackEvent": [ + "answered", + "no-answer", + "busy", + "failed", + "completed", + ], + "StatusCallbackMethod": "POST", + } + + # Add any additional kwargs + data.update(kwargs) + + try: + logger.debug(f"Transfer call data: {data}") + + async with aiohttp.ClientSession() as session: + auth = aiohttp.BasicAuth(self.account_sid, self.auth_token) + async with session.post(endpoint, data=data, auth=auth) as response: + response_status = response.status + response_text = await response.text() + + logger.info( + f"Twilio transfer API response status: {response_status}" + ) + logger.debug(f"Twilio transfer API response body: {response_text}") + + if response_status in [200, 201]: + try: + response_data = await response.json() + call_sid = response_data.get("sid") + logger.info( + f"Transfer call initiated successfully: {call_sid}" + ) + + return { + "call_sid": call_sid, + "status": response_data.get("status", "queued"), + "provider": self.PROVIDER_NAME, + "from_number": from_number, + "to_number": destination, + "raw_response": response_data, + } + except Exception as e: + logger.error( + f"Failed to parse Twilio transfer response JSON: {e}" + ) + raise Exception(f"Failed to parse transfer response: {e}") + else: + error_msg = f"Twilio API call failed with status {response_status}: {response_text}" + logger.error(error_msg) + raise Exception(error_msg) + + except Exception as e: + logger.error(f"Exception during Twilio transfer call: {e}") + raise + + def supports_transfers(self) -> bool: + """ + Twilio supports call transfers. + + Returns: + True - Twilio provider supports call transfers + """ + return True diff --git a/api/services/telephony/providers/vobiz_provider.py b/api/services/telephony/providers/vobiz_provider.py index 0666d85..7e91bed 100644 --- a/api/services/telephony/providers/vobiz_provider.py +++ b/api/services/telephony/providers/vobiz_provider.py @@ -533,3 +533,30 @@ class VobizProvider(TelephonyProvider): """ return Response(content=vobiz_xml_content, media_type="application/xml") + + # ======== CALL TRANSFER METHODS ======== + + async def transfer_call( + self, + destination: str, + transfer_id: str, + conference_name: str, + timeout: int = 30, + **kwargs: Any, + ) -> Dict[str, Any]: + """ + Vobiz provider does not support call transfers. + + Raises: + NotImplementedError: Vobiz call transfers are yet to be implemented + """ + raise NotImplementedError("Vobiz provider does not support call transfers") + + def supports_transfers(self) -> bool: + """ + Vobiz does not support call transfers. + + Returns: + False - Vobiz provider does not support call transfers + """ + return False diff --git a/api/services/telephony/providers/vonage_provider.py b/api/services/telephony/providers/vonage_provider.py index ee25d78..357d5b4 100644 --- a/api/services/telephony/providers/vonage_provider.py +++ b/api/services/telephony/providers/vonage_provider.py @@ -484,3 +484,30 @@ class VonageProvider(TelephonyProvider): ] return Response(content=json.dumps(error_ncco), media_type="application/json") + + # ======== CALL TRANSFER METHODS ======== + + async def transfer_call( + self, + destination: str, + transfer_id: str, + conference_name: str, + timeout: int = 30, + **kwargs: Any, + ) -> Dict[str, Any]: + """ + Vonage provider does not support call transfers. + + Raises: + NotImplementedError: call transfers are yet to be implemented + """ + raise NotImplementedError("Vonage provider does not support call transfers") + + def supports_transfers(self) -> bool: + """ + Vonage does not support call transfers. + + Returns: + False - Vonage provider does not support call transfers + """ + return False diff --git a/api/services/telephony/stasis_event_protocol.py b/api/services/telephony/stasis_event_protocol.py deleted file mode 100644 index 81b87e8..0000000 --- a/api/services/telephony/stasis_event_protocol.py +++ /dev/null @@ -1,184 +0,0 @@ -"""Redis communication protocol for distributed ARI architecture. - -Defines message formats and helpers for ARI Manager <-> Worker communication. -""" - -import json -from dataclasses import asdict, dataclass -from enum import Enum -from typing import Any, Dict, List, Optional - - -class EventType(str, Enum): - """Types of events sent from ARI Manager to Workers.""" - - STASIS_START = "stasis_start" - STASIS_END = "stasis_end" - CHANNEL_UPDATE = "channel_update" - ERROR = "error" - - -class CommandType(str, Enum): - """Types of commands sent from Workers to ARI Manager.""" - - DISCONNECT = "disconnect" - TRANSFER = "transfer" - UPDATE_STATE = "update_state" - SOCKET_CLOSED = "socket_closed" - - -@dataclass -class BaseWorkerToARIManagerCommand: - """Base class for all commands sent from Workers to ARI Manager.""" - - type: str - channel_id: str = "" - - def to_json(self) -> str: - return json.dumps(asdict(self)) - - @classmethod - def from_json(cls, data: str): - return cls(**json.loads(data)) - - -@dataclass -class StasisStartEvent: - """Event sent when a new call is bridged and ready.""" - - type: str = EventType.STASIS_START - channel_id: str = "" - caller_channel_id: str = "" - em_channel_id: Optional[str] = None - bridge_id: Optional[str] = None - local_addr: List[Any] = None # [host, port] - remote_addr: Optional[List[Any]] = None # [host, port] with UNICASTRTP_LOCAL_PORT - call_context_vars: Dict[str, Any] = None - - def __post_init__(self): - if self.local_addr is None: - self.local_addr = [] - if self.call_context_vars is None: - self.call_context_vars = {} - - def to_json(self) -> str: - return json.dumps(asdict(self)) - - @classmethod - def from_json(cls, data: str) -> "StasisStartEvent": - return cls(**json.loads(data)) - - -@dataclass -class StasisEndEvent: - """Event sent when a call ends.""" - - type: str = EventType.STASIS_END - channel_id: str = "" - reason: Optional[str] = None - - def to_json(self) -> str: - return json.dumps(asdict(self)) - - @classmethod - def from_json(cls, data: str) -> "StasisEndEvent": - return cls(**json.loads(data)) - - -@dataclass -class DisconnectCommand(BaseWorkerToARIManagerCommand): - """Command to disconnect a call.""" - - type: str = CommandType.DISCONNECT - reason: str = "worker_requested" - - -@dataclass -class TransferCommand(BaseWorkerToARIManagerCommand): - """Command to transfer a call.""" - - type: str = CommandType.TRANSFER - context: Dict[str, Any] = None - - def __post_init__(self): - if self.context is None: - self.context = {} - - -@dataclass -class SocketClosedCommand(BaseWorkerToARIManagerCommand): - """Command to notify that RTP sockets have been closed.""" - - type: str = CommandType.SOCKET_CLOSED - - -class RedisChannels: - """Redis channel naming conventions.""" - - @staticmethod - def worker_events(worker_id: str) -> str: - """Channel for events sent to a specific worker.""" - return f"ari:events:worker:{worker_id}" - - @staticmethod - def channel_commands(channel_id: str) -> str: - """Channel for commands related to a specific call channel.""" - return f"ari:commands:{channel_id}" - - @staticmethod - def channel_updates(channel_id: str) -> str: - """Channel for state updates about a specific call.""" - return f"ari:updates:{channel_id}" - - -class RedisKeys: - """Redis key naming conventions for worker registration and discovery.""" - - @staticmethod - def worker_active(worker_id: str) -> str: - """Key for active worker status and metadata.""" - return f"workers:active:{worker_id}" - - @staticmethod - def workers_set() -> str: - """Set containing all registered worker IDs.""" - return "workers:set" - - @staticmethod - def round_robin_index() -> str: - """Counter for round-robin worker selection.""" - return "workers:round_robin:index" - - -def parse_event(data: str) -> Any: - """Parse a Redis event message.""" - try: - parsed = json.loads(data) - event_type = parsed.get("type") - - if event_type == EventType.STASIS_START: - return StasisStartEvent(**parsed) - elif event_type == EventType.STASIS_END: - return StasisEndEvent(**parsed) - else: - return parsed - except Exception: - return None - - -def parse_command(data: str) -> Any: - """Parse a Redis command message.""" - try: - parsed = json.loads(data) - cmd_type = parsed.get("type") - - if cmd_type == CommandType.DISCONNECT: - return DisconnectCommand(**parsed) - elif cmd_type == CommandType.TRANSFER: - return TransferCommand(**parsed) - elif cmd_type == CommandType.SOCKET_CLOSED: - return SocketClosedCommand(**parsed) - else: - return parsed - except Exception: - return None diff --git a/api/services/telephony/stasis_rtp_client.py b/api/services/telephony/stasis_rtp_client.py deleted file mode 100644 index 1281b7a..0000000 --- a/api/services/telephony/stasis_rtp_client.py +++ /dev/null @@ -1,315 +0,0 @@ -"""Low-level RTP transport for Asterisk externalMedia sessions. - -stasis_rtp_client.py -~~~~~~~~~~~~~~~~~~~~ - -* Sends and receives **proper RTP/UDP** (PT 0 PCMU/μ-law). -* Uses 20 ms frames (160 bytes payload) by default; automatically - chunks or concatenates data so timestamps stay correct. -* Verifies the RTP header on the receive path (SSRC and PT). -""" - -import asyncio -import secrets -import socket -import struct -from typing import TYPE_CHECKING, AsyncIterator, Optional - -from loguru import logger - -if TYPE_CHECKING: - from api.services.telephony.stasis_rtp_connection import StasisRTPConnection - from api.services.telephony.stasis_rtp_transport import StasisRTPCallbacks - -# ─────────────────────────────────────────────────────────────────── helpers - - -_RTP_HDR = struct.Struct("!BBHII") # v/p/x/cc, m/pt, seq, ts, ssrc -_PT_PCMU = 0 # static payload type for μ-law - - -class _RTPEncoder: - """Builds PCMU RTP headers for the packets we SEND to Asterisk.""" - - def __init__(self): - self.ssrc = secrets.randbits(32) - self.seq = secrets.randbits(16) - self.ts = 0 # incremented by #payload bytes - - def pack(self, payload: bytes, mark=False) -> bytes: - b0 = 0x80 # V=2 - b1 = (0x80 if mark else 0x00) | _PT_PCMU - hdr = _RTP_HDR.pack(b0, b1, self.seq, self.ts, self.ssrc) - self.seq = (self.seq + 1) & 0xFFFF - self.ts += len(payload) # 1 sample/byte @ 8 kHz - return hdr + payload - - -class _RTPDecoder: - """Very forgiving RTP decoder. - - Latches on the first valid packet and then insists - that SSRC & PT match afterwards. Returns *None* if the packet - should be ignored. - """ - - def __init__(self): - self.peer_ssrc: int | None = None # learned from first packet - - def unpack(self, packet: bytes) -> bytes | None: - if len(packet) < _RTP_HDR.size: - return None - b0, b1, seq, ts, ssrc = _RTP_HDR.unpack_from(packet) - if (b0 & 0xC0) != 0x80: # RTP v2? - return None - if (b1 & 0x7F) != _PT_PCMU: # payload-type 0? - return None - if self.peer_ssrc is None: - self.peer_ssrc = ssrc # latch on first good packet - elif ssrc != self.peer_ssrc: - return None # stray stream – drop - return packet[_RTP_HDR.size :] - - -# ──────────────────────────────────────────────────────────────── client - - -class StasisRTPClient: - """Low-level wrapper around StasisRTPConnection. - - Public API - ────────── - • await setup(start_frame) kept for parity (does nothing) - • await connect() - • async for payload in receive(): # μ-law bytes (20 ms each) - … - • await send(data) # any length; will be chunked - • await disconnect() - """ - - _FRAME_SIZE = 160 # 20 ms @ 8 kHz PCMU - - def __init__( - self, - connection: "StasisRTPConnection", - callbacks: "StasisRTPCallbacks", - ): - """Initialize Stasis RTP client. - - Args: - connection: RTP connection parameters. - callbacks: Callback handlers for transport events. - """ - from typing import Any - - self._connection = connection - self._callbacks = callbacks - self._encoder = _RTPEncoder() - self._decoder = _RTPDecoder() - - self._recv_sock: Optional[socket.socket] = None - self._send_sock: Optional[socket.socket] = None - self._closing = False - self._recv_sock_ready = asyncio.Event() # Signal when recv socket is ready - self._leave_counter = 0 # Track input/output transport usage - - # ── wire event handlers to the connection ──────────────── - @self._connection.event_handler("connected") - async def _on_connected(_: Any): - await self._setup_sockets() - await self._callbacks.on_client_connected( - self._connection.caller_channel_id - ) - - @self._connection.event_handler("disconnected") - async def _on_disconnected(_: Any): - logger.debug("In _on_disconnected of StasisRTPClient") - await self._callbacks.on_client_disconnected( - self._connection.caller_channel_id - ) - - # ─── public helpers ────────────────────────────────────────── - - async def setup(self, _): - """Setup method for compatibility.""" - self._leave_counter += 1 - - async def connect(self): - """Connect to the RTP socket.""" - if self._connection.is_connected(): - return - await self._connection.connect() - - async def disconnect(self): - """Disconnect from the RTP socket.""" - # Decrement leave counter when disconnect is called - logger.debug(f"StasisRTPClient.disconnect leave_counter: {self._leave_counter}") - self._leave_counter -= 1 - if self._leave_counter > 0: - # Early return - InputTransport called first, OutputTransport will call later - # Only proceed when counter reaches 0 (OutputTransport's call) - return - - # Close sockets - logger.debug("Going to close sockets") - await self._close_sockets() - - if self._closing: - # We might have received the disconnected callback from the StasisRTPConnection - # due to user hangup. We will just return. We have already closed the sockets - # in disconnected callback handler. - return - self._closing = True - - # If we have initiated transfer before, we would ignore _connection.disconnect() - # in the connection. (since is_closing would be set by transfer) - try: - await self._connection.disconnect() - except Exception as exc: - logger.error(f"Failed to disconnect RTP connection: {exc}") - - # ─── socket management ────────────────────────────────────── - - async def _setup_sockets(self): - if self._recv_sock and self._send_sock: - return - - logger.debug( - f"Setting up Sockets - local {self._connection.local_addr}, remote: {self._connection.remote_addr}" - ) - - # receive socket – bind to local address provided by connection - if not self._recv_sock: - rs = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - rs.setblocking(False) - rs.bind(self._connection.local_addr) - self._recv_sock = rs - self._recv_sock_ready.set() # Signal that recv socket is ready - - # send socket – connect to remote (Asterisk) address - if not self._send_sock: - ss = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - ss.setblocking(False) - ss.connect(self._connection.remote_addr) - self._send_sock = ss - - logger.debug( - f"Socket setup complete - recv_fd: {self._recv_sock.fileno()}, send_fd: {self._send_sock.fileno()}" - ) - - async def _close_sockets(self): - """Safely close sockets with proper error handling.""" - for sock_name, sock in [("recv", self._recv_sock), ("send", self._send_sock)]: - if sock: - try: - # Shutdown the socket first to break any pending operations - sock.shutdown(socket.SHUT_RDWR) - except OSError: - # Socket might already be closed or in a bad state - pass - try: - sock.close() - except Exception as exc: - logger.debug(f"Error closing {sock_name} socket: {exc}") - - self._recv_sock = None - self._send_sock = None - self._recv_sock_ready.clear() # Reset the event for potential reconnection - - # Notify the connection that sockets are closed so ARI Manager can clean up ports - await self._connection.notify_sockets_closed() - - logger.debug("Closed sockets in StasisRTPClient") - - # ─── receive path ──────────────────────────────────────────── - - async def receive(self) -> AsyncIterator[bytes]: - """Async generator yielding μ-law frames (exactly 160 bytes each). - - Silently drops any packet whose RTP header does not match our SSRC/PT. - """ - loop = asyncio.get_running_loop() - - # Wait for recv socket to be created - try: - await self._recv_sock_ready.wait() - except asyncio.CancelledError: - return - - logger.debug("Going to receive from the socket now") - - while not self._closing: - try: - # each loop gets 172 bytes UDP packet, which is 160 bytes of - # audio data (Asterisk sends 20ms audio chunks with 8k sample rate) - # and 12 bytes of RTP header - data = await loop.sock_recv(self._recv_sock, 2048) - except asyncio.CancelledError: - logger.debug("RTP receive task cancelled") - break - except (OSError, socket.error) as exc: - logger.warning(f"RTP receive failed (socket closed): {exc}") - break - except Exception as exc: - logger.debug(f"Unexpected error in receive: {exc}") - break - - payload = self._decoder.unpack(data) - if payload is None: - continue # header failed validation - - # In practice Asterisk sends 20 ms frames – assert just in case. - if len(payload) != self._FRAME_SIZE: - logger.warning(f"Dropping non-20 ms packet len={len(payload)}") - continue - yield payload - - # ─── send path ─────────────────────────────────────────────── - - async def send(self, data: bytes): - """Send μ-law data of arbitrary length. - - Splits/aggregates into 160-byte chunks before RTP-wrapping. - """ - if self._closing or not self._send_sock: - return - loop = asyncio.get_running_loop() - - # chunk/concat to 160-byte frames - chunks = self._chunk_ulaw(data, self._FRAME_SIZE) - for i, chunk in enumerate(chunks): - mark = i == 0 # set marker on the first packet of talk-spurt - packet = self._encoder.pack(chunk, mark=mark) - try: - await loop.sock_sendall(self._send_sock, packet) - except (OSError, socket.error) as exc: - logger.warning(f"RTP send failed (socket closed): {exc}") - break - except Exception as exc: - logger.error(f"RTP send failed: {exc}") - break - - def _chunk_ulaw(self, buf: bytes, size: int) -> list[bytes]: - """Split / aggregate μ-law bytes to exact *size* multiples. - - • If buf length is not a multiple of *size*, pad the last chunk with 0xFF - (silence). That keeps timestamps monotonic. - """ - if not buf: - return [] - if len(buf) % size: - pad = size - (len(buf) % size) - buf += b"\xff" * pad - return [buf[i : i + size] for i in range(0, len(buf), size)] - - # ─── properties ────────────────────────────────────────────── - - @property - def is_connected(self) -> bool: - """Check if client is connected.""" - return self._connection.is_connected() and not self._closing - - @property - def is_closing(self) -> bool: - """Check if client is closing.""" - return self._closing diff --git a/api/services/telephony/stasis_rtp_connection.py b/api/services/telephony/stasis_rtp_connection.py deleted file mode 100644 index a592f35..0000000 --- a/api/services/telephony/stasis_rtp_connection.py +++ /dev/null @@ -1,191 +0,0 @@ -"""Stasis RTP connection for worker processes - is used by stasis rtp transport. - -This connection works without direct ARI access and communicates with -the ARI Manager via Redis for all control operations. -""" - -from typing import Optional, Tuple - -import redis.asyncio as aioredis -from loguru import logger - -from api.services.telephony.stasis_event_protocol import ( - DisconnectCommand, - RedisChannels, - SocketClosedCommand, - TransferCommand, -) -from pipecat.utils.base_object import BaseObject - - -class StasisRTPConnection(BaseObject): - """Worker-side connection that communicates with ARI Manager via Redis. - - This class provides the same API as the original StasisRTPConnection but - without direct ARI client access. All channel operations are delegated - to the ARI Manager process via Redis. - """ - - _SUPPORTED_EVENTS = [ - "connecting", - "connected", - "disconnected", - "closed", - "failed", - "new", - ] - - def __init__( - self, - redis_client: aioredis.Redis, - channel_id: str, - caller_channel_id: str, - em_channel_id: Optional[str], - bridge_id: Optional[str], - local_addr: Optional[Tuple[str, int]], - remote_addr: Optional[Tuple[str, int]], - workflow_run_id: Optional[int] = None, - ): - """Initialize distributed connection with pre-established details. - - Args: - redis_client: Redis client for communication - channel_id: Primary channel ID for this connection - caller_channel_id: Caller's channel ID - em_channel_id: External media channel ID - bridge_id: Bridge ID (already created by ARI Manager) - local_addr: Local RTP address (host, port) - remote_addr: Remote RTP address with UNICASTRTP_LOCAL_PORT - workflow_run_id: Workflow run ID for logging context - """ - super().__init__() - - self.redis = redis_client - self.channel_id = channel_id - self.caller_channel_id = caller_channel_id - self.em_channel_id = em_channel_id - self.bridge_id = bridge_id - self.workflow_run_id = workflow_run_id - - # RTP addressing (same as StasisRTPConnection) - self.local_addr = local_addr - self.remote_addr = remote_addr - - # State tracking - # self._closed_by_stasis_end should only be set True after we get - # StasisEnd from the transport - self._closed_by_stasis_end = False - - # self._closing should be True if we have received disconnect - # or transfer request - self._closing = False - - self._connect_invoked = False - - # Register event handlers - for evt in self._SUPPORTED_EVENTS: - self._register_event_handler(evt) - - logger.debug( - f"channelID: {channel_id} StasisRTPConnection created: " - f"bridgeID: {bridge_id}, local_addr={local_addr}, remote_addr={remote_addr}" - ) - - async def connect(self): - """Signal readiness to start the call. - - Since the bridge is already established by ARI Manager, - we can immediately trigger the connected event. - """ - self._connect_invoked = True - if self.is_connected(): - await self._call_event_handler("connected") - else: - logger.warning( - "StasisRTPConnection is not connected - did not call connected handler" - ) - - async def disconnect(self): - """Request disconnection via Redis command to ARI Manager. Usually called - when there is a disconnect triggered by workflow""" - # If we have already received user hangup via StasisEnd, lets - # return - if self._closed_by_stasis_end or self._closing: - return - - self._closing = True - - logger.info(f"channelID: {self.channel_id} Requesting disconnect") - - # Send disconnect command to ARI Manager - command = DisconnectCommand(channel_id=self.channel_id) - channel = RedisChannels.channel_commands(self.channel_id) - await self.redis.publish(channel, command.to_json()) - - async def transfer(self, call_transfer_context: dict): - """Request call transfer via Redis command to ARI Manager.""" - # If we have already received user hangup via StasisEnd, lets - # return - if self._closed_by_stasis_end or self._closing: - return - - self._closing = True - - logger.info(f"channelID: {self.channel_id} Requesting transfer") - - # Send transfer command to ARI Manager - command = TransferCommand( - channel_id=self.channel_id, context=call_transfer_context - ) - channel = RedisChannels.channel_commands(self.channel_id) - await self.redis.publish(channel, command.to_json()) - - async def notify_sockets_closed(self): - """Notify ARI Manager that RTP sockets have been closed.""" - logger.info( - f"channelID: {self.channel_id} Notifying ARI Manager that sockets are closed" - ) - - # Send socket_closed command to ARI Manager - command = SocketClosedCommand(channel_id=self.channel_id) - channel = RedisChannels.channel_commands(self.channel_id) - await self.redis.publish(channel, command.to_json()) - - def is_connected(self) -> bool: - """Check if connection is established. - - Returns True once connect() has been called and connection is not closed. - """ - return ( - self._connect_invoked - and not self._closed_by_stasis_end - and not self._closing - ) - - async def handle_remote_disconnect(self): - """Handle disconnection initiated by ARI Manager. Is called when the user hangs up.""" - if self._closed_by_stasis_end or self._closing: - return - - self._closed_by_stasis_end = True - - if self._connect_invoked: - # Unless self._connect_invoked is True, the event handlers won't be registered. We only - # register the event handler of client when the transports are initiated during pipeline - # initialisation. Any caller must check and wait for _connect_invoked before - # calling the method - await self._call_event_handler("disconnected") - else: - logger.warning( - f"ChannelID: {self.channel_id} Got remote disconnect before connection was invoked" - ) - - logger.info(f"channelID: {self.channel_id} StasisRTPConnection disconnected") - - def __repr__(self): - """String representation of connection.""" - return ( - f"" - ) diff --git a/api/services/telephony/stasis_rtp_serializer.py b/api/services/telephony/stasis_rtp_serializer.py deleted file mode 100644 index c4caf02..0000000 --- a/api/services/telephony/stasis_rtp_serializer.py +++ /dev/null @@ -1,116 +0,0 @@ -# Copyright (c) 2024–2025, Daily -# -# SPDX-License-Identifier: BSD 2-Clause License -"""Stasis RTP frame serializer. - -This serializer converts between Pipecat frames and the raw μ-law RTP payload -stream expected by an Stasis *External Media* channel. - -The serializer: - -* Down-samples PCM to 8-kHz μ-law for **outgoing** audio (:class:`AudioRawFrame`). -* Up-samples μ-law to the pipeline's native rate for **incoming** audio. -""" - -from typing import Optional - -from loguru import logger -from pydantic import BaseModel - -from pipecat.audio.utils import create_default_resampler, pcm_to_ulaw, ulaw_to_pcm -from pipecat.frames.frames import ( - AudioRawFrame, - Frame, - InputAudioRawFrame, - StartFrame, -) -from pipecat.serializers.base_serializer import FrameSerializer - - -class StasisRTPFrameSerializer(FrameSerializer): - """Serializer for Asterisk External Media streams (raw μ-law).""" - - class InputParams(BaseModel): - """Configuration parameters. - - Attributes: - ---------- - stasis_sample_rate : int, default 8000 - The sample-rate used by Stasis when sending μ-law (PCMU). - sample_rate : Optional[int] - Override for the pipeline's *input* sample-rate. When omitted the - value from the :class:`StartFrame` is used. - """ - - stasis_sample_rate: int = 8000 - sample_rate: Optional[int] = None - - def __init__(self, params: Optional[InputParams] = None): - """Initialize Stasis RTP frame serializer. - - Args: - params: Optional configuration parameters for the serializer. - """ - self._params = params or self.InputParams() - - # Wire / pipeline rates - self._stasis_sample_rate = self._params.stasis_sample_rate - self._sample_rate = 0 # pipeline rate, filled in *setup* - - # Resampler shared between encode / decode paths - self._resampler = create_default_resampler() - - async def setup(self, frame: StartFrame): - """Remember pipeline configuration.""" - self._sample_rate = self._params.sample_rate or frame.audio_in_sample_rate - - async def serialize(self, frame: Frame) -> bytes | str | None: - """Convert a Pipecat frame to a wire payload. - - Only :class:`AudioRawFrame` instances are translated all other frame - types are silently ignored, allowing higher-level transports to deal - with them as needed. - """ - if isinstance(frame, AudioRawFrame): - try: - # Pipeline PCM → 8-kHz μ-law - encoded = await pcm_to_ulaw( - frame.audio, - frame.sample_rate, - self._stasis_sample_rate, - self._resampler, - ) - return encoded # raw bytes - except Exception as exc: # pragma: no cover – robustness - logger.error( - f"StasisRTPFrameSerializer.serialize: encode failed: {exc}" - ) - return None - - # Non-audio frames are not transmitted on the media path - return None - - async def deserialize(self, data: bytes | str) -> Frame | None: - """Convert wire payloads to Pipecat frames. - - The Stasis media socket delivers bare μ-law bytes, therefore *data* - must be *bytes*. Any *str* is ignored. - """ - if not isinstance(data, (bytes, bytearray)): - return None - - try: - pcm = await ulaw_to_pcm( - bytes(data), - self._stasis_sample_rate, - self._sample_rate, - self._resampler, - ) - return InputAudioRawFrame( - audio=pcm, - sample_rate=self._sample_rate, - num_channels=1, - ) - except Exception as exc: # pragma: no cover - logger.error(f"StasisRTPFrameSerializer.deserialize: decode failed: {exc}") - return None diff --git a/api/services/telephony/stasis_rtp_transport.py b/api/services/telephony/stasis_rtp_transport.py deleted file mode 100644 index 20a6b64..0000000 --- a/api/services/telephony/stasis_rtp_transport.py +++ /dev/null @@ -1,300 +0,0 @@ -# transports/ari_external_media.py (new file) - -"""Stasis RTP transport for Asterisk External Media integration.""" - -import asyncio -import time -from typing import Awaitable, Callable, Optional - -from loguru import logger -from pydantic import BaseModel - -from api.services.telephony.stasis_rtp_client import StasisRTPClient -from api.services.telephony.stasis_rtp_connection import StasisRTPConnection -from pipecat.frames.frames import ( - CancelFrame, - EndFrame, - InputAudioRawFrame, - OutputAudioRawFrame, - StartFrame, - TransportMessageFrame, - TransportMessageUrgentFrame, -) -from pipecat.serializers.base_serializer import FrameSerializer -from pipecat.transports.base_input import BaseInputTransport -from pipecat.transports.base_output import BaseOutputTransport -from pipecat.transports.base_transport import BaseTransport, TransportParams - - -class StasisRTPTransportParams(TransportParams): - """Transport parameters for Stasis RTP transport.""" - - serializer: FrameSerializer - - -class StasisRTPCallbacks(BaseModel): - """Callbacks for Stasis RTP transport events.""" - - on_client_connected: Callable[[str], Awaitable[None]] - on_client_disconnected: Callable[[str], Awaitable[None]] - on_client_closed: Callable[[str], Awaitable[None]] - - -# ------------------------------------------------ Input Transport ------------------------- - -""" -Transport calls client receive to receive the audio from the socket. This happens in the self._receive_audio task. -Then the audio frames are pushed to _audio_in_queue using push_audio_frame method. Then the _audio_task_handler processes -the frames from the _audio_in_queue and pushes them to the VAD analyzer, turn analyzer and pushes the audio -further downstream to tts. - -The BaseInputTransport pipeline is responsible for: -- Resampling the audio to the correct sample rate -- Applying the audio filter -- Pushing the audio frames to the VAD analyzer -- Pushing the audio frames to the turn analyzer -- Pushing the audio frames to the bot interruption analyzer -- Pushing the audio frames down the pipeline to the tts - -stop method is called from process_frame of the BaseInputTransport. super.stop() stops _audio_task_handler. It then -calls _client.disconnect. Transport's callbacks are sent to the client using StasisRTPCallbacks. -""" - - -class StasisRTPInputTransport(BaseInputTransport): - """Input transport for receiving audio over Stasis RTP.""" - - def __init__( - self, - transport: BaseTransport, - client: StasisRTPClient, - params: StasisRTPTransportParams, - **kwargs, - ): - """Initialize Stasis RTP input transport. - - Args: - transport: Parent transport instance. - client: Stasis RTP client for socket communication. - params: Transport parameters including serializer. - **kwargs: Additional keyword arguments for BaseInputTransport. - """ - super().__init__(params, **kwargs) - self._transport = transport - self._client = client - self._params = params - - self._receive_task: Optional[asyncio.Task] = None - - async def start(self, frame: StartFrame): - """Start the input transport.""" - await super().start(frame) - - await self._client.setup(frame) - await self._params.serializer.setup(frame) - - # Ensure underlying connection is established and socket ready. - await self._client.connect() - - if not self._receive_task: - self._receive_task = self.create_task(self._receive_audio()) - - await self.set_transport_ready(frame) - - async def _stop_tasks(self): - if self._receive_task: - await self.cancel_task(self._receive_task) - self._receive_task = None - - async def stop(self, frame: EndFrame): - """Stop the input transport.""" - await super().stop(frame) - await self._stop_tasks() - await self._client.disconnect() - logger.debug("Successfully disconnected from StasisRTPClient") - - async def cancel(self, frame: CancelFrame): - """Cancel the input transport.""" - await super().cancel(frame) - await self._stop_tasks() - await self._client.disconnect() - - async def _receive_audio(self): - try: - async for payload in self._client.receive(): - frame = await self._params.serializer.deserialize(payload) - if not frame: - continue - - if isinstance(frame, InputAudioRawFrame): - await self.push_audio_frame(frame) - else: - await self.push_frame(frame) - except Exception as exc: - logger.error(f"StasisRTPInputTransport receive error: {exc}") - - # No app-messages in RTP path, but keep compatibility - async def push_app_message(self, message): - """Push app message (not supported in RTP transport).""" - logger.debug("StasisRTPInputTransport received app message ignored (RTP only)") - - -# ------------------------------------------------ Output Transport ------------------------ - - -class StasisRTPOutputTransport(BaseOutputTransport): - """Output transport for sending audio over Stasis RTP.""" - - def __init__( - self, - transport: BaseTransport, - client: StasisRTPClient, - params: StasisRTPTransportParams, - **kwargs, - ): - """Initialize Stasis RTP output transport. - - Args: - transport: Parent transport instance. - client: Stasis RTP client for socket communication. - params: Transport parameters including serializer. - **kwargs: Additional keyword arguments for BaseOutputTransport. - """ - super().__init__(params, **kwargs) - - self._transport = transport - self._client = client - self._params = params - - # Pace outgoing audio so we don't dump buffers instantly (simulate 10-ms chunks) - self._send_interval: float = 0 - self._next_send_time: float = 0 - - async def start(self, frame: StartFrame): - """Start the output transport.""" - await super().start(frame) - - await self._client.setup(frame) - await self._params.serializer.setup(frame) - - self._send_interval = self._params.audio_out_10ms_chunks * 10 / 1000 # ms - - await self.set_transport_ready(frame) - - async def stop(self, frame: EndFrame): - """Stop the output transport.""" - await super().stop(frame) - await self._client.disconnect() - - async def cancel(self, frame: CancelFrame): - """Cancel the output transport.""" - await super().cancel(frame) - await self._client.disconnect() - - async def send_message( - self, frame: TransportMessageFrame | TransportMessageUrgentFrame - ): - """Send message frame (not supported in RTP transport).""" - # RTP path has no generic message channel; ignore. - pass - - async def write_audio_frame(self, frame: OutputAudioRawFrame): - """Write audio frame to RTP stream.""" - if self._client.is_closing: - return False - - if not self._client.is_connected: - # If not connected yet, just simulate playback delay. - await self._write_audio_sleep() - return - - payload = await self._params.serializer.serialize(frame) - if payload: - await self._client.send(payload) - - await self._write_audio_sleep() - - async def _write_audio_sleep(self): - """Simulates real-time audio playback timing by introducing controlled delays. - - This method implements a clock simulation to pace audio transmission at realistic - intervals. Without this pacing, audio frames would be sent as fast as possible, - which could overwhelm receivers or cause buffering issues. - - The method: - 1. Calculates how long to sleep based on when the next frame should be sent - 2. Sleeps for the calculated duration (or 0 if we're already behind schedule) - 3. Updates _next_send_time for the next audio chunk - - The _send_interval is computed as: (audio_chunk_size / sample_rate) / 2 - This creates timing that simulates how an actual audio device would output - audio at the proper rate (e.g., every 10ms for 10ms audio chunks). - """ - current_time = time.monotonic() - sleep_duration = max(0, self._next_send_time - current_time) - await asyncio.sleep(sleep_duration) - if sleep_duration == 0: - self._next_send_time = time.monotonic() + self._send_interval - else: - self._next_send_time += self._send_interval - - -class StasisRTPTransport(BaseTransport): - """Main transport class for Stasis RTP communication.""" - - def __init__( - self, - stasis_connection: StasisRTPConnection, - params: StasisRTPTransportParams, - input_name: Optional[str] = None, - output_name: Optional[str] = None, - ): - """Initialize Stasis RTP transport. - - Args: - stasis_connection: Connection parameters for Stasis RTP. - params: Transport parameters including serializer. - input_name: Optional name for input transport. - output_name: Optional name for output transport. - """ - super().__init__(input_name=input_name, output_name=output_name) - - self._params = params - - client_callbacks = StasisRTPCallbacks( - on_client_connected=self._on_client_connected, - on_client_disconnected=self._on_client_disconnected, - on_client_closed=self._on_client_closed, - ) - self._client = StasisRTPClient(stasis_connection, client_callbacks) - - self._input = StasisRTPInputTransport( - self, self._client, self._params, name=self._input_name - ) - - self._output = StasisRTPOutputTransport( - self, self._client, self._params, name=self._output_name - ) - - # expose handlers - self._register_event_handler("on_client_connected") - self._register_event_handler("on_client_disconnected") - self._register_event_handler("on_client_closed") - - def input(self) -> StasisRTPInputTransport: - """Get the input transport.""" - return self._input - - def output(self) -> StasisRTPOutputTransport: - """Get the output transport.""" - return self._output - - # ------------------------------------------------ event adapters ---------- - async def _on_client_connected(self, chan_id: str): - await self._call_event_handler("on_client_connected", chan_id) - - async def _on_client_disconnected(self, chan_id: str): - await self._call_event_handler("on_client_disconnected", chan_id) - - async def _on_client_closed(self, chan_id: str): - await self._call_event_handler("on_client_closed", chan_id) diff --git a/api/services/telephony/test_asyncari_ping.py b/api/services/telephony/test_asyncari_ping.py deleted file mode 100644 index 8e97371..0000000 --- a/api/services/telephony/test_asyncari_ping.py +++ /dev/null @@ -1,105 +0,0 @@ -#!/usr/bin/env python3 -"""Test script to verify asyncari ping functionality.""" - -import asyncio -import os -import sys -from pathlib import Path - -# Add the asyncari src to Python path for testing -asyncari_path = Path(__file__).parent.parent.parent.parent.parent / "asyncari" / "src" -sys.path.insert(0, str(asyncari_path)) - -import asyncari -from loguru import logger - - -async def test_ping(): - """Test the ping functionality with asyncari.""" - - # Configure from environment or use defaults - base_url = os.getenv("ARI_STASIS_ENDPOINT", "http://localhost:8088") - username = os.getenv("ARI_STASIS_USER", "asterisk") - password = os.getenv("ARI_STASIS_USER_PASSWORD", "asterisk") - apps = os.getenv("ARI_STASIS_APP_NAME", "test-app") - - logger.info(f"Connecting to ARI at {base_url}") - - try: - async with asyncari.connect( - base_url=base_url, apps=apps, username=username, password=password - ) as client: - logger.info("Connected to ARI") - - # Test REST API ping - logger.info("Testing REST API ping...") - result = await client.asterisk.ping() - logger.info(f"REST API ping successful: {result}") - - # Test WebSocket ping (should work with our wrapper) - logger.info("Testing WebSocket ping...") - for ws in client.websockets: - try: - await ws.ping() - logger.info("WebSocket ping() called successfully (no-op)") - except AttributeError: - logger.error("WebSocket doesn't have ping() method") - except Exception as e: - logger.error(f"WebSocket ping failed: {e}") - - # Test the keep_alive function - from ari_client_manager import keep_alive - - logger.info("Starting keep_alive task...") - keep_alive_task = asyncio.create_task(keep_alive(client, interval=5.0)) - - # Run for 20 seconds to see several pings - await asyncio.sleep(20) - - # Cancel keep_alive - keep_alive_task.cancel() - try: - await keep_alive_task - except asyncio.CancelledError: - logger.info("keep_alive task cancelled") - - logger.info("Test completed successfully!") - - except Exception as e: - logger.exception(f"Test failed: {e}") - return False - - return True - - -async def test_with_manager(): - """Test using the ARI client manager.""" - from ari_client_manager import setup_ari_client_supervisor - - async def on_stasis_call(client, channel, context_vars): - logger.info(f"Received call: {channel.id}") - - # Enable ARI Stasis for testing - os.environ["ENABLE_ARI_STASIS"] = "true" - - supervisor = await setup_ari_client_supervisor(on_stasis_call) - - if supervisor: - logger.info("ARI Stasis supervisor started with ping support") - - # Run for 30 seconds - await asyncio.sleep(30) - - await supervisor.close() - logger.info("Supervisor closed") - else: - logger.error("Failed to start supervisor") - - -if __name__ == "__main__": - import sys - - if len(sys.argv) > 1 and sys.argv[1] == "manager": - asyncio.run(test_with_manager()) - else: - asyncio.run(test_ping()) diff --git a/api/services/telephony/test_real_ping.py b/api/services/telephony/test_real_ping.py deleted file mode 100644 index 02236ba..0000000 --- a/api/services/telephony/test_real_ping.py +++ /dev/null @@ -1,83 +0,0 @@ -#!/usr/bin/env python3 -"""Test script to verify real WebSocket ping frames are being sent.""" - -import asyncio -import os -import sys -from pathlib import Path - -# Add the asyncari src to Python path -asyncari_path = Path(__file__).parent.parent.parent.parent.parent / "asyncari" / "src" -sys.path.insert(0, str(asyncari_path)) - -import asyncari -from loguru import logger - -# Enable debug logging to see ping frames -logger.add(sys.stderr, level="DEBUG") - - -async def test_real_ping(): - """Test that real WebSocket ping frames are sent.""" - - # Configure from environment or use defaults - base_url = os.getenv("ARI_STASIS_ENDPOINT", "http://localhost:8088") - username = os.getenv("ARI_STASIS_USER", "asterisk") - password = os.getenv("ARI_STASIS_USER_PASSWORD", "asterisk") - apps = os.getenv("ARI_STASIS_APP_NAME", "test-app") - - logger.info(f"Connecting to ARI at {base_url}") - - try: - async with asyncari.connect( - base_url=base_url, apps=apps, username=username, password=password - ) as client: - logger.info("Connected to ARI") - - # Get the WebSocket - for ws in client.websockets: - logger.info(f"WebSocket type: {type(ws)}") - logger.info( - f"WebSocket wrapper active: {'WebSocketWrapper' in str(type(ws))}" - ) - - # Check internal structure - if hasattr(ws, "_websocket"): - inner_ws = ws._websocket - logger.info(f"Inner WebSocket type: {type(inner_ws)}") - logger.info(f"Has _connection: {hasattr(inner_ws, '_connection')}") - logger.info(f"Has _sock: {hasattr(inner_ws, '_sock')}") - - # Send a test ping - logger.info("Sending test ping...") - try: - await ws.ping(b"test-ping-123") - logger.info("Ping sent successfully!") - except Exception as e: - logger.error(f"Ping failed: {e}") - - # Test the keep_alive function - logger.info("\nTesting keep_alive function...") - from ari_client_manager import keep_alive - - # Run keep_alive for a short time - keep_alive_task = asyncio.create_task(keep_alive(client, interval=3.0)) - - # Let it run for 10 seconds to see multiple pings - await asyncio.sleep(10) - - # Cancel and cleanup - keep_alive_task.cancel() - try: - await keep_alive_task - except asyncio.CancelledError: - pass - - logger.info("Test completed!") - - except Exception as e: - logger.exception(f"Test failed: {e}") - - -if __name__ == "__main__": - asyncio.run(test_real_ping()) diff --git a/api/services/telephony/transfer_event_protocol.py b/api/services/telephony/transfer_event_protocol.py new file mode 100644 index 0000000..6260676 --- /dev/null +++ b/api/services/telephony/transfer_event_protocol.py @@ -0,0 +1,102 @@ +"""Redis communication protocol for call transfer coordination. + +Defines event formats and Redis channels for coordinating call transfers +across multiple API server instances. +""" + +import json +from dataclasses import asdict, dataclass +from enum import Enum +from typing import Any, Dict, Optional + + +class TransferEventType(str, Enum): + """Types of transfer events sent between instances.""" + + TRANSFER_INITIATED = "transfer_initiated" + TRANSFER_ANSWERED = "transfer_answered" + TRANSFER_COMPLETED = "transfer_completed" + TRANSFER_FAILED = "transfer_failed" + TRANSFER_CANCELLED = "transfer_cancelled" + TRANSFER_TIMEOUT = "transfer_timeout" + + +@dataclass +class TransferEvent: + """Event data structure for transfer coordination.""" + + type: TransferEventType + transfer_id: str + original_call_sid: str + transfer_call_sid: Optional[str] = None + target_number: Optional[str] = None + conference_name: Optional[str] = None + message: Optional[str] = None + status: Optional[str] = None + action: Optional[str] = None + reason: Optional[str] = None + end_call: bool = False + timestamp: Optional[float] = None + + def to_json(self) -> str: + """Convert event to JSON string.""" + return json.dumps(asdict(self)) + + @classmethod + def from_json(cls, data: str) -> "TransferEvent": + """Create event from JSON string.""" + return cls(**json.loads(data)) + + def to_result_dict(self) -> Dict[str, Any]: + """Convert to function call result format.""" + result = { + "status": self.status or "success", + "message": self.message or "", + "action": self.action or self.type, + "conference_id": self.conference_name, + "transfer_call_sid": self.transfer_call_sid, + "original_call_sid": self.original_call_sid, + "end_call": self.end_call, + "reason": self.reason, + } + return result + + +@dataclass +class TransferContext: + """Transfer context data stored in Redis.""" + + transfer_id: str + call_sid: Optional[str] + target_number: str + tool_uuid: str + original_call_sid: str + conference_name: str + initiated_at: float + + def to_json(self) -> str: + """Convert context to JSON string.""" + return json.dumps(asdict(self)) + + @classmethod + def from_json(cls, data: str) -> "TransferContext": + """Create context from JSON string.""" + return cls(**json.loads(data)) + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary.""" + return asdict(self) + + +class TransferRedisChannels: + """Redis channel naming conventions for transfer events.""" + + @staticmethod + def transfer_events(transfer_id: str) -> str: + """Channel for transfer events for a specific transfer.""" + return f"transfer:events:{transfer_id}" + + @staticmethod + def transfer_context_key(transfer_id: str) -> str: + """Redis key for transfer context storage.""" + return f"transfer:context:{transfer_id}" diff --git a/api/services/telephony/worker_event_subscriber.py b/api/services/telephony/worker_event_subscriber.py deleted file mode 100644 index 6126372..0000000 --- a/api/services/telephony/worker_event_subscriber.py +++ /dev/null @@ -1,371 +0,0 @@ -"""Worker Event Subscriber for distributed ARI architecture. - -This component runs in each FastAPI worker process and subscribes to -Redis events from the ARI Manager. It creates pipelines for assigned calls -without any direct ARI connection. -""" - -import asyncio -import json -import uuid -from typing import Awaitable, Callable, Optional - -import redis.asyncio as aioredis -from loguru import logger - -from api.routes.stasis_rtp import on_stasis_call -from api.services.telephony.stasis_event_protocol import ( - DisconnectCommand, - RedisChannels, - RedisKeys, - StasisEndEvent, - StasisStartEvent, - parse_event, -) -from api.services.telephony.stasis_rtp_connection import StasisRTPConnection -from pipecat.utils.run_context import set_current_run_id - - -class WorkerEventSubscriber: - """Subscribes to ARI events from Redis and processes them in the worker.""" - - def __init__( - self, - redis_client: aioredis.Redis, - on_stasis_call: Callable[[StasisRTPConnection, dict], Awaitable[None]], - ): - self.redis = redis_client - self.worker_id = str(uuid.uuid4()) # Generate unique worker ID - self.on_stasis_call = on_stasis_call - self._running = False - self._task: Optional[asyncio.Task] = None - self._heartbeat_task: Optional[asyncio.Task] = None - self._active_connections: dict[str, StasisRTPConnection] = {} - self._active_tasks: dict[str, asyncio.Task] = {} - self._cleanup_tasks: dict[str, asyncio.Task] = {} - self._shutting_down = False - self._shutdown_event = asyncio.Event() - - async def start(self): - """Start the event subscriber.""" - if self._task is None: - self._running = True - - # Register worker in Redis - await self._register_worker() - - # Start main event loop - self._task = asyncio.create_task( - self._run(), name=f"worker_subscriber_{self.worker_id}" - ) - - # Start heartbeat task - self._heartbeat_task = asyncio.create_task( - self._heartbeat_loop(), name=f"worker_heartbeat_{self.worker_id}" - ) - - logger.info(f"Worker {self.worker_id} event subscriber started") - - async def _register_worker(self): - """Register this worker in Redis.""" - worker_key = RedisKeys.worker_active(self.worker_id) - worker_data = json.dumps({"status": "ready", "active_calls": 0}) - - # Set with TTL of 30 seconds (will be refreshed by heartbeat) - await self.redis.setex(worker_key, 30, worker_data) - - # Add to workers set - await self.redis.sadd(RedisKeys.workers_set(), self.worker_id) - - logger.info(f"Worker {self.worker_id} registered in Redis") - - async def _heartbeat_loop(self): - """Send periodic heartbeats to Redis.""" - try: - while self._running: - # Update worker status with current active call count - worker_key = RedisKeys.worker_active(self.worker_id) - worker_data = json.dumps( - { - "status": "draining" if self._shutting_down else "ready", - "active_calls": len(self._active_tasks), - } - ) - - # Refresh TTL to 30 seconds - await self.redis.setex(worker_key, 30, worker_data) - - # Wait 10 seconds before next heartbeat - await asyncio.sleep(10) - - except asyncio.CancelledError: - logger.debug(f"Worker {self.worker_id} heartbeat cancelled") - except Exception as e: - logger.exception(f"Worker {self.worker_id} heartbeat error: {e}") - - async def graceful_shutdown(self, max_wait_seconds: int = 300): - """Gracefully shutdown the worker, waiting for calls to complete. - - Args: - max_wait_seconds: Maximum time to wait for calls to complete (default 5 minutes) - """ - logger.info(f"Worker {self.worker_id} starting graceful shutdown") - - # Mark as shutting down to prevent new calls - self._shutting_down = True - - # Update status in Redis to 'draining' - worker_key = RedisKeys.worker_active(self.worker_id) - worker_data = json.dumps( - {"status": "draining", "active_calls": len(self._active_tasks)} - ) - await self.redis.setex(worker_key, 30, worker_data) - - # Wait for active tasks to complete (with timeout) - start_time = asyncio.get_event_loop().time() - while ( - self._active_tasks - and (asyncio.get_event_loop().time() - start_time) < max_wait_seconds - ): - active_count = len(self._active_tasks) - logger.info( - f"Worker {self.worker_id} waiting for {active_count} active calls to complete" - ) - - # Update Redis with current status - worker_data = json.dumps( - {"status": "draining", "active_calls": active_count} - ) - await self.redis.setex(worker_key, 30, worker_data) - - # Wait a bit before checking again - await asyncio.sleep(5) - - # Force stop if timeout reached - if self._active_tasks: - logger.warning( - f"Worker {self.worker_id} forcefully stopping {len(self._active_tasks)} active calls after timeout channel_ids: {list(self._active_tasks.keys())}" - ) - - await self.stop() - - async def stop(self): - """Stop the event subscriber and deregister from Redis.""" - self._running = False - - # Deregister from Redis - await self._deregister_worker() - - # Cancel all active call processing tasks - for channel_id, task in list(self._active_tasks.items()): - if not task.done(): - logger.info(f"Cancelling active call task for channel {channel_id}") - task.cancel() - - # Cancel all cleanup tasks - for channel_id, task in list(self._cleanup_tasks.items()): - if not task.done(): - logger.info(f"Cancelling cleanup task for channel {channel_id}") - task.cancel() - - # Wait for all tasks to complete - all_tasks = list(self._active_tasks.values()) + list( - self._cleanup_tasks.values() - ) - if all_tasks: - await asyncio.gather(*all_tasks, return_exceptions=True) - - # Cancel heartbeat task - if self._heartbeat_task: - self._heartbeat_task.cancel() - try: - await self._heartbeat_task - except asyncio.CancelledError: - pass - - if self._task: - self._task.cancel() - try: - await self._task - except asyncio.CancelledError: - pass - - logger.info(f"Worker {self.worker_id} event subscriber stopped") - - async def _deregister_worker(self): - """Remove this worker from Redis.""" - try: - # Remove from active workers - await self.redis.delete(RedisKeys.worker_active(self.worker_id)) - - # Remove from workers set - await self.redis.srem(RedisKeys.workers_set(), self.worker_id) - - logger.info(f"Worker {self.worker_id} deregistered from Redis") - except Exception as e: - logger.error(f"Error deregistering worker {self.worker_id}: {e}") - - async def _run(self): - """Main subscriber loop.""" - self._running = True - channel = RedisChannels.worker_events(self.worker_id) - pubsub = self.redis.pubsub() - - try: - await pubsub.subscribe(channel) - logger.info(f"Worker {self.worker_id} subscribed to {channel}") - - async for message in pubsub.listen(): - if not self._running: - break - - if message["type"] == "message": - try: - await self._handle_event(message["data"]) - except Exception as e: - logger.exception(f"Error handling event: {e}") - - except asyncio.CancelledError: - logger.debug(f"Worker {self.worker_id} subscriber cancelled") - except Exception as e: - logger.exception(f"Worker {self.worker_id} subscriber error: {e}") - finally: - await pubsub.unsubscribe(channel) - await pubsub.aclose() - - async def _handle_event(self, data: str): - """Handle an event from the ARI Manager.""" - event = parse_event(data) - if not event: - logger.warning(f"Failed to parse event: {data}") - return - - if isinstance(event, StasisStartEvent): - await self._handle_stasis_start(event) - elif isinstance(event, StasisEndEvent): - await self._handle_stasis_end(event) - else: - logger.warning( - f"channelID: {event.channel_id} Unhandled event type: {type(event)}" - ) - - async def _handle_stasis_start(self, event: StasisStartEvent): - """Handle a new call assignment.""" - - channel_id = event.channel_id - logger.info( - f"channelID: {channel_id} Worker {self.worker_id} handling StasisStart" - ) - - try: - # Create StasisRTPConnection without ARI client - connection = StasisRTPConnection( - redis_client=self.redis, - channel_id=channel_id, - caller_channel_id=event.caller_channel_id, - em_channel_id=event.em_channel_id, - bridge_id=event.bridge_id, - local_addr=tuple(event.local_addr) if event.local_addr else None, - remote_addr=tuple(event.remote_addr) if event.remote_addr else None, - ) - - # Store connection for cleanup - self._active_connections[channel_id] = connection - - # Create a background task to handle the call - task = asyncio.create_task( - self._process_call(connection, event.call_context_vars, channel_id), - name=f"call_handler_{channel_id}", - ) - self._active_tasks[channel_id] = task - - except Exception as e: - logger.exception(f"Error handling StasisStart for {channel_id}: {e}") - # Send disconnect command if setup fails - await self._send_disconnect(channel_id, "setup_failed") - - async def _process_call( - self, connection: StasisRTPConnection, call_context_vars: dict, channel_id: str - ): - """Process a call in the background.""" - try: - await self.on_stasis_call(connection, call_context_vars) - except Exception as e: - logger.exception(f"Error processing call for {channel_id}: {e}") - # Send disconnect command if call processing fails - await self._send_disconnect(channel_id, "processing_failed") - finally: - # Clean up task reference - if channel_id in self._active_tasks: - del self._active_tasks[channel_id] - - async def _process_cleanup(self, channel_id: str): - """Process call cleanup in the background.""" - try: - if channel_id in self._active_connections: - connection: StasisRTPConnection = self._active_connections[channel_id] - - # We must wait for the connection's invocation - # before sending in remote disconnect. Otherwise, - # the event handlers won't be registered and we won't - # be able to call on_client_disconnected to cancel the - # pipeline - while not connection._connect_invoked: - await asyncio.sleep(0.1) - - # Set the run_id context so that we can have it in logs - if connection.workflow_run_id: - set_current_run_id(connection.workflow_run_id) - - await connection.handle_remote_disconnect() - del self._active_connections[channel_id] - except Exception as e: - logger.exception(f"Error during cleanup for {channel_id}: {e}") - finally: - # Clean up task reference from cleanup tasks dictionary - if channel_id in self._cleanup_tasks: - del self._cleanup_tasks[channel_id] - - async def _handle_stasis_end(self, event: StasisEndEvent): - """Handle call termination.""" - channel_id = event.channel_id - logger.info( - f"channelID: {channel_id} Worker {self.worker_id} handling StasisEnd" - ) - - # Create a background task to handle the cleanup - if channel_id in self._active_connections: - # Check if there's already a cleanup task for this channel - if ( - channel_id not in self._cleanup_tasks - or self._cleanup_tasks[channel_id].done() - ): - # Lets start a new task, since we need to poll for - # connection to be invoked from the pipeline before - # caling remote disconnect - task = asyncio.create_task( - self._process_cleanup(channel_id), - name=f"cleanup_handler_{channel_id}", - ) - self._cleanup_tasks[channel_id] = task - else: - logger.warning( - f"channelID: {channel_id} Cleanup skipped - cleanup task still running" - ) - - async def _send_disconnect(self, channel_id: str, reason: str): - """Send disconnect command to ARI Manager.""" - - command = DisconnectCommand(channel_id=channel_id, reason=reason) - channel = RedisChannels.channel_commands(channel_id) - await self.redis.publish(channel, command.to_json()) - - -async def setup_worker_subscriber( - redis_client: aioredis.Redis, -) -> WorkerEventSubscriber: - """Setup the worker event subscriber with dynamic registration.""" - subscriber = WorkerEventSubscriber(redis_client, on_stasis_call) - logger.info(f"Setting up worker event subscriber with ID {subscriber.worker_id}") - await subscriber.start() - return subscriber diff --git a/api/services/workflow/pipecat_engine.py b/api/services/workflow/pipecat_engine.py index 7a47b0c..74e33b1 100644 --- a/api/services/workflow/pipecat_engine.py +++ b/api/services/workflow/pipecat_engine.py @@ -15,11 +15,9 @@ from pipecat.frames.frames import ( from pipecat.pipeline.task import PipelineTask from pipecat.processors.aggregators.llm_context import LLMContext from pipecat.services.llm_service import FunctionCallParams -from pipecat.transports.base_transport import BaseTransport from pipecat.utils.enums import EndTaskReason if TYPE_CHECKING: - from api.services.telephony.stasis_rtp_connection import StasisRTPConnection from pipecat.frames.frames import Frame from pipecat.services.anthropic.llm import AnthropicLLMService from pipecat.services.google.llm import GoogleLLMService @@ -61,7 +59,6 @@ class PipecatEngine: task: Optional[PipelineTask] = None, llm: Optional["LLMService"] = None, context: Optional[LLMContext] = None, - transport: Optional[BaseTransport] = None, workflow: WorkflowGraph, call_context_vars: dict, workflow_run_id: Optional[int] = None, @@ -75,7 +72,6 @@ class PipecatEngine: self.task = task self.llm = llm self.context = context - self.transport = transport self.workflow = workflow self._call_context_vars = call_context_vars self._workflow_run_id = workflow_run_id @@ -86,9 +82,6 @@ class PipecatEngine: self._gathered_context: dict = {} self._user_response_timeout_task: Optional[asyncio.Task] = None - # Stasis connection for immediate transfers - self._stasis_connection: Optional["StasisRTPConnection"] = None - # Will be set later in initialize() when we have # access to _context self._variable_extraction_manager = None @@ -113,6 +106,9 @@ class PipecatEngine: self._embeddings_model: Optional[str] = embeddings_model self._embeddings_base_url: Optional[str] = embeddings_base_url + # Audio configuration (set via set_audio_config from _run_pipeline) + self._audio_config = None + async def _get_organization_id(self) -> Optional[int]: """Get and cache the organization ID from workflow run.""" if self._custom_tool_manager: @@ -207,15 +203,14 @@ class PipecatEngine: ) logger.info(f"Arguments: {function_call_params.arguments}") - # Perform variable extraction and call tags extraction before transitioning to new node - await self._perform_variable_extraction_if_needed(self._current_node) - await self._perform_call_tags_extraction_if_needed(self._current_node) - - # Set context for the new node, so that when the function call result - # frame is received by LLMContextAggregator and an LLM generation - # is done, we have updated context and functions - await self.set_node(transition_to_node) try: + # Perform variable extraction before transitioning to new node + await self._perform_variable_extraction_if_needed(self._current_node) + + # Set context for the new node, so that when the function call result + # frame is received by LLMContextAggregator and an LLM generation + # is done, we have updated context and functions + await self.set_node(transition_to_node) async def on_context_updated() -> None: """ @@ -246,6 +241,7 @@ class PipecatEngine: await function_call_params.result_callback( result, properties=properties ) + except Exception as e: logger.error(f"Error in transition function {name}: {str(e)}") error_result = {"status": "error", "error": str(e)} @@ -278,6 +274,7 @@ class PipecatEngine: async def calculate_func(function_call_params: FunctionCallParams) -> None: logger.info(f"LLM Function Call EXECUTED: safe_calculator") logger.info(f"Arguments: {function_call_params.arguments}") + try: expr = function_call_params.arguments.get("expression", "") result = safe_calculator(expr) @@ -293,6 +290,7 @@ class PipecatEngine: ) -> None: logger.info(f"LLM Function Call EXECUTED: get_current_time") logger.info(f"Arguments: {function_call_params.arguments}") + try: timezone = function_call_params.arguments.get("timezone", "UTC") result = get_current_time(timezone) @@ -303,6 +301,7 @@ class PipecatEngine: async def convert_time_func(function_call_params: FunctionCallParams) -> None: logger.info(f"LLM Function Call EXECUTED: convert_time") logger.info(f"Arguments: {function_call_params.arguments}") + try: result = convert_time( function_call_params.arguments.get("source_timezone"), @@ -333,6 +332,7 @@ class PipecatEngine: async def retrieve_kb_func(function_call_params: FunctionCallParams) -> None: logger.info("LLM Function Call EXECUTED: retrieve_from_knowledge_base") logger.info(f"Arguments: {function_call_params.arguments}") + try: query = function_call_params.arguments.get("query", "") organization_id = await self._get_organization_id() @@ -584,7 +584,9 @@ class PipecatEngine: self._current_node, run_in_background=False ) - frame_to_push = CancelFrame() if abort_immediately else EndFrame() + frame_to_push = ( + CancelFrame(reason=reason) if abort_immediately else EndFrame(reason=reason) + ) # Apply disposition mapping - first try call_disposition if it is, # extracted from the call conversation then fall back to reason @@ -740,22 +742,21 @@ class PipecatEngine: """ self.task = task - def set_stasis_connection( - self, connection: Optional["StasisRTPConnection"] - ) -> None: - """Set the Stasis RTP connection for immediate transfers. + def set_audio_config(self, audio_config) -> None: + """Set the audio configuration for the pipeline.""" + self._audio_config = audio_config - This allows the engine to initiate transfers immediately when XFER - disposition is detected, without waiting for pipeline shutdown. + def set_mute_pipeline(self, mute: bool) -> None: + """Set the pipeline mute state. + + This controls whether user input should be muted via the CallbackUserMuteStrategy. + When muted, the user's audio input will be blocked. Args: - connection: The StasisRTPConnection instance, or None for non-Stasis transports + mute: True to mute user input, False to allow input """ - self._stasis_connection = connection - if connection: - logger.debug( - f"Stasis connection set for immediate transfers: {connection.channel_id}" - ) + logger.debug(f"Setting pipeline mute state to: {mute}") + self._mute_pipeline = mute async def handle_llm_text_frame(self, text: str): """Accumulate LLM text frames to build reference text.""" diff --git a/api/services/workflow/pipecat_engine_custom_tools.py b/api/services/workflow/pipecat_engine_custom_tools.py index b60ea79..5bd4809 100644 --- a/api/services/workflow/pipecat_engine_custom_tools.py +++ b/api/services/workflow/pipecat_engine_custom_tools.py @@ -6,12 +6,20 @@ during workflow execution. from __future__ import annotations +import asyncio +import re +import time +import uuid from typing import TYPE_CHECKING, Any, Optional from loguru import logger +from api.constants import APP_ROOT_DIR from api.db import db_client -from api.enums import ToolCategory +from api.enums import ToolCategory, WorkflowRunMode +from api.services.telephony.call_transfer_manager import get_call_transfer_manager +from api.services.telephony.factory import get_telephony_provider +from api.services.telephony.transfer_event_protocol import TransferContext from api.services.workflow.disposition_mapper import ( get_organization_id_from_workflow_run, ) @@ -20,8 +28,13 @@ from api.services.workflow.tools.custom_tool import ( execute_http_tool, tool_to_function_schema, ) +from api.utils.hold_audio import load_hold_audio from pipecat.adapters.schemas.function_schema import FunctionSchema -from pipecat.frames.frames import FunctionCallResultProperties, TTSSpeakFrame +from pipecat.frames.frames import ( + FunctionCallResultProperties, + OutputAudioRawFrame, + TTSSpeakFrame, +) from pipecat.services.llm_service import FunctionCallParams from pipecat.utils.enums import EndTaskReason @@ -115,8 +128,15 @@ class CustomToolManager: function_name = schema["function"]["name"] # Create and register the handler - handler = self._create_handler(tool, function_name) - self._engine.llm.register_function(function_name, handler) + handler, disable_timeout, cancel_on_interruption = self._create_handler( + tool, function_name + ) + self._engine.llm.register_function( + function_name, + handler, + cancel_on_interruption=cancel_on_interruption, + disable_timeout=disable_timeout, + ) logger.debug( f"Registered custom tool handler: {function_name} " @@ -136,10 +156,21 @@ class CustomToolManager: Returns: Async handler function for the tool """ - if tool.category == ToolCategory.END_CALL.value: - return self._create_end_call_handler(tool, function_name) + # Whether to disable function call timeout + disable_timeout = False + cancel_on_interruption = True - return self._create_http_tool_handler(tool, function_name) + if tool.category == ToolCategory.END_CALL.value: + cancel_on_interruption = False + handler = self._create_end_call_handler(tool, function_name) + elif tool.category == ToolCategory.TRANSFER_CALL.value: + disable_timeout = True + cancel_on_interruption = False + handler = self._create_transfer_call_handler(tool, function_name) + else: + handler = self._create_http_tool_handler(tool, function_name) + + return handler, disable_timeout, cancel_on_interruption def _create_http_tool_handler(self, tool: Any, function_name: str): """Create a handler function for an HTTP API tool. @@ -230,3 +261,337 @@ class CustomToolManager: ) return end_call_handler + + def _create_transfer_call_handler(self, tool: Any, function_name: str): + """Create a handler function for a transfer call tool. + + Args: + tool: The ToolModel instance + function_name: The function name used by the LLM + + Returns: + Async handler function for the transfer call tool + """ + + properties = FunctionCallResultProperties(run_llm=False) + + async def transfer_call_handler( + function_call_params: FunctionCallParams, + ) -> None: + logger.info(f"Transfer Call Tool EXECUTED: {function_name}") + logger.info(f"Arguments: {function_call_params.arguments}") + + try: + # Get the transfer call configuration + config = tool.definition.get("config", {}) + destination = config.get("destination", "") + message_type = config.get("messageType", "none") + custom_message = config.get("customMessage", "") + timeout_seconds = config.get( + "timeout", 30 + ) # Default 30 seconds if not configured + + # Check if this is a WebRTC call - transfers are not supported + workflow_run = await db_client.get_workflow_run_by_id( + self._engine._workflow_run_id + ) + if workflow_run.mode in [ + WorkflowRunMode.WEBRTC.value, + WorkflowRunMode.SMALLWEBRTC.value, + ]: + webrtc_error_result = { + "status": "failed", + "message": "I'm sorry, but call transfers are not available for web calls. Please try a telephony call.", + "action": "transfer_failed", + "reason": "webrtc_not_supported", + "end_call": True, + } + await self._handle_transfer_result( + webrtc_error_result, function_call_params, properties + ) + return + + # Validate destination phone number + if not destination or not destination.strip(): + validation_error_result = { + "status": "failed", + "message": "I'm sorry, but I don't have a phone number configured for the transfer. Please contact support to set up call transfer.", + "action": "transfer_failed", + "reason": "no_destination", + "end_call": True, + } + await self._handle_transfer_result( + validation_error_result, function_call_params, properties + ) + return + + # Validate E.164 format + E164_PHONE_REGEX = r"^\+[1-9]\d{1,14}$" + if not re.match(E164_PHONE_REGEX, destination): + validation_error_result = { + "status": "failed", + "message": "I'm sorry, but the transfer phone number appears to be invalid. Please contact support to verify the transfer settings.", + "action": "transfer_failed", + "reason": "invalid_destination", + "end_call": True, + } + await self._handle_transfer_result( + validation_error_result, function_call_params, properties + ) + return + + if message_type == "custom" and custom_message: + logger.info(f"Playing pre-transfer message: {custom_message}") + await self._engine.task.queue_frame(TTSSpeakFrame(custom_message)) + + # Get organization ID for provider configuration + organization_id = await self.get_organization_id() + if not organization_id: + validation_error_result = { + "status": "failed", + "message": "I'm sorry, there's an issue with this call transfer. Please contact support.", + "action": "transfer_failed", + "reason": "no_organization_id", + "end_call": False, + } + await self._handle_transfer_result( + validation_error_result, function_call_params, properties + ) + return + + # Get telephony provider directly (no HTTP round-trip) + provider = await get_telephony_provider(organization_id) + if not provider.supports_transfers() or not provider.validate_config(): + validation_error_result = { + "status": "failed", + "message": "I'm sorry, there's an issue with this call transfer. Please contact support.", + "action": "transfer_failed", + "reason": "provider_does_not_support_transfer", + "end_call": False, + } + await self._handle_transfer_result( + validation_error_result, function_call_params, properties + ) + return + + original_call_sid = workflow_run.gathered_context.get("call_id") + + # Generate a unique transfer ID for tracking this transfer + transfer_id = str(uuid.uuid4()) + + # Compute conference name from original call SID + conference_name = f"transfer-{original_call_sid}" + + # Mute the pipeline + self._engine.set_mute_pipeline(True) + + # Initiate transfer via provider with inline TwiML + transfer_result = await provider.transfer_call( + destination=destination, + transfer_id=transfer_id, + conference_name=conference_name, + timeout=timeout_seconds, + ) + + call_sid = transfer_result.get("call_sid") + logger.info(f"Transfer call initiated successfully: {call_sid}") + + # TODO: Possible race here between saving the transfer context + # and getting a callback response from Twilio? Should we store_transfer_context + # before sending request to Twilio and update the transfer context afterwards? + + # Store transfer context in Redis + call_transfer_manager = await get_call_transfer_manager() + transfer_context = TransferContext( + transfer_id=transfer_id, + call_sid=call_sid, + target_number=destination, + tool_uuid=tool.tool_uuid, + original_call_sid=original_call_sid, + conference_name=conference_name, + initiated_at=time.time(), + ) + await call_transfer_manager.store_transfer_context(transfer_context) + + # Wait for status callback completion using Redis pub/sub + logger.info( + f"Transfer call initiated for {destination} (transfer_id={transfer_id}), waiting for completion..." + ) + + # Start hold music during transfer waiting period + hold_music_stop_event = asyncio.Event() + hold_music_task = None + + try: + # Use audio config for sample rate (set during pipeline setup) + sample_rate = ( + self._engine._audio_config.transport_out_sample_rate + if self._engine._audio_config + else 8000 + ) + + logger.info( + f"Starting hold music at {sample_rate}Hz while waiting for transfer" + ) + + # Start hold music as background task + hold_music_task = asyncio.create_task( + self.play_hold_music_loop(hold_music_stop_event, sample_rate) + ) + + # Wait for transfer completion using Redis pub/sub + logger.info("Waiting for transfer completion via Redis pub/sub...") + transfer_event = ( + await call_transfer_manager.wait_for_transfer_completion( + transfer_id, timeout_seconds + ) + ) + + except Exception as e: + logger.error(f"Error during transfer wait: {e}") + transfer_event = None + + finally: + # Single cleanup point: stop hold music, unmute pipeline, remove context + logger.info( + "Transfer wait ended, cleaning up hold music, pipeline state, and transfer context" + ) + hold_music_stop_event.set() + if hold_music_task: + await hold_music_task + self._engine.set_mute_pipeline(False) + await call_transfer_manager.remove_transfer_context(transfer_id) + + # Handle result (after cleanup) + if transfer_event: + final_result = transfer_event.to_result_dict() + await self._handle_transfer_result( + final_result, function_call_params, properties + ) + else: + logger.error( + f"Transfer call timed out or failed after {timeout_seconds} seconds" + ) + timeout_result = { + "status": "failed", + "message": "I'm sorry, but the call is taking longer than expected to connect. The person might not be available right now. Please try calling back later.", + "action": "transfer_failed", + "reason": "timeout", + "end_call": True, + } + await self._handle_transfer_result( + timeout_result, function_call_params, properties + ) + + except Exception as e: + logger.error( + f"Transfer call tool '{function_name}' execution failed: {e}" + ) + self._engine.set_mute_pipeline(False) + + # Handle generic exception with user-friendly message + exception_result = { + "status": "failed", + "message": "I'm sorry, but something went wrong while trying to transfer your call. Please try again later or contact support if the problem persists.", + "action": "transfer_failed", + "reason": "execution_error", + "end_call": True, + } + + await self._handle_transfer_result( + exception_result, function_call_params, properties + ) + + return transfer_call_handler + + async def _handle_transfer_result( + self, result: dict, function_call_params, properties + ): + """Handle different transfer call outcomes and take appropriate action.""" + action = result.get("action", "") + status = result.get("status", "") + + logger.info(f"Handling transfer result: action={action}, status={status}") + + if action == "transfer_success": + # Successful transfer - add original caller to conference and end pipeline + conference_id = result.get("conference_id") + original_call_sid = result.get("original_call_sid") + transfer_call_sid = result.get("transfer_call_sid") + + logger.info( + f"Transfer successful! Conference: {conference_id}, Original: {original_call_sid}, Transfer: {transfer_call_sid}" + ) + + # Inform LLM of success and end the call with Transfer call reason + response_properties = FunctionCallResultProperties(run_llm=False) + await function_call_params.result_callback( + { + "status": "transfer_success", + "message": "Transfer successful - connecting to conference", + "conference_id": conference_id, + }, + properties=response_properties, + ) + + await self._engine.end_call_with_reason( + EndTaskReason.TRANSFER_CALL.value, abort_immediately=False + ) + + elif action == "transfer_failed": + # Transfer failed - inform user via LLM and then end the call + reason = result.get("reason", "unknown") + logger.info(f"Transfer failed ({reason}), informing user") + + await function_call_params.result_callback( + { + "status": "transfer_failed", + "reason": reason, + "message": "Transfer failed", + } + ) + else: + # Unknown action, treat as generic success + logger.warning(f"Unknown transfer action: {action}, treating as success") + await function_call_params.result_callback(result) + + async def play_hold_music_loop( + self, stop_event: asyncio.Event, sample_rate: int = 8000 + ): + """Play hold music in a loop until stop event is triggered. + + Args: + stop_event: Event to stop the hold music loop + sample_rate: Sample rate for the hold music (default 8000Hz for Twilio) + """ + try: + # Path to hold music file based on sample rate + hold_music_file = ( + APP_ROOT_DIR / "assets" / f"transfer_hold_ring_{sample_rate}.wav" + ) + hold_audio_data = load_hold_audio(hold_music_file, sample_rate) + num_samples = len(hold_audio_data) // 2 + duration = int(num_samples / sample_rate) + + logger.info(f"Starting hold music loop with file: {hold_music_file}") + + while not stop_event.is_set(): + # Queue the hold audio frame + frame = OutputAudioRawFrame( + audio=hold_audio_data, + sample_rate=sample_rate, + num_channels=1, + ) + await self._engine.task.queue_frame(frame) + + # Wait for the audio to play or until stopped + try: + await asyncio.wait_for(stop_event.wait(), timeout=duration + 1.5) + break # Stop event was set + except asyncio.TimeoutError: + pass # Continue looping + + logger.info("Hold music loop stopped") + + except Exception as e: + logger.error(f"Error in hold music loop: {e}") diff --git a/api/tests/test_circuit_breaker.py b/api/tests/test_circuit_breaker.py new file mode 100644 index 0000000..a88a5ea --- /dev/null +++ b/api/tests/test_circuit_breaker.py @@ -0,0 +1,504 @@ +""" +Tests for Campaign Circuit Breaker. + +These tests verify: +1. Circuit breaker records call outcomes (success/failure) +2. Circuit breaker trips when failure rate exceeds threshold +3. Circuit breaker does NOT trip when below threshold or min_calls +4. Circuit breaker reset clears state +5. Integration: _process_status_update pauses campaign on circuit breaker trip +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +# ============================================================================= +# Unit tests for CircuitBreaker class +# ============================================================================= + + +class TestCircuitBreakerRecordOutcome: + """Tests for recording call outcomes and trip detection.""" + + @pytest.mark.asyncio + async def test_no_trip_below_min_calls(self): + """Circuit breaker should NOT trip when total calls < min_calls_in_window.""" + from api.services.campaign.circuit_breaker import CircuitBreaker + + cb = CircuitBreaker() + + # Mock Redis to simulate a window with 3 failures out of 3 total + # (100% failure rate, but below min_calls=5) + mock_redis = AsyncMock() + mock_redis.eval = AsyncMock( + return_value=[0, 3, 0, 3] # [not_tripped, failures, successes, total] + ) + cb.redis_client = mock_redis + + tripped, stats = await cb.record_call_outcome(campaign_id=1, is_failure=True) + + assert tripped is False + assert stats is not None + assert stats["failure_count"] == 3 + assert stats["success_count"] == 0 + + @pytest.mark.asyncio + async def test_trip_when_threshold_exceeded(self): + """Circuit breaker should trip when failure rate >= threshold and total >= min_calls.""" + from api.services.campaign.circuit_breaker import CircuitBreaker + + cb = CircuitBreaker() + + # Mock Redis to simulate: 4 failures out of 6 total = 66% > 50% threshold + mock_redis = AsyncMock() + mock_redis.eval = AsyncMock( + return_value=[1, 4, 2, 6] # [tripped, failures, successes, total] + ) + cb.redis_client = mock_redis + + tripped, stats = await cb.record_call_outcome(campaign_id=1, is_failure=True) + + assert tripped is True + assert stats is not None + assert stats["failure_rate"] == pytest.approx(4 / 6) + assert stats["failure_count"] == 4 + assert stats["success_count"] == 2 + + @pytest.mark.asyncio + async def test_no_trip_below_threshold(self): + """Circuit breaker should NOT trip when failure rate < threshold.""" + from api.services.campaign.circuit_breaker import CircuitBreaker + + cb = CircuitBreaker() + + # Mock Redis: 2 failures out of 8 total = 25% < 50% threshold + mock_redis = AsyncMock() + mock_redis.eval = AsyncMock( + return_value=[0, 2, 6, 8] # [not_tripped, failures, successes, total] + ) + cb.redis_client = mock_redis + + tripped, stats = await cb.record_call_outcome(campaign_id=1, is_failure=False) + + assert tripped is False + assert stats["failure_rate"] == pytest.approx(2 / 8) + + @pytest.mark.asyncio + async def test_disabled_circuit_breaker(self): + """Circuit breaker should not record or trip when disabled.""" + from api.services.campaign.circuit_breaker import CircuitBreaker + + cb = CircuitBreaker() + mock_redis = AsyncMock() + cb.redis_client = mock_redis + + tripped, stats = await cb.record_call_outcome( + campaign_id=1, + is_failure=True, + config={"enabled": False}, + ) + + assert tripped is False + assert stats is None + # Redis should not have been called + mock_redis.eval.assert_not_called() + + @pytest.mark.asyncio + async def test_custom_config_override(self): + """Per-campaign config should override defaults.""" + from api.services.campaign.circuit_breaker import CircuitBreaker + + cb = CircuitBreaker() + + # With custom threshold of 0.8, 4/6 = 66% should NOT trip + mock_redis = AsyncMock() + mock_redis.eval = AsyncMock( + return_value=[0, 4, 2, 6] # Lua script respects the threshold we pass + ) + cb.redis_client = mock_redis + + tripped, stats = await cb.record_call_outcome( + campaign_id=1, + is_failure=True, + config={"failure_threshold": 0.8, "min_calls_in_window": 3}, + ) + + assert tripped is False + + @pytest.mark.asyncio + async def test_redis_error_fails_open(self): + """On Redis error, circuit breaker should fail open (not trip).""" + from api.services.campaign.circuit_breaker import CircuitBreaker + + cb = CircuitBreaker() + + mock_redis = AsyncMock() + mock_redis.eval = AsyncMock(side_effect=Exception("Redis connection lost")) + cb.redis_client = mock_redis + + tripped, stats = await cb.record_call_outcome(campaign_id=1, is_failure=True) + + assert tripped is False + assert stats is None + + +class TestCircuitBreakerIsOpen: + """Tests for read-only circuit state check.""" + + @pytest.mark.asyncio + async def test_is_open_when_threshold_exceeded(self): + """is_circuit_open should return True when failure rate exceeds threshold.""" + from api.services.campaign.circuit_breaker import CircuitBreaker + + cb = CircuitBreaker() + + mock_redis = AsyncMock() + mock_redis.eval = AsyncMock( + return_value=[1, 5, 2, 7] # [is_open, failures, successes, total] + ) + cb.redis_client = mock_redis + + is_open, stats = await cb.is_circuit_open(campaign_id=1) + + assert is_open is True + assert stats["failure_count"] == 5 + + @pytest.mark.asyncio + async def test_is_not_open_when_healthy(self): + """is_circuit_open should return False when failure rate is low.""" + from api.services.campaign.circuit_breaker import CircuitBreaker + + cb = CircuitBreaker() + + mock_redis = AsyncMock() + mock_redis.eval = AsyncMock(return_value=[0, 1, 9, 10]) + cb.redis_client = mock_redis + + is_open, stats = await cb.is_circuit_open(campaign_id=1) + + assert is_open is False + assert stats["failure_rate"] == pytest.approx(0.1) + + +class TestCircuitBreakerReset: + """Tests for circuit breaker reset.""" + + @pytest.mark.asyncio + async def test_reset_deletes_redis_keys(self): + """Reset should delete both failure and success keys for the campaign.""" + from api.services.campaign.circuit_breaker import CircuitBreaker + + cb = CircuitBreaker() + + mock_redis = AsyncMock() + mock_redis.delete = AsyncMock(return_value=2) + cb.redis_client = mock_redis + + result = await cb.reset(campaign_id=42) + + assert result is True + mock_redis.delete.assert_called_once_with("cb_failures:42", "cb_successes:42") + + @pytest.mark.asyncio + async def test_reset_on_redis_error(self): + """Reset should return False on Redis error.""" + from api.services.campaign.circuit_breaker import CircuitBreaker + + cb = CircuitBreaker() + + mock_redis = AsyncMock() + mock_redis.delete = AsyncMock(side_effect=Exception("Redis down")) + cb.redis_client = mock_redis + + result = await cb.reset(campaign_id=42) + + assert result is False + + +# ============================================================================= +# Tests for record_and_evaluate (the high-level method on CircuitBreaker) +# ============================================================================= + + +class TestRecordAndEvaluate: + """Test circuit_breaker.record_and_evaluate which handles the full + flow: record outcome, check trip, pause campaign, publish event.""" + + @pytest.mark.asyncio + async def test_trips_and_pauses_campaign(self): + """When record_call_outcome returns tripped, campaign should be paused.""" + from api.services.campaign.circuit_breaker import CircuitBreaker + + cb = CircuitBreaker() + + mock_campaign = MagicMock() + mock_campaign.id = 42 + mock_campaign.state = "running" + mock_campaign.orchestrator_metadata = {} + + stats = { + "failure_rate": 0.6, + "failure_count": 6, + "success_count": 4, + "threshold": 0.5, + "window_seconds": 120, + } + + with ( + patch("api.services.campaign.circuit_breaker.db_client") as mock_db, + patch( + "api.services.campaign.circuit_breaker.get_campaign_event_publisher" + ) as mock_get_publisher, + ): + mock_db.get_campaign_by_id = AsyncMock(return_value=mock_campaign) + mock_db.update_campaign = AsyncMock() + + mock_publisher = AsyncMock() + mock_get_publisher.return_value = mock_publisher + + # Mock the internal record_call_outcome to return tripped + cb.record_call_outcome = AsyncMock(return_value=(True, stats)) + + await cb.record_and_evaluate(campaign_id=42, is_failure=True) + + # Verify campaign was paused + mock_db.update_campaign.assert_called_once_with( + campaign_id=42, state="paused" + ) + + # Verify event was published + mock_publisher.publish_circuit_breaker_tripped.assert_called_once() + + @pytest.mark.asyncio + async def test_no_pause_when_not_tripped(self): + """When record_call_outcome does NOT trip, campaign should not be paused.""" + from api.services.campaign.circuit_breaker import CircuitBreaker + + cb = CircuitBreaker() + + mock_campaign = MagicMock() + mock_campaign.id = 42 + mock_campaign.state = "running" + mock_campaign.orchestrator_metadata = {} + + with patch("api.services.campaign.circuit_breaker.db_client") as mock_db: + mock_db.get_campaign_by_id = AsyncMock(return_value=mock_campaign) + + cb.record_call_outcome = AsyncMock(return_value=(False, None)) + + await cb.record_and_evaluate(campaign_id=42, is_failure=False) + + mock_db.update_campaign.assert_not_called() + + @pytest.mark.asyncio + async def test_skips_when_campaign_not_running(self): + """Should skip when campaign is not in running state.""" + from api.services.campaign.circuit_breaker import CircuitBreaker + + cb = CircuitBreaker() + + mock_campaign = MagicMock() + mock_campaign.id = 42 + mock_campaign.state = "paused" + + with patch("api.services.campaign.circuit_breaker.db_client") as mock_db: + mock_db.get_campaign_by_id = AsyncMock(return_value=mock_campaign) + + cb.record_call_outcome = AsyncMock() + + await cb.record_and_evaluate(campaign_id=42, is_failure=True) + + # Should not even attempt to record + cb.record_call_outcome.assert_not_called() + + @pytest.mark.asyncio + async def test_reads_config_from_orchestrator_metadata(self): + """Should pass circuit_breaker config from orchestrator_metadata.""" + from api.services.campaign.circuit_breaker import CircuitBreaker + + cb = CircuitBreaker() + + custom_config = {"failure_threshold": 0.3, "min_calls_in_window": 10} + mock_campaign = MagicMock() + mock_campaign.id = 42 + mock_campaign.state = "running" + mock_campaign.orchestrator_metadata = {"circuit_breaker": custom_config} + + with patch("api.services.campaign.circuit_breaker.db_client") as mock_db: + mock_db.get_campaign_by_id = AsyncMock(return_value=mock_campaign) + + cb.record_call_outcome = AsyncMock(return_value=(False, None)) + + await cb.record_and_evaluate(campaign_id=42, is_failure=True) + + cb.record_call_outcome.assert_called_once_with( + campaign_id=42, + is_failure=True, + config=custom_config, + ) + + @pytest.mark.asyncio + async def test_error_is_swallowed(self): + """Errors inside record_and_evaluate should be caught, not raised.""" + from api.services.campaign.circuit_breaker import CircuitBreaker + + cb = CircuitBreaker() + + with patch("api.services.campaign.circuit_breaker.db_client") as mock_db: + mock_db.get_campaign_by_id = AsyncMock(side_effect=Exception("DB exploded")) + + # Should NOT raise + await cb.record_and_evaluate(campaign_id=42, is_failure=True) + + +# ============================================================================= +# Integration tests: _process_status_update calls circuit_breaker +# ============================================================================= + + +class TestProcessStatusUpdateCircuitBreaker: + """Test that _process_status_update calls circuit_breaker.record_and_evaluate + for campaign calls.""" + + @pytest.mark.asyncio + async def test_failure_status_calls_record_and_evaluate(self): + """When a campaign call fails, record_and_evaluate should be called + with is_failure=True.""" + + from api.routes.telephony import StatusCallbackRequest, _process_status_update + + mock_workflow_run = MagicMock() + mock_workflow_run.id = 100 + mock_workflow_run.campaign_id = 42 + mock_workflow_run.queued_run_id = 10 + mock_workflow_run.state = "running" + mock_workflow_run.logs = {"telephony_status_callbacks": []} + mock_workflow_run.gathered_context = {} + + status = StatusCallbackRequest( + call_id="call-123", + status="failed", + ) + + with ( + patch("api.routes.telephony.db_client") as mock_db, + patch("api.routes.telephony.campaign_call_dispatcher") as mock_dispatcher, + patch("api.routes.telephony.circuit_breaker") as mock_cb, + patch( + "api.routes.telephony.get_campaign_event_publisher" + ) as mock_get_publisher, + ): + mock_db.get_workflow_run_by_id = AsyncMock(return_value=mock_workflow_run) + mock_db.update_workflow_run = AsyncMock() + + mock_dispatcher.release_call_slot = AsyncMock(return_value=True) + mock_cb.record_and_evaluate = AsyncMock() + + mock_publisher = AsyncMock() + mock_get_publisher.return_value = mock_publisher + + await _process_status_update(100, status) + + mock_cb.record_and_evaluate.assert_called_once_with(42, is_failure=True) + + @pytest.mark.asyncio + async def test_success_status_calls_record_and_evaluate(self): + """When a campaign call succeeds, record_and_evaluate should be called + with is_failure=False.""" + + from api.routes.telephony import StatusCallbackRequest, _process_status_update + + mock_workflow_run = MagicMock() + mock_workflow_run.id = 100 + mock_workflow_run.campaign_id = 42 + mock_workflow_run.state = "running" + mock_workflow_run.logs = {"telephony_status_callbacks": []} + mock_workflow_run.gathered_context = {} + + status = StatusCallbackRequest( + call_id="call-456", + status="completed", + ) + + with ( + patch("api.routes.telephony.db_client") as mock_db, + patch("api.routes.telephony.campaign_call_dispatcher") as mock_dispatcher, + patch("api.routes.telephony.circuit_breaker") as mock_cb, + ): + mock_db.get_workflow_run_by_id = AsyncMock(return_value=mock_workflow_run) + mock_db.update_workflow_run = AsyncMock() + + mock_dispatcher.release_call_slot = AsyncMock(return_value=True) + mock_cb.record_and_evaluate = AsyncMock() + + await _process_status_update(100, status) + + mock_cb.record_and_evaluate.assert_called_once_with(42, is_failure=False) + + @pytest.mark.asyncio + async def test_non_campaign_call_skips_circuit_breaker(self): + """Calls without campaign_id should not interact with circuit breaker.""" + + from api.routes.telephony import StatusCallbackRequest, _process_status_update + + mock_workflow_run = MagicMock() + mock_workflow_run.id = 100 + mock_workflow_run.campaign_id = None # Not a campaign call + mock_workflow_run.state = "running" + mock_workflow_run.logs = {"telephony_status_callbacks": []} + mock_workflow_run.gathered_context = {} + + status = StatusCallbackRequest( + call_id="call-789", + status="failed", + ) + + with ( + patch("api.routes.telephony.db_client") as mock_db, + patch("api.routes.telephony.circuit_breaker") as mock_cb, + ): + mock_db.get_workflow_run_by_id = AsyncMock(return_value=mock_workflow_run) + mock_db.update_workflow_run = AsyncMock() + + await _process_status_update(100, status) + + # Circuit breaker should NOT be called for non-campaign calls + mock_cb.record_and_evaluate.assert_not_called() + + +# ============================================================================= +# Integration test: resume_campaign resets circuit breaker +# ============================================================================= + + +class TestResumeCampaignResetsCircuitBreaker: + """Test that resuming a campaign resets the circuit breaker.""" + + @pytest.mark.asyncio + async def test_resume_resets_circuit_breaker(self): + """Resuming a paused campaign should reset the circuit breaker state.""" + from api.services.campaign.runner import CampaignRunnerService + + mock_campaign = MagicMock() + mock_campaign.id = 42 + mock_campaign.state = "paused" + + with ( + patch("api.services.campaign.runner.db_client") as mock_db, + patch("api.services.campaign.runner.circuit_breaker") as mock_cb, + ): + mock_db.get_campaign_by_id = AsyncMock(return_value=mock_campaign) + mock_db.update_campaign = AsyncMock() + mock_cb.reset = AsyncMock(return_value=True) + + runner = CampaignRunnerService() + await runner.resume_campaign(42) + + # Verify circuit breaker was reset + mock_cb.reset.assert_called_once_with(42) + + # Verify campaign state was updated + mock_db.update_campaign.assert_called_once_with( + campaign_id=42, state="running" + ) diff --git a/api/tests/test_user_turn_stop_scenarios.py b/api/tests/test_user_turn_stop_scenarios.py new file mode 100644 index 0000000..8cafcdf --- /dev/null +++ b/api/tests/test_user_turn_stop_scenarios.py @@ -0,0 +1,960 @@ +"""Tests validating user turn stop strategy behavior during bot speaking scenarios. + +These tests validate the scenarios described in scenarios.md. They demonstrate +how the ExternalUserTurnStopStrategy and UserTurnController interact when frames +are suppressed (muted) during bot speaking. + +Key concepts: +- When the bot is speaking, AlwaysUserMuteStrategy causes the LLMUserAggregator + to suppress user frames (UserStartedSpeaking, UserStoppedSpeaking, Transcription, VAD). +- The ExternalUserTurnStopStrategy accumulates _text from TranscriptionFrames and + triggers a stop when _user_speaking is False and _text is truthy. +- The UserTurnController only allows a stop if _user_turn is True (a start must + have occurred first). When a stop is rejected, the controller unconditionally + resets all stop strategies, clearing any dangling state (e.g. _text). +- This unconditional reset prevents stale _text from causing premature stops + or contaminating subsequent turns. +""" + +import asyncio + +import pytest + +from pipecat.frames.frames import ( + BotStartedSpeakingFrame, + BotStoppedSpeakingFrame, + EndTaskFrame, + Frame, + TranscriptionFrame, + UserStartedSpeakingFrame, + UserStoppedSpeakingFrame, + VADUserStartedSpeakingFrame, + VADUserStoppedSpeakingFrame, +) +from pipecat.pipeline.pipeline import Pipeline +from pipecat.pipeline.runner import PipelineRunner +from pipecat.pipeline.task import PipelineParams, PipelineTask +from pipecat.processors.aggregators.llm_context import LLMContext +from pipecat.processors.aggregators.llm_response_universal import ( + LLMAssistantAggregatorParams, + LLMContextAggregatorPair, + LLMUserAggregatorParams, +) +from pipecat.processors.frame_processor import FrameDirection, FrameProcessor +from pipecat.tests import MockLLMService +from pipecat.turns.user_mute import AlwaysUserMuteStrategy +from pipecat.turns.user_start import VADUserTurnStartStrategy +from pipecat.turns.user_stop import ExternalUserTurnStopStrategy +from pipecat.turns.user_turn_strategies import UserTurnStrategies +from pipecat.utils.time import time_now_iso8601 + +# Short timeout for faster tests +STOP_STRATEGY_TIMEOUT = 0.15 +# Delay to allow async processing +ASYNC_DELAY = 0.05 +# Delay to wait for stop strategy timeout to fire +TIMEOUT_WAIT = STOP_STRATEGY_TIMEOUT + 0.1 + + +class FrameInjector(FrameProcessor): + """Simple processor that can inject frames into the pipeline.""" + + async def process_frame(self, frame: Frame, direction: FrameDirection): + await super().process_frame(frame, direction) + await self.push_frame(frame, direction) + + async def inject( + self, frame: Frame, direction: FrameDirection = FrameDirection.DOWNSTREAM + ): + """Inject a frame into the pipeline.""" + await self.push_frame(frame, direction) + + +def _build_components(llm_steps=None): + """Build pipeline components for testing. + + Uses: + - VADUserTurnStartStrategy: turn starts only when VADUserStartedSpeakingFrame arrives + - ExternalUserTurnStopStrategy: turn stops based on UserStoppedSpeakingFrame + _text + - AlwaysUserMuteStrategy: suppresses user frames while bot is speaking + + Returns a tuple of (injector, user_aggregator, stop_strategy, turn_controller, mock_llm, pipeline). + """ + context = LLMContext() + + stop_strategy = ExternalUserTurnStopStrategy(timeout=STOP_STRATEGY_TIMEOUT) + + user_turn_strategies = UserTurnStrategies( + start=[VADUserTurnStartStrategy()], + stop=[stop_strategy], + ) + + user_params = LLMUserAggregatorParams( + user_turn_strategies=user_turn_strategies, + user_mute_strategies=[AlwaysUserMuteStrategy()], + ) + assistant_params = LLMAssistantAggregatorParams(expect_stripped_words=True) + + context_aggregator = LLMContextAggregatorPair( + context, assistant_params=assistant_params, user_params=user_params + ) + user_agg = context_aggregator.user() + assistant_agg = context_aggregator.assistant() + + if llm_steps is None: + llm_steps = [ + MockLLMService.create_text_chunks(text="Response 1"), + MockLLMService.create_text_chunks(text="Response 2"), + MockLLMService.create_text_chunks(text="Response 3"), + ] + mock_llm = MockLLMService(mock_steps=llm_steps, chunk_delay=0.001) + + injector = FrameInjector() + pipeline = Pipeline([injector, user_agg, mock_llm, assistant_agg]) + + turn_controller = user_agg._user_turn_controller + + return ( + injector, + user_agg, + stop_strategy, + turn_controller, + mock_llm, + context, + pipeline, + ) + + +async def _run_scenario(pipeline, inject_fn): + """Run a pipeline with a frame injection coroutine.""" + task = PipelineTask(pipeline, params=PipelineParams(), enable_rtvi=False) + runner = PipelineRunner() + + async def run(): + await runner.run(task) + + async def inject(): + # Wait for pipeline to start (StartFrame to propagate) + await asyncio.sleep(ASYNC_DELAY) + await inject_fn() + + await asyncio.gather(run(), inject()) + + +async def _inject_user_turn(injector, text, delay=ASYNC_DELAY): + """Inject a complete user turn: VAD start + external start + transcription + external stop. + + This simulates what happens in a real pipeline when the user speaks: + 1. VAD detects speech -> VADUserStartedSpeakingFrame (triggers turn start) + 2. External processor sends UserStartedSpeakingFrame (stop strategy tracks _user_speaking) + 3. STT produces TranscriptionFrame (stop strategy accumulates _text) + 4. External processor sends UserStoppedSpeakingFrame (stop strategy triggers stop) + """ + await injector.inject(VADUserStartedSpeakingFrame()) + await asyncio.sleep(0) + await injector.inject(UserStartedSpeakingFrame()) + await asyncio.sleep(0) + await injector.inject(UserStoppedSpeakingFrame()) + await asyncio.sleep(delay) + await injector.inject(TranscriptionFrame(text, "user-1", time_now_iso8601())) + + +class TestUserTurnStopScenarios: + """Test scenarios from scenarios.md. + + Each test simulates a specific frame ordering to validate the interaction + between ExternalUserTurnStopStrategy and UserTurnController, particularly + around frame suppression during bot speaking. + """ + + # ========================================================================= + # Scenario 1 (✅): All frames suppressed during bot speaking + # + # BotStartedSpeaking (muted) + # UserStartedSpeaking (suppressed) + # TranscriptionFrame (suppressed) + # UserStoppedSpeaking (suppressed) + # BotStoppedSpeaking (unmuted) + # + # Stop strategy _text is empty because TranscriptionFrame was suppressed. + # ========================================================================= + + @pytest.mark.asyncio + async def test_scenario_1_all_suppressed_then_bot_stops(self): + """All user frames suppressed during bot speaking, then bot stops. + + Expected: _text is empty, no turn triggered, clean state. + Second turn works correctly. + """ + injector, user_agg, stop_strategy, turn_ctrl, mock_llm, context, pipeline = ( + _build_components() + ) + + async def inject(): + # === Turn 1: Bot speaking, all user frames suppressed === + await injector.inject(BotStartedSpeakingFrame()) + await asyncio.sleep(ASYNC_DELAY) + + # These are all suppressed by mute + await injector.inject(VADUserStartedSpeakingFrame()) + await asyncio.sleep(0) + await injector.inject(UserStartedSpeakingFrame()) + await asyncio.sleep(0) + await injector.inject( + TranscriptionFrame("hello", "user-1", time_now_iso8601()) + ) + await asyncio.sleep(0) + await injector.inject(UserStoppedSpeakingFrame()) + await asyncio.sleep(0) + await injector.inject(VADUserStoppedSpeakingFrame()) + await asyncio.sleep(ASYNC_DELAY) + + await injector.inject(BotStoppedSpeakingFrame()) + await asyncio.sleep(TIMEOUT_WAIT) + + # Assert: _text should be empty (all frames suppressed) + assert stop_strategy._text == "", ( + f"Expected empty _text after all frames suppressed, got '{stop_strategy._text}'" + ) + assert not turn_ctrl._user_turn, "Expected _user_turn to be False" + + # === Turn 2: Normal turn should work correctly === + await _inject_user_turn(injector, "second turn text") + await asyncio.sleep(TIMEOUT_WAIT) + + # Assert: turn completed, _text cleared by reset + assert stop_strategy._text == "", ( + f"Expected empty _text after clean turn, got '{stop_strategy._text}'" + ) + assert not turn_ctrl._user_turn, ( + "Expected _user_turn to be False after turn" + ) + assert mock_llm.get_current_step() == 1, ( + f"Expected 1 LLM call (turn 2 only), got {mock_llm.get_current_step()}" + ) + + await injector.inject(EndTaskFrame(), direction=FrameDirection.UPSTREAM) + + await _run_scenario(pipeline, inject) + + # ========================================================================= + # Scenario 2 (✅): User frames suppressed, user stops after bot stops + # + # BotStartedSpeaking (muted) + # UserStartedSpeaking (suppressed) + # TranscriptionFrame (suppressed) + # BotStoppedSpeaking (unmuted) + # UserStoppedSpeaking (stop strategy has no _text -> no trigger) + # ========================================================================= + + @pytest.mark.asyncio + async def test_scenario_2_user_stops_after_bot_stops_no_text(self): + """User stops speaking after bot stops, but transcription was suppressed. + + Expected: _text is empty because transcription was suppressed. + UserStoppedSpeaking doesn't trigger stop (no _text). + """ + injector, user_agg, stop_strategy, turn_ctrl, mock_llm, context, pipeline = ( + _build_components() + ) + + async def inject(): + # === Turn 1: Bot speaking, user frames partially suppressed === + await injector.inject(BotStartedSpeakingFrame()) + await asyncio.sleep(ASYNC_DELAY) + + # Suppressed during bot speaking + await injector.inject(VADUserStartedSpeakingFrame()) + await asyncio.sleep(0) + await injector.inject(UserStartedSpeakingFrame()) + await asyncio.sleep(0) + await injector.inject( + TranscriptionFrame("hello", "user-1", time_now_iso8601()) + ) + await asyncio.sleep(ASYNC_DELAY) + + # Bot stops -> unmuted + await injector.inject(BotStoppedSpeakingFrame()) + await asyncio.sleep(ASYNC_DELAY) + + # UserStoppedSpeaking arrives after unmute, but _text is empty + await injector.inject(UserStoppedSpeakingFrame()) + await asyncio.sleep(TIMEOUT_WAIT) + + # Assert: _text empty (TranscriptionFrame was suppressed) + assert stop_strategy._text == "", ( + f"Expected empty _text, got '{stop_strategy._text}'" + ) + assert not turn_ctrl._user_turn, "Expected _user_turn to be False" + + # === Turn 2: Normal turn should work === + await _inject_user_turn(injector, "second turn") + await asyncio.sleep(TIMEOUT_WAIT) + + assert stop_strategy._text == "", "Expected clean _text after turn 2" + assert mock_llm.get_current_step() == 1, ( + f"Expected 1 LLM call, got {mock_llm.get_current_step()}" + ) + + await injector.inject(EndTaskFrame(), direction=FrameDirection.UPSTREAM) + + await _run_scenario(pipeline, inject) + + # ========================================================================= + # Scenario 3 (✅ after fix): Transcription arrives after unmute + # + # BotStartedSpeaking (muted) + # UserStartedSpeaking (suppressed) + # BotStoppedSpeaking (unmuted) + # TranscriptionFrame -> stop strategy _text = "hello" + # UserStoppedSpeaking -> stop strategy triggers (text truthy, not speaking) + # Turn controller ignores (user_turn is False), BUT unconditionally + # resets stop strategies -> _text cleared. No dangling state. + # ========================================================================= + + @pytest.mark.asyncio + async def test_scenario_3_transcription_after_unmute_text_cleared(self): + """Transcription arrives after bot stops but turn was never started. + + The VADUserStartedSpeakingFrame was suppressed, so no turn started. + But TranscriptionFrame arrives after unmute and accumulates _text. + The stop strategy triggers, but the turn controller rejects it + (no active turn). The unconditional reset clears _text, preventing + any dangling state from contaminating subsequent turns. + """ + injector, user_agg, stop_strategy, turn_ctrl, mock_llm, context, pipeline = ( + _build_components() + ) + + async def inject(): + # === Turn 1: Rejected stop with unconditional reset === + await injector.inject(BotStartedSpeakingFrame()) + await asyncio.sleep(ASYNC_DELAY) + + # Suppressed: VAD and UserStartedSpeaking + await injector.inject(VADUserStartedSpeakingFrame()) + await asyncio.sleep(0) + await injector.inject(UserStartedSpeakingFrame()) + await asyncio.sleep(ASYNC_DELAY) + + # Bot stops -> unmuted + await injector.inject(BotStoppedSpeakingFrame()) + await asyncio.sleep(ASYNC_DELAY) + + # TranscriptionFrame arrives AFTER unmute -> reaches stop strategy + await injector.inject( + TranscriptionFrame("hello", "user-1", time_now_iso8601()) + ) + await asyncio.sleep(ASYNC_DELAY) + + # Install spy on trigger_user_turn_stopped to track every call + # and the _user_turn state at the time of each call. + trigger_stop_calls = [] + original_trigger_stop = stop_strategy.trigger_user_turn_stopped + + async def spy_trigger_stop(): + trigger_stop_calls.append(turn_ctrl._user_turn) + await original_trigger_stop() + + stop_strategy.trigger_user_turn_stopped = spy_trigger_stop + + # UserStoppedSpeaking arrives AFTER unmute + # Stop strategy: _user_speaking is False (UserStartedSpeaking was suppressed), + # _text is "hello" -> triggers stop via _handle_user_stopped_speaking + # Turn controller: _user_turn is False -> rejects, but resets -> _text cleared + await injector.inject(UserStoppedSpeakingFrame()) + await asyncio.sleep(ASYNC_DELAY) + + # Call #1: _handle_user_stopped_speaking -> _maybe_trigger_user_turn_stopped + assert len(trigger_stop_calls) == 1, ( + f"Expected exactly 1 trigger_user_turn_stopped call from " + f"_handle_user_stopped_speaking, got {len(trigger_stop_calls)}" + ) + assert trigger_stop_calls[0] is False, ( + "Expected _user_turn=False when _handle_user_stopped_speaking triggered stop" + ) + + # Wait for _task_handler timeout period + await asyncio.sleep(TIMEOUT_WAIT) + + # The unconditional reset cleared _text after the rejected stop, + # so the timeout's _maybe_trigger_user_turn_stopped sees _text="" and + # does NOT call trigger_user_turn_stopped again. + assert len(trigger_stop_calls) == 1, ( + f"Expected no additional trigger_user_turn_stopped calls after " + f"reset cleared _text, but got {len(trigger_stop_calls)} total call(s)" + ) + + # Restore original method + stop_strategy.trigger_user_turn_stopped = original_trigger_stop + + # Transcript is not suppressed, so we should have hello in user aggregator + assert user_agg._aggregation[0].text == "hello" + + # Assert: _text is cleared by the unconditional reset (no dangling state) + assert stop_strategy._text == "", ( + f"Expected empty _text after unconditional reset, got '{stop_strategy._text}'" + ) + assert not turn_ctrl._user_turn, ( + "Expected _user_turn to be False (turn was never started)" + ) + # No LLM call should have happened + assert mock_llm.get_current_step() == 0, ( + f"Expected 0 LLM calls, got {mock_llm.get_current_step()}" + ) + + # === Turn 2: No premature stop, normal flow === + # _text is clean, so UserStoppedSpeaking won't trigger a premature stop. + # The turn completes normally when the timeout fires after TranscriptionFrame. + # The aggregator still has dangling "hello" from turn 1, which gets + # combined with turn 2's "world" — this is acceptable behavior. + await _inject_user_turn(injector, "world") + await asyncio.sleep(TIMEOUT_WAIT) + + assert stop_strategy._text == "", ( + f"Expected clean _text after normal turn, got '{stop_strategy._text}'" + ) + assert mock_llm.get_current_step() == 1, ( + f"Expected 1 LLM call (normal turn), got {mock_llm.get_current_step()}" + ) + + # The LLM received both "hello" (dangling in aggregator from turn 1) + # and "world" (from turn 2). This is acceptable — the aggregator's + # _aggregation is a separate concern from the stop strategy's _text. + messages = context.messages + user_messages = [m for m in messages if m.get("role") == "user"] + assert len(user_messages) == 1, ( + f"Expected 1 user message, got {len(user_messages)}" + ) + user_text = user_messages[0]["content"] + assert "hello" in user_text, ( + f"Expected 'hello' (from aggregator) in user message, got: '{user_text}'" + ) + assert "world" in user_text, ( + f"Expected 'world' (from turn 2) in user message, got: '{user_text}'" + ) + + await injector.inject(EndTaskFrame(), direction=FrameDirection.UPSTREAM) + + await _run_scenario(pipeline, inject) + + # ========================================================================= + # Scenario 4 (✅): User speaks after bot stops -> normal flow + # + # BotStartedSpeaking (muted) + # BotStoppedSpeaking (unmuted) + # UserStartedSpeaking (triggers interruption/turn start) + # TranscriptionFrame + # UserStoppedSpeaking + # + # Turn starts because VAD frame is not suppressed. Everything works. + # ========================================================================= + + @pytest.mark.asyncio + async def test_scenario_4_user_speaks_after_bot_stops(self): + """User speaks after bot stops speaking. Normal flow, everything works. + + All frames arrive after unmute, so VAD triggers turn start normally. + """ + injector, user_agg, stop_strategy, turn_ctrl, mock_llm, context, pipeline = ( + _build_components() + ) + + async def inject(): + # === Turn 1: Bot speaks, then user speaks after bot stops === + await injector.inject(BotStartedSpeakingFrame()) + await asyncio.sleep(ASYNC_DELAY) + await injector.inject(BotStoppedSpeakingFrame()) + await asyncio.sleep(ASYNC_DELAY) + + # Normal user turn after bot stopped + await _inject_user_turn(injector, "hello after bot") + await asyncio.sleep(TIMEOUT_WAIT) + + # Assert: clean state + assert stop_strategy._text == "", ( + f"Expected empty _text after clean turn, got '{stop_strategy._text}'" + ) + assert not turn_ctrl._user_turn, "Expected _user_turn False after turn" + assert mock_llm.get_current_step() == 1, ( + f"Expected 1 LLM call, got {mock_llm.get_current_step()}" + ) + + # === Turn 2: Another normal turn === + await _inject_user_turn(injector, "second turn") + await asyncio.sleep(TIMEOUT_WAIT) + + assert stop_strategy._text == "", "Expected clean _text after turn 2" + assert mock_llm.get_current_step() == 2, ( + f"Expected 2 LLM calls, got {mock_llm.get_current_step()}" + ) + + # Verify clean context - each turn should be separate + user_messages = [m for m in context.messages if m.get("role") == "user"] + assert len(user_messages) == 2, ( + f"Expected 2 user messages (one per turn), got {len(user_messages)}" + ) + + await injector.inject(EndTaskFrame(), direction=FrameDirection.UPSTREAM) + + await _run_scenario(pipeline, inject) + + # ========================================================================= + # Scenario 5 (✅): Late transcription - all suppressed + # + # BotStartedSpeaking (muted) + # UserStartedSpeaking (suppressed) + # UserStoppedSpeaking (suppressed) + # TranscriptionFrame (suppressed) <- late, but still during bot speaking + # BotStoppedSpeaking (unmuted) + # + # Everything suppressed, _text empty. Clean state. + # ========================================================================= + + @pytest.mark.asyncio + async def test_scenario_5_late_transcription_all_suppressed(self): + """Late transcription arrives during bot speaking. All suppressed. + + Even though transcription is late, it still arrives before BotStoppedSpeaking + so it's still muted. Clean state. + """ + injector, user_agg, stop_strategy, turn_ctrl, mock_llm, context, pipeline = ( + _build_components() + ) + + async def inject(): + # === Turn 1: Late transcription, but all still suppressed === + await injector.inject(BotStartedSpeakingFrame()) + await asyncio.sleep(ASYNC_DELAY) + + await injector.inject(VADUserStartedSpeakingFrame()) + await asyncio.sleep(0) + await injector.inject(UserStartedSpeakingFrame()) + await asyncio.sleep(0) + await injector.inject(UserStoppedSpeakingFrame()) + await asyncio.sleep(0) + await injector.inject(VADUserStoppedSpeakingFrame()) + await asyncio.sleep(0) + # Late transcription - but still during bot speaking + await injector.inject( + TranscriptionFrame("late hello", "user-1", time_now_iso8601()) + ) + await asyncio.sleep(ASYNC_DELAY) + + await injector.inject(BotStoppedSpeakingFrame()) + await asyncio.sleep(TIMEOUT_WAIT) + + # Assert: all suppressed, clean state + assert stop_strategy._text == "", ( + f"Expected empty _text, got '{stop_strategy._text}'" + ) + assert not turn_ctrl._user_turn + + # === Turn 2: Normal turn works === + await _inject_user_turn(injector, "clean turn") + await asyncio.sleep(TIMEOUT_WAIT) + + assert stop_strategy._text == "" + assert mock_llm.get_current_step() == 1 + + await injector.inject(EndTaskFrame(), direction=FrameDirection.UPSTREAM) + + await _run_scenario(pipeline, inject) + + # ========================================================================= + # Scenario 6 (✅ after fix): Late transcription arrives after bot stops + # + # BotStartedSpeaking (muted) + # UserStartedSpeaking (suppressed) + # UserStoppedSpeaking (suppressed) + # BotStoppedSpeaking (unmuted) + # TranscriptionFrame -> reaches stop strategy, _text = "late hello" + # + # Stop strategy timeout fires: _user_speaking is False (from initial state, + # UserStartedSpeaking was suppressed), _text truthy -> triggers stop. + # Turn controller: _user_turn False -> rejects, but unconditionally resets + # -> _text cleared. No dangling state. + # ========================================================================= + + @pytest.mark.asyncio + async def test_scenario_6_late_transcription_after_unmute_text_cleared(self): + """Late transcription arrives after bot stops. No turn was started. + + UserStartedSpeaking was suppressed so _user_turn never started. + The late TranscriptionFrame accumulates _text after unmute. + The stop strategy timeout triggers, but controller rejects it. + The unconditional reset clears _text, preventing dangling state. + """ + injector, user_agg, stop_strategy, turn_ctrl, mock_llm, context, pipeline = ( + _build_components() + ) + + async def inject(): + # === Turn 1: Late transcription scenario === + await injector.inject(BotStartedSpeakingFrame()) + await asyncio.sleep(ASYNC_DELAY) + + # Suppressed + await injector.inject(VADUserStartedSpeakingFrame()) + await asyncio.sleep(0) + await injector.inject(UserStartedSpeakingFrame()) + await asyncio.sleep(0) + await injector.inject(UserStoppedSpeakingFrame()) + await asyncio.sleep(0) + await injector.inject(VADUserStoppedSpeakingFrame()) + await asyncio.sleep(ASYNC_DELAY) + + # Bot stops -> unmuted + await injector.inject(BotStoppedSpeakingFrame()) + await asyncio.sleep(ASYNC_DELAY) + + # Install spy on trigger_user_turn_stopped to track calls + trigger_stop_calls = [] + original_trigger_stop = stop_strategy.trigger_user_turn_stopped + + async def spy_trigger_stop(): + trigger_stop_calls.append(turn_ctrl._user_turn) + await original_trigger_stop() + + stop_strategy.trigger_user_turn_stopped = spy_trigger_stop + + # Late transcription arrives after unmute + await injector.inject( + TranscriptionFrame("late hello", "user-1", time_now_iso8601()) + ) + + # No UserStoppedSpeakingFrame in this scenario — the stop is + # triggered ONLY by the _task_handler timeout path. + await asyncio.sleep(TIMEOUT_WAIT) + + # The _task_handler timeout fired _maybe_trigger_user_turn_stopped: + # _user_speaking=False (UserStartedSpeaking was suppressed), + # _text="late hello" -> trigger_user_turn_stopped called + # Turn controller: _user_turn=False -> rejects, but resets -> _text cleared + assert len(trigger_stop_calls) == 1, ( + f"Expected exactly 1 trigger_user_turn_stopped call from " + f"_task_handler timeout, got {len(trigger_stop_calls)}" + ) + assert trigger_stop_calls[0] is False, ( + "Expected _user_turn=False when timeout triggered stop" + ) + + # Restore original method + stop_strategy.trigger_user_turn_stopped = original_trigger_stop + + # Transcript is not suppressed, so we should have late hello in user aggregator + assert user_agg._aggregation[0].text == "late hello" + + # Assert: _text is cleared by the unconditional reset (no dangling state) + assert stop_strategy._text == "", ( + f"Expected empty _text after unconditional reset, got '{stop_strategy._text}'" + ) + assert not turn_ctrl._user_turn, "Turn should not have started" + assert mock_llm.get_current_step() == 0, "No LLM call expected" + + # === Turn 2: No premature stop, normal flow === + # _text is clean, so no premature stop occurs. + # The turn completes normally when the timeout fires after TranscriptionFrame. + # The aggregator still has dangling "late hello" from turn 1, which gets + # combined with turn 2's "real speech" — this is acceptable behavior. + await _inject_user_turn(injector, "real speech") + await asyncio.sleep(TIMEOUT_WAIT) + + assert stop_strategy._text == "", ( + f"Expected clean _text after normal turn, got '{stop_strategy._text}'" + ) + assert mock_llm.get_current_step() == 1, ( + f"Expected 1 LLM call (normal turn), got {mock_llm.get_current_step()}" + ) + + # The LLM received both "late hello" (dangling in aggregator from turn 1) + # and "real speech" (from turn 2). + user_messages = [m for m in context.messages if m.get("role") == "user"] + assert len(user_messages) == 1, ( + f"Expected 1 user message, got {len(user_messages)}" + ) + user_text = user_messages[0]["content"] + assert "late hello" in user_text, ( + f"Expected 'late hello' (from aggregator) in user message, got: '{user_text}'" + ) + assert "real speech" in user_text, ( + f"Expected 'real speech' (from turn 2) in user message, got: '{user_text}'" + ) + + await injector.inject(EndTaskFrame(), direction=FrameDirection.UPSTREAM) + + await _run_scenario(pipeline, inject) + + # ========================================================================= + # Scenario 7 (✅ after fix): Late transcription - user stops before transcription + # + # BotStartedSpeaking (muted) + # UserStartedSpeaking (suppressed) + # BotStoppedSpeaking (unmuted) + # UserStoppedSpeaking (no _text yet -> no trigger from _handle_user_stopped) + # TranscriptionFrame -> _text = "late", timeout triggers stop + # + # Turn controller: _user_turn False -> rejects, but unconditionally resets + # -> _text cleared. No dangling state. + # ========================================================================= + + @pytest.mark.asyncio + async def test_scenario_7_late_transcription_after_user_stops_text_cleared(self): + """User stops speaking, then late transcription arrives. No turn started. + + UserStoppedSpeaking arrives first (no _text yet, so no trigger). + Then TranscriptionFrame arrives (sets _text). The timeout fires and + triggers stop, but controller rejects it. The unconditional reset + clears _text, preventing dangling state. + """ + injector, user_agg, stop_strategy, turn_ctrl, mock_llm, context, pipeline = ( + _build_components() + ) + + async def inject(): + # === Turn 1: Late transcription after user stops === + await injector.inject(BotStartedSpeakingFrame()) + await asyncio.sleep(ASYNC_DELAY) + + # Suppressed + await injector.inject(VADUserStartedSpeakingFrame()) + await asyncio.sleep(0) + await injector.inject(UserStartedSpeakingFrame()) + await asyncio.sleep(ASYNC_DELAY) + + # Bot stops -> unmuted + await injector.inject(BotStoppedSpeakingFrame()) + await asyncio.sleep(ASYNC_DELAY) + + # UserStoppedSpeaking arrives after unmute, but _text is still empty + # -> _maybe_trigger_user_turn_stopped: _text is "" -> no trigger + await injector.inject(UserStoppedSpeakingFrame()) + await asyncio.sleep(ASYNC_DELAY) + + # Late transcription arrives AFTER user stopped + await injector.inject( + TranscriptionFrame("late text", "user-1", time_now_iso8601()) + ) + # Wait for timeout to fire + await asyncio.sleep(TIMEOUT_WAIT) + + # Transcript is not suppressed, so we should have late text in user aggregator + assert user_agg._aggregation[0].text == "late text" + + # Assert: _text is cleared by the unconditional reset + # The timeout fired _maybe_trigger_user_turn_stopped: + # _user_speaking=False (was never set, UserStartedSpeaking suppressed), + # _text="late text" -> triggers stop + # Turn controller: _user_turn=False -> rejects, but resets -> _text cleared + assert stop_strategy._text == "", ( + f"Expected empty _text after unconditional reset, got '{stop_strategy._text}'" + ) + assert not turn_ctrl._user_turn + assert mock_llm.get_current_step() == 0 + + # === Turn 2: No premature stop, normal flow === + # _text is clean, so no premature stop occurs. + # The turn completes normally when the timeout fires after TranscriptionFrame. + # The aggregator still has dangling "late text" from turn 1, which gets + # combined with turn 2's "next speech" — this is acceptable behavior. + await _inject_user_turn(injector, "next speech") + await asyncio.sleep(TIMEOUT_WAIT) + + assert stop_strategy._text == "", ( + f"Expected clean _text after normal turn, got '{stop_strategy._text}'" + ) + assert mock_llm.get_current_step() == 1 + + # The LLM received both "late text" (dangling in aggregator from turn 1) + # and "next speech" (from turn 2). + user_messages = [m for m in context.messages if m.get("role") == "user"] + assert len(user_messages) == 1 + user_text = user_messages[0]["content"] + assert "late text" in user_text, ( + f"Expected 'late text' (from aggregator) in context, got: '{user_text}'" + ) + assert "next speech" in user_text, ( + f"Expected 'next speech' (from turn 2) in context, got: '{user_text}'" + ) + + await injector.inject(EndTaskFrame(), direction=FrameDirection.UPSTREAM) + + await _run_scenario(pipeline, inject) + + # ========================================================================= + # Scenario 8 (✅): Late transcription - user speaks after bot stops + # + # BotStartedSpeaking (muted) + # BotStoppedSpeaking (unmuted) + # UserStartedSpeaking (not suppressed -> turn starts, start strategies reset) + # UserStoppedSpeaking (no _text -> no trigger) + # TranscriptionFrame (timeout triggers stop) + # + # Turn controller: _user_turn IS True -> allows stop -> resets strategies + # Clean state! + # ========================================================================= + + @pytest.mark.asyncio + async def test_scenario_8_late_transcription_user_speaks_after_bot_stops(self): + """User speaks after bot stops, then late transcription arrives. + + Because user spoke after unmute, VAD triggers turn start -> _user_turn=True. + When the late transcription triggers the stop, controller allows it and + resets strategies. Clean state. + """ + injector, user_agg, stop_strategy, turn_ctrl, mock_llm, context, pipeline = ( + _build_components() + ) + + async def inject(): + # === Turn 1: Late transcription but user spoke after unmute === + await injector.inject(BotStartedSpeakingFrame()) + await asyncio.sleep(ASYNC_DELAY) + await injector.inject(BotStoppedSpeakingFrame()) + await asyncio.sleep(ASYNC_DELAY) + + # User speaks AFTER bot stops -> not suppressed + await injector.inject(VADUserStartedSpeakingFrame()) + await asyncio.sleep(0) + await injector.inject(UserStartedSpeakingFrame()) + await asyncio.sleep(ASYNC_DELAY) + + # User stops speaking (no _text yet, so stop strategy doesn't trigger) + await injector.inject(UserStoppedSpeakingFrame()) + await asyncio.sleep(0) + await injector.inject(VADUserStoppedSpeakingFrame()) + await asyncio.sleep(ASYNC_DELAY) + + # Late transcription arrives + await injector.inject( + TranscriptionFrame("late but ok", "user-1", time_now_iso8601()) + ) + # Wait for timeout to trigger stop + await asyncio.sleep(TIMEOUT_WAIT) + + # Assert: turn controller allowed the stop, strategies were reset + assert stop_strategy._text == "", ( + f"Expected clean _text after allowed stop, got '{stop_strategy._text}'" + ) + assert not turn_ctrl._user_turn, "Turn should have stopped" + assert mock_llm.get_current_step() == 1, ( + f"Expected 1 LLM call, got {mock_llm.get_current_step()}" + ) + + # === Turn 2: Clean subsequent turn === + await _inject_user_turn(injector, "clean turn") + await asyncio.sleep(TIMEOUT_WAIT) + + assert stop_strategy._text == "" + assert mock_llm.get_current_step() == 2 + + # Verify each turn is separate in context + user_messages = [m for m in context.messages if m.get("role") == "user"] + assert len(user_messages) == 2, ( + f"Expected 2 separate user messages, got {len(user_messages)}" + ) + + await injector.inject(EndTaskFrame(), direction=FrameDirection.UPSTREAM) + + await _run_scenario(pipeline, inject) + + # ========================================================================= + # Combined test: validates _text is cleared independently after each + # rejected stop, preventing accumulation across muted periods. + # ========================================================================= + + @pytest.mark.asyncio + async def test_text_cleared_independently_across_failed_stops(self): + """Validates _text does not accumulate across multiple failed stop attempts. + + Two consecutive muted periods with late transcriptions each trigger + a rejected stop. The unconditional reset clears _text after each + rejection, so no accumulation occurs. The subsequent normal turn + completes correctly. + """ + injector, user_agg, stop_strategy, turn_ctrl, mock_llm, context, pipeline = ( + _build_components() + ) + + async def inject(): + # === Muted period 1: _text cleared after rejected stop === + await injector.inject(BotStartedSpeakingFrame()) + await asyncio.sleep(ASYNC_DELAY) + await injector.inject(VADUserStartedSpeakingFrame()) # suppressed + await asyncio.sleep(0) + await injector.inject(UserStartedSpeakingFrame()) # suppressed + await asyncio.sleep(ASYNC_DELAY) + await injector.inject(BotStoppedSpeakingFrame()) + await asyncio.sleep(ASYNC_DELAY) + + # Late transcription after unmute + await injector.inject( + TranscriptionFrame("first", "user-1", time_now_iso8601()) + ) + await asyncio.sleep(0) + await injector.inject(UserStoppedSpeakingFrame()) + await asyncio.sleep(TIMEOUT_WAIT) + + # Transcript is not suppressed, so we should have first in user aggregator + assert user_agg._aggregation[0].text == "first" + + # _text is cleared by the unconditional reset after rejected stop + assert stop_strategy._text == "", ( + f"Expected empty _text after unconditional reset, got '{stop_strategy._text}'" + ) + + # === Muted period 2: _text cleared independently, no accumulation === + await injector.inject(BotStartedSpeakingFrame()) + await asyncio.sleep(ASYNC_DELAY) + await injector.inject(VADUserStartedSpeakingFrame()) # suppressed + await asyncio.sleep(0) + await injector.inject(UserStartedSpeakingFrame()) # suppressed + await asyncio.sleep(ASYNC_DELAY) + await injector.inject(BotStoppedSpeakingFrame()) + await asyncio.sleep(ASYNC_DELAY) + + await injector.inject( + TranscriptionFrame("second", "user-1", time_now_iso8601()) + ) + await asyncio.sleep(0) + await injector.inject(UserStoppedSpeakingFrame()) + await asyncio.sleep(TIMEOUT_WAIT) + + # _text is cleared again — no accumulation of "first" + "second" + assert stop_strategy._text == "", ( + f"Expected empty _text after second unconditional reset, got '{stop_strategy._text}'" + ) + # Aggregator accumulated both (separate concern, acceptable) + assert len(user_agg._aggregation) == 2 + assert user_agg._aggregation[0].text == "first" + assert user_agg._aggregation[1].text == "second" + + # === Turn 3: No premature stop, normal flow === + # _text is clean, so no premature stop occurs. + # The turn completes normally when the timeout fires after TranscriptionFrame. + # The aggregator has dangling "first" + "second" from muted periods, + # which get combined with turn 3's "actual speech". + await _inject_user_turn(injector, "actual speech") + await asyncio.sleep(TIMEOUT_WAIT) + + assert stop_strategy._text == "", ( + f"Expected clean _text after normal turn, got '{stop_strategy._text}'" + ) + assert mock_llm.get_current_step() == 1 + + # The LLM received all three: "first" + "second" (from aggregator) + # and "actual speech" (from turn 3). + user_messages = [m for m in context.messages if m.get("role") == "user"] + assert len(user_messages) == 1, ( + f"Expected 1 user message, got {len(user_messages)}" + ) + user_text = user_messages[0]["content"] + assert "first" in user_text, f"Expected 'first' in '{user_text}'" + assert "second" in user_text, f"Expected 'second' in '{user_text}'" + assert "actual speech" in user_text, ( + f"Expected 'actual speech' in '{user_text}'" + ) + + await injector.inject(EndTaskFrame(), direction=FrameDirection.UPSTREAM) + + await _run_scenario(pipeline, inject) diff --git a/api/utils/hold_audio.py b/api/utils/hold_audio.py new file mode 100644 index 0000000..e77f24f --- /dev/null +++ b/api/utils/hold_audio.py @@ -0,0 +1,94 @@ +""" +Hold audio utility for loading and caching hold music files. + +This module provides functionality to load hold music audio files at specific sample rates +with caching to improve performance during multiple calls. +""" + +from typing import Dict, Optional, Tuple + +import numpy as np +from loguru import logger + +try: + import soundfile as sf +except ModuleNotFoundError as e: + logger.error(f"Exception: {e}") + logger.error("In order to use hold audio, you need to `pip install soundfile`.") + raise Exception(f"Missing module: {e}") + + +# Global cache for loaded hold music data +_hold_audio_cache: Dict[Tuple[str, int], np.ndarray] = {} + + +def load_hold_audio(file_path: str, sample_rate: int) -> Optional[bytes]: + """Load hold music audio file at the specified sample rate with caching. + + Args: + file_path: Path to the hold music audio file + sample_rate: Target sample rate (8000 or 16000 Hz supported) + + Returns: + Audio data as bytes (PCM16) or None if loading failed + """ + cache_key = (file_path, sample_rate) + + # Check cache first + if cache_key in _hold_audio_cache: + logger.debug(f"Using cached hold audio for {file_path} at {sample_rate}Hz") + audio_data = _hold_audio_cache[cache_key] + return audio_data.tobytes() + + try: + logger.info(f"Loading hold audio from {file_path} at {sample_rate}Hz") + + # Load audio file + sound, file_sample_rate = sf.read(file_path, dtype="int16") + logger.info( + f"Audio file loaded - file sample_rate: {file_sample_rate}, target: {sample_rate}" + ) + + # Ensure mono audio (take first channel if stereo) + if len(sound.shape) > 1: + sound = sound[:, 0] + + # Resample if needed + if file_sample_rate != sample_rate: + logger.warning( + f"Hold music file has sample rate {file_sample_rate}, expected {sample_rate}" + ) + # For now, we'll use the audio as-is and let the transport handle resampling + # In a production system, you might want to use librosa or scipy for proper resampling + + # Convert to int16 and cache + audio_data = sound.astype(np.int16) + _hold_audio_cache[cache_key] = audio_data + + logger.info( + f"Hold audio loaded successfully: {len(audio_data)} samples at {sample_rate}Hz" + ) + return audio_data.tobytes() + + except Exception as e: + logger.error(f"Failed to load hold audio file {file_path}: {e}") + return None + + +def clear_hold_audio_cache(): + """Clear the hold audio cache to free memory.""" + global _hold_audio_cache + _hold_audio_cache.clear() + logger.info("Hold audio cache cleared") + + +def get_cache_info() -> Dict[str, int]: + """Get information about the current cache state. + + Returns: + Dictionary with cache statistics + """ + return { + "cached_files": len(_hold_audio_cache), + "total_cache_size": sum(len(data) for data in _hold_audio_cache.values()), + } diff --git a/docker-compose.yaml b/docker-compose.yaml index 5fb3c53..21402f7 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -214,4 +214,4 @@ volumes: networks: app-network: - driver: bridge + driver: bridge \ No newline at end of file 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/pipecat b/pipecat index e180bd3..fbc9a76 160000 --- a/pipecat +++ b/pipecat @@ -1 +1 @@ -Subproject commit e180bd3c2abc3cebbdf5e2d7955d9928cca5d219 +Subproject commit fbc9a768445e8f683721744659fc8904d4012081 diff --git a/ui/package-lock.json b/ui/package-lock.json index 866d461..2862dc7 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "ui", - "version": "1.10.0", + "version": "1.13.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ui", - "version": "1.10.0", + "version": "1.13.0", "dependencies": { "@dagrejs/dagre": "^1.1.4", "@hey-api/client-fetch": "^0.10.0", @@ -777,7 +777,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.10.tgz", "integrity": "sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==", "license": "MIT", - "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.26.2", @@ -970,6 +969,7 @@ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz", "integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==", "license": "MIT", + "peer": true, "dependencies": { "regenerator-runtime": "^0.14.0" }, @@ -1093,6 +1093,7 @@ "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-module-imports": "^7.16.7", "@babel/runtime": "^7.18.3", @@ -1111,13 +1112,15 @@ "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@emotion/babel-plugin/node_modules/source-map": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", "license": "BSD-3-Clause", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -1127,6 +1130,7 @@ "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", "license": "MIT", + "peer": true, "dependencies": { "@emotion/memoize": "^0.9.0", "@emotion/sheet": "^1.4.0", @@ -1139,19 +1143,22 @@ "version": "0.9.2", "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@emotion/memoize": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@emotion/react": { "version": "11.14.0", "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -1176,6 +1183,7 @@ "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", "license": "MIT", + "peer": true, "dependencies": { "@emotion/hash": "^0.9.2", "@emotion/memoize": "^0.9.0", @@ -1188,19 +1196,22 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@emotion/unitless": { "version": "0.10.0", "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@emotion/use-insertion-effect-with-fallbacks": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", "license": "MIT", + "peer": true, "peerDependencies": { "react": ">=16.8.0" } @@ -1209,13 +1220,15 @@ "version": "1.4.2", "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@emotion/weak-memoize": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.2", @@ -1833,7 +1846,6 @@ "resolved": "https://registry.npmjs.org/@hey-api/openapi-ts/-/openapi-ts-0.66.2.tgz", "integrity": "sha512-77nofk/zacBNDwVb86kjS2sMIrwbwoBgUNw10crhPPrhV7HUs6A4SzZxePLEGRyHbM54v0g+XL6P8DSr98BM+A==", "license": "MIT", - "peer": true, "dependencies": { "@hey-api/json-schema-ref-parser": "1.0.4", "c12": "2.0.1", @@ -2492,6 +2504,7 @@ "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", "license": "MIT", + "peer": true, "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" @@ -2749,7 +2762,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -2759,6 +2771,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.200.0.tgz", "integrity": "sha512-IKJBQxh91qJ+3ssRly5hYEJ8NDHu9oY/B1PXVSCWf7zytmYO9RNLB0Ox9XQ/fJ8m6gY6Q6NtBWlmXfaXt5Uc4Q==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/api": "^1.3.0" }, @@ -4140,6 +4153,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz", "integrity": "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/semantic-conventions": "^1.29.0" @@ -4174,7 +4188,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.34.0.tgz", "integrity": "sha512-aKcOkyrorBGlajjRdVoJWHTxfxO1vCNHLJVlSDaRHDIdjU+pX8IYQPvPDkYiujKLbRnWU+1TBwEt0QRgSm4SGA==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=14" } @@ -8924,7 +8937,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -11069,6 +11081,7 @@ "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -11079,6 +11092,7 @@ "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", "license": "MIT", + "peer": true, "dependencies": { "@types/eslint": "*", "@types/estree": "*" @@ -11131,7 +11145,8 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/pg": { "version": "8.6.1", @@ -11158,7 +11173,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.0.tgz", "integrity": "sha512-UaicktuQI+9UKyA4njtDOGBD/67t8YEBt2xdfqu8+gP9hqPUPsiXlNPcpS2gVdjmis5GKPG3fCxbQLVgxsQZ8w==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -11169,7 +11183,6 @@ "integrity": "sha512-jFf/woGTVTjUJsl2O7hcopJ1r0upqoq/vIOoCj0yLh3RIXxWcljlpuZ+vEBRXsymD1jhfeJrlyTy/S1UW+4y1w==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.0.0" } @@ -11179,6 +11192,7 @@ "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "*" } @@ -11701,6 +11715,7 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", "license": "MIT", + "peer": true, "dependencies": { "@webassemblyjs/helper-numbers": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2" @@ -11710,25 +11725,29 @@ "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@webassemblyjs/helper-api-error": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@webassemblyjs/helper-buffer": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@webassemblyjs/helper-numbers": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", "license": "MIT", + "peer": true, "dependencies": { "@webassemblyjs/floating-point-hex-parser": "1.13.2", "@webassemblyjs/helper-api-error": "1.13.2", @@ -11739,13 +11758,15 @@ "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@webassemblyjs/helper-wasm-section": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", "license": "MIT", + "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", @@ -11758,6 +11779,7 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", "license": "MIT", + "peer": true, "dependencies": { "@xtuc/ieee754": "^1.2.0" } @@ -11767,6 +11789,7 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@xtuc/long": "4.2.2" } @@ -11775,13 +11798,15 @@ "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@webassemblyjs/wasm-edit": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", "license": "MIT", + "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", @@ -11798,6 +11823,7 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", "license": "MIT", + "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", @@ -11811,6 +11837,7 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", "license": "MIT", + "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", @@ -11823,6 +11850,7 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", "license": "MIT", + "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-api-error": "1.13.2", @@ -11837,6 +11865,7 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", "license": "MIT", + "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@xtuc/long": "4.2.2" @@ -11846,13 +11875,15 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/@xtuc/long": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", - "license": "Apache-2.0" + "license": "Apache-2.0", + "peer": true }, "node_modules/@xyflow/react": { "version": "12.9.2", @@ -11919,7 +11950,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -11980,6 +12010,7 @@ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "license": "MIT", + "peer": true, "dependencies": { "ajv": "^8.0.0" }, @@ -11997,6 +12028,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -12012,7 +12044,8 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ansi-regex": { "version": "5.0.1", @@ -12330,6 +12363,7 @@ "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.12.5", "cosmiconfig": "^7.0.0", @@ -12436,7 +12470,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001688", "electron-to-chromium": "^1.5.73", @@ -12617,6 +12650,7 @@ "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=6.0" } @@ -12837,6 +12871,7 @@ "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", "license": "MIT", + "peer": true, "dependencies": { "@types/parse-json": "^4.0.0", "import-fresh": "^3.2.1", @@ -12988,7 +13023,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -13295,6 +13329,7 @@ "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" @@ -13386,6 +13421,7 @@ "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", "license": "MIT", + "peer": true, "dependencies": { "is-arrayish": "^0.2.1" } @@ -13394,7 +13430,8 @@ "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/es-abstract": { "version": "1.23.9", @@ -13512,7 +13549,8 @@ "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/es-object-atoms": { "version": "1.1.1", @@ -13661,7 +13699,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -13835,7 +13872,6 @@ "integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.8", @@ -14123,6 +14159,7 @@ "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.8.x" } @@ -14221,7 +14258,8 @@ "url": "https://opencollective.com/fastify" } ], - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/fast-xml-parser": { "version": "5.2.5", @@ -14286,7 +14324,8 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/find-up": { "version": "5.0.0", @@ -14650,7 +14689,8 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", - "license": "BSD-2-Clause" + "license": "BSD-2-Clause", + "peer": true }, "node_modules/glob/node_modules/brace-expansion": { "version": "2.0.2", @@ -14915,7 +14955,6 @@ "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz", "integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/immer" @@ -15482,6 +15521,7 @@ "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", @@ -15496,6 +15536,7 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "license": "MIT", + "peer": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -15583,7 +15624,8 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/json-schema": { "version": "0.4.0", @@ -15921,13 +15963,15 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/loader-runner": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", "license": "MIT", + "peer": true, "engines": { "node": ">=6.11.5" } @@ -16003,13 +16047,15 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/merge2": { "version": "1.4.1", @@ -16202,7 +16248,6 @@ "resolved": "https://registry.npmjs.org/next/-/next-15.5.9.tgz", "integrity": "sha512-agNLK89seZEtC5zUHwtut0+tNrc0Xw4FT/Dg+B/VLEo9pAcS9rtTKpek3V6kVcVwsB2YlqMaHdfZL4eLEVYuCg==", "license": "MIT", - "peer": true, "dependencies": { "@next/env": "15.5.9", "@swc/helpers": "0.5.15", @@ -16614,6 +16659,7 @@ "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", @@ -16672,6 +16718,7 @@ "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -17084,6 +17131,7 @@ "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", "license": "MIT", + "peer": true, "dependencies": { "safe-buffer": "^5.1.0" } @@ -17103,7 +17151,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -17134,7 +17181,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -17161,7 +17207,6 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.56.4.tgz", "integrity": "sha512-Rob7Ftz2vyZ/ZGsQZPaRdIefkgOSrQSPXfqBdvOPwJfoGnjwRJUs7EM7Kc1mcoDv3NOtqBzPGbcMB8CGn9CKgw==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -17186,15 +17231,13 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-redux": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -17334,6 +17377,7 @@ "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", "license": "BSD-3-Clause", + "peer": true, "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", @@ -17398,8 +17442,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -17437,7 +17480,8 @@ "version": "0.14.1", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", @@ -17474,6 +17518,7 @@ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -17573,7 +17618,6 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.35.0.tgz", "integrity": "sha512-kg6oI4g+vc41vePJyO6dHt/yl0Rz3Thv0kJeVQ3D1kS3E5XSuKbPc29G4IpT/Kv1KQwgHVcN+HtyS+HYLNSvQg==", "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.6" }, @@ -17675,7 +17719,8 @@ "url": "https://feross.org/support" } ], - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/safe-push-apply": { "version": "1.0.0", @@ -17732,6 +17777,7 @@ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.2.tgz", "integrity": "sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==", "license": "MIT", + "peer": true, "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", @@ -17768,6 +17814,7 @@ "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3" }, @@ -17779,7 +17826,8 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/secure-json-parse": { "version": "4.0.0", @@ -17814,6 +17862,7 @@ "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", "license": "BSD-3-Clause", + "peer": true, "dependencies": { "randombytes": "^2.1.0" } @@ -18385,7 +18434,8 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/supports-color": { "version": "7.2.0", @@ -18425,8 +18475,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.1.tgz", "integrity": "sha512-QNbdmeS979Efzim2g/bEvfuh+fTcIdp1y7gA+sb6OYSW74rt7Cr7M78AKdf6HqWT3d5AiTb7SwTT3sLQxr4/qw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tailwindcss-animate": { "version": "1.0.7", @@ -18468,6 +18517,7 @@ "resolved": "https://registry.npmjs.org/terser/-/terser-5.42.0.tgz", "integrity": "sha512-UYCvU9YQW2f/Vwl+P0GfhxJxbUGLwd+5QrrGgLajzWAtC/23AX0vcise32kkP7Eu0Wu9VlzzHAXkLObgjQfFlQ==", "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.14.0", @@ -18486,6 +18536,7 @@ "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz", "integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==", "license": "MIT", + "peer": true, "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", @@ -18519,7 +18570,8 @@ "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/thread-stream": { "version": "3.1.0", @@ -18592,7 +18644,6 @@ "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -18654,8 +18705,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "peer": true + "license": "0BSD" }, "node_modules/tsx": { "version": "4.19.3", @@ -18793,7 +18843,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz", "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -18999,6 +19048,7 @@ "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz", "integrity": "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==", "license": "MIT", + "peer": true, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, @@ -19085,6 +19135,7 @@ "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz", "integrity": "sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==", "license": "MIT", + "peer": true, "dependencies": { "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.1.2" @@ -19110,6 +19161,7 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.99.9.tgz", "integrity": "sha512-brOPwM3JnmOa+7kd3NsmOUOwbDAj8FT9xDsG3IW0MgbN9yZV7Oi/s/+MNQ/EcSMqw7qfoRyXPoeEWT8zLVdVGg==", "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.6", @@ -19172,6 +19224,7 @@ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "license": "BSD-2-Clause", + "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -19185,6 +19238,7 @@ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "license": "BSD-2-Clause", + "peer": true, "engines": { "node": ">=4.0" } @@ -19448,6 +19502,7 @@ "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", "license": "ISC", + "peer": true, "engines": { "node": ">= 6" } @@ -19556,7 +19611,6 @@ "resolved": "https://registry.npmjs.org/yup/-/yup-1.7.1.tgz", "integrity": "sha512-GKHFX2nXul2/4Dtfxhozv701jLQHdf6J34YDh2cEkpqoo8le5Mg6/LrdseVLrFarmFygZTlfIhHx/QKfb/QWXw==", "license": "MIT", - "peer": true, "dependencies": { "property-expr": "^2.0.5", "tiny-case": "^1.0.3", @@ -19599,7 +19653,6 @@ "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz", "integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==", "license": "MIT", - "peer": true, "engines": { "node": ">=12.20.0" }, diff --git a/ui/package.json b/ui/package.json index ee19670..c2766d0 100644 --- a/ui/package.json +++ b/ui/package.json @@ -1,6 +1,6 @@ { "name": "ui", - "version": "1.13.0", + "version": "1.14.0", "private": true, "scripts": { "dev": "NODE_OPTIONS='--enable-source-maps' next dev --turbopack", diff --git a/ui/src/app/api/auth/oss/route.ts b/ui/src/app/api/auth/oss/route.ts index 3b21806..6679fb4 100644 --- a/ui/src/app/api/auth/oss/route.ts +++ b/ui/src/app/api/auth/oss/route.ts @@ -1,5 +1,5 @@ /* - Helps provide authentication token to LocalAuthService once its loaded + Provides authentication token to LocalProviderWrapper once loaded in the browser */ import { cookies } from 'next/headers'; diff --git a/ui/src/app/campaigns/CampaignAdvancedSettings.tsx b/ui/src/app/campaigns/CampaignAdvancedSettings.tsx new file mode 100644 index 0000000..63a76ef --- /dev/null +++ b/ui/src/app/campaigns/CampaignAdvancedSettings.tsx @@ -0,0 +1,300 @@ +"use client"; + +import { Plus, X } from 'lucide-react'; +import { useId } from 'react'; +import TimezoneSelect, { type ITimezoneOption } from 'react-timezone-select'; + +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Separator } from '@/components/ui/separator'; +import { Switch } from '@/components/ui/switch'; + +export type TimeSlot = { day_of_week: number; start_time: string; end_time: string }; + +export interface CampaignAdvancedSettingsProps { + // Concurrency + maxConcurrency: string; + onMaxConcurrencyChange: (value: string) => void; + effectiveLimit: number; + orgConcurrentLimit: number; + fromNumbersCount: number; + // Retry config + retryEnabled: boolean; + onRetryEnabledChange: (value: boolean) => void; + maxRetries: string; + onMaxRetriesChange: (value: string) => void; + retryDelaySeconds: string; + onRetryDelaySecondsChange: (value: string) => void; + retryOnBusy: boolean; + onRetryOnBusyChange: (value: boolean) => void; + retryOnNoAnswer: boolean; + onRetryOnNoAnswerChange: (value: boolean) => void; + retryOnVoicemail: boolean; + onRetryOnVoicemailChange: (value: boolean) => void; + // Schedule config + scheduleEnabled: boolean; + onScheduleEnabledChange: (value: boolean) => void; + scheduleTimezone: ITimezoneOption | string; + onScheduleTimezoneChange: (value: ITimezoneOption | string) => void; + timeSlots: TimeSlot[]; + onTimeSlotsChange: (value: TimeSlot[]) => void; +} + +/** Extract the string timezone value from ITimezoneOption | string */ +export function getTimezoneValue(tz: ITimezoneOption | string): string { + return typeof tz === 'string' ? tz : tz.value; +} + +const timezoneSelectStyles = { + control: (base: Record, state: { isFocused: boolean }) => ({ + ...base, + minHeight: '36px', + fontSize: '14px', + backgroundColor: 'var(--background)', + borderColor: state.isFocused ? 'var(--ring)' : 'var(--border)', + boxShadow: state.isFocused ? '0 0 0 2px color-mix(in srgb, var(--ring) 20%, transparent)' : 'none', + '&:hover': { borderColor: 'var(--border)' }, + }), + menu: (base: Record) => ({ + ...base, + zIndex: 9999, + backgroundColor: 'var(--popover)', + border: '1px solid var(--border)', + boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)', + }), + menuList: (base: Record) => ({ + ...base, + backgroundColor: 'var(--popover)', + padding: 0, + }), + option: (base: Record, state: { isSelected: boolean; isFocused: boolean }) => ({ + ...base, + backgroundColor: state.isSelected ? 'var(--accent)' : state.isFocused ? 'var(--accent)' : 'var(--popover)', + color: 'var(--foreground)', + cursor: 'pointer', + '&:active': { backgroundColor: 'var(--accent)' }, + }), + singleValue: (base: Record) => ({ ...base, color: 'var(--foreground)' }), + input: (base: Record) => ({ ...base, color: 'var(--foreground)' }), + placeholder: (base: Record) => ({ ...base, color: 'var(--muted-foreground)' }), + indicatorSeparator: (base: Record) => ({ ...base, backgroundColor: 'var(--border)' }), + dropdownIndicator: (base: Record) => ({ + ...base, + color: 'var(--muted-foreground)', + '&:hover': { color: 'var(--foreground)' }, + }), +}; + +export default function CampaignAdvancedSettings({ + maxConcurrency, onMaxConcurrencyChange, effectiveLimit, orgConcurrentLimit, fromNumbersCount, + retryEnabled, onRetryEnabledChange, maxRetries, onMaxRetriesChange, + retryDelaySeconds, onRetryDelaySecondsChange, + retryOnBusy, onRetryOnBusyChange, retryOnNoAnswer, onRetryOnNoAnswerChange, + retryOnVoicemail, onRetryOnVoicemailChange, + scheduleEnabled, onScheduleEnabledChange, scheduleTimezone, onScheduleTimezoneChange, + timeSlots, onTimeSlotsChange, +}: CampaignAdvancedSettingsProps) { + const timezoneSelectId = useId(); + + return ( +
+ {/* Max Concurrent Calls */} +
+ + onMaxConcurrencyChange(e.target.value)} + min={1} + max={effectiveLimit} + /> +

+ Maximum number of simultaneous calls. Leave empty to use {effectiveLimit}. + {fromNumbersCount > 0 && ` You have ${fromNumbersCount} CLI${fromNumbersCount !== 1 ? 's' : ''} and an org limit of ${orgConcurrentLimit}.`} +

+ {fromNumbersCount > 0 && fromNumbersCount < orgConcurrentLimit && ( +

+ Concurrency is limited to {fromNumbersCount} by your configured phone numbers. To use the full org limit of {orgConcurrentLimit}, add more CLIs in Telephony Configuration. +

+ )} + {fromNumbersCount === 0 && ( +

+ No phone numbers configured. Add CLIs in Telephony Configuration before running the campaign. +

+ )} +
+ + {/* Retry Configuration */} +
+
+
+ +

+ Automatically retry failed calls +

+
+ +
+ + {retryEnabled && ( +
+
+
+ + onMaxRetriesChange(e.target.value)} + min={0} + max={10} + /> +
+
+ + onRetryDelaySecondsChange(e.target.value)} + min={30} + max={3600} + /> +
+
+ +
+ +
+
+ Busy Signal + +
+
+ No Answer + +
+
+ Voicemail + +
+
+
+
+ )} +
+ + + + {/* Call Schedule */} +
+
+
+ +

+ Restrict when calls are made +

+
+ +
+ + {scheduleEnabled && ( +
+
+ + +
+ +
+ + {timeSlots.map((slot, index) => ( +
+ + { + const updated = [...timeSlots]; + updated[index] = { ...updated[index], start_time: e.target.value }; + onTimeSlotsChange(updated); + }} + className="w-[130px]" + /> + to + { + const updated = [...timeSlots]; + updated[index] = { ...updated[index], end_time: e.target.value }; + onTimeSlotsChange(updated); + }} + className="w-[130px]" + /> + {timeSlots.length > 1 && ( + + )} +
+ ))} + +
+
+ )} +
+
+ ); +} diff --git a/ui/src/app/campaigns/[campaignId]/edit/page.tsx b/ui/src/app/campaigns/[campaignId]/edit/page.tsx new file mode 100644 index 0000000..02eb5d5 --- /dev/null +++ b/ui/src/app/campaigns/[campaignId]/edit/page.tsx @@ -0,0 +1,364 @@ +"use client"; + +import { ArrowLeft } from 'lucide-react'; +import { useParams, useRouter } from 'next/navigation'; +import { useCallback, useEffect, useState } from 'react'; +import type { ITimezoneOption } from 'react-timezone-select'; +import { toast } from 'sonner'; + +import { + getCampaignApiV1CampaignCampaignIdGet, + getCampaignLimitsApiV1OrganizationsCampaignLimitsGet, + updateCampaignApiV1CampaignCampaignIdPatch, +} from '@/client/sdk.gen'; +import type { CampaignResponse } from '@/client/types.gen'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Separator } from '@/components/ui/separator'; +import { useAuth } from '@/lib/auth'; + +import CampaignAdvancedSettings, { getTimezoneValue, type TimeSlot } from '../../CampaignAdvancedSettings'; + +export default function EditCampaignPage() { + const { user, getAccessToken, redirectToLogin, loading } = useAuth(); + const router = useRouter(); + const params = useParams(); + const campaignId = parseInt(params.campaignId as string); + + // Loading state + const [isLoading, setIsLoading] = useState(true); + const [campaign, setCampaign] = useState(null); + + // Form state + const [campaignName, setCampaignName] = useState(''); + const [maxConcurrency, setMaxConcurrency] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(null); + + // Limits state + const [orgConcurrentLimit, setOrgConcurrentLimit] = useState(2); + const [fromNumbersCount, setFromNumbersCount] = useState(0); + + // Retry config state + const [retryEnabled, setRetryEnabled] = useState(true); + const [maxRetries, setMaxRetries] = useState('2'); + const [retryDelaySeconds, setRetryDelaySeconds] = useState('120'); + const [retryOnBusy, setRetryOnBusy] = useState(true); + const [retryOnNoAnswer, setRetryOnNoAnswer] = useState(true); + const [retryOnVoicemail, setRetryOnVoicemail] = useState(true); + + // Schedule config state + const [scheduleEnabled, setScheduleEnabled] = useState(false); + const [scheduleTimezone, setScheduleTimezone] = useState('UTC'); + const [timeSlots, setTimeSlots] = useState([ + { day_of_week: 0, start_time: '09:00', end_time: '17:00' }, + ]); + + // Redirect if not authenticated + useEffect(() => { + if (!loading && !user) { + redirectToLogin(); + } + }, [loading, user, redirectToLogin]); + + // Fetch campaign and populate form + const fetchCampaign = useCallback(async () => { + if (!user) return; + try { + const accessToken = await getAccessToken(); + const response = await getCampaignApiV1CampaignCampaignIdGet({ + path: { campaign_id: campaignId }, + headers: { 'Authorization': `Bearer ${accessToken}` }, + }); + + if (response.data) { + const c = response.data; + + // Redirect if campaign is completed or failed + if (['completed', 'failed'].includes(c.state)) { + router.replace(`/campaigns/${campaignId}`); + return; + } + + setCampaign(c); + + // Populate form state + setCampaignName(c.name); + setMaxConcurrency(c.max_concurrency ? String(c.max_concurrency) : ''); + + // Retry config + setRetryEnabled(c.retry_config.enabled); + setMaxRetries(String(c.retry_config.max_retries)); + setRetryDelaySeconds(String(c.retry_config.retry_delay_seconds)); + setRetryOnBusy(c.retry_config.retry_on_busy); + setRetryOnNoAnswer(c.retry_config.retry_on_no_answer); + setRetryOnVoicemail(c.retry_config.retry_on_voicemail); + + // Schedule config + if (c.schedule_config) { + setScheduleEnabled(c.schedule_config.enabled); + setScheduleTimezone(c.schedule_config.timezone); + if (c.schedule_config.slots.length > 0) { + setTimeSlots(c.schedule_config.slots.map(s => ({ ...s }))); + } + } + } + } catch (error) { + console.error('Failed to fetch campaign:', error); + toast.error('Failed to load campaign'); + router.replace(`/campaigns/${campaignId}`); + } finally { + setIsLoading(false); + } + }, [user, getAccessToken, campaignId, router]); + + // Fetch campaign limits + const fetchCampaignLimits = useCallback(async () => { + if (!user) return; + try { + const accessToken = await getAccessToken(); + const response = await getCampaignLimitsApiV1OrganizationsCampaignLimitsGet({ + headers: { 'Authorization': `Bearer ${accessToken}` }, + }); + + if (response.data) { + setOrgConcurrentLimit(response.data.concurrent_call_limit); + setFromNumbersCount(response.data.from_numbers_count); + } + } catch (error) { + console.error('Failed to fetch campaign limits:', error); + } + }, [user, getAccessToken]); + + // Initial load + useEffect(() => { + if (user) { + fetchCampaign(); + fetchCampaignLimits(); + } + }, [fetchCampaign, fetchCampaignLimits, user]); + + // Effective concurrency limit + const effectiveLimit = fromNumbersCount > 0 + ? Math.min(orgConcurrentLimit, fromNumbersCount) + : orgConcurrentLimit; + + // Handle form submission + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setSubmitError(null); + + if (!campaignName.trim()) { + toast.error('Campaign name is required'); + return; + } + + // Validate max_concurrency if provided + const maxConcurrencyValue = maxConcurrency ? parseInt(maxConcurrency) : null; + if (maxConcurrencyValue !== null) { + if (isNaN(maxConcurrencyValue) || maxConcurrencyValue < 1 || maxConcurrencyValue > 100) { + toast.error('Max concurrent calls must be between 1 and 100'); + return; + } + if (maxConcurrencyValue > effectiveLimit) { + if (fromNumbersCount > 0 && fromNumbersCount < orgConcurrentLimit) { + toast.error(`Max concurrent calls cannot exceed ${effectiveLimit}. You have ${fromNumbersCount} phone number(s) configured — add more CLIs to increase concurrency.`); + } else { + toast.error(`Max concurrent calls cannot exceed organization limit (${effectiveLimit})`); + } + return; + } + } + + // Validate schedule slots if enabled + if (scheduleEnabled) { + if (timeSlots.length === 0) { + toast.error('Add at least one time slot'); + return; + } + for (const slot of timeSlots) { + if (slot.start_time >= slot.end_time) { + toast.error('Start time must be before end time for each slot'); + return; + } + } + } + + setIsSubmitting(true); + + try { + const accessToken = await getAccessToken(); + + const retryConfig = { + enabled: retryEnabled, + max_retries: parseInt(maxRetries) || 2, + retry_delay_seconds: parseInt(retryDelaySeconds) || 120, + retry_on_busy: retryOnBusy, + retry_on_no_answer: retryOnNoAnswer, + retry_on_voicemail: retryOnVoicemail, + }; + + const timezoneValue = getTimezoneValue(scheduleTimezone); + const scheduleConfig = scheduleEnabled && timeSlots.length > 0 + ? { + enabled: true, + timezone: timezoneValue, + slots: timeSlots, + } + : { + enabled: false, + timezone: timezoneValue, + slots: [{ day_of_week: 0, start_time: '09:00', end_time: '17:00' }], + }; + + const response = await updateCampaignApiV1CampaignCampaignIdPatch({ + path: { campaign_id: campaignId }, + body: { + name: campaignName, + retry_config: retryConfig, + max_concurrency: maxConcurrencyValue, + schedule_config: scheduleConfig, + }, + headers: { 'Authorization': `Bearer ${accessToken}` }, + }); + + if (response.error) { + const errorDetail = (response.error as { detail?: string })?.detail; + const errorMessage = errorDetail || 'Failed to update campaign'; + setSubmitError(errorMessage); + toast.error(errorMessage); + return; + } + + if (response.data) { + toast.success('Campaign updated successfully'); + router.push(`/campaigns/${campaignId}`); + } + } catch (error) { + console.error('Failed to update campaign:', error); + const errorMessage = 'Failed to update campaign'; + setSubmitError(errorMessage); + toast.error(errorMessage); + } finally { + setIsSubmitting(false); + } + }; + + const handleBack = () => { + router.push(`/campaigns/${campaignId}`); + }; + + if (isLoading) { + return ( +
+
+
+
+
+
+ ); + } + + if (!campaign) { + return ( +
+

Campaign not found

+
+ ); + } + + return ( +
+
+ +

Edit Campaign

+

Modify campaign settings

+
+ + + + Campaign Settings + + Update name, concurrency, retry, and schedule configuration + + + +
+ {/* Campaign Name */} +
+ + setCampaignName(e.target.value)} + maxLength={255} + required + /> +
+ + + + + + {submitError && ( +
+ {submitError} +
+ )} + +
+ + +
+ +
+
+
+ ); +} diff --git a/ui/src/app/campaigns/[campaignId]/page.tsx b/ui/src/app/campaigns/[campaignId]/page.tsx index 2354b88..b3b328b 100644 --- a/ui/src/app/campaigns/[campaignId]/page.tsx +++ b/ui/src/app/campaigns/[campaignId]/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { ArrowLeft, Check, Pause, Play, RefreshCw, X } from 'lucide-react'; +import { ArrowLeft, Check, Clock, Pause, Pencil, Play, RefreshCw, X } from 'lucide-react'; import { useParams, useRouter, useSearchParams } from 'next/navigation'; import { useCallback, useEffect, useState } from 'react'; import { toast } from 'sonner'; @@ -10,7 +10,8 @@ import { getCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGet, pauseCampaignApiV1CampaignCampaignIdPausePost, resumeCampaignApiV1CampaignCampaignIdResumePost, - startCampaignApiV1CampaignCampaignIdStartPost} from '@/client/sdk.gen'; + startCampaignApiV1CampaignCampaignIdStartPost, +} from '@/client/sdk.gen'; import type { CampaignResponse } from '@/client/types.gen'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; @@ -236,31 +237,49 @@ export default function CampaignDetailPage() { } }; + const canEdit = campaign && ['created', 'running', 'paused'].includes(campaign.state); + // Render action button based on state const renderActionButton = () => { if (!campaign || isExecutingAction) return null; + const editButton = canEdit ? ( + + ) : null; + switch (campaign.state) { case 'created': return ( - +
+ {editButton} + +
); case 'running': return ( - +
+ {editButton} + +
); case 'paused': return ( - +
+ {editButton} + +
); default: return null; @@ -449,6 +468,51 @@ export default function CampaignDetailPage() { )} + + + + {/* Call Schedule (read-only) */} +
+
+ Call Schedule +
+ {campaign.schedule_config?.enabled ? ( + + + Enabled + + ) : ( + + + Not configured + + )} +
+
+ + {campaign.schedule_config?.enabled && ( +
+
+
Timezone
+
{campaign.schedule_config.timezone.replace(/_/g, ' ')}
+
+
+
Time Slots
+
+ {campaign.schedule_config.slots.map((slot, index) => { + const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + return ( +
+ {dayNames[slot.day_of_week]} + {slot.start_time} - {slot.end_time} +
+ ); + })} +
+
+
+ )} +
diff --git a/ui/src/app/campaigns/new/page.tsx b/ui/src/app/campaigns/new/page.tsx index b49aeef..7ef0754 100644 --- a/ui/src/app/campaigns/new/page.tsx +++ b/ui/src/app/campaigns/new/page.tsx @@ -3,6 +3,7 @@ import { ArrowLeft, ChevronDown, ChevronRight } from 'lucide-react'; import { useRouter } from 'next/navigation'; import { useCallback, useEffect, useState } from 'react'; +import type { ITimezoneOption } from 'react-timezone-select'; import { toast } from 'sonner'; import { @@ -23,9 +24,9 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { Switch } from '@/components/ui/switch'; import { useAuth } from '@/lib/auth'; +import CampaignAdvancedSettings, { getTimezoneValue, type TimeSlot } from '../CampaignAdvancedSettings'; import CsvUploadSelector from '../CsvUploadSelector'; import GoogleSheetSelector from '../GoogleSheetSelector'; @@ -59,6 +60,18 @@ export default function NewCampaignPage() { const [retryOnBusy, setRetryOnBusy] = useState(true); const [retryOnNoAnswer, setRetryOnNoAnswer] = useState(true); const [retryOnVoicemail, setRetryOnVoicemail] = useState(true); + // Schedule config state + const [scheduleEnabled, setScheduleEnabled] = useState(false); + const [scheduleTimezone, setScheduleTimezone] = useState(() => { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone; + } catch { + return 'UTC'; + } + }); + const [timeSlots, setTimeSlots] = useState([ + { day_of_week: 0, start_time: '09:00', end_time: '17:00' }, + ]); // Redirect if not authenticated useEffect(() => { @@ -163,7 +176,6 @@ export default function NewCampaignPage() { try { const accessToken = await getAccessToken(); - // Build retry_config only if user has modified settings from defaults const retryConfig = { enabled: retryEnabled, max_retries: parseInt(maxRetries) || 2, @@ -173,6 +185,16 @@ export default function NewCampaignPage() { retry_on_voicemail: retryOnVoicemail, }; + // Build schedule_config if enabled + const timezoneValue = getTimezoneValue(scheduleTimezone); + const scheduleConfig = scheduleEnabled && timeSlots.length > 0 + ? { + enabled: true, + timezone: timezoneValue, + slots: timeSlots, + } + : undefined; + const response = await createCampaignApiV1CampaignCreatePost({ body: { name: campaignName, @@ -181,6 +203,7 @@ export default function NewCampaignPage() { source_id: sourceId, retry_config: retryConfig, max_concurrency: maxConcurrencyValue, + schedule_config: scheduleConfig, }, headers: { 'Authorization': `Bearer ${accessToken}`, @@ -353,107 +376,32 @@ export default function NewCampaignPage() { )} - - {/* Max Concurrent Calls */} -
- - setMaxConcurrency(e.target.value)} - min={1} - max={effectiveLimit} - /> -

- Maximum number of simultaneous calls. Leave empty to use {effectiveLimit}. - {fromNumbersCount > 0 && ` You have ${fromNumbersCount} CLI${fromNumbersCount !== 1 ? 's' : ''} and an org limit of ${orgConcurrentLimit}.`} -

- {fromNumbersCount > 0 && fromNumbersCount < orgConcurrentLimit && ( -

- Concurrency is limited to {fromNumbersCount} by your configured phone numbers. To use the full org limit of {orgConcurrentLimit}, add more CLIs in Telephony Configuration. -

- )} - {fromNumbersCount === 0 && ( -

- No phone numbers configured. Add CLIs in Telephony Configuration before running the campaign. -

- )} -
- - {/* Retry Configuration */} -
-
-
- -

- Automatically retry failed calls -

-
- -
- - {retryEnabled && ( -
-
-
- - setMaxRetries(e.target.value)} - min={0} - max={10} - /> -
-
- - setRetryDelaySeconds(e.target.value)} - min={30} - max={3600} - /> -
-
- -
- -
-
- Busy Signal - -
-
- No Answer - -
-
- Voicemail - -
-
-
-
- )} -
+ + diff --git a/ui/src/app/files/DocumentList.tsx b/ui/src/app/files/DocumentList.tsx index fd66c32..a6830a7 100644 --- a/ui/src/app/files/DocumentList.tsx +++ b/ui/src/app/files/DocumentList.tsx @@ -16,27 +16,21 @@ import { Skeleton } from '@/components/ui/skeleton'; import logger from '@/lib/logger'; interface DocumentListProps { - accessToken: string; refreshTrigger: number; } -export default function DocumentList({ accessToken, refreshTrigger }: DocumentListProps) { +export default function DocumentList({ refreshTrigger }: DocumentListProps) { const [documents, setDocuments] = useState([]); const [isLoading, setIsLoading] = useState(true); const [searchQuery, setSearchQuery] = useState(''); const [error, setError] = useState(null); const fetchDocuments = useCallback(async () => { - if (!accessToken) return; - try { setIsLoading(true); setError(null); const response = await listDocumentsApiV1KnowledgeBaseDocumentsGet({ - headers: { - 'Authorization': `Bearer ${accessToken}`, - }, query: { limit: 100, offset: 0, @@ -54,7 +48,7 @@ export default function DocumentList({ accessToken, refreshTrigger }: DocumentLi } finally { setIsLoading(false); } - }, [accessToken]); + }, []); // Fetch documents on mount and when refreshTrigger changes useEffect(() => { @@ -85,9 +79,6 @@ export default function DocumentList({ accessToken, refreshTrigger }: DocumentLi path: { document_uuid: documentUuid, }, - headers: { - 'Authorization': `Bearer ${accessToken}`, - }, }); if (response.error) { diff --git a/ui/src/app/files/DocumentUpload.tsx b/ui/src/app/files/DocumentUpload.tsx index 3117bfd..e9367c3 100644 --- a/ui/src/app/files/DocumentUpload.tsx +++ b/ui/src/app/files/DocumentUpload.tsx @@ -14,14 +14,13 @@ import { Progress } from '@/components/ui/progress'; import logger from '@/lib/logger'; interface DocumentUploadProps { - accessToken: string; onUploadSuccess: () => void; } const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100MB const ACCEPTED_FILE_TYPES = ['.pdf', '.docx', '.doc', '.txt']; -export default function DocumentUpload({ accessToken, onUploadSuccess }: DocumentUploadProps) { +export default function DocumentUpload({ onUploadSuccess }: DocumentUploadProps) { const [uploading, setUploading] = useState(false); const [uploadProgress, setUploadProgress] = useState(0); const [dragActive, setDragActive] = useState(false); @@ -62,9 +61,6 @@ export default function DocumentUpload({ accessToken, onUploadSuccess }: Documen uploaded_at: new Date().toISOString(), }, }, - headers: { - 'Authorization': `Bearer ${accessToken}`, - }, }); if (uploadUrlResponse.error || !uploadUrlResponse.data) { @@ -98,9 +94,6 @@ export default function DocumentUpload({ accessToken, onUploadSuccess }: Documen document_uuid: uploadData.document_uuid, s3_key: uploadData.s3_key, }, - headers: { - 'Authorization': `Bearer ${accessToken}`, - }, }); if (processResponse.error) { diff --git a/ui/src/app/files/page.tsx b/ui/src/app/files/page.tsx index dd389e1..249613b 100644 --- a/ui/src/app/files/page.tsx +++ b/ui/src/app/files/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; @@ -11,9 +11,8 @@ import DocumentList from "./DocumentList"; import DocumentUpload from "./DocumentUpload"; export default function FilesPage() { - const { user, getAccessToken, redirectToLogin, loading } = useAuth(); + const { user, redirectToLogin, loading } = useAuth(); const [refreshKey, setRefreshKey] = useState(0); - const [accessToken, setAccessToken] = useState(''); // Redirect if not authenticated useEffect(() => { @@ -22,24 +21,12 @@ export default function FilesPage() { } }, [loading, user, redirectToLogin]); - // Get access token - const fetchAccessToken = useCallback(async () => { - if (user) { - const token = await getAccessToken(); - setAccessToken(token); - } - }, [user, getAccessToken]); - - useEffect(() => { - fetchAccessToken(); - }, [fetchAccessToken]); - const handleUploadSuccess = () => { // Trigger refresh of document list setRefreshKey(prev => prev + 1); }; - if (loading || !user || !accessToken) { + if (loading || !user) { return (
@@ -75,7 +62,6 @@ export default function FilesPage() { @@ -92,7 +78,6 @@ export default function FilesPage() { diff --git a/ui/src/app/reports/page.tsx b/ui/src/app/reports/page.tsx index 4d4123c..b65b923 100644 --- a/ui/src/app/reports/page.tsx +++ b/ui/src/app/reports/page.tsx @@ -16,6 +16,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Skeleton } from '@/components/ui/skeleton'; import { useUserConfig } from '@/context/UserConfigContext'; +import { useAuth } from '@/lib/auth'; import { DispositionChart } from './components/DispositionChart'; import { DurationChart } from './components/DurationChart'; @@ -55,20 +56,18 @@ export default function ReportsPage() { const [report, setReport] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const { userConfig, accessToken } = useUserConfig(); + const { userConfig } = useUserConfig(); + const auth = useAuth(); const timezone = userConfig?.timezone || 'America/New_York'; // Fetch workflows on mount useEffect(() => { const fetchWorkflows = async () => { - if (!accessToken) return; + if (!auth.isAuthenticated) return; try { const response = await getWorkflowOptionsApiV1OrganizationsReportsWorkflowsGet({ - headers: { - Authorization: `Bearer ${accessToken}` - } }); if (response.data) { setWorkflows(response.data); @@ -78,12 +77,12 @@ export default function ReportsPage() { } }; fetchWorkflows(); - }, [accessToken]); + }, [auth.isAuthenticated]); // Fetch report data when date or workflow changes useEffect(() => { const fetchReport = async () => { - if (!accessToken) return; + if (!auth.isAuthenticated) return; setLoading(true); setError(null); @@ -98,9 +97,6 @@ export default function ReportsPage() { timezone, ...(workflowId && { workflow_id: workflowId }) }, - headers: { - Authorization: `Bearer ${accessToken}` - } }); if (response.data) { @@ -115,7 +111,7 @@ export default function ReportsPage() { }; fetchReport(); - }, [selectedDate, selectedWorkflow, timezone, accessToken]); + }, [selectedDate, selectedWorkflow, timezone, auth.isAuthenticated]); const handlePreviousDay = () => { setSelectedDate(subDays(selectedDate, 1)); @@ -126,7 +122,7 @@ export default function ReportsPage() { }; const handleDownloadCSV = async () => { - if (!accessToken) return; + if (!auth.isAuthenticated) return; try { const dateStr = format(selectedDate, 'yyyy-MM-dd'); @@ -139,9 +135,6 @@ export default function ReportsPage() { timezone, ...(workflowId && { workflow_id: workflowId }) }, - headers: { - Authorization: `Bearer ${accessToken}` - } }); if (response.data && response.data.length > 0) { diff --git a/ui/src/app/superadmin/runs/page.tsx b/ui/src/app/superadmin/runs/page.tsx index c58961f..4f0f4ee 100644 --- a/ui/src/app/superadmin/runs/page.tsx +++ b/ui/src/app/superadmin/runs/page.tsx @@ -30,7 +30,7 @@ import { } from "@/components/ui/table"; import { Textarea } from '@/components/ui/textarea'; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { useUserConfig } from '@/context/UserConfigContext'; +import { useAuth } from '@/lib/auth'; import{ superadminFilterAttributes } from "@/lib/filterAttributes"; import { decodeFiltersFromURL, encodeFiltersToURL } from '@/lib/filters'; import { impersonateAsSuperadmin } from '@/lib/utils'; @@ -107,10 +107,10 @@ export default function RunsPage() { const [commentText, setCommentText] = useState(''); const [selectedRowId, setSelectedRowId] = useState(null); - const { accessToken } = useUserConfig(); + const auth = useAuth(); // Media preview dialog - const mediaPreview = MediaPreviewDialog({ accessToken }); + const mediaPreview = MediaPreviewDialog(); const fetchRuns = useCallback(async ( page: number, @@ -119,7 +119,7 @@ export default function RunsPage() { sortByParam?: string | null, sortOrderParam?: 'asc' | 'desc' ) => { - if (!accessToken) return; + if (!auth.isAuthenticated) return; // Don't show loading state for auto-refresh to prevent UI flicker if (!isAutoRefresh) { @@ -148,9 +148,6 @@ export default function RunsPage() { ...(sortByParam && { sort_by: sortByParam }), ...(sortOrderParam && { sort_order: sortOrderParam }), }, - headers: { - 'Authorization': `Bearer ${accessToken}`, - } }); if (response.data) { @@ -170,7 +167,7 @@ export default function RunsPage() { setIsAutoRefreshing(false); } } - }, [limit, accessToken]); + }, [limit, auth.isAuthenticated]); const updatePageInUrl = useCallback((page: number, filters?: ActiveFilter[], sortByParam?: string | null, sortOrderParam?: 'asc' | 'desc') => { const params = new URLSearchParams(); @@ -195,11 +192,11 @@ export default function RunsPage() { }, [router]); useEffect(() => { - // Fetch runs when token is available and when page/sort changes - if (accessToken) { + // Fetch runs when auth is available and when page/sort changes + if (auth.isAuthenticated) { fetchRuns(currentPage, appliedFilters, false, sortBy, sortOrder); } - }, [currentPage, accessToken, appliedFilters, fetchRuns, sortBy, sortOrder]); + }, [currentPage, auth.isAuthenticated, appliedFilters, fetchRuns, sortBy, sortOrder]); // Auto-refresh every 5 seconds when enabled and filters are active useEffect(() => { @@ -262,7 +259,7 @@ export default function RunsPage() { // Save comment function declared outside JSX (requirement #2) const saveAdminComment = useCallback(async () => { - if (commentRunId === null || !accessToken) return; + if (commentRunId === null || !auth.isAuthenticated) return; try { await setAdminCommentApiV1SuperuserWorkflowRunsRunIdCommentPost({ path: { @@ -271,9 +268,6 @@ export default function RunsPage() { body: { admin_comment: commentText, }, - headers: { - 'Authorization': `Bearer ${accessToken}`, - }, }); // Optimistically update UI @@ -284,7 +278,7 @@ export default function RunsPage() { console.error('Failed to set admin comment', err); alert('Failed to save comment. Please try again.'); } - }, [commentRunId, commentText, accessToken]); + }, [commentRunId, commentText, auth.isAuthenticated]); /** * ---------------------------------------------------------------------------------- @@ -308,10 +302,11 @@ export default function RunsPage() { */ const impersonateAndMaybeRedirect = useCallback( async (targetUserId: number | undefined, redirectPath?: string) => { - if (!targetUserId || !accessToken) return; + if (!targetUserId || !auth.isAuthenticated) return; try { + const token = await auth.getAccessToken(); await impersonateAsSuperadmin({ - accessToken: accessToken, + accessToken: token, userId: targetUserId, redirectPath, openInNewTab: true, @@ -321,7 +316,7 @@ export default function RunsPage() { alert('Failed to impersonate the user. Please try again.'); } }, - [accessToken], + [auth], ); if (isLoading && runs.length === 0) { diff --git a/ui/src/app/telephony-configurations/page.tsx b/ui/src/app/telephony-configurations/page.tsx index 99a742e..acc126d 100644 --- a/ui/src/app/telephony-configurations/page.tsx +++ b/ui/src/app/telephony-configurations/page.tsx @@ -8,6 +8,8 @@ import { toast } from "sonner"; import { getTelephonyConfigurationApiV1OrganizationsTelephonyConfigGet, saveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPost } from "@/client/sdk.gen"; import type { + AriConfigurationRequest, + AriConfigurationResponse, CloudonixConfigurationRequest, CloudonixConfigurationResponse, TelephonyConfigurationResponse, @@ -51,6 +53,12 @@ interface TelephonyConfigForm { // Cloudonix fields bearer_token?: string; domain_id?: string; + // ARI fields + ari_endpoint?: string; + app_name?: string; + app_password?: string; + ws_client_name?: string; + inbound_workflow_id?: number; // Common field - multiple phone numbers from_numbers: string[]; } @@ -140,6 +148,19 @@ export default function ConfigureTelephonyPage() { setValue("bearer_token", cloudonixConfig.bearer_token); setValue("domain_id", cloudonixConfig.domain_id); setValue("from_numbers", cloudonixConfig.from_numbers?.length > 0 ? cloudonixConfig.from_numbers : [""]); + } else if ((response.data as TelephonyConfigurationResponse)?.ari) { + const ariConfig = (response.data as TelephonyConfigurationResponse).ari as AriConfigurationResponse; + setHasExistingConfig(true); + setValue("provider", "ari"); + setValue("ari_endpoint", ariConfig.ari_endpoint); + 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 : [""]); } } } catch (error) { @@ -161,12 +182,13 @@ export default function ConfigureTelephonyPage() { | TwilioConfigurationRequest | VonageConfigurationRequest | VobizConfigurationRequest - | CloudonixConfigurationRequest; + | CloudonixConfigurationRequest + | AriConfigurationRequest; const filteredNumbers = data.from_numbers.filter(n => n.trim() !== ""); - // Validate phone numbers are provided (except for Cloudonix where optional) - if (data.provider !== "cloudonix" && filteredNumbers.length === 0) { + // Validate phone numbers are provided (except for Cloudonix/ARI where optional) + if (data.provider !== "cloudonix" && data.provider !== "ari" && filteredNumbers.length === 0) { toast.error("At least one phone number is required"); setIsLoading(false); return; @@ -185,6 +207,10 @@ export default function ConfigureTelephonyPage() { } else if (data.provider === "cloudonix") { pattern = cloudonixPattern; formatMessage = "(e.g., +1234567890)"; + } else if (data.provider === "ari") { + // ARI uses SIP extensions - skip phone number validation + pattern = /^.+$/; + formatMessage = "(SIP extension or number)"; } else { pattern = vonageVobizPattern; formatMessage = "without + prefix (e.g., 14155551234)"; @@ -220,14 +246,24 @@ export default function ConfigureTelephonyPage() { auth_id: data.auth_id, auth_token: data.vobiz_auth_token, } as VobizConfigurationRequest; - } else { - // Cloudonix + } else if (data.provider === "cloudonix") { requestBody = { provider: data.provider, from_numbers: filteredNumbers, bearer_token: data.bearer_token!, domain_id: data.domain_id!, } as CloudonixConfigurationRequest; + } else { + // ARI + requestBody = { + provider: data.provider, + from_numbers: filteredNumbers, + ari_endpoint: data.ari_endpoint!, + 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; } const response = await saveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPost({ @@ -276,11 +312,18 @@ export default function ConfigureTelephonyPage() { ? "Vonage" : selectedProvider === "vobiz" ? "Vobiz" + : selectedProvider === "ari" + ? "Asterisk ARI" : "Cloudonix"}{" "} Setup Guide - {selectedProvider === "cloudonix" ? ( + {selectedProvider === "ari" ? ( + <> + Connect Dograh to your Asterisk PBX using the Asterisk REST Interface (ARI). + ARI provides a WebSocket-based event model for controlling calls via Stasis applications. + + ) : selectedProvider === "cloudonix" ? ( <> Cloudonix is an AI Connectivity platform, enabling you to connect Dograh to any SIP product or SIP Telephony Provider.